From dbc7f2b43c159933c65408b079dd6b5f6eb6b395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 14 Sep 2025 17:51:11 +0200 Subject: [PATCH 01/20] Remove Home Connect stale code (#152307) --- .../home_connect/application_credentials.py | 10 ---- .../components/home_connect/coordinator.py | 14 ----- .../components/home_connect/repairs.py | 60 ------------------- 3 files changed, 84 deletions(-) delete mode 100644 homeassistant/components/home_connect/repairs.py diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 20a3a211b6aa02..d66255e6810856 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -12,13 +12,3 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN, ) - - -async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: - """Return description placeholders for the credentials dialog.""" - return { - "developer_dashboard_url": "https://developer.home-connect.com/", - "applications_url": "https://developer.home-connect.com/applications", - "register_application_url": "https://developer.home-connect.com/application/add", - "redirect_url": "https://my.home-assistant.io/redirect/oauth", - } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 81f785b55aeacf..92ede6a5a3afc8 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -659,17 +659,3 @@ def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: ) return False - - async def reset_execution_tracker(self, appliance_ha_id: str) -> None: - """Reset the execution tracker for a specific appliance.""" - self._execution_tracker.pop(appliance_ha_id, None) - appliance_info = await self.client.get_specific_appliance(appliance_ha_id) - - appliance_data = await self._get_appliance_data( - appliance_info, self.data.get(appliance_info.ha_id) - ) - self.data[appliance_ha_id].update(appliance_data) - 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(appliance_ha_id) diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py deleted file mode 100644 index 21c6775e549bca..00000000000000 --- a/homeassistant/components/home_connect/repairs.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Repairs flows for Home Connect.""" - -from typing import cast - -import voluptuous as vol - -from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from .coordinator import HomeConnectConfigEntry - - -class EnableApplianceUpdatesFlow(RepairsFlow): - """Handler for enabling appliance's updates after being refreshed too many times.""" - - 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 await self.async_step_confirm() - - 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.""" - if user_input is not None: - assert self.data - entry = self.hass.config_entries.async_get_entry( - cast(str, self.data["entry_id"]) - ) - assert entry - entry = cast(HomeConnectConfigEntry, entry) - await entry.runtime_data.reset_execution_tracker( - cast(str, self.data["appliance_ha_id"]) - ) - return self.async_create_entry(data={}) - - issue_registry = ir.async_get(self.hass) - description_placeholders = None - if issue := issue_registry.async_get_issue(self.handler, self.issue_id): - description_placeholders = issue.translation_placeholders - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=description_placeholders, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None] | None, -) -> RepairsFlow: - """Create flow.""" - if issue_id.startswith("home_connect_too_many_connected_paired_events"): - return EnableApplianceUpdatesFlow() - return ConfirmRepairFlow() From f832002afd0a51badb3a762e9e23bf7d230be821 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 14 Sep 2025 17:51:47 +0200 Subject: [PATCH 02/20] Bump holidays to 0.80 (#152306) --- 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 5ea0d217f141f5..40c27762f00816 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.79", "babel==2.15.0"] + "requirements": ["holidays==0.80", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 0e336632b2ebf0..8b917d5d8bd35c 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.79"] + "requirements": ["holidays==0.80"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5494ed4d89b09e..e85b5c1d1f2f0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1178,7 +1178,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.79 +holidays==0.80 # homeassistant.components.frontend home-assistant-frontend==20250903.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1fb4afe54039b..2ee841a6202195 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.79 +holidays==0.80 # homeassistant.components.frontend home-assistant-frontend==20250903.5 From a3a4433d628a2660d4caf184d232081fc123265d Mon Sep 17 00:00:00 2001 From: Bram Gerritsen Date: Sun, 14 Sep 2025 19:00:44 +0200 Subject: [PATCH 03/20] Add missing unit conversion for BTU/h (#152300) --- homeassistant/util/unit_conversion.py | 2 ++ tests/components/sensor/test_recorder.py | 20 ++++++++++++++++---- tests/util/test_unit_conversion.py | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5502163472def6..493de266080f58 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -412,6 +412,7 @@ class PowerConverter(BaseUnitConverter): UnitOfPower.MEGA_WATT: 1 / 1e6, UnitOfPower.GIGA_WATT: 1 / 1e9, UnitOfPower.TERA_WATT: 1 / 1e12, + UnitOfPower.BTU_PER_HOUR: 1 / 0.29307107, } VALID_UNITS = { UnitOfPower.MILLIWATT, @@ -420,6 +421,7 @@ class PowerConverter(BaseUnitConverter): UnitOfPower.MEGA_WATT, UnitOfPower.GIGA_WATT, UnitOfPower.TERA_WATT, + UnitOfPower.BTU_PER_HOUR, } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index df38a246a7a95c..50754d2244b3c3 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4941,9 +4941,15 @@ def set_state(entity_id, state, **kwargs): POWER_SENSOR_ATTRIBUTES, "W", "kW", - "GW, MW, TW, W, kW, mW", + "BTU/h, GW, MW, TW, W, kW, mW", + ), + ( + METRIC_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "BTU/h, GW, MW, TW, W, kW, mW", ), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -5159,9 +5165,15 @@ async def test_validate_statistics_unit_ignore_device_class( POWER_SENSOR_ATTRIBUTES, "W", "kW", - "GW, MW, TW, W, kW, mW", + "BTU/h, GW, MW, TW, W, kW, mW", + ), + ( + METRIC_SYSTEM, + POWER_SENSOR_ATTRIBUTES, + "W", + "kW", + "BTU/h, GW, MW, TW, W, kW, mW", ), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW, mW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index c25a40f5fc0e9e..2938db4732ec2b 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -664,6 +664,7 @@ (10, UnitOfPower.TERA_WATT, 10e12, UnitOfPower.WATT), (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), (10, UnitOfPower.MILLIWATT, 0.01, UnitOfPower.WATT), + (10, UnitOfPower.BTU_PER_HOUR, 2.9307107, UnitOfPower.WATT), ], PressureConverter: [ (1000, UnitOfPressure.HPA, 14.5037743897, UnitOfPressure.PSI), From c97f16a96d5c7f4e15fa9687730baa517697dead Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 12:02:11 -0500 Subject: [PATCH 04/20] Bump aiohomekit to 3.2.17 (#152297) --- 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 ef4fdadb24c41f..e9ea92c78e82da 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.16"], + "requirements": ["aiohomekit==3.2.17"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e85b5c1d1f2f0b..eec5061af9dd15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -271,7 +271,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.16 +aiohomekit==3.2.17 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee841a6202195..2325669acf90d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -256,7 +256,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.16 +aiohomekit==3.2.17 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From 49e75c9cf887300b5cae589d6a47750ca49c43be Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 14 Sep 2025 10:04:59 -0700 Subject: [PATCH 05/20] Fix browse by language in radio browser (#152296) --- homeassistant/components/radio_browser/media_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index e5bbf2db9f2aee..2cc243323a1043 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -134,7 +134,7 @@ async def _async_build_by_country( ) -> list[BrowseMediaSource]: """Handle browsing radio stations by country.""" category, _, country_code = (item.identifier or "").partition("/") - if country_code: + if category == "country" and country_code: stations = await radios.stations( filter_by=FilterBy.COUNTRY_CODE_EXACT, filter_term=country_code, @@ -185,7 +185,7 @@ async def _async_build_by_language( return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"language/{language.code}", + identifier=f"language/{language.name.lower()}", media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, title=language.name, From af9717c1cd720b5c0c277f2183a0502079615f9d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 14 Sep 2025 19:17:26 +0200 Subject: [PATCH 06/20] Raise error for entity services without a correct schema (#151165) --- homeassistant/helpers/service.py | 14 +++++--------- tests/helpers/test_entity_component.py | 10 ++++------ tests/helpers/test_entity_platform.py | 12 ++++++------ tests/helpers/test_service.py | 25 +++++++++++-------------- 4 files changed, 26 insertions(+), 35 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 70bded4b599324..3b4bafeded7bca 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1118,18 +1118,14 @@ async def execute_service(self, service_call: ServiceCall) -> None: def _validate_entity_service_schema( - schema: VolDictType | VolSchemaType | None, + schema: VolDictType | VolSchemaType | None, service: str ) -> VolSchemaType: """Validate that a schema is an entity service schema.""" if schema is None or isinstance(schema, dict): return cv.make_entity_service_schema(schema) if not cv.is_entity_service_schema(schema): - from .frame import ReportBehavior, report_usage # noqa: PLC0415 - - report_usage( - "registers an entity service with a non entity service schema", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.9", + raise HomeAssistantError( + f"The {service} service registers an entity service with a non entity service schema" ) return schema @@ -1153,7 +1149,7 @@ def async_register_entity_service( EntityPlatform.async_register_entity_service and should not be called directly by integrations. """ - schema = _validate_entity_service_schema(schema) + schema = _validate_entity_service_schema(schema, f"{domain}.{name}") service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) @@ -1189,7 +1185,7 @@ def async_register_platform_entity_service( """Help registering a platform entity service.""" from .entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES # noqa: PLC0415 - schema = _validate_entity_service_schema(schema) + schema = _validate_entity_service_schema(schema, f"{service_domain}.{service_name}") service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 20c243d070158e..c81c4dcd5cf392 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -560,11 +560,10 @@ def appender(**kwargs): async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" component = EntityComponent(_LOGGER, DOMAIN, hass) - expected_message = "registers an entity service with a non entity service schema" for idx, schema in enumerate( ( @@ -573,9 +572,9 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - component.async_register_entity_service(f"hello_{idx}", schema, Mock()) - assert expected_message in caplog.text - caplog.clear() + expected_message = f"The test_domain.hello_{idx} service registers an entity service with a non entity service schema" + with pytest.raises(HomeAssistantError, match=expected_message): + component.async_register_entity_service(f"hello_{idx}", schema, Mock()) for idx, schema in enumerate( ( @@ -585,7 +584,6 @@ async def test_register_entity_service_non_entity_service_schema( ) ): component.async_register_entity_service(f"test_service_{idx}", schema, Mock()) - assert expected_message not in caplog.text async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 53331b676feb94..e973de0d2b4600 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1878,13 +1878,12 @@ def handle_service(entity, *_): async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" entity_platform = MockEntityPlatform( hass, domain="mock_integration", platform_name="mock_platform", platform=None ) - expected_message = "registers an entity service with a non entity service schema" for idx, schema in enumerate( ( @@ -1893,9 +1892,11 @@ async def test_register_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - entity_platform.async_register_entity_service(f"hello_{idx}", schema, Mock()) - assert expected_message in caplog.text - caplog.clear() + expected_message = f"The mock_platform.hello_{idx} service registers an entity service with a non entity service schema" + with pytest.raises(HomeAssistantError, match=expected_message): + entity_platform.async_register_entity_service( + f"hello_{idx}", schema, Mock() + ) for idx, schema in enumerate( ( @@ -1907,7 +1908,6 @@ async def test_register_entity_service_non_entity_service_schema( entity_platform.async_register_entity_service( f"test_service_{idx}", schema, Mock() ) - assert expected_message not in caplog.text @pytest.mark.parametrize("update_before_add", [True, False]) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 73f4afc1f6d7bd..da4cdec4a0a486 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -5,7 +5,6 @@ from copy import deepcopy import dataclasses import io -import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -39,6 +38,7 @@ SupportsResponse, callback, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, config_validation as cv, @@ -2761,7 +2761,7 @@ def handle_service(entity, *_): async def test_register_platform_entity_service_non_entity_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test attempting to register a service with a non entity service schema.""" expected_message = "registers an entity service with a non entity service schema" @@ -2773,16 +2773,15 @@ async def test_register_platform_entity_service_non_entity_service_schema( vol.Any(vol.Schema({"some": str})), ) ): - service.async_register_platform_entity_service( - hass, - "mock_platform", - f"hello_{idx}", - entity_domain="mock_integration", - schema=schema, - func=Mock(), - ) - assert expected_message in caplog.text - caplog.clear() + with pytest.raises(HomeAssistantError, match=expected_message): + service.async_register_platform_entity_service( + hass, + "mock_platform", + f"hello_{idx}", + entity_domain="mock_integration", + schema=schema, + func=Mock(), + ) for idx, schema in enumerate( ( @@ -2799,5 +2798,3 @@ async def test_register_platform_entity_service_non_entity_service_schema( schema=schema, func=Mock(), ) - assert expected_message not in caplog.text - assert not any(x.levelno > logging.DEBUG for x in caplog.records) From 1509c429d6d82aecd8aac404c6de9541b6e29ab9 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:32:10 +0200 Subject: [PATCH 07/20] Improve husqvarna_automower_ble config flow (#144877) --- .../husqvarna_automower_ble/config_flow.py | 76 ++++++++++--------- .../test_config_flow.py | 21 ++++- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index 7d1977f930c1aa..fdca16a2765fb8 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -21,6 +21,21 @@ from .const import DOMAIN, LOGGER +BLUETOOTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PIN): str, + } +) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_PIN): str, + } +) + +REAUTH_SCHEMA = BLUETOOTH_SCHEMA + def _is_supported(discovery_info: BluetoothServiceInfo): """Check if device is supported.""" @@ -78,6 +93,10 @@ async def async_step_bluetooth( if not _is_supported(discovery_info): return self.async_abort(reason="no_devices_found") + self.context["title_placeholders"] = { + "name": discovery_info.name, + "address": discovery_info.address, + } self.address = discovery_info.address await self.async_set_unique_id(self.address) self._abort_if_unique_id_configured() @@ -100,12 +119,7 @@ async def async_step_bluetooth_confirm( return self.async_show_form( step_id="bluetooth_confirm", data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_PIN): str, - }, - ), - user_input, + BLUETOOTH_SCHEMA, user_input ), description_placeholders={"name": self.mower_name or self.address}, errors=errors, @@ -129,15 +143,7 @@ async def async_step_user( return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_ADDRESS): str, - vol.Required(CONF_PIN): str, - }, - ), - user_input, - ), + data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input), errors=errors, ) @@ -184,7 +190,24 @@ async def check_mower( title = await self.probe_mower(device) if title is None: - return self.async_abort(reason="cannot_connect") + if self.source == SOURCE_BLUETOOTH: + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=BLUETOOTH_SCHEMA, + description_placeholders={"name": self.address}, + errors={"base": "cannot_connect"}, + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, + { + CONF_ADDRESS: self.address, + CONF_PIN: self.pin, + }, + ), + errors={"base": "cannot_connect"}, + ) self.mower_name = title try: @@ -209,11 +232,7 @@ async def check_mower( if self.source == SOURCE_BLUETOOTH: return self.async_show_form( step_id="bluetooth_confirm", - data_schema=vol.Schema( - { - vol.Required(CONF_PIN): str, - }, - ), + data_schema=BLUETOOTH_SCHEMA, description_placeholders={ "name": self.mower_name or self.address }, @@ -230,13 +249,7 @@ async def check_mower( return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_ADDRESS): str, - vol.Required(CONF_PIN): str, - }, - ), - suggested_values, + USER_SCHEMA, suggested_values ), errors=errors, ) @@ -312,12 +325,7 @@ async def async_step_reauth_confirm( return self.async_show_form( step_id="reauth_confirm", data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_PIN): str, - }, - ), - {CONF_PIN: self.pin}, + REAUTH_SCHEMA, {CONF_PIN: self.pin} ), description_placeholders={"name": self.mower_name}, errors=errors, diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index affa3715ab8208..967502f284d045 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -49,6 +49,22 @@ async def test_user_selection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + # mock connection error + with patch( + "homeassistant.components.husqvarna_automower_ble.config_flow.HusqvarnaAutomowerBleConfigFlow.probe_mower", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -488,9 +504,8 @@ async def test_exception_probe( result["flow_id"], user_input={CONF_PIN: "1234"}, ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} async def test_exception_connect( From d2b255ba92931e75f272853afe4e0abd7ee2270e Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Sun, 14 Sep 2025 19:33:43 +0200 Subject: [PATCH 08/20] nitpick: Add parameter types to `_test_selector` function signature (#152226) --- tests/helpers/test_selector.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 36fde1847711eb..9628e6f9bf850e 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1,5 +1,6 @@ """Test selectors.""" +from collections.abc import Callable, Iterable from enum import Enum from typing import Any @@ -42,7 +43,11 @@ def test_invalid_base_schema(schema) -> None: def _test_selector( - selector_type, schema, valid_selections, invalid_selections, converter=None + selector_type: str, + schema: dict, + valid_selections: Iterable[Any], + invalid_selections: Iterable[Any], + converter: Callable[[Any], Any] | None = None, ): """Help test a selector.""" From d877d6d93f302906374bf268cd1b0b72c78cf9f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 12:35:18 -0500 Subject: [PATCH 09/20] Fix Lutron Caseta shade stuttering and improve stop functionality (#152207) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/lutron_caseta/cover.py | 46 ++- .../components/lutron_caseta/entity.py | 6 +- tests/components/lutron_caseta/__init__.py | 28 ++ tests/components/lutron_caseta/test_cover.py | 287 +++++++++++++++++- 4 files changed, 363 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index e05fddb996fc46..ad1530bef5e2d4 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -1,5 +1,6 @@ """Support for Lutron Caseta shades.""" +from enum import Enum from typing import Any from homeassistant.components.cover import ( @@ -17,6 +18,14 @@ from .models import LutronCasetaConfigEntry +class ShadeMovementDirection(Enum): + """Enum for shade movement direction.""" + + OPENING = "opening" + CLOSING = "closing" + STOPPED = "stopped" + + class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity): """Representation of a Lutron shade with open/close functionality.""" @@ -27,6 +36,8 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) _attr_device_class = CoverDeviceClass.SHADE + _previous_position: int | None = None + _movement_direction: ShadeMovementDirection | None = None @property def is_closed(self) -> bool: @@ -38,19 +49,50 @@ def current_cover_position(self) -> int: """Return the current position of cover.""" return self._device["current_state"] + def _handle_bridge_update(self) -> None: + """Handle updated data from the bridge and track movement direction.""" + current_position = self.current_cover_position + + # Track movement direction based on position changes or endpoint status + if self._previous_position is not None: + if current_position > self._previous_position or current_position >= 100: + # Moving up or at fully open + self._movement_direction = ShadeMovementDirection.OPENING + elif current_position < self._previous_position or current_position <= 0: + # Moving down or at fully closed + self._movement_direction = ShadeMovementDirection.CLOSING + else: + # Stopped + self._movement_direction = ShadeMovementDirection.STOPPED + + self._previous_position = current_position + super()._handle_bridge_update() + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._smartbridge.lower_cover(self.device_id) + # Use set_value to avoid the stuttering issue + await self._smartbridge.set_value(self.device_id, 0) await self.async_update() self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" + # Send appropriate directional command before stop to ensure it works correctly + # Use tracked direction if moving, otherwise use position-based heuristic + if self._movement_direction == ShadeMovementDirection.OPENING or ( + self._movement_direction in (ShadeMovementDirection.STOPPED, None) + and self.current_cover_position >= 50 + ): + await self._smartbridge.raise_cover(self.device_id) + else: + await self._smartbridge.lower_cover(self.device_id) + await self._smartbridge.stop_cover(self.device_id) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._smartbridge.raise_cover(self.device_id) + # Use set_value to avoid the stuttering issue + await self._smartbridge.set_value(self.device_id, 100) await self.async_update() self.async_write_ha_state() diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index 5ab211ed87b756..8cae22f5042ed7 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -65,7 +65,11 @@ def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: async def async_added_to_hass(self) -> None: """Register callbacks.""" - self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) + self._smartbridge.add_subscriber(self.device_id, self._handle_bridge_update) + + def _handle_bridge_update(self) -> None: + """Handle updated data from the bridge.""" + self.async_write_ha_state() def _handle_none_serial(self, serial: str | int | None) -> str | int: """Handle None serial returned by RA3 and QSX processors.""" diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 5f146cd988ac31..03b78b1e44ec13 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -100,6 +100,7 @@ def __init__(self, can_connect=True, timeout_on_connect=False) -> None: self.scenes = self.get_scenes() self.devices = self.load_devices() self.buttons = self.load_buttons() + self._subscribers: dict[str, list] = {} async def connect(self): """Connect the mock bridge.""" @@ -110,10 +111,23 @@ async def connect(self): def add_subscriber(self, device_id: str, callback_): """Mock a listener to be notified of state changes.""" + if device_id not in self._subscribers: + self._subscribers[device_id] = [] + self._subscribers[device_id].append(callback_) def add_button_subscriber(self, button_id: str, callback_): """Mock a listener for button presses.""" + def call_subscribers(self, device_id: str): + """Notify subscribers of a device state change.""" + if device_id in self._subscribers: + for callback in self._subscribers[device_id]: + callback() + + def get_device_by_id(self, device_id: str): + """Get a device by its ID.""" + return self.devices.get(device_id) + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected @@ -309,6 +323,20 @@ def get_buttons(self): def tap_button(self, button_id: str): """Mock a button press and release message for the given button ID.""" + async def set_value(self, device_id: str, value: int) -> None: + """Mock setting a device value.""" + if device_id in self.devices: + self.devices[device_id]["current_state"] = value + + async def raise_cover(self, device_id: str) -> None: + """Mock raising a cover.""" + + async def lower_cover(self, device_id: str) -> None: + """Mock lowering a cover.""" + + async def stop_cover(self, device_id: str) -> None: + """Mock stopping a cover.""" + async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False diff --git a/tests/components/lutron_caseta/test_cover.py b/tests/components/lutron_caseta/test_cover.py index 5d45f185aeff54..43c7d986d1b483 100644 --- a/tests/components/lutron_caseta/test_cover.py +++ b/tests/components/lutron_caseta/test_cover.py @@ -1,18 +1,303 @@ """Tests for the Lutron Caseta integration.""" +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import MockBridge, async_setup_integration +@pytest.fixture +async def mock_bridge_with_cover_mocks(hass: HomeAssistant) -> MockBridge: + """Set up mock bridge with all cover methods mocked for testing.""" + instance = MockBridge() + + def factory(*args: Any, **kwargs: Any) -> MockBridge: + """Return the mock bridge instance.""" + return instance + + # Patch all cover methods on the instance with AsyncMocks + instance.set_value = AsyncMock() + instance.raise_cover = AsyncMock() + instance.lower_cover = AsyncMock() + instance.stop_cover = AsyncMock() + + await async_setup_integration(hass, factory) + await hass.async_block_till_done() + + return instance + + async def test_cover_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a light unique id.""" + """Test a cover unique ID.""" await async_setup_integration(hass, MockBridge) cover_entity_id = "cover.basement_bedroom_left_shade" # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID assert entity_registry.async_get(cover_entity_id).unique_id == "000004d2_802" + + +async def test_cover_open_close_using_set_value( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that open/close commands use set_value to avoid stuttering.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test opening the cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should use set_value(100) instead of raise_cover + mock_instance.set_value.assert_called_with("802", 100) + mock_instance.raise_cover.assert_not_called() + + mock_instance.set_value.reset_mock() + mock_instance.lower_cover.reset_mock() + + # Test closing the cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should use set_value(0) instead of lower_cover + mock_instance.set_value.assert_called_with("802", 0) + mock_instance.lower_cover.assert_not_called() + + +async def test_cover_stop_with_direction_tracking( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that stop command sends appropriate directional command first.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Simulate shade moving up (opening) + mock_instance.devices["802"]["current_state"] = 30 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + mock_instance.devices["802"]["current_state"] = 60 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop while opening + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should send raise_cover before stop_cover when opening + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + mock_instance.lower_cover.assert_not_called() + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Simulate shade moving down (closing) + mock_instance.devices["802"]["current_state"] = 40 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + mock_instance.devices["802"]["current_state"] = 20 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop while closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should send lower_cover before stop_cover when closing + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + mock_instance.raise_cover.assert_not_called() + + +async def test_cover_stop_at_endpoints( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command behavior when shade is at fully open or closed.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test stop at fully open (100) - should infer it was opening + mock_instance.devices["802"]["current_state"] = 100 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # At fully open, should send raise_cover before stop + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Test stop at fully closed (0) - should infer it was closing + mock_instance.devices["802"]["current_state"] = 0 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # At fully closed, should send lower_cover before stop + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_position_heuristic_fallback( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command uses position heuristic when movement direction is unknown.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Test stop at position < 50 with no movement + # Update the device data directly in the bridge's devices dict + mock_instance.devices["802"]["current_state"] = 30 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position < 50, should send lower_cover + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.lower_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Test stop at position >= 50 with no movement + mock_instance.devices["802"]["current_state"] = 70 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position >= 50, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_stopped_movement_detection( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test that movement direction is set to STOPPED when position doesn't change.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Set initial position + mock_instance.devices["802"]["current_state"] = 50 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Send same position again - should detect as stopped + mock_instance.devices["802"]["current_state"] = 50 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now stop command should use position heuristic (>= 50) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Position >= 50 with STOPPED direction, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + +async def test_cover_startup_with_shade_in_motion( + hass: HomeAssistant, mock_bridge_with_cover_mocks: MockBridge +) -> None: + """Test stop command when HA starts with shade already in motion.""" + mock_instance = mock_bridge_with_cover_mocks + cover_entity_id = "cover.basement_bedroom_left_shade" + + # Shade starts at position 50 (simulating HA startup with shade in motion) + # First stop without seeing movement should use position heuristic + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should have used position heuristic since we haven't seen movement yet + # Initial position is 100 from MockBridge, so >= 50, should send raise_cover + mock_instance.raise_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") + + mock_instance.raise_cover.reset_mock() + mock_instance.stop_cover.reset_mock() + + # Now simulate shade moving down (shade was actually in motion) + mock_instance.devices["802"]["current_state"] = 45 + mock_instance.call_subscribers("802") + await hass.async_block_till_done() + + # Now we've detected downward movement + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_entity_id}, + blocking=True, + ) + + # Should now correctly send lower_cover since we detected downward movement + mock_instance.lower_cover.assert_called_with("802") + mock_instance.stop_cover.assert_called_with("802") From 9bf467e6d182b51982eb5f158e70b973b314e01e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 12:39:44 -0500 Subject: [PATCH 10/20] Bump aioesphomeapi to 40.2.0 (#152272) --- 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 0e4a2c40d4657f..92f9266859bd3c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==40.1.0", + "aioesphomeapi==40.2.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index eec5061af9dd15..e8bc69e54bfd74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2325669acf90d6..42467e066dc552 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 # homeassistant.components.flo aioflo==2021.11.0 From 1fcc6df1fdf96193ff241f0b88e8964682085ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 14 Sep 2025 19:47:01 +0200 Subject: [PATCH 11/20] Add proper error handling for /actions endpoint for miele (#152290) --- homeassistant/components/miele/coordinator.py | 20 +++++++++++-- tests/components/miele/test_init.py | 28 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index 98f5c9f8b1cfd8..b3eb1185bd1af9 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -2,12 +2,13 @@ from __future__ import annotations -import asyncio.timeouts +import asyncio from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging +from aiohttp import ClientResponseError from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.config_entries import ConfigEntry @@ -66,7 +67,22 @@ async def _async_update_data(self) -> MieleCoordinatorData: self.devices = devices actions = {} for device_id in devices: - actions_json = await self.api.get_actions(device_id) + try: + actions_json = await self.api.get_actions(device_id) + except ClientResponseError as err: + _LOGGER.debug( + "Error fetching actions for device %s: Status: %s, Message: %s", + device_id, + err.status, + err.message, + ) + actions_json = {} + except TimeoutError: + _LOGGER.debug( + "Timeout fetching actions for device %s", + device_id, + ) + actions_json = {} actions[device_id] = MieleAction(actions_json) return MieleCoordinatorData(devices=devices, actions=actions) diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index cdf1a39b4213cf..0448096a1157ec 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -5,7 +5,7 @@ import time from unittest.mock import MagicMock -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from freezegun.api import FrozenDateTimeFactory from pymiele import OAUTH2_TOKEN import pytest @@ -210,3 +210,29 @@ async def test_setup_all_platforms( # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" assert hass.states.get("sensor.oven_temperature_2").state == "175.0" + + +@pytest.mark.parametrize( + "side_effect", + [ + ClientResponseError("test", "Test"), + TimeoutError, + ], + ids=[ + "ClientResponseError", + "TimeoutError", + ], +) +async def test_load_entry_with_action_error( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test load with error from actions endpoint.""" + mock_miele_client.get_actions.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + assert mock_miele_client.get_actions.call_count == 5 From 58d6549f1c9f801262a485dc676df9660fead9f2 Mon Sep 17 00:00:00 2001 From: GSzabados <35445496+GSzabados@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:49:59 +0200 Subject: [PATCH 12/20] Add display precision for rain rate and rain count (#151822) --- homeassistant/components/ecowitt/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 9ad00c69ab140b..167e1f70c2c5b4 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -152,24 +152,28 @@ native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, ), EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription( key="RAIN_COUNT_INCHES", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, ), EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription( key="RAIN_RATE_MM", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=1, ), EcoWittSensorTypes.RAIN_RATE_INCHES: SensorEntityDescription( key="RAIN_RATE_INCHES", native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=2, ), EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription( key="LIGHTNING_DISTANCE_KM", From 75d22191a0e310680fc89b07505e848e581f1b12 Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Sun, 14 Sep 2025 19:53:41 +0200 Subject: [PATCH 13/20] Fix local_todo capitalization to preserve user input (#150814) --- homeassistant/components/local_todo/todo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 30df24ea854ffd..97e0d316ff55f8 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -132,7 +132,7 @@ def __init__( self._store = store self._calendar = calendar self._calendar_lock = asyncio.Lock() - self._attr_name = name.capitalize() + self._attr_name = name self._attr_unique_id = unique_id def _new_todo_store(self) -> TodoStore: From c13002bdd5b78a52d552290ef77227494435fa7d Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Mon, 15 Sep 2025 02:00:15 +0800 Subject: [PATCH 14/20] Add supported device[Plug-Mini-EU] for switchbot cloud (#151019) --- .../components/switchbot_cloud/__init__.py | 1 + .../components/switchbot_cloud/sensor.py | 41 +- .../components/switchbot_cloud/switch.py | 7 +- .../snapshots/test_sensor.ambr | 385 +++--------------- .../components/switchbot_cloud/test_sensor.py | 65 ++- 5 files changed, 167 insertions(+), 332 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 536273df28f26f..7eaac3af8f9d5f 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -143,6 +143,7 @@ async def make_device_data( "Relay Switch 1PM", "Plug Mini (US)", "Plug Mini (JP)", + "Plug Mini (EU)", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index b2d375573efebf..5b5274909b35d3 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -1,5 +1,9 @@ """Platform for sensor integration.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + from switchbot_api import Device, SwitchBotAPI from homeassistant.components.sensor import ( @@ -14,6 +18,7 @@ PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfEnergy, UnitOfPower, UnitOfTemperature, ) @@ -32,9 +37,26 @@ SENSOR_TYPE_POWER = "power" SENSOR_TYPE_VOLTAGE = "voltage" SENSOR_TYPE_CURRENT = "electricCurrent" +SENSOR_TYPE_USED_ELECTRICITY = "usedElectricity" SENSOR_TYPE_LIGHTLEVEL = "lightLevel" +@dataclass(frozen=True, kw_only=True) +class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): + """Plug Mini Eu UsedElectricity Sensor EntityDescription.""" + + value_fn: Callable[[Any], Any] = lambda value: value + + +USED_ELECTRICITY_DESCRIPTION = SwitchbotCloudSensorEntityDescription( + key=SENSOR_TYPE_USED_ELECTRICITY, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + value_fn=lambda data: (data.get(SENSOR_TYPE_USED_ELECTRICITY) or 0) / 60000, +) + TEMPERATURE_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, @@ -129,6 +151,12 @@ VOLTAGE_DESCRIPTION, CURRENT_DESCRIPTION_IN_MA, ), + "Plug Mini (EU)": ( + POWER_DESCRIPTION, + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_MA, + USED_ELECTRICITY_DESCRIPTION, + ), "Hub 2": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, @@ -198,4 +226,15 @@ def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if not self.coordinator.data: return - self._attr_native_value = self.coordinator.data.get(self.entity_description.key) + + if isinstance( + self.entity_description, + SwitchbotCloudSensorEntityDescription, + ): + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + else: + self._attr_native_value = self.coordinator.data.get( + self.entity_description.key + ) diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index ebe20620d3e9b2..df21ae12adc3b8 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -83,13 +83,10 @@ def _async_make_entity( """Make a SwitchBotCloudSwitch or SwitchBotCloudRemoteSwitch.""" if isinstance(device, Remote): return SwitchBotCloudRemoteSwitch(api, device, coordinator) + if device.device_type in ["Relay Switch 1PM", "Relay Switch 1", "Plug Mini (EU)"]: + return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) if "Plug" in device.device_type: return SwitchBotCloudPlugSwitch(api, device, coordinator) - if device.device_type in [ - "Relay Switch 1PM", - "Relay Switch 1", - ]: - return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) if "Bot" in device.device_type: return SwitchBotCloudSwitch(api, device, coordinator) raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 85b2fcc2dcf474..90939eb50e4ab2 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_meter[device_info0-0][sensor.meter_1_battery-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -36,7 +36,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_battery-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -52,7 +52,7 @@ 'state': 'unknown', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_humidity-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +89,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_humidity-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -105,7 +105,7 @@ 'state': 'unknown', }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_temperature-entry] +# name: test_no_coordinator_data[Meter][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -145,7 +145,7 @@ 'unit_of_measurement': , }) # --- -# name: test_meter[device_info0-0][sensor.meter_1_temperature-state] +# name: test_no_coordinator_data[Meter][sensor.meter_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -161,7 +161,7 @@ 'state': 'unknown', }) # --- -# name: test_meter[device_info1-1][sensor.meter_1_battery-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -176,113 +176,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meter_1_battery', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_battery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'meter-1 Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.meter_1_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_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.meter_1_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'meter-1 Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.meter_1_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '32', - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_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.meter_1_temperature', + 'entity_id': 'sensor.meter_1_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -292,201 +186,44 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 0, }), }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'meter-id-1_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_meter[device_info1-1][sensor.meter_1_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'meter-1 Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meter_1_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.8', - }) -# --- -# name: test_meter[device_info2-2][sensor.contact_sensor_name_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': None, - 'entity_id': 'sensor.contact_sensor_name_battery', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Current', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'contact-sensor-id_battery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info2-2][sensor.contact_sensor_name_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'contact-sensor-name Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.contact_sensor_name_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_meter[device_info3-3][sensor.hub_3_name_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.hub_3_name_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'hub3-id_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_meter[device_info3-3][sensor.hub_3_name_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Hub-3-name Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.hub_3_name_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '55', + 'unique_id': 'meter-id-1_electricCurrent', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info3-3][sensor.hub_3_name_light_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.hub_3_name_light_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': 'Light level', - 'platform': 'switchbot_cloud', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light_level', - 'unique_id': 'hub3-id_lightLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_meter[device_info3-3][sensor.hub_3_name_light_level-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Hub-3-name Light level', + 'device_class': 'current', + 'friendly_name': 'meter-1 Current', 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.hub_3_name_light_level', + 'entity_id': 'sensor.meter_1_current', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10', + 'state': 'unknown', }) # --- -# name: test_meter[device_info3-3][sensor.hub_3_name_temperature-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -495,7 +232,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hub_3_name_temperature', + 'entity_id': 'sensor.meter_1_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -505,38 +242,38 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 2, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Temperature', + 'original_name': 'Energy', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'hub3-id_temperature', - 'unit_of_measurement': , + 'unique_id': 'meter-id-1_usedElectricity', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info3-3][sensor.hub_3_name_temperature-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Hub-3-name Temperature', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'energy', + 'friendly_name': 'meter-1 Energy', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.hub_3_name_temperature', + 'entity_id': 'sensor.meter_1_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26.5', + 'state': 'unknown', }) # --- -# name: test_meter[device_info4-4][sensor.motion_sensor_name_battery-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -551,7 +288,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.motion_sensor_name_battery', + 'entity_id': 'sensor.meter_1_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -560,36 +297,39 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Power', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'motion-sensor-id_battery', - 'unit_of_measurement': '%', + 'unique_id': 'meter-id-1_power', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info4-4][sensor.motion_sensor_name_battery-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'motion-sensor-name Battery', + 'device_class': 'power', + 'friendly_name': 'meter-1 Power', 'state_class': , - 'unit_of_measurement': '%', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.motion_sensor_name_battery', + 'entity_id': 'sensor.meter_1_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '20', + 'state': 'unknown', }) # --- -# name: test_meter[device_info5-5][sensor.water_detector_name_battery-entry] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -604,7 +344,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_detector_name_battery', + 'entity_id': 'sensor.meter_1_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -613,32 +353,35 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Voltage', 'platform': 'switchbot_cloud', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'water-detector-id_battery', - 'unit_of_measurement': '%', + 'unique_id': 'meter-id-1_voltage', + 'unit_of_measurement': , }) # --- -# name: test_meter[device_info5-5][sensor.water_detector_name_battery-state] +# name: test_no_coordinator_data[Plug Mini (EU)][sensor.meter_1_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'water-detector-name Battery', + 'device_class': 'voltage', + 'friendly_name': 'meter-1 Voltage', 'state_class': , - 'unit_of_measurement': '%', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_detector_name_battery', + 'entity_id': 'sensor.meter_1_voltage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '90', + 'state': 'unknown', }) # --- diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 07a7521686bd0c..c132c5d8ca47a7 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -6,7 +6,7 @@ from switchbot_api import Device from syrupy.assertion import SnapshotAssertion -from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,7 +20,7 @@ configure_integration, ) -from tests.common import async_load_json_array_fixture, snapshot_platform +from tests.common import snapshot_platform @pytest.mark.parametrize( @@ -45,10 +45,65 @@ async def test_meter( ) -> None: """Test all sensors.""" - mock_list_devices.return_value = [device_info] - json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) - mock_get_status.return_value = json_data[index] +async def test_plug_mini_eu( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test plug_mini_eu Used Electricity.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="Plug-id-1", + deviceName="Plug-1", + deviceType="Plug Mini (EU)", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + { + "usedElectricity": 3255, + "deviceId": "94A99054855E", + "deviceType": "Plug Mini (EU)", + }, + ] + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "device_model", + [ + "Meter", + "Plug Mini (EU)", + ], +) +async def test_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_model, +) -> None: + """Test meter sensors are unknown without coordinator data.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="meter-id-1", + deviceName="meter-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): entry = await configure_integration(hass) From 5ba580bc25f7a7ce9bd4e2b02da35610257ec0a4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 14 Sep 2025 20:35:47 +0200 Subject: [PATCH 15/20] Capitalize "Supervisor" in two issues strings of `hassio` (#152303) Co-authored-by: Franck Nijhof --- homeassistant/components/hassio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 393fe480057385..96855097b8b3f2 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -37,14 +37,14 @@ }, "issue_addon_detached_addon_missing": { "title": "Missing repository for an installed add-on", - "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." + "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." }, "issue_addon_detached_addon_removed": { "title": "Installed add-on has been removed from repository", "fix_flow": { "step": { "addon_execute_remove": { - "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." } }, "abort": { From dd0f6a702b80b4b933a0d3ffe8f6221d0192db2f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 14 Sep 2025 20:36:05 +0200 Subject: [PATCH 16/20] Small fixes of user-facing strings in `esphome` (#152311) --- homeassistant/components/esphome/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 77cd7ccb35adc9..c14bc1e67074cb 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -12,7 +12,7 @@ "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_ip": "Missing IP address in MQTT properties.", - "mqtt_missing_payload": "Missing MQTT Payload.", + "mqtt_missing_payload": "Missing MQTT payload.", "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).", @@ -91,7 +91,7 @@ "subscribe_logs": "Subscribe to logs from the device." }, "data_description": { - "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.", + "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.", "subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } @@ -154,7 +154,7 @@ "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." }, "api_password_deprecated": { - "title": "API Password deprecated on {name}", + "title": "API password deprecated on {name}", "description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue." }, "service_calls_not_allowed": { @@ -193,10 +193,10 @@ "message": "Error communicating with the device {device_name}: {error}" }, "error_compiling": { - "message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information." + "message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information." }, "error_uploading": { - "message": "Error during OTA (Over-The-Air) of {configuration}; Try again in ESPHome dashboard for more information." + "message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information." }, "ota_in_progress": { "message": "An OTA (Over-The-Air) update is already in progress for {configuration}." From 2f4c69bbd5734e411ecda2103b1fa0962fc97717 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 14 Sep 2025 22:05:05 +0200 Subject: [PATCH 17/20] Simplify description of `direction_command_topic` in `mqtt` (#150617) --- 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 860336735f43fe..2075345e038e1b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -669,7 +669,7 @@ "direction_value_template": "Direction value template" }, "data_description": { - "direction_command_topic": "The MQTT topic to publish commands to change the fan direction payload, either `forward` or `reverse`. Use the direction command template to customize the payload. [Learn more.]({url}#direction_command_topic)", + "direction_command_topic": "The MQTT topic to publish commands to change the fan direction. The payload will be either `forward` or `reverse` and can be customized using the direction command template. [Learn more.]({url}#direction_command_topic)", "direction_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the direction command topic. The template variable `value` will be either `forward` or `reverse`.", "direction_state_topic": "The MQTT topic subscribed to receive fan direction state. Accepted state payloads are `forward` or `reverse`. [Learn more.]({url}#direction_state_topic)", "direction_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract fan direction state value. The template should return either `forward` or `reverse`. When the template returns an empty string, the direction will be ignored." From e40ecdfb000934d12f86fe753eeca04fe2e2c228 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 14 Sep 2025 23:43:37 +0300 Subject: [PATCH 18/20] Remove Shelly empty sub-devices (#152251) --- homeassistant/components/shelly/__init__.py | 3 +++ homeassistant/components/shelly/utils.py | 24 +++++++++++++++++ tests/components/shelly/__init__.py | 5 ++-- tests/components/shelly/test_init.py | 29 ++++++++++++++++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5582ab488df5e2..d12236177b8103 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -67,6 +67,7 @@ get_http_port, get_rpc_scripts_event_types, get_ws_context, + remove_empty_sub_devices, remove_stale_blu_trv_devices, ) @@ -223,6 +224,7 @@ async def _async_setup_block_entry( await hass.config_entries.async_forward_entry_setups( entry, runtime_data.platforms ) + remove_empty_sub_devices(hass, entry) elif ( sleep_period is None or device_entry is None @@ -334,6 +336,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) hass, entry, ) + remove_empty_sub_devices(hass, entry) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index c814c987621ba5..075040cb92996a 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -884,3 +884,27 @@ def remove_stale_blu_trv_devices( LOGGER.debug("Removing stale BLU TRV device %s", device.name) dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) + + +@callback +def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove sub devices without entities.""" + dev_reg = dr.async_get(hass) + entity_reg = er.async_get(hass) + + devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + + for device in devices: + if not device.via_device_id: + # Device is not a sub-device, skip + continue + + if er.async_entries_for_device(entity_reg, device.id, True): + # Device has entities, skip + continue + + if any(identifier[0] == DOMAIN for identifier in device.identifiers): + LOGGER.debug("Removing empty sub-device %s", device.name) + dev_reg.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index b1c3d1487b4ab0..69a7e266dcab67 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -26,7 +26,6 @@ CONNECTION_NETWORK_MAC, DeviceEntry, DeviceRegistry, - format_mac, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -152,7 +151,7 @@ def register_device( """Register Shelly device.""" return device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, + connections={(CONNECTION_NETWORK_MAC, MOCK_MAC)}, ) @@ -163,7 +162,7 @@ def register_sub_device( return device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, f"{MOCK_MAC}-{unique_id}")}, - via_device=(DOMAIN, format_mac(MOCK_MAC)), + via_device=(DOMAIN, MOCK_MAC), ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 703df09bb6156d..8457354351f617 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -41,7 +41,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component -from . import MOCK_MAC, init_integration, mutate_rpc_device_status +from . import MOCK_MAC, init_integration, mutate_rpc_device_status, register_sub_device async def test_custom_coap_port( @@ -653,3 +653,30 @@ async def test_blu_trv_stale_device_removal( assert hass.states.get(trv_201_entity_id) is None assert device_registry.async_get(trv_201_entry.device_id) is None + + +async def test_empty_device_removal( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Test removal of empty devices due to device configuration changes.""" + config_entry = await init_integration(hass, 3) + + # create empty sub-device + sub_device_entry = register_sub_device( + device_registry, + config_entry, + "boolean:201-boolean", + ) + + # verify that the sub-device is created + assert device_registry.async_get(sub_device_entry.id) is not None + + # device config change triggers a reload + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # verify that the empty sub-device is removed + assert device_registry.async_get(sub_device_entry.id) is None From f5535db24ca2bba8744acd1d4aad244227ec4caf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 14 Sep 2025 16:44:48 -0400 Subject: [PATCH 19/20] Automatically generate entity platform enum (#152193) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/conversation/icons.json | 5 ++ .../components/conversation/manifest.json | 2 +- homeassistant/const.py | 51 ++---------------- homeassistant/generated/entity_platforms.py | 54 +++++++++++++++++++ script/hassfest/__main__.py | 2 + script/hassfest/integration_info.py | 42 +++++++++++++++ tests/components/knx/test_config_store.py | 2 +- 7 files changed, 108 insertions(+), 50 deletions(-) create mode 100644 homeassistant/generated/entity_platforms.py create mode 100644 script/hassfest/integration_info.py diff --git a/homeassistant/components/conversation/icons.json b/homeassistant/components/conversation/icons.json index 658783f9ae26f9..55bacf838a8663 100644 --- a/homeassistant/components/conversation/icons.json +++ b/homeassistant/components/conversation/icons.json @@ -1,4 +1,9 @@ { + "entity_component": { + "_": { + "default": "mdi:forum-outline" + } + }, "services": { "process": { "service": "mdi:message-processing" diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 36db24ce5453d3..8101f8c8b5f6f6 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"], "dependencies": ["http", "intent"], "documentation": "https://www.home-assistant.io/integrations/conversation", - "integration_type": "system", + "integration_type": "entity", "quality_scale": "internal", "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index 913ef5e177f89a..3c9de2af87cc53 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,6 +6,7 @@ from functools import partial from typing import TYPE_CHECKING, Final +from .generated.entity_platforms import EntityPlatforms from .helpers.deprecation import ( DeprecatedConstant, DeprecatedConstantEnum, @@ -36,54 +37,8 @@ # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" - -class Platform(StrEnum): - """Available entity platforms.""" - - AI_TASK = "ai_task" - AIR_QUALITY = "air_quality" - ALARM_CONTROL_PANEL = "alarm_control_panel" - ASSIST_SATELLITE = "assist_satellite" - BINARY_SENSOR = "binary_sensor" - BUTTON = "button" - CALENDAR = "calendar" - CAMERA = "camera" - CLIMATE = "climate" - CONVERSATION = "conversation" - COVER = "cover" - DATE = "date" - DATETIME = "datetime" - DEVICE_TRACKER = "device_tracker" - EVENT = "event" - FAN = "fan" - GEO_LOCATION = "geo_location" - HUMIDIFIER = "humidifier" - IMAGE = "image" - IMAGE_PROCESSING = "image_processing" - LAWN_MOWER = "lawn_mower" - LIGHT = "light" - LOCK = "lock" - MEDIA_PLAYER = "media_player" - NOTIFY = "notify" - NUMBER = "number" - REMOTE = "remote" - SCENE = "scene" - SELECT = "select" - SENSOR = "sensor" - SIREN = "siren" - STT = "stt" - SWITCH = "switch" - TEXT = "text" - TIME = "time" - TODO = "todo" - TTS = "tts" - UPDATE = "update" - VACUUM = "vacuum" - VALVE = "valve" - WAKE_WORD = "wake_word" - WATER_HEATER = "water_heater" - WEATHER = "weather" - +# Type alias to avoid 1000 MyPy errors +Platform = EntityPlatforms BASE_PLATFORMS: Final = {platform.value for platform in Platform} diff --git a/homeassistant/generated/entity_platforms.py b/homeassistant/generated/entity_platforms.py new file mode 100644 index 00000000000000..7010ffc9be73c1 --- /dev/null +++ b/homeassistant/generated/entity_platforms.py @@ -0,0 +1,54 @@ +"""Automatically generated file. + +To update, run python3 -m script.hassfest +""" + +from enum import StrEnum + + +class EntityPlatforms(StrEnum): + """Available entity platforms.""" + + AI_TASK = "ai_task" + AIR_QUALITY = "air_quality" + ALARM_CONTROL_PANEL = "alarm_control_panel" + ASSIST_SATELLITE = "assist_satellite" + BINARY_SENSOR = "binary_sensor" + BUTTON = "button" + CALENDAR = "calendar" + CAMERA = "camera" + CLIMATE = "climate" + CONVERSATION = "conversation" + COVER = "cover" + DATE = "date" + DATETIME = "datetime" + DEVICE_TRACKER = "device_tracker" + EVENT = "event" + FAN = "fan" + GEO_LOCATION = "geo_location" + HUMIDIFIER = "humidifier" + IMAGE = "image" + IMAGE_PROCESSING = "image_processing" + LAWN_MOWER = "lawn_mower" + LIGHT = "light" + LOCK = "lock" + MEDIA_PLAYER = "media_player" + NOTIFY = "notify" + NUMBER = "number" + REMOTE = "remote" + SCENE = "scene" + SELECT = "select" + SENSOR = "sensor" + SIREN = "siren" + STT = "stt" + SWITCH = "switch" + TEXT = "text" + TIME = "time" + TODO = "todo" + TTS = "tts" + UPDATE = "update" + VACUUM = "vacuum" + VALVE = "valve" + WAKE_WORD = "wake_word" + WATER_HEATER = "water_heater" + WEATHER = "weather" diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index dfa99c6bc75146..43a6cc7678b832 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -19,6 +19,7 @@ dhcp, docker, icons, + integration_info, json, manifest, metadata, @@ -44,6 +45,7 @@ dependencies, dhcp, icons, + integration_info, json, manifest, mqtt, diff --git a/script/hassfest/integration_info.py b/script/hassfest/integration_info.py new file mode 100644 index 00000000000000..8747e256be7e84 --- /dev/null +++ b/script/hassfest/integration_info.py @@ -0,0 +1,42 @@ +"""Write integration constants.""" + +from __future__ import annotations + +from .model import Config, Integration +from .serializer import format_python + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate integrations file.""" + + if config.specific_integrations: + return + + int_type = "entity" + + domains = [ + integration.domain + for integration in integrations.values() + if integration.manifest.get("integration_type") == int_type + # Tag is type "entity" but has no entity platform + and integration.domain != "tag" + ] + + code = [ + "from enum import StrEnum", + "class EntityPlatforms(StrEnum):", + f' """Available {int_type} platforms."""', + ] + code.extend([f' {domain.upper()} = "{domain}"' for domain in sorted(domains)]) + + config.cache[f"integrations_{int_type}"] = format_python( + "\n".join(code), generator="script.hassfest" + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate integration file.""" + int_type = "entity" + filename = "entity_platforms" + platform_path = config.root / f"homeassistant/generated/{filename}.py" + platform_path.write_text(config.cache[f"integrations_{int_type}"]) diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index bb6af6408b8c78..8f11888d1f2453 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -88,7 +88,7 @@ async def test_create_entity_error( assert res["success"], res assert not res["result"]["success"] assert res["result"]["errors"][0]["path"] == ["platform"] - assert res["result"]["error_base"].startswith("expected Platform or one of") + assert res["result"]["error_base"].startswith("expected EntityPlatforms or one of") # create entity with unsupported platform await client.send_json_auto_id( From 1483c9488f9bd8c9efde51b7f6821683cc7df543 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 14 Sep 2025 14:07:31 -0700 Subject: [PATCH 20/20] Update authorization server to prefer absolute urls (#152313) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/auth/login_flow.py | 19 +++++++-- tests/components/auth/test_login_flow.py | 46 ++++++++++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 69ae3eb65bd4b4..675c2d10fea37c 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -92,7 +92,11 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.network import is_cloud_connection +from homeassistant.helpers.network import ( + NoURLAvailableError, + get_url, + is_cloud_connection, +) from homeassistant.util.network import is_local from . import indieauth @@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Return the well known OAuth2 authorization info.""" + hass = request.app[KEY_HASS] + # Some applications require absolute urls, so we prefer using the + # current requests url if possible, with fallback to a relative url. + try: + url_prefix = get_url(hass, require_current_request=True) + except NoURLAvailableError: + url_prefix = "" return self.json( { - "authorization_endpoint": "/auth/authorize", - "token_endpoint": "/auth/token", - "revocation_endpoint": "/auth/revoke", + "authorization_endpoint": f"{url_prefix}/auth/authorize", + "token_endpoint": f"{url_prefix}/auth/token", + "revocation_endpoint": f"{url_prefix}/auth/revoke", "response_types_supported": ["code"], "service_documentation": ( "https://developers.home-assistant.io/docs/auth_api" diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index af9a2cf62f1a8d..f7d20687c926f6 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -7,6 +7,7 @@ import pytest from homeassistant.core import HomeAssistant +from homeassistant.core_config import async_process_ha_core_config from . import BASE_CONFIG, async_setup_auth @@ -371,19 +372,54 @@ async def test_login_exist_user_ip_changes( assert response == {"message": "IP address changed"} +@pytest.mark.usefixtures("current_request_with_host") # Has example.com host +@pytest.mark.parametrize( + ("config", "expected_url_prefix"), + [ + ( + { + "internal_url": "http://192.168.1.100:8123", + # Current request matches external url + "external_url": "https://example.com", + }, + "https://example.com", + ), + ( + { + # Current request matches internal url + "internal_url": "https://example.com", + "external_url": "https://other.com", + }, + "https://example.com", + ), + ( + { + # Current request does not match either url + "internal_url": "https://other.com", + "external_url": "https://again.com", + }, + "", + ), + ], + ids=["external_url", "internal_url", "no_match"], +) async def test_well_known_auth_info( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + config: dict[str, str], + expected_url_prefix: str, ) -> None: - """Test logging in and the ip address changes results in an rejection.""" + """Test the well-known OAuth authorization server endpoint with different URL configurations.""" + await async_process_ha_core_config(hass, config) client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.get( "/.well-known/oauth-authorization-server", ) assert resp.status == 200 assert await resp.json() == { - "authorization_endpoint": "/auth/authorize", - "token_endpoint": "/auth/token", - "revocation_endpoint": "/auth/revoke", + "authorization_endpoint": f"{expected_url_prefix}/auth/authorize", + "token_endpoint": f"{expected_url_prefix}/auth/token", + "revocation_endpoint": f"{expected_url_prefix}/auth/revoke", "response_types_supported": ["code"], "service_documentation": "https://developers.home-assistant.io/docs/auth_api", }