From 17b12d29af09c612316555546b9d50dddf3bee8c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 Sep 2025 18:57:19 +0000 Subject: [PATCH 001/103] Bump version to 2025.10.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3b9702b972ee..edd1a04c1973 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 366482ec7fc3..00849157f9f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0.dev0" +version = "2025.10.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 05820a49d0200ebd3753dcfa38ad5164c4c65751 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 25 Sep 2025 03:39:52 -0400 Subject: [PATCH 002/103] Fix logical error when user has no Roborock maps (#152752) --- .../components/roborock/coordinator.py | 10 ++----- tests/components/roborock/test_coordinator.py | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 39966273908d..e36208dfee11 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -351,13 +351,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): def _set_current_map(self) -> None: if ( self.roborock_device_info.props.status is not None - and self.roborock_device_info.props.status.map_status is not None + and self.roborock_device_info.props.status.current_map is not None ): - # The map status represents the map flag as flag * 4 + 3 - - # so we have to invert that in order to get the map flag that we can use to set the current map. - self.current_map = ( - self.roborock_device_info.props.status.map_status - 3 - ) // 4 + self.current_map = self.roborock_device_info.props.status.current_map async def set_current_map_rooms(self) -> None: """Fetch all of the rooms for the current map and set on RoborockMapInfo.""" @@ -440,7 +436,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If either of these fail, we don't care, and we want to continue. await asyncio.gather(*tasks, return_exceptions=True) - if len(self.maps) != 1: + if len(self.maps) > 1: # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 22efddf5817f..7da19e9418cb 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from unittest.mock import patch import pytest +from roborock import MultiMapsList from roborock.exceptions import RoborockException from vacuum_map_parser_base.config.color import SupportedColor @@ -135,3 +136,30 @@ async def test_dynamic_local_scan_interval( async_fire_time_changed(hass, dt_util.utcnow() + interval) assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" + + +async def test_no_maps( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, +) -> None: + """Test that a device with no maps is handled correctly.""" + prop = copy.deepcopy(PROP) + prop.status.map_status = 252 + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_multi_maps_list", + return_value=MultiMapsList( + max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] + ), + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map" + ) as load_map, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert load_map.call_count == 0 From 21a5aaf35c8a4141c3a2836b916efac5cbfd0196 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 25 Sep 2025 09:45:50 +0200 Subject: [PATCH 003/103] Update IQS to platinum for Alexa Devices (#152905) --- homeassistant/components/alexa_devices/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 824f735b184a..437c11e0a4c1 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aioamazondevices==6.0.0"] } From 274f6eb54a46bf4a1cc3a0cc9d499f4a30356271 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Sep 2025 23:03:31 +0200 Subject: [PATCH 004/103] Update IQS to platinum for Comelit SimpleHome (#152906) --- homeassistant/components/comelit/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 44101f0fd06c..4e8fee1bba63 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiocomelit==0.12.3"] } From b4417a76d58a951ee422b655d98cb6457b4f5621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 21:27:40 -0500 Subject: [PATCH 005/103] Fix ESPHome reauth not being triggered on incorrect password (#152911) --- .../components/esphome/config_flow.py | 10 ++++- homeassistant/components/esphome/manager.py | 10 +++++ tests/components/esphome/test_config_flow.py | 38 ++++++++++++++++++- tests/components/esphome/test_dashboard.py | 4 +- tests/components/esphome/test_manager.py | 31 +++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index e1aedb90b3cb..6197716f617e 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -57,6 +57,7 @@ from .manager import async_replace_device ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" +ERROR_INVALID_PASSWORD_AUTH = "invalid_auth" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" @@ -137,6 +138,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._password = "" return await self._async_authenticate_or_add() + if error == ERROR_INVALID_PASSWORD_AUTH or ( + error is None and self._device_info and self._device_info.uses_password + ): + return await self.async_step_authenticate() + if error is None and entry_data.get(CONF_NOISE_PSK): # Device was configured with encryption but now connects without it. # Check if it's the same device before offering to remove encryption. @@ -690,13 +696,15 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): cli = APIClient( host, port or DEFAULT_PORT, - "", + self._password or "", zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, ) try: await cli.connect() self._device_info = await cli.device_info() + except InvalidAuthAPIError: + return ERROR_INVALID_PASSWORD_AUTH except RequiresEncryptionAPIError: return ERROR_REQUIRES_ENCRYPTION_KEY except InvalidEncryptionKeyAPIError as ex: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a14eb3f5a164..c3db4c3e9e8e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -372,6 +372,9 @@ class ESPHomeManager: """Subscribe to states and list entities on successful API login.""" try: await self._on_connect() + except InvalidAuthAPIError as err: + _LOGGER.warning("Authentication failed for %s: %s", self.host, err) + await self._start_reauth_and_disconnect() except APIConnectionError as err: _LOGGER.warning( "Error getting setting up connection for %s: %s", self.host, err @@ -641,7 +644,14 @@ class ESPHomeManager: if self.reconnect_logic: await self.reconnect_logic.stop() return + await self._start_reauth_and_disconnect() + + async def _start_reauth_and_disconnect(self) -> None: + """Start reauth flow and stop reconnection attempts.""" self.entry.async_start_reauth(self.hass) + await self.cli.disconnect() + if self.reconnect_logic: + await self.reconnect_logic.stop() async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f3bb1c77e408..27d585bea6f3 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1184,6 +1184,42 @@ async def test_reauth_attempt_to_change_mac_aborts( } +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_password_changed( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth when password has changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "old_password"}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.connect.side_effect = InvalidAuthAPIError("Invalid password") + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == { + "name": "Mock Title", + } + + mock_client.connect.side_effect = None + mock_client.connect.return_value = None + mock_client.device_info.return_value = DeviceInfo( + uses_password=True, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new_password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_PASSWORD] == "new_password" + + @pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, @@ -1239,7 +1275,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 340a10a86d16..36542b2bd098 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import patch -from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError +from aioesphomeapi import APIClient, DeviceInfo, InvalidEncryptionKeyAPIError import pytest from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard @@ -194,7 +194,7 @@ async def test_new_dashboard_fix_reauth( ) -> None: """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 86dfb6e9ea3f..319d70b4e426 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1455,6 +1455,37 @@ async def test_no_reauth_wrong_mac( ) +async def test_auth_error_during_on_connect_triggers_reauth( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test that InvalidAuthAPIError during on_connect triggers reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="11:22:33:44:55:aa", + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "wrong_password", + }, + ) + entry.add_to_hass(hass) + + mock_client.device_info_and_list_entities = AsyncMock( + side_effect=InvalidAuthAPIError("Invalid password!") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert mock_client.disconnect.call_count >= 1 + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, From d8b24ccccdad9782988e632aedc1eace970f8dd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 16:00:45 -0500 Subject: [PATCH 006/103] Bump aioesphomeapi to 41.9.3 to fix segfault (#152912) --- 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 4835ead20494..39ff0bc184c8 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==41.9.0", + "aioesphomeapi==41.9.3", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1f16fc78a345..7d3421674a52 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==41.9.0 +aioesphomeapi==41.9.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48ad0d5f077c..bec1bb02bf22 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==41.9.0 +aioesphomeapi==41.9.3 # homeassistant.components.flo aioflo==2021.11.0 From d9521ac2a04e081bd1a0817a15f094ada27b14a6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 Sep 2025 23:12:20 +0200 Subject: [PATCH 007/103] Bump to home-assistant/wheels@2025.09.0 (#152920) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4aa9724f5152..984d1e91c8a2 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -160,7 +160,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@2025.09.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -221,7 +221,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@2025.09.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From e9bde225fe3033096a797983512d5521e2fadec9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 19:16:48 -0500 Subject: [PATCH 008/103] Bump aioesphomeapi to 41.9.4 (#152923) --- 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 39ff0bc184c8..674ced0bf9c6 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==41.9.3", + "aioesphomeapi==41.9.4", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 7d3421674a52..6feb2fe6840c 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==41.9.3 +aioesphomeapi==41.9.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bec1bb02bf22..249f309297cb 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==41.9.3 +aioesphomeapi==41.9.4 # homeassistant.components.flo aioflo==2021.11.0 From a255585ab6dedd950b7d181d8e35593f0a1dff1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 22:15:29 -0400 Subject: [PATCH 009/103] Remove some more domains from common controls (#152927) --- homeassistant/components/usage_prediction/common_control.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 995d3c5a559c..9d86b5f27666 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -38,13 +38,11 @@ ALLOWED_DOMAINS = { Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, - Platform.CALENDAR, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, - Platform.IMAGE, Platform.LAWN_MOWER, Platform.LIGHT, Platform.LOCK, @@ -55,7 +53,6 @@ ALLOWED_DOMAINS = { Platform.SENSOR, Platform.SIREN, Platform.SWITCH, - Platform.TEXT, Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, From 79599e12843810b3f3a37356371082d94a16abbb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Sep 2025 04:18:06 +0200 Subject: [PATCH 010/103] Add block Spook < 4.0.0 as breaking Home Assistant (#152930) --- homeassistant/loader.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 07c4a9345737..fc10223a182f 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -121,6 +121,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { "variable": BlockedIntegration( AwesomeVersion("3.4.4"), "prevents recorder from working" ), + # Added in 2025.10.0 because of + # https://github.com/frenck/spook/issues/1066 + "spook": BlockedIntegration(AwesomeVersion("4.0.0"), "breaks the template engine"), } DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( From be6f056f305561d25fe4865ad2cff276cde70a8f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 23:09:54 -0400 Subject: [PATCH 011/103] Prevent common control calling async methods from thread (#152931) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../usage_prediction/common_control.py | 112 +++++++++--------- .../usage_prediction/test_common_control.py | 61 +++++++--- 2 files changed, 101 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 9d86b5f27666..69f2164fc763 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections import Counter -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime, timedelta from functools import cache import logging from typing import Any, Literal, cast from sqlalchemy import select +from sqlalchemy.engine.row import Row from sqlalchemy.orm import Session from homeassistant.components.recorder import get_instance @@ -90,61 +91,32 @@ async def async_predict_common_control( Args: hass: Home Assistant instance user_id: User ID to filter events by. - - Returns: - Dictionary with time categories as keys and lists of most common entity IDs as values """ # Get the recorder instance to ensure it's ready recorder = get_instance(hass) ent_reg = er.async_get(hass) # Execute the database operation in the recorder's executor - return await recorder.async_add_executor_job( + data = await recorder.async_add_executor_job( _fetch_with_session, hass, _fetch_and_process_data, ent_reg, user_id ) - - -def _fetch_and_process_data( - session: Session, ent_reg: er.EntityRegistry, user_id: str -) -> EntityUsagePredictions: - """Fetch and process service call events from the database.""" # Prepare a dictionary to track results results: dict[str, Counter[str]] = { time_cat: Counter() for time_cat in TIME_CATEGORIES } + allowed_entities = set(hass.states.async_entity_ids(ALLOWED_DOMAINS)) + hidden_entities: set[str] = set() + # Keep track of contexts that we processed so that we will only process # the first service call in a context, and not subsequent calls. context_processed: set[bytes] = set() - thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() - user_id_bytes = uuid_hex_to_bytes_or_none(user_id) - if not user_id_bytes: - raise ValueError("Invalid user_id format") - - # Build the main query for events with their data - query = ( - select( - Events.context_id_bin, - Events.time_fired_ts, - EventData.shared_data, - ) - .select_from(Events) - .outerjoin(EventData, Events.data_id == EventData.data_id) - .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) - .where(Events.time_fired_ts >= thirty_days_ago_ts) - .where(Events.context_user_id_bin == user_id_bytes) - .where(EventTypes.event_type == "call_service") - .order_by(Events.time_fired_ts) - ) - # Execute the query context_id: bytes time_fired_ts: float shared_data: str | None local_time_zone = dt_util.get_default_time_zone() - for context_id, time_fired_ts, shared_data in ( - session.connection().execute(query).all() - ): + for context_id, time_fired_ts, shared_data in data: # Skip if we have already processed an event that was part of this context if context_id in context_processed: continue @@ -153,7 +125,7 @@ def _fetch_and_process_data( context_processed.add(context_id) # Parse the event data - if not shared_data: + if not time_fired_ts or not shared_data: continue try: @@ -187,27 +159,26 @@ def _fetch_and_process_data( if not isinstance(entity_ids, list): entity_ids = [entity_ids] - # Filter out entity IDs that are not in allowed domains - entity_ids = [ - entity_id - for entity_id in entity_ids - if entity_id.split(".")[0] in ALLOWED_DOMAINS - and ((entry := ent_reg.async_get(entity_id)) is None or not entry.hidden) - ] + # Convert to local time for time category determination + period = time_category( + datetime.fromtimestamp(time_fired_ts, local_time_zone).hour + ) + period_results = results[period] - if not entity_ids: - continue + # Count entity usage + for entity_id in entity_ids: + if entity_id not in allowed_entities or entity_id in hidden_entities: + continue - # Convert timestamp to datetime and determine time category - if time_fired_ts: - # Convert to local time for time category determination - period = time_category( - datetime.fromtimestamp(time_fired_ts, local_time_zone).hour - ) + if ( + entity_id not in period_results + and (entry := ent_reg.async_get(entity_id)) + and entry.hidden + ): + hidden_entities.add(entity_id) + continue - # Count entity usage - for entity_id in entity_ids: - results[period][entity_id] += 1 + period_results[entity_id] += 1 return EntityUsagePredictions( morning=[ @@ -226,11 +197,40 @@ def _fetch_and_process_data( ) +def _fetch_and_process_data( + session: Session, ent_reg: er.EntityRegistry, user_id: str +) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: + """Fetch and process service call events from the database.""" + thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() + user_id_bytes = uuid_hex_to_bytes_or_none(user_id) + if not user_id_bytes: + raise ValueError("Invalid user_id format") + + # Build the main query for events with their data + query = ( + select( + Events.context_id_bin, + Events.time_fired_ts, + EventData.shared_data, + ) + .select_from(Events) + .outerjoin(EventData, Events.data_id == EventData.data_id) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .where(Events.time_fired_ts >= thirty_days_ago_ts) + .where(Events.context_user_id_bin == user_id_bytes) + .where(EventTypes.event_type == "call_service") + .order_by(Events.time_fired_ts) + ) + return session.connection().execute(query).all() + + def _fetch_with_session( hass: HomeAssistant, - fetch_func: Callable[[Session], EntityUsagePredictions], + fetch_func: Callable[ + [Session], Sequence[Row[tuple[bytes | None, float | None, str | None]]] + ], *args: object, -) -> EntityUsagePredictions: +) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: """Execute a fetch function with a database session.""" with session_scope(hass=hass, read_only=True) as session: return fetch_func(session, *args) diff --git a/tests/components/usage_prediction/test_common_control.py b/tests/components/usage_prediction/test_common_control.py index de6db0254722..090d9ddf7ffc 100644 --- a/tests/components/usage_prediction/test_common_control.py +++ b/tests/components/usage_prediction/test_common_control.py @@ -62,9 +62,15 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: """Test function with actual service call events in database.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("climate.thermostat", "off") + hass.states.async_set("light.bedroom", "off") + hass.states.async_set("lock.front_door", "locked") + # Create service call events at different times of day # Morning events - use separate service calls to get around context deduplication - with freeze_time("2023-07-01 07:00:00+00:00"): # Morning + with freeze_time("2023-07-01 07:00:00"): # Morning hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -77,7 +83,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Afternoon events - with freeze_time("2023-07-01 14:00:00+00:00"): # Afternoon + with freeze_time("2023-07-01 14:00:00"): # Afternoon hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -90,7 +96,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Evening events - with freeze_time("2023-07-01 19:00:00+00:00"): # Evening + with freeze_time("2023-07-01 19:00:00"): # Evening hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -103,7 +109,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Night events - with freeze_time("2023-07-01 23:00:00+00:00"): # Night + with freeze_time("2023-07-01 23:00:00"): # Night hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -119,7 +125,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Get predictions - make sure we're still in a reasonable timeframe - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Verify results contain the expected entities in the correct time periods @@ -151,7 +157,12 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: suggested_object_id="kitchen", ) - with freeze_time("2023-07-01 10:00:00+00:00"): # Morning + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("light.hallway", "off") + hass.states.async_set("not_allowed.domain", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -163,6 +174,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: "light.kitchen", "light.hallway", "not_allowed.domain", + "light.not_in_state_machine", ] }, }, @@ -172,7 +184,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Two lights should be counted (10:00 UTC = 02:00 local = night) @@ -189,7 +201,10 @@ async def test_context_deduplication(hass: HomeAssistant) -> None: user_id = str(uuid.uuid4()) context = Context(user_id=user_id) - with freeze_time("2023-07-01 10:00:00+00:00"): # Morning + hass.states.async_set("light.living_room", "off") + hass.states.async_set("switch.coffee_maker", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning # Fire multiple events with the same context hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -215,7 +230,7 @@ async def test_context_deduplication(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Only the first event should be processed (10:00 UTC = 02:00 local = night) @@ -232,8 +247,11 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: """Test that events older than 30 days are excluded.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.old_event", "off") + hass.states.async_set("light.recent_event", "off") + # Create an old event (35 days ago) - with freeze_time("2023-05-27 10:00:00+00:00"): # 35 days before July 1st + with freeze_time("2023-05-27 10:00:00"): # 35 days before July 1st hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -246,7 +264,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Create a recent event (5 days ago) - with freeze_time("2023-06-26 10:00:00+00:00"): # 5 days before July 1st + with freeze_time("2023-06-26 10:00:00"): # 5 days before July 1st hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -261,7 +279,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Query with current time - with freeze_time("2023-07-01 10:00:00+00:00"): + with freeze_time("2023-07-01 10:00:00"): results = await async_predict_common_control(hass, user_id) # Only recent event should be included (10:00 UTC = 02:00 local = night) @@ -278,8 +296,16 @@ async def test_entities_limit(hass: HomeAssistant) -> None: """Test that only top entities are returned per time category.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.most_used", "off") + hass.states.async_set("light.second", "off") + hass.states.async_set("light.third", "off") + hass.states.async_set("light.fourth", "off") + hass.states.async_set("light.fifth", "off") + hass.states.async_set("light.sixth", "off") + hass.states.async_set("light.seventh", "off") + # Create more than 5 different entities in morning - with freeze_time("2023-07-01 08:00:00+00:00"): + with freeze_time("2023-07-01 08:00:00"): # Create entities with different frequencies entities_with_counts = [ ("light.most_used", 10), @@ -308,7 +334,7 @@ async def test_entities_limit(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) with ( - freeze_time("2023-07-02 10:00:00+00:00"), + freeze_time("2023-07-02 10:00:00"), patch( "homeassistant.components.usage_prediction.common_control.RESULTS_TO_INCLUDE", 5, @@ -335,7 +361,10 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: user_id_1 = str(uuid.uuid4()) user_id_2 = str(uuid.uuid4()) - with freeze_time("2023-07-01 10:00:00+00:00"): + hass.states.async_set("light.user1_light", "off") + hass.states.async_set("light.user2_light", "off") + + with freeze_time("2023-07-01 10:00:00"): # User 1 events hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -363,7 +392,7 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Get results for each user - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results_user1 = await async_predict_common_control(hass, user_id_1) results_user2 = await async_predict_common_control(hass, user_id_2) From 2f75661c203a4fd94cc4cfeb1c79a9e2cf03c8b1 Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:42:31 +0200 Subject: [PATCH 012/103] Bump librehardwaremonitor-api to version 1.4.0 (#152938) --- homeassistant/components/libre_hardware_monitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/libre_hardware_monitor/manifest.json b/homeassistant/components/libre_hardware_monitor/manifest.json index 66623db1f2d7..322f3f2934f1 100644 --- a/homeassistant/components/libre_hardware_monitor/manifest.json +++ b/homeassistant/components/libre_hardware_monitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["librehardwaremonitor-api==1.3.1"] + "requirements": ["librehardwaremonitor-api==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6feb2fe6840c..3830c097adc3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,7 +1364,7 @@ libpyfoscamcgi==0.0.7 libpyvivotek==0.4.0 # homeassistant.components.libre_hardware_monitor -librehardwaremonitor-api==1.3.1 +librehardwaremonitor-api==1.4.0 # homeassistant.components.mikrotik librouteros==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 249f309297cb..fb508dd12fb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1180,7 +1180,7 @@ letpot==0.6.2 libpyfoscamcgi==0.0.7 # homeassistant.components.libre_hardware_monitor -librehardwaremonitor-api==1.3.1 +librehardwaremonitor-api==1.4.0 # homeassistant.components.mikrotik librouteros==3.2.0 From 731064f7e956ea2857852e457831a1028416f009 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 25 Sep 2025 09:49:22 +0200 Subject: [PATCH 013/103] Portainer fix unique entity (#152941) Co-authored-by: Franck Nijhof Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/portainer/binary_sensor.py | 10 +++++++++- homeassistant/components/portainer/entity.py | 2 +- .../portainer/snapshots/test_binary_sensor.ambr | 10 +++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 5545cfc9b931..543bdeaf335d 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -131,7 +131,15 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity): self.entity_description = entity_description super().__init__(device_info, coordinator, via_device) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" + # Container ID's are ephemeral, so use the container name for the unique ID + # The first one, should always be unique, it's fine if users have aliases + # According to Docker's API docs, the first name is unique + device_identifier = ( + self._device_info.names[0].replace("/", " ").strip() + if self._device_info.names + else None + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" @property def available(self) -> bool: diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py index ecabafc4663f..5fd53236cd82 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -60,7 +60,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity): self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_id}") + (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}") }, manufacturer=DEFAULT_NAME, model="Container", diff --git a/tests/components/portainer/snapshots/test_binary_sensor.ambr b/tests/components/portainer/snapshots/test_binary_sensor.ambr index 922b4d6cddf8..7ec3900e49bb 100644 --- a/tests/components/portainer/snapshots/test_binary_sensor.ambr +++ b/tests/components/portainer/snapshots/test_binary_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_focused_einstein_status', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_status', 'unit_of_measurement': None, }) # --- @@ -177,7 +177,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_practical_morse_status', 'unit_of_measurement': None, }) # --- @@ -226,7 +226,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_serene_banach_status', 'unit_of_measurement': None, }) # --- @@ -275,7 +275,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'status', - 'unique_id': 'portainer_test_entry_123_cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unique_id': 'portainer_test_entry_123_stoic_turing_status', 'unit_of_measurement': None, }) # --- From cc2a5b43dd19e83f2c609615c22941a70f596de1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 25 Sep 2025 11:33:01 +0200 Subject: [PATCH 014/103] Update frontend to 20250925.0 (#152945) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 11e703cd73e4..bf7c9642c131 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250924.0"] + "requirements": ["home-assistant-frontend==20250925.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 36f01d11b695..4867585cc4dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3830c097adc3..cf835109ab6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb508dd12fb2..6fc33e991bc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250924.0 +home-assistant-frontend==20250925.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From 156a0f1a3d4766c78bee2fee2bf25e64135a69e9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Sep 2025 09:37:33 +0000 Subject: [PATCH 015/103] Bump version to 2025.10.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index edd1a04c1973..2b34f49c1ccb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 00849157f9f9..c3b34802c55b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b0" +version = "2025.10.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From cdf613d3f8f30a90342ead402572cf980f08e299 Mon Sep 17 00:00:00 2001 From: Daniel Potthast Date: Thu, 25 Sep 2025 17:20:43 +0200 Subject: [PATCH 016/103] Update mvglive component (#146479) Co-authored-by: Erik Montnemery --- .../components/mvglive/manifest.json | 6 +- homeassistant/components/mvglive/sensor.py | 204 ++++++++++-------- requirements_all.txt | 3 + 3 files changed, 122 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json index 2c4e6a7e735a..8058c602dc4d 100644 --- a/homeassistant/components/mvglive/manifest.json +++ b/homeassistant/components/mvglive/manifest.json @@ -2,10 +2,8 @@ "domain": "mvglive", "name": "MVG", "codeowners": [], - "disabled": "This integration is disabled because it uses non-open source code to operate.", "documentation": "https://www.home-assistant.io/integrations/mvglive", "iot_class": "cloud_polling", - "loggers": ["MVGLive"], - "quality_scale": "legacy", - "requirements": ["PyMVGLive==1.1.4"] + "loggers": ["MVG"], + "requirements": ["mvg==1.4.0"] } diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index d8b435177118..031ec164ecd7 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -1,13 +1,14 @@ """Support for departure information for public transport in Munich.""" -# mypy: ignore-errors from __future__ import annotations +from collections.abc import Mapping from copy import deepcopy from datetime import timedelta import logging +from typing import Any -import MVGLive +from mvg import MvgApi, MvgApiError, TransportType import voluptuous as vol from homeassistant.components.sensor import ( @@ -19,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -44,53 +46,55 @@ ICONS = { "SEV": "mdi:checkbox-blank-circle-outline", "-": "mdi:clock", } -ATTRIBUTION = "Data provided by MVG-live.de" + +ATTRIBUTION = "Data provided by mvg.de" SCAN_INTERVAL = timedelta(seconds=30) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NEXT_DEPARTURE): [ - { - vol.Required(CONF_STATION): cv.string, - vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv, - vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv, - vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv, - vol.Optional( - CONF_PRODUCTS, default=DEFAULT_PRODUCT - ): cv.ensure_list_csv, - vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, - vol.Optional(CONF_NUMBER, default=1): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - } - ] - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_DIRECTIONS), + SENSOR_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NEXT_DEPARTURE): [ + { + vol.Required(CONF_STATION): cv.string, + vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv, + vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv, + vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv, + vol.Optional( + CONF_PRODUCTS, default=DEFAULT_PRODUCT + ): cv.ensure_list_csv, + vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, + vol.Optional(CONF_NUMBER, default=1): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + } + ] + } + ), ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the MVGLive sensor.""" - add_entities( - ( - MVGLiveSensor( - nextdeparture.get(CONF_STATION), - nextdeparture.get(CONF_DESTINATIONS), - nextdeparture.get(CONF_DIRECTIONS), - nextdeparture.get(CONF_LINES), - nextdeparture.get(CONF_PRODUCTS), - nextdeparture.get(CONF_TIMEOFFSET), - nextdeparture.get(CONF_NUMBER), - nextdeparture.get(CONF_NAME), - ) - for nextdeparture in config[CONF_NEXT_DEPARTURE] - ), - True, - ) + sensors = [ + MVGLiveSensor( + hass, + nextdeparture.get(CONF_STATION), + nextdeparture.get(CONF_DESTINATIONS), + nextdeparture.get(CONF_LINES), + nextdeparture.get(CONF_PRODUCTS), + nextdeparture.get(CONF_TIMEOFFSET), + nextdeparture.get(CONF_NUMBER), + nextdeparture.get(CONF_NAME), + ) + for nextdeparture in config[CONF_NEXT_DEPARTURE] + ] + add_entities(sensors, True) class MVGLiveSensor(SensorEntity): @@ -100,38 +104,38 @@ class MVGLiveSensor(SensorEntity): def __init__( self, - station, + hass: HomeAssistant, + station_name, destinations, - directions, lines, products, timeoffset, number, name, - ): + ) -> None: """Initialize the sensor.""" - self._station = station self._name = name + self._station_name = station_name self.data = MVGLiveData( - station, destinations, directions, lines, products, timeoffset, number + hass, station_name, destinations, lines, products, timeoffset, number ) self._state = None self._icon = ICONS["-"] @property - def name(self): + def name(self) -> str | None: """Return the name of the sensor.""" if self._name: return self._name - return self._station + return self._station_name @property - def native_value(self): + def native_value(self) -> str | None: """Return the next departure time.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" if not (dep := self.data.departures): return None @@ -140,88 +144,114 @@ class MVGLiveSensor(SensorEntity): return attr @property - def icon(self): + def icon(self) -> str | None: """Icon to use in the frontend, if any.""" return self._icon @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and update the state.""" - self.data.update() + await self.data.update() if not self.data.departures: - self._state = "-" + self._state = None self._icon = ICONS["-"] else: - self._state = self.data.departures[0].get("time", "-") - self._icon = ICONS[self.data.departures[0].get("product", "-")] + self._state = self.data.departures[0].get("time_in_mins", "-") + self._icon = self.data.departures[0].get("icon", ICONS["-"]) + + +def _get_minutes_until_departure(departure_time: int) -> int: + """Calculate the time difference in minutes between the current time and a given departure time. + + Args: + departure_time: Unix timestamp of the departure time, in seconds. + + Returns: + The time difference in minutes, as an integer. + + """ + current_time = dt_util.utcnow() + departure_datetime = dt_util.utc_from_timestamp(departure_time) + time_difference = (departure_datetime - current_time).total_seconds() + return int(time_difference / 60.0) class MVGLiveData: - """Pull data from the mvg-live.de web page.""" + """Pull data from the mvg.de web page.""" def __init__( - self, station, destinations, directions, lines, products, timeoffset, number - ): + self, + hass: HomeAssistant, + station_name, + destinations, + lines, + products, + timeoffset, + number, + ) -> None: """Initialize the sensor.""" - self._station = station + self._hass = hass + self._station_name = station_name + self._station_id = None self._destinations = destinations - self._directions = directions self._lines = lines self._products = products self._timeoffset = timeoffset self._number = number - self._include_ubahn = "U-Bahn" in self._products - self._include_tram = "Tram" in self._products - self._include_bus = "Bus" in self._products - self._include_sbahn = "S-Bahn" in self._products - self.mvg = MVGLive.MVGLive() - self.departures = [] + self.departures: list[dict[str, Any]] = [] - def update(self): + async def update(self): """Update the connection data.""" + if self._station_id is None: + try: + station = await MvgApi.station_async(self._station_name) + self._station_id = station["id"] + except MvgApiError as err: + _LOGGER.error( + "Failed to resolve station %s: %s", self._station_name, err + ) + self.departures = [] + return + try: - _departures = self.mvg.getlivedata( - station=self._station, - timeoffset=self._timeoffset, - ubahn=self._include_ubahn, - tram=self._include_tram, - bus=self._include_bus, - sbahn=self._include_sbahn, + _departures = await MvgApi.departures_async( + station_id=self._station_id, + offset=self._timeoffset, + limit=self._number, + transport_types=[ + transport_type + for transport_type in TransportType + if transport_type.value[0] in self._products + ] + if self._products + else None, ) except ValueError: self.departures = [] _LOGGER.warning("Returned data not understood") return self.departures = [] - for i, _departure in enumerate(_departures): - # find the first departure meeting the criteria + for _departure in _departures: if ( "" not in self._destinations[:1] and _departure["destination"] not in self._destinations ): continue - if ( - "" not in self._directions[:1] - and _departure["direction"] not in self._directions - ): + if "" not in self._lines[:1] and _departure["line"] not in self._lines: continue - if "" not in self._lines[:1] and _departure["linename"] not in self._lines: + time_to_departure = _get_minutes_until_departure(_departure["time"]) + + if time_to_departure < self._timeoffset: continue - if _departure["time"] < self._timeoffset: - continue - - # now select the relevant data _nextdep = {} - for k in ("destination", "linename", "time", "direction", "product"): + for k in ("destination", "line", "type", "cancelled", "icon"): _nextdep[k] = _departure.get(k, "") - _nextdep["time"] = int(_nextdep["time"]) + _nextdep["time_in_mins"] = time_to_departure self.departures.append(_nextdep) - if i == self._number - 1: - break diff --git a/requirements_all.txt b/requirements_all.txt index cf835109ab6c..9b5031fa8c02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1499,6 +1499,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.mvglive +mvg==1.4.0 + # homeassistant.components.permobil mypermobil==0.1.8 From cee88473a20b686c104d57d056cdd9b95b41610c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 25 Sep 2025 18:59:53 +0200 Subject: [PATCH 017/103] Remove deprecated sensors and update remaning for Alexa Devices (#151230) --- .../components/alexa_devices/binary_sensor.py | 74 +++++++++---------- .../components/alexa_devices/config_flow.py | 4 +- .../components/alexa_devices/coordinator.py | 2 +- .../components/alexa_devices/diagnostics.py | 4 +- .../components/alexa_devices/icons.json | 40 ---------- .../components/alexa_devices/manifest.json | 2 +- .../components/alexa_devices/sensor.py | 13 ++++ .../components/alexa_devices/strings.json | 20 ----- .../components/alexa_devices/switch.py | 34 +++++++-- .../components/alexa_devices/utils.py | 25 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/const.py | 17 ++--- .../snapshots/test_binary_sensor.ambr | 48 ------------ .../snapshots/test_diagnostics.ambr | 26 +++++-- .../snapshots/test_services.ambr | 24 ++++-- .../alexa_devices/snapshots/test_switch.ambr | 2 +- tests/components/alexa_devices/test_sensor.py | 30 +++++++- tests/components/alexa_devices/test_switch.py | 50 ++++++++----- tests/components/alexa_devices/test_utils.py | 40 ++++++++++ 20 files changed, 250 insertions(+), 209 deletions(-) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 410ea4555e24..296f4c417f02 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -10,6 +10,7 @@ from aioamazondevices.api import AmazonDevice from aioamazondevices.const import SENSOR_STATE_OFF from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -20,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import async_update_unique_id # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -31,6 +33,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): is_on_fn: Callable[[AmazonDevice, str], bool] is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True + is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True BINARY_SENSORS: Final = ( @@ -41,46 +44,15 @@ BINARY_SENSORS: Final = ( is_on_fn=lambda device, _: device.online, ), AmazonBinarySensorEntityDescription( - key="bluetooth", - entity_category=EntityCategory.DIAGNOSTIC, - translation_key="bluetooth", - is_on_fn=lambda device, _: device.bluetooth_state, - ), - AmazonBinarySensorEntityDescription( - key="babyCryDetectionState", - translation_key="baby_cry_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="beepingApplianceDetectionState", - translation_key="beeping_appliance_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="coughDetectionState", - translation_key="cough_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="dogBarkDetectionState", - translation_key="dog_bark_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="humanPresenceDetectionState", + key="detectionState", device_class=BinarySensorDeviceClass.MOTION, - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), - is_supported=lambda device, key: device.sensors.get(key) is not None, - ), - AmazonBinarySensorEntityDescription( - key="waterSoundsDetectionState", - translation_key="water_sounds_detection", - is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_on_fn=lambda device, key: bool( + device.sensors[key].value != SENSOR_STATE_OFF + ), is_supported=lambda device, key: device.sensors.get(key) is not None, + is_available_fn=lambda device, key: ( + device.online and device.sensors[key].error is False + ), ), ) @@ -94,6 +66,22 @@ async def async_setup_entry( coordinator = entry.runtime_data + # Replace unique id for "detectionState" binary sensor + await async_update_unique_id( + hass, + coordinator, + BINARY_SENSOR_DOMAIN, + "humanPresenceDetectionState", + "detectionState", + ) + + async_add_entities( + AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in BINARY_SENSORS + for serial_num in coordinator.data + if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) + ) + known_devices: set[str] = set() def _check_device() -> None: @@ -125,3 +113,13 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): return self.entity_description.is_on_fn( self.device, self.entity_description.key ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.entity_description.is_available_fn( + self.device, self.entity_description.key + ) + and super().available + ) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index a3bcce1965b2..e863f137f70a 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): data = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except (CannotAuthenticate, TypeError): + except CannotAuthenticate: errors["base"] = "invalid_auth" except CannotRetrieveData: errors["base"] = "cannot_retrieve_data" @@ -112,7 +112,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): ) except CannotConnect: errors["base"] = "cannot_connect" - except (CannotAuthenticate, TypeError): + except CannotAuthenticate: errors["base"] = "invalid_auth" except CannotRetrieveData: errors["base"] = "cannot_retrieve_data" diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 3b14324fdb68..6ce21aa22163 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -68,7 +68,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): translation_key="cannot_retrieve_data_with_error", translation_placeholders={"error": repr(err)}, ) from err - except (CannotAuthenticate, TypeError) as err: + except CannotAuthenticate as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index 0c4cb7944168..938a20fb2189 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -60,7 +60,5 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]: "online": device.online, "serial number": device.serial_number, "software version": device.software_version, - "do not disturb": device.do_not_disturb, - "response style": device.response_style, - "bluetooth state": device.bluetooth_state, + "sensors": device.sensors, } diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index bedd4af17342..f9e8de057d02 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -1,44 +1,4 @@ { - "entity": { - "binary_sensor": { - "bluetooth": { - "default": "mdi:bluetooth-off", - "state": { - "on": "mdi:bluetooth" - } - }, - "baby_cry_detection": { - "default": "mdi:account-voice-off", - "state": { - "on": "mdi:account-voice" - } - }, - "beeping_appliance_detection": { - "default": "mdi:bell-off", - "state": { - "on": "mdi:bell-ring" - } - }, - "cough_detection": { - "default": "mdi:blur-off", - "state": { - "on": "mdi:blur" - } - }, - "dog_bark_detection": { - "default": "mdi:dog-side-off", - "state": { - "on": "mdi:dog-side" - } - }, - "water_sounds_detection": { - "default": "mdi:water-pump-off", - "state": { - "on": "mdi:water-pump" - } - } - } - }, "services": { "send_sound": { "service": "mdi:cast-audio" diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 437c11e0a4c1..14b2ddf90d96 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==6.0.0"] + "requirements": ["aioamazondevices==6.2.6"] } diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 1a863e87c1a7..e6dbc251b950 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -31,6 +31,9 @@ class AmazonSensorEntityDescription(SensorEntityDescription): """Amazon Devices sensor entity description.""" native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None + is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( + device.online and device.sensors[key].error is False + ) SENSORS: Final = ( @@ -99,3 +102,13 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity): def native_value(self) -> StateType: """Return the state of the sensor.""" return self.device.sensors[self.entity_description.key].value + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.entity_description.is_available_fn( + self.device, self.entity_description.key + ) + and super().available + ) diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 8e56a7a51b61..f6b850f0920a 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -58,26 +58,6 @@ } }, "entity": { - "binary_sensor": { - "bluetooth": { - "name": "Bluetooth" - }, - "baby_cry_detection": { - "name": "Baby crying" - }, - "beeping_appliance_detection": { - "name": "Beeping appliance" - }, - "cough_detection": { - "name": "Coughing" - }, - "dog_bark_detection": { - "name": "Dog barking" - }, - "water_sounds_detection": { - "name": "Water sounds" - } - }, "notify": { "speak": { "name": "Speak" diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index 138013666c6e..2994ab777514 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -8,13 +8,17 @@ from typing import TYPE_CHECKING, Any, Final from aioamazondevices.api import AmazonDevice -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity -from .utils import alexa_api_call +from .utils import alexa_api_call, async_update_unique_id PARALLEL_UPDATES = 1 @@ -24,16 +28,17 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription): """Alexa Devices switch entity description.""" is_on_fn: Callable[[AmazonDevice], bool] - subkey: str + is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: ( + device.online and device.sensors[key].error is False + ) method: str SWITCHES: Final = ( AmazonSwitchEntityDescription( - key="do_not_disturb", - subkey="AUDIO_PLAYER", + key="dnd", translation_key="do_not_disturb", - is_on_fn=lambda _device: _device.do_not_disturb, + is_on_fn=lambda device: bool(device.sensors["dnd"].value), method="set_do_not_disturb", ), ) @@ -48,6 +53,11 @@ async def async_setup_entry( coordinator = entry.runtime_data + # Replace unique id for "DND" switch and remove from Speaker Group + await async_update_unique_id( + hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd" + ) + known_devices: set[str] = set() def _check_device() -> None: @@ -59,7 +69,7 @@ async def async_setup_entry( AmazonSwitchEntity(coordinator, serial_num, switch_desc) for switch_desc in SWITCHES for serial_num in new_devices - if switch_desc.subkey in coordinator.data[serial_num].capabilities + if switch_desc.key in coordinator.data[serial_num].sensors ) _check_device() @@ -94,3 +104,13 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): def is_on(self) -> bool: """Return True if switch is on.""" return self.entity_description.is_on_fn(self.device) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.entity_description.is_available_fn( + self.device, self.entity_description.key + ) + and super().available + ) diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py index 437b681413b6..f8898aa5fe46 100644 --- a/homeassistant/components/alexa_devices/utils.py +++ b/homeassistant/components/alexa_devices/utils.py @@ -6,9 +6,12 @@ from typing import Any, Concatenate from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er -from .const import DOMAIN +from .const import _LOGGER, DOMAIN +from .coordinator import AmazonDevicesCoordinator from .entity import AmazonEntity @@ -38,3 +41,23 @@ def alexa_api_call[_T: AmazonEntity, **_P]( ) from err return cmd_wrapper + + +async def async_update_unique_id( + hass: HomeAssistant, + coordinator: AmazonDevicesCoordinator, + domain: str, + old_key: str, + new_key: str, +) -> None: + """Update unique id for entities created with old format.""" + entity_registry = er.async_get(hass) + + for serial_num in coordinator.data: + unique_id = f"{serial_num}-{old_key}" + if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id): + _LOGGER.debug("Updating unique_id for %s", entity_id) + new_unique_id = unique_id.replace(old_key, new_key) + + # Update the registry with the new unique_id + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/requirements_all.txt b/requirements_all.txt index 9b5031fa8c02..c22b7072ad90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.1 # homeassistant.components.alexa_devices -aioamazondevices==6.0.0 +aioamazondevices==6.2.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fc33e991bc1..0f75a9d8bff2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.1 # homeassistant.components.alexa_devices -aioamazondevices==6.0.0 +aioamazondevices==6.2.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index d078e92199ed..05a6ff587196 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -18,15 +18,13 @@ TEST_DEVICE_1 = AmazonDevice( online=True, serial_number=TEST_DEVICE_1_SN, software_version="echo_test_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", + endpoint_id="G1234567890123456789012345678A", sensors={ + "dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None), "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" - ) + name="temperature", value="22.5", error=False, scale="CELSIUS" + ), }, ) @@ -42,14 +40,11 @@ TEST_DEVICE_2 = AmazonDevice( online=True, serial_number=TEST_DEVICE_2_SN, software_version="echo_test_2_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", + endpoint_id="G1234567890123456789012345678A", sensors={ "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" + name="temperature", value="22.5", error=False, scale="CELSIUS" ) }, ) diff --git a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr index 16f9eeaedae8..c6b9a2afa08a 100644 --- a/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr +++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr @@ -1,52 +1,4 @@ # serializer version: 1 -# name: test_all_entities[binary_sensor.echo_test_bluetooth-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.echo_test_bluetooth', - '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': 'Bluetooth', - 'platform': 'alexa_devices', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'bluetooth', - 'unique_id': 'echo_test_serial_number-bluetooth', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[binary_sensor.echo_test_bluetooth-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Echo Test Bluetooth', - }), - 'context': , - 'entity_id': 'binary_sensor.echo_test_bluetooth', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[binary_sensor.echo_test_connectivity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 9ae5832ce334..2450d9e7d7bb 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -2,7 +2,6 @@ # name: test_device_diagnostics dict({ 'account name': 'Echo Test', - 'bluetooth state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -12,9 +11,17 @@ ]), 'device family': 'mine', 'device type': 'echo', - 'do not disturb': False, 'online': True, - 'response style': None, + 'sensors': dict({ + 'dnd': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)", + }), + 'temperature': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')", + }), + }), 'serial number': 'echo_test_serial_number', 'software version': 'echo_test_software_version', }) @@ -25,7 +32,6 @@ 'devices': list([ dict({ 'account name': 'Echo Test', - 'bluetooth state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -35,9 +41,17 @@ ]), 'device family': 'mine', 'device type': 'echo', - 'do not disturb': False, 'online': True, - 'response style': None, + 'sensors': dict({ + 'dnd': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)", + }), + 'temperature': dict({ + '__type': "", + 'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')", + }), + }), 'serial number': 'echo_test_serial_number', 'software version': 'echo_test_software_version', }), diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index 12eab4a683bf..dc15796c32c6 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -4,8 +4,6 @@ tuple( dict({ 'account_name': 'Echo Test', - 'appliance_id': 'G1234567890123456789012345678A', - 'bluetooth_state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -16,12 +14,18 @@ 'device_family': 'mine', 'device_owner_customer_id': 'amazon_ower_id', 'device_type': 'echo', - 'do_not_disturb': False, + 'endpoint_id': 'G1234567890123456789012345678A', 'entity_id': '11111111-2222-3333-4444-555555555555', 'online': True, - 'response_style': None, 'sensors': dict({ + 'dnd': dict({ + 'error': False, + 'name': 'dnd', + 'scale': None, + 'value': False, + }), 'temperature': dict({ + 'error': False, 'name': 'temperature', 'scale': 'CELSIUS', 'value': '22.5', @@ -41,8 +45,6 @@ tuple( dict({ 'account_name': 'Echo Test', - 'appliance_id': 'G1234567890123456789012345678A', - 'bluetooth_state': True, 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', @@ -53,12 +55,18 @@ 'device_family': 'mine', 'device_owner_customer_id': 'amazon_ower_id', 'device_type': 'echo', - 'do_not_disturb': False, + 'endpoint_id': 'G1234567890123456789012345678A', 'entity_id': '11111111-2222-3333-4444-555555555555', 'online': True, - 'response_style': None, 'sensors': dict({ + 'dnd': dict({ + 'error': False, + 'name': 'dnd', + 'scale': None, + 'value': False, + }), 'temperature': dict({ + 'error': False, 'name': 'temperature', 'scale': 'CELSIUS', 'value': '22.5', diff --git a/tests/components/alexa_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr index c622cc67ea75..3ce484cf95b9 100644 --- a/tests/components/alexa_devices/snapshots/test_switch.ambr +++ b/tests/components/alexa_devices/snapshots/test_switch.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'do_not_disturb', - 'unique_id': 'echo_test_serial_number-do_not_disturb', + 'unique_id': 'echo_test_serial_number-dnd', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index 560a7e10b90d..3bb1b3f0a0d8 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -134,10 +134,38 @@ async def test_unit_of_measurement( mock_amazon_devices_client.get_devices_data.return_value[ TEST_DEVICE_1_SN - ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)} + ].sensors = { + sensor: AmazonDeviceSensor( + name=sensor, value=api_value, error=False, scale=scale + ) + } await setup_integration(hass, mock_config_entry) assert (state := hass.states.get(entity_id)) assert state.state == state_value assert state.attributes["unit_of_measurement"] == unit + + +async def test_sensor_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is unavailable.""" + + entity_id = "sensor.echo_test_illuminance" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_DEVICE_1_SN + ].sensors = { + "illuminance": AmazonDeviceSensor( + name="illuminance", value="800", error=True, scale=None + ) + } + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index c5039d68da25..6bbc1f68d021 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -1,7 +1,9 @@ """Tests for the Alexa Devices switch platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch +from aioamazondevices.api import AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -23,10 +25,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import TEST_DEVICE_1_SN +from .conftest import TEST_DEVICE_1, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +ENTITY_ID = "switch.echo_test_do_not_disturb" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( @@ -52,48 +56,59 @@ async def test_switch_dnd( """Test switching DND.""" await setup_integration(hass, mock_config_entry) - entity_id = "switch.echo_test_do_not_disturb" - - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1 - mock_amazon_devices_client.get_devices_data.return_value[ - TEST_DEVICE_1_SN - ].do_not_disturb = True + device_data = deepcopy(TEST_DEVICE_1) + device_data.sensors = { + "dnd": AmazonDeviceSensor(name="dnd", value=True, error=False, scale=None), + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", error=False, scale="CELSIUS" + ), + } + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: device_data + } freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) - mock_amazon_devices_client.get_devices_data.return_value[ - TEST_DEVICE_1_SN - ].do_not_disturb = False + device_data.sensors = { + "dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None), + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", error=False, scale="CELSIUS" + ), + } + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: device_data + } freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_amazon_devices_client.set_do_not_disturb.call_count == 2 - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_OFF @@ -104,16 +119,13 @@ async def test_offline_device( mock_config_entry: MockConfigEntry, ) -> None: """Test offline device handling.""" - - entity_id = "switch.echo_test_do_not_disturb" - mock_amazon_devices_client.get_devices_data.return_value[ TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ @@ -124,5 +136,5 @@ async def test_offline_device( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get(entity_id)) + assert (state := hass.states.get(ENTITY_ID)) assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py index 1cf190bd2976..020971d8f76f 100644 --- a/tests/components/alexa_devices/test_utils.py +++ b/tests/components/alexa_devices/test_utils.py @@ -10,8 +10,10 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TUR from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry @@ -54,3 +56,41 @@ async def test_alexa_api_call_exceptions( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == key assert exc_info.value.translation_placeholders == {"error": error} + + +async def test_alexa_unique_id_migration( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unique_id migration.""" + + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Amazon", + model="Echo Dot", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + entity = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb", + device_id=device.id, + config_entry=mock_config_entry, + has_entity_name=True, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.unique_id == f"{TEST_DEVICE_1_SN}-dnd" From 3905723900c76dc0976b1112ea49efd678a1d450 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 25 Sep 2025 18:30:47 +0200 Subject: [PATCH 018/103] Bump accuweather to version 4.2.2 (#152965) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 09ea76d022dc..11f927c6aeb6 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.2.1"] + "requirements": ["accuweather==4.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c22b7072ad90..c92ca366ef24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.2.1 +accuweather==4.2.2 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f75a9d8bff2..abe8724a78e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.2.1 +accuweather==4.2.2 # homeassistant.components.adax adax==0.4.0 From ccc50f24121c56a8a39aaab073e1b441af36b68e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 12:21:17 -0500 Subject: [PATCH 019/103] Bump aioesphomeapi to 41.10.0 (#152975) Co-authored-by: Michael Hansen --- 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 674ced0bf9c6..2918f79ed2d2 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==41.9.4", + "aioesphomeapi==41.10.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index c92ca366ef24..981b4bd670c1 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==41.9.4 +aioesphomeapi==41.10.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abe8724a78e4..04ac30fb4d80 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==41.9.4 +aioesphomeapi==41.10.0 # homeassistant.components.flo aioflo==2021.11.0 From d857d8850ca8ef7f5ff16507e9c403045f7944a3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 25 Sep 2025 17:57:30 +0200 Subject: [PATCH 020/103] Bump pySmartThings to 3.3.0 (#152977) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 951d1372a699..96c6d94da4f9 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.9"] + "requirements": ["pysmartthings==3.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 981b4bd670c1..f5019ba970b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,7 +2384,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.2.9 +pysmartthings==3.3.0 # homeassistant.components.smarty pysmarty2==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04ac30fb4d80..5f6cbc0c974e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1987,7 +1987,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.2.9 +pysmartthings==3.3.0 # homeassistant.components.smarty pysmarty2==0.10.3 From 09e45f6f54a14cbd0fbedb61504d1fe3aaa0707a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 25 Sep 2025 11:18:24 -0400 Subject: [PATCH 021/103] Fix incorrect Roborock test (#152980) --- tests/components/roborock/test_coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 7da19e9418cb..315ab14bdb50 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -152,7 +152,7 @@ async def test_no_maps( return_value=prop, ), patch( - "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_multi_maps_list", + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list", return_value=MultiMapsList( max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[] ), From a5af97420970a0445131d5c0123d1816b973984e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 25 Sep 2025 19:05:12 +0200 Subject: [PATCH 022/103] Update frontend to 20250925.1 (#152985) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index bf7c9642c131..618711c5354a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250925.0"] + "requirements": ["home-assistant-frontend==20250925.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4867585cc4dd..981a4b28a098 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250925.0 +home-assistant-frontend==20250925.1 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f5019ba970b1..36aca335bbc3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250925.0 +home-assistant-frontend==20250925.1 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f6cbc0c974e..19a90cf81003 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250925.0 +home-assistant-frontend==20250925.1 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From 6aaddad56bea9138cedcf55186f601f43b1281e8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Sep 2025 18:19:29 +0000 Subject: [PATCH 023/103] Bump version to 2025.10.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2b34f49c1ccb..6c088e0edd16 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index c3b34802c55b..c2ac2231e925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b1" +version = "2025.10.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 750e849f09b86fb245aef07ce3cbc00101d460fc Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Fri, 26 Sep 2025 11:35:04 +0100 Subject: [PATCH 024/103] Protect against last_comms being None (#149366) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/geniushub/entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 24917ab5e95e..e47bb59c3d39 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -77,10 +77,10 @@ class GeniusDevice(GeniusEntity): async def async_update(self) -> None: """Update an entity's state data.""" - if "_state" in self._device.data: # only via v3 API - self._last_comms = dt_util.utc_from_timestamp( - self._device.data["_state"]["lastComms"] - ) + if (state := self._device.data.get("_state")) and ( + last_comms := state.get("lastComms") + ) is not None: # only via v3 API + self._last_comms = dt_util.utc_from_timestamp(last_comms) class GeniusZone(GeniusEntity): From 1b2eab00bea66a99afc5e9d27f0b27f6c7bcdbe4 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 26 Sep 2025 21:38:27 +0200 Subject: [PATCH 025/103] Add SSL options during config_flow for airOS (#150325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Åke Strandberg Co-authored-by: G Johansson Co-authored-by: Norbert Rittel --- homeassistant/components/airos/__init__.py | 39 +++- homeassistant/components/airos/config_flow.py | 27 ++- homeassistant/components/airos/const.py | 5 + homeassistant/components/airos/entity.py | 11 +- homeassistant/components/airos/strings.json | 12 ++ tests/components/airos/conftest.py | 30 ++-- .../airos/snapshots/test_diagnostics.ambr | 4 + tests/components/airos/test_config_flow.py | 17 +- tests/components/airos/test_init.py | 169 ++++++++++++++++++ 9 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 tests/components/airos/test_init.py diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index 3d8ecf4a5e07..9eea047f9b7e 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -4,10 +4,18 @@ from __future__ import annotations from airos.airos8 import AirOS8 -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator _PLATFORMS: list[Platform] = [ @@ -21,13 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo # By default airOS 8 comes with self-signed SSL certificates, # with no option in the web UI to change or upload a custom certificate. - session = async_get_clientsession(hass, verify_ssl=False) + session = async_get_clientsession( + hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] + ) airos_device = AirOS8( host=entry.data[CONF_HOST], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], session=session, + use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL], ) coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) @@ -40,6 +51,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo return True +async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Migrate old config entry.""" + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version == 1: + new_data = {**entry.data} + advanced_data = { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + } + new_data[SECTION_ADVANCED_SETTINGS] = advanced_data + + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=2, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index e66878221fea..f0e4b48a8cc0 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -15,10 +15,17 @@ from airos.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS from .coordinator import AirOS8 _LOGGER = logging.getLogger(__name__) @@ -28,6 +35,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_HOST): str, vol.Required(CONF_USERNAME, default="ubnt"): str, vol.Required(CONF_PASSWORD): str, + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Required(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } + ), + {"collapsed": True}, + ), } ) @@ -36,6 +52,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, @@ -46,13 +63,17 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: # By default airOS 8 comes with self-signed SSL certificates, # with no option in the web UI to change or upload a custom certificate. - session = async_get_clientsession(self.hass, verify_ssl=False) + session = async_get_clientsession( + self.hass, + verify_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], + ) airos_device = AirOS8( host=user_input[CONF_HOST], username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], session=session, + use_ssl=user_input[SECTION_ADVANCED_SETTINGS][CONF_SSL], ) try: await airos_device.login() diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py index f4be2594613c..29a5f6a9e55b 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -7,3 +7,8 @@ DOMAIN = "airos" SCAN_INTERVAL = timedelta(minutes=1) MANUFACTURER = "Ubiquiti" + +DEFAULT_VERIFY_SSL = False +DEFAULT_SSL = True + +SECTION_ADVANCED_SETTINGS = "advanced_settings" diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py index e54962110fc1..0b1245694c1e 100644 --- a/homeassistant/components/airos/entity.py +++ b/homeassistant/components/airos/entity.py @@ -2,11 +2,11 @@ from __future__ import annotations -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_SSL from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS from .coordinator import AirOSDataUpdateCoordinator @@ -20,9 +20,14 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]): super().__init__(coordinator) airos_data = self.coordinator.data + url_schema = ( + "https" + if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] + else "http" + ) configuration_url: str | None = ( - f"https://{coordinator.config_entry.data[CONF_HOST]}" + f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}" ) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 53681292f50a..a6e83aae8692 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -12,6 +12,18 @@ "host": "IP address or hostname of the airOS device", "username": "Administrator username for the airOS device, normally 'ubnt'", "password": "Password configured through the UISP app or web interface" + }, + "sections": { + "advanced_settings": { + "data": { + "ssl": "Use HTTPS", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "ssl": "Whether the connection should be encrypted (required for most devices)", + "verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates" + } + } } } }, diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index a86eb8fd39bd..8c341a670d25 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -1,7 +1,7 @@ """Common fixtures for the Ubiquiti airOS tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from airos.airos8 import AirOS8Data import pytest @@ -28,22 +28,26 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_airos_class() -> Generator[MagicMock]: + """Fixture to mock the AirOS class itself.""" + with ( + patch("homeassistant.components.airos.AirOS8", autospec=True) as mock_class, + patch("homeassistant.components.airos.config_flow.AirOS8", new=mock_class), + patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_class), + ): + yield mock_class + + @pytest.fixture def mock_airos_client( - request: pytest.FixtureRequest, ap_fixture: AirOS8Data + mock_airos_class: MagicMock, ap_fixture: AirOS8Data ) -> Generator[AsyncMock]: """Fixture to mock the AirOS API client.""" - with ( - patch( - "homeassistant.components.airos.config_flow.AirOS8", autospec=True - ) as mock_airos, - patch("homeassistant.components.airos.coordinator.AirOS8", new=mock_airos), - patch("homeassistant.components.airos.AirOS8", new=mock_airos), - ): - client = mock_airos.return_value - client.status.return_value = ap_fixture - client.login.return_value = True - yield client + client = mock_airos_class.return_value + client.status.return_value = ap_fixture + client.login.return_value = True + return client @pytest.fixture diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index f4561ec6d994..4e94beae4733 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -632,6 +632,10 @@ }), }), 'entry_data': dict({ + 'advanced_settings': dict({ + 'ssl': True, + 'verify_ssl': False, + }), 'host': '**REDACTED**', 'password': '**REDACTED**', 'username': 'ubnt', diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 212c80dfc2bf..a502f9f2f3bc 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -10,9 +10,15 @@ from airos.exceptions import ( ) import pytest -from homeassistant.components.airos.const import DOMAIN +from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -22,6 +28,10 @@ MOCK_CONFIG = { CONF_HOST: "1.1.1.1", CONF_USERNAME: "ubnt", CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: True, + CONF_VERIFY_SSL: False, + }, } @@ -33,7 +43,8 @@ async def test_form_creates_entry( ) -> None: """Test we get the form and create the appropriate entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py new file mode 100644 index 000000000000..30e2498d7d76 --- /dev/null +++ b/tests/components/airos/test_init.py @@ -0,0 +1,169 @@ +"""Test for airOS integration setup.""" + +from __future__ import annotations + +from unittest.mock import ANY, MagicMock + +from homeassistant.components.airos.const import ( + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, + SECTION_ADVANCED_SETTINGS, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_V1 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", +} + +MOCK_CONFIG_PLAIN = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, +} + +MOCK_CONFIG_V1_2 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + SECTION_ADVANCED_SETTINGS: { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, +} + + +async def test_setup_entry_with_default_ssl( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_airos_client: MagicMock, + mock_airos_class: MagicMock, +) -> None: + """Test setting up a config entry with default SSL options.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_airos_class.assert_called_once_with( + host=mock_config_entry.data[CONF_HOST], + username=mock_config_entry.data[CONF_USERNAME], + password=mock_config_entry.data[CONF_PASSWORD], + session=ANY, + use_ssl=DEFAULT_SSL, + ) + + assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True + assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False + + +async def test_setup_entry_without_ssl( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_airos_class: MagicMock, +) -> None: + """Test setting up a config entry adjusted to plain HTTP.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_PLAIN, + entry_id="1", + unique_id="airos_device", + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + mock_airos_class.assert_called_once_with( + host=entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=ANY, + use_ssl=False, + ) + + assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False + assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False + + +async def test_migrate_entry(hass: HomeAssistant, mock_airos_client: MagicMock) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1, + entry_id="1", + unique_id="airos_device", + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data == MOCK_CONFIG_V1_2 + + +async def test_migrate_future_return( + hass: HomeAssistant, + mock_airos_client: MagicMock, +) -> None: + """Test migrate entry unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V1_2, + entry_id="1", + unique_id="airos_device", + version=2, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup and unload config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 7b26a93d385d6d29ad2a435052cb4f7c3bc9d145 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 26 Sep 2025 21:32:49 +0200 Subject: [PATCH 026/103] Portainer add ability to skip SSL verification (#152955) --- homeassistant/components/portainer/__init__.py | 7 ++++--- homeassistant/components/portainer/config_flow.py | 5 +++-- homeassistant/components/portainer/strings.json | 6 ++++-- tests/components/portainer/conftest.py | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 602302a7c3a4..b945e60b545c 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from pyportainer import Portainer from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -19,11 +19,12 @@ type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: """Set up Portainer from a config entry.""" - session = async_create_clientsession(hass) client = Portainer( api_url=entry.data[CONF_HOST], api_key=entry.data[CONF_API_KEY], - session=session, + session=async_create_clientsession( + hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] + ), ) coordinator = PortainerCoordinator(hass, entry, client) diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 9cf9598cc956..2fc4f3a722a2 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -14,7 +14,7 @@ from pyportainer import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -26,6 +26,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, } ) @@ -36,7 +37,7 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: client = Portainer( api_url=data[CONF_HOST], api_key=data[CONF_API_KEY], - session=async_get_clientsession(hass), + session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]), ) try: await client.get_endpoints() diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 89530efc2129..acdd0d362a3b 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -4,11 +4,13 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "host": "The host/URL, including the port, of your Portainer instance", - "api_key": "The API key for authenticating with Portainer" + "api_key": "The API key for authenticating with Portainer", + "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" }, "description": "You can create an API key in the Portainer UI. Go to **My account > API keys** and select **Add API key**" } diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 2d0f8e34d33f..d6127c434402 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -8,13 +8,14 @@ from pyportainer.models.portainer import Endpoint import pytest from homeassistant.components.portainer.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL from tests.common import MockConfigEntry, load_json_array_fixture MOCK_TEST_CONFIG = { CONF_HOST: "https://127.0.0.1:9000/", CONF_API_KEY: "test_api_key", + CONF_VERIFY_SSL: True, } From 3d945b0fc55b7840d2c8631ba29a8227942cf966 Mon Sep 17 00:00:00 2001 From: lliwog <43934544+lliwog@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:47:11 +0200 Subject: [PATCH 027/103] Fix EZVIZ devices merging due to empty MAC addr (#152939) (#152981) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ezviz/entity.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 54614e4899ac..0a76871285b8 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -26,11 +26,14 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): super().__init__(coordinator) self._serial = serial self._camera_name = self.data["name"] + + connections = set() + if mac_address := self.data["mac_address"]: + connections.add((CONNECTION_NETWORK_MAC, mac_address)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, - connections={ - (CONNECTION_NETWORK_MAC, self.data["mac_address"]), - }, + connections=connections, manufacturer=MANUFACTURER, model=self.data["device_sub_category"], name=self.data["name"], @@ -62,11 +65,14 @@ class EzvizBaseEntity(Entity): self._serial = serial self.coordinator = coordinator self._camera_name = self.data["name"] + + connections = set() + if mac_address := self.data["mac_address"]: + connections.add((CONNECTION_NETWORK_MAC, mac_address)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, - connections={ - (CONNECTION_NETWORK_MAC, self.data["mac_address"]), - }, + connections=connections, manufacturer=MANUFACTURER, model=self.data["device_sub_category"], name=self.data["name"], From 68c51dc7aa37ae62514bd8e94985a5a13ca81261 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 26 Sep 2025 08:59:03 +0200 Subject: [PATCH 028/103] Fix PIN failure if starting with 0 for Comelit SimpleHome (#152983) --- .../components/comelit/config_flow.py | 12 +++-- tests/components/comelit/const.py | 7 +-- tests/components/comelit/test_config_flow.py | 46 ++++++++++++++++++- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 5b09b582c66a..0f47d88fad19 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -25,23 +25,27 @@ from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN from .utils import async_client_session DEFAULT_HOST = "192.168.1.252" -DEFAULT_PIN = 111111 +DEFAULT_PIN = "111111" +pin_regex = r"^[0-9]{4,10}$" + USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), } ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int}) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PIN): cv.matches_regex(pin_regex)} +) STEP_RECONFIGURE = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), } ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 3a253e4b5964..f275c192dd4f 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -20,13 +20,14 @@ from aiocomelit.const import ( BRIDGE_HOST = "fake_bridge_host" BRIDGE_PORT = 80 -BRIDGE_PIN = 1234 +BRIDGE_PIN = "1234" VEDO_HOST = "fake_vedo_host" VEDO_PORT = 8080 -VEDO_PIN = 5678 +VEDO_PIN = "5678" -FAKE_PIN = 0000 +FAKE_PIN = "0000" +BAD_PIN = "abcd" LIGHT0 = ComelitSerialBridgeObject( index=0, diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 1751a837026e..90622bbe457c 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -10,9 +10,10 @@ from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from .const import ( + BAD_PIN, BRIDGE_HOST, BRIDGE_PIN, BRIDGE_PORT, @@ -310,3 +311,46 @@ async def test_reconfigure_fails( CONF_PIN: BRIDGE_PIN, CONF_TYPE: BRIDGE, } + + +async def test_pin_format_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test PIN is valid format.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with pytest.raises(InvalidData): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BAD_PIN, + }, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() From 99a0380ec5f24e840bc74843216e3d523622e05c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 26 Sep 2025 01:17:01 -0400 Subject: [PATCH 029/103] Ignore discovery for existing ZHA entries (#152984) --- homeassistant/components/zha/config_flow.py | 49 +++++++--- tests/components/zha/test_config_flow.py | 99 +++++++++++++++++---- 2 files changed, 115 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5f90a3fc7d6e..dab157977dfc 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.homeassistant_hardware import silabs_multiprotocol from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.config_entries import ( SOURCE_IGNORE, + SOURCE_ZEROCONF, ConfigEntry, ConfigEntryBaseFlow, ConfigEntryState, @@ -183,27 +184,17 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self._hass = hass self._radio_mgr.hass = hass - async def _get_config_entry_data(self) -> dict: + def _get_config_entry_data(self) -> dict[str, Any]: """Extract ZHA config entry data from the radio manager.""" assert self._radio_mgr.radio_type is not None assert self._radio_mgr.device_path is not None assert self._radio_mgr.device_settings is not None - try: - device_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, self._radio_mgr.device_path - ) - except OSError as error: - raise AbortFlow( - reason="cannot_resolve_path", - description_placeholders={"path": self._radio_mgr.device_path}, - ) from error - return { CONF_DEVICE: DEVICE_SCHEMA( { **self._radio_mgr.device_settings, - CONF_DEVICE_PATH: device_path, + CONF_DEVICE_PATH: self._radio_mgr.device_path, } ), CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, @@ -703,6 +694,36 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): DOMAIN, include_ignore=False ) + if self._radio_mgr.device_path is not None: + # Ensure the radio manager device path is unique and will match ZHA's + try: + self._radio_mgr.device_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, self._radio_mgr.device_path + ) + except OSError as error: + raise AbortFlow( + reason="cannot_resolve_path", + description_placeholders={"path": self._radio_mgr.device_path}, + ) from error + + # mDNS discovery can advertise the same adapter on multiple IPs or via a + # hostname, which should be considered a duplicate + current_device_paths = {self._radio_mgr.device_path} + + if self.source == SOURCE_ZEROCONF: + discovery_info = self.init_data + current_device_paths |= { + f"socket://{ip}:{discovery_info.port}" + for ip in discovery_info.ip_addresses + } + + for entry in zha_config_entries: + path = entry.data.get(CONF_DEVICE, {}).get(CONF_DEVICE_PATH) + + # Abort discovery if the device path is already configured + if path is not None and path in current_device_paths: + return self.async_abort(reason="single_instance_allowed") + # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. if user_input is not None or ( @@ -873,7 +894,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): zha_config_entries = self.hass.config_entries.async_entries( DOMAIN, include_ignore=False ) - data = await self._get_config_entry_data() + data = self._get_config_entry_data() if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( @@ -976,7 +997,7 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): # Avoid creating both `.options` and `.data` by directly writing `data` here self.hass.config_entries.async_update_entry( entry=self.config_entry, - data=await self._get_config_entry_data(), + data=self._get_config_entry_data(), options=self.config_entry.options, ) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index ff4c7443fa13..0ddea074c799 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -857,6 +857,40 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non } +async def test_discovery_via_usb_same_device_already_setup(hass: HomeAssistant) -> None: + """Test discovery aborting if ZHA is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/serial/by-id/usb-device123"}}, + ).add_to_hass(hass) + + # Discovery info with the same device but different path format + discovery_info = UsbServiceInfo( + device="/dev/ttyUSB0", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.zha.config_flow.usb.get_serial_by_id", + return_value="/dev/serial/by-id/usb-device123", + ) as mock_get_serial_by_id: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + # Verify get_serial_by_id was called to normalize the path + assert mock_get_serial_by_id.mock_calls == [call("/dev/ttyUSB0")] + + # Should abort since it's the same device + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: @@ -890,6 +924,39 @@ async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> N assert confirm_result["step_id"] == "choose_migration_strategy" +async def test_zeroconf_discovery_via_socket_already_setup_with_ip_match( + hass: HomeAssistant, +) -> None: + """Test zeroconf discovery aborting when ZHA is already setup with socket and one IP matches.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: {CONF_DEVICE_PATH: "socket://192.168.1.101:6638"}}, + ).add_to_hass(hass) + + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ + ip_address("192.168.1.100"), + ip_address("192.168.1.101"), # Matches config entry + ], + hostname="tube-zigbee-gw.local.", + name="mock_name", + port=6638, + properties={"name": "tube_123456"}, + type="mock_type", + ) + + # Discovery should abort due to single instance check + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + await hass.async_block_till_done() + + # Should abort since one of the advertised IPs matches existing socket path + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", mock_detect_radio_type(radio_type=RadioType.deconz), @@ -2289,34 +2356,28 @@ async def test_config_flow_serial_resolution_oserror( ) -> None: """Test that OSError during serial port resolution is handled.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "manual_pick_radio_type"}, - data={CONF_RADIO_TYPE: RadioType.ezsp.description}, + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_setup_strategy" - with ( patch( - "homeassistant.components.usb.get_serial_by_id", + "homeassistant.components.zha.config_flow.usb.get_serial_by_id", side_effect=OSError("Test error"), ), ): - setup_result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info ) - assert setup_result["type"] is FlowResultType.ABORT - assert setup_result["reason"] == "cannot_resolve_path" - assert setup_result["description_placeholders"] == {"path": "/dev/ttyUSB33"} + assert result_init["type"] is FlowResultType.ABORT + assert result_init["reason"] == "cannot_resolve_path" + assert result_init["description_placeholders"] == {"path": "/dev/ttyZIGBEE"} @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") From fbed66ef1fb67aa3d4768ba99cceef6c5a165d80 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:34:29 -0500 Subject: [PATCH 030/103] Bump aiorussound to 4.8.2 (#152988) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index efaf8f195adc..b1b35385495d 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.8.1"], + "requirements": ["aiorussound==4.8.2"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 36aca335bbc3..633d5d6fc852 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.8.1 +aiorussound==4.8.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19a90cf81003..a111c4a9e876 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -357,7 +357,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.8.1 +aiorussound==4.8.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 06a57473a9e0e86970c14f388fe7066fdcdbc233 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:54:06 -0400 Subject: [PATCH 031/103] Rename service to action in ESPHome (#152997) --- homeassistant/components/esphome/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c3db4c3e9e8e..239dfe5662ac 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1073,7 +1073,7 @@ def _async_register_service( service_name, { "description": ( - f"Calls the service {service.name} of the node {device_info.name}" + f"Performs the action {service.name} of the node {device_info.name}" ), "fields": fields, }, From 0a44682014c1e68f554e69444650538f6152455c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 26 Sep 2025 10:12:56 -0400 Subject: [PATCH 032/103] Push ESPHome discovery to ZJS addon (#153004) --- .../components/zwave_js/config_flow.py | 41 +++++++++++++----- tests/components/zwave_js/test_config_flow.py | 43 +++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index be6efc03be9b..944c15e7081f 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -376,10 +376,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): new_addon_config = addon_config | config_updates - if not new_addon_config[CONF_ADDON_DEVICE]: - new_addon_config.pop(CONF_ADDON_DEVICE) - if not new_addon_config[CONF_ADDON_SOCKET]: - new_addon_config.pop(CONF_ADDON_SOCKET) + if new_addon_config.get(CONF_ADDON_DEVICE) is None: + new_addon_config.pop(CONF_ADDON_DEVICE, None) + if new_addon_config.get(CONF_ADDON_SOCKET) is None: + new_addon_config.pop(CONF_ADDON_SOCKET, None) if new_addon_config == addon_config: return @@ -1470,14 +1470,33 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): if not is_hassio(self.hass): return self.async_abort(reason="not_hassio") - if discovery_info.zwave_home_id: - await self.async_set_unique_id(str(discovery_info.zwave_home_id)) - self._abort_if_unique_id_configured( - { - CONF_USB_PATH: None, - CONF_SOCKET_PATH: discovery_info.socket_path, - } + if ( + discovery_info.zwave_home_id + and ( + current_config_entries := self._async_current_entries( + include_ignore=False + ) ) + and (home_id := str(discovery_info.zwave_home_id)) + and ( + existing_entry := next( + ( + entry + for entry in current_config_entries + if entry.unique_id == home_id + ), + None, + ) + ) + # Only update existing entries that are configured via sockets + and existing_entry.data.get(CONF_SOCKET_PATH) + ): + await self._async_set_addon_config( + {CONF_ADDON_SOCKET: discovery_info.socket_path} + ) + # Reloading will sync add-on options to config entry data + self.hass.config_entries.async_schedule_reload(existing_entry.entry_id) + return self.async_abort(reason="already_configured") self.socket_path = discovery_info.socket_path self.context["title_placeholders"] = { diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 42bad7e0f55e..1345247b0924 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1290,6 +1290,49 @@ async def test_esphome_discovery( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") +async def test_esphome_discovery_already_configured( + hass: HomeAssistant, + set_addon_options: AsyncMock, + addon_options: dict[str, Any], +) -> None: + """Test ESPHome discovery success path.""" + addon_options[CONF_ADDON_SOCKET] = "esphome://existing-device:6053" + addon_options["another_key"] = "should_not_be_touched" + + entry = MockConfigEntry( + entry_id="mock-entry-id", + domain=DOMAIN, + data={CONF_SOCKET_PATH: "esphome://existing-device:6053"}, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + mock_reload.assert_called_once_with(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Addon got updated + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "another_key": "should_not_be_touched", + } + ), + ) + + @pytest.mark.usefixtures("supervisor", "addon_installed") async def test_discovery_addon_not_running( hass: HomeAssistant, From 46504947f7369feafc725681084df65169e0fa5c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:44:25 -0400 Subject: [PATCH 033/103] Bump ZHA to 0.0.73 (#153007) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 86763f9c2127..307b287d8f54 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.72"], + "requirements": ["zha==0.0.73"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 633d5d6fc852..66e46fe5aa42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3235,7 +3235,7 @@ zeroconf==0.147.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.72 +zha==0.0.73 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a111c4a9e876..990227651523 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2682,7 +2682,7 @@ zeroconf==0.147.2 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.72 +zha==0.0.73 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From 1386c017336bbd4d14d881a9ee9faa1a8b4bd993 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 26 Sep 2025 01:39:00 -0400 Subject: [PATCH 034/103] Allow ZHA discovery if discovery `unique_id` conflicts with config entry (#153009) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/zha/config_flow.py | 9 ++------- tests/components/zha/test_config_flow.py | 13 ++++--------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index dab157977dfc..95c4593089b6 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -653,13 +653,8 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): """Set the flow's unique ID and update the device path in an ignored flow.""" current_entry = await self.async_set_unique_id(unique_id) - if not current_entry: - return - - if current_entry.source != SOURCE_IGNORE: - self._abort_if_unique_id_configured() - else: - # Only update the current entry if it is an ignored discovery + # Only update the current entry if it is an ignored discovery + if current_entry and current_entry.source == SOURCE_IGNORE: self._abort_if_unique_id_configured( updates={ CONF_DEVICE: { diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 0ddea074c799..cb0ad5dc6d7e 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -708,8 +708,8 @@ async def test_multiple_zha_entries_aborts(hass: HomeAssistant, mock_app) -> Non @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> None: - """Test usb flow already set up and the path does not change.""" +async def test_discovery_via_usb_duplicate_unique_id(hass: HomeAssistant) -> None: + """Test USB discovery when a config entry with a duplicate unique_id already exists.""" entry = MockConfigEntry( domain=DOMAIN, @@ -737,13 +737,8 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "/dev/ttyUSB1", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) From 4058ca59eda5cd7c5cce89b8ccc4f2b3fd7781c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 05:31:59 -0500 Subject: [PATCH 035/103] Bump aioesphomeapi to 41.11.0 (#153014) --- 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 2918f79ed2d2..5229dfddee26 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==41.10.0", + "aioesphomeapi==41.11.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 66e46fe5aa42..596365175db4 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==41.10.0 +aioesphomeapi==41.11.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 990227651523..c3dec69b1d28 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==41.10.0 +aioesphomeapi==41.11.0 # homeassistant.components.flo aioflo==2021.11.0 From cf223880e8c49741069aba022091f886c31657ba Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:34:45 +0200 Subject: [PATCH 036/103] Use satellite entity area in the assist pipeline (#153017) --- .../components/assist_pipeline/pipeline.py | 69 +++++++++++++------ .../assist_pipeline/test_pipeline.py | 19 +++-- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 8af0c9157b5b..764a036bb35c 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1308,7 +1308,9 @@ class PipelineRun: # instead of a full response. all_targets_in_satellite_area = ( self._get_all_targets_in_satellite_area( - conversation_result.response, self._device_id + conversation_result.response, + self._satellite_id, + self._device_id, ) ) @@ -1337,39 +1339,62 @@ class PipelineRun: return (speech, all_targets_in_satellite_area) def _get_all_targets_in_satellite_area( - self, intent_response: intent.IntentResponse, device_id: str | None + self, + intent_response: intent.IntentResponse, + satellite_id: str | None, + device_id: str | None, ) -> bool: """Return true if all targeted entities were in the same area as the device.""" if ( - (intent_response.response_type != intent.IntentResponseType.ACTION_DONE) - or (not intent_response.matched_states) - or (not device_id) - ): - return False - - device_registry = dr.async_get(self.hass) - - if (not (device := device_registry.async_get(device_id))) or ( - not device.area_id + intent_response.response_type != intent.IntentResponseType.ACTION_DONE + or not intent_response.matched_states ): return False entity_registry = er.async_get(self.hass) - for state in intent_response.matched_states: - entity = entity_registry.async_get(state.entity_id) - if not entity: + device_registry = dr.async_get(self.hass) + + area_id: str | None = None + + if ( + satellite_id is not None + and (target_entity_entry := entity_registry.async_get(satellite_id)) + is not None + ): + area_id = target_entity_entry.area_id + device_id = target_entity_entry.device_id + + if area_id is None: + if device_id is None: return False - if (entity_area_id := entity.area_id) is None: - if (entity.device_id is None) or ( - (entity_device := device_registry.async_get(entity.device_id)) - is None - ): + device_entry = device_registry.async_get(device_id) + if device_entry is None: + return False + + area_id = device_entry.area_id + if area_id is None: + return False + + for state in intent_response.matched_states: + target_entity_entry = entity_registry.async_get(state.entity_id) + if target_entity_entry is None: + return False + + target_area_id = target_entity_entry.area_id + if target_area_id is None: + if target_entity_entry.device_id is None: return False - entity_area_id = entity_device.area_id + target_device_entry = device_registry.async_get( + target_entity_entry.device_id + ) + if target_device_entry is None: + return False - if entity_area_id != device.area_id: + target_area_id = target_device_entry.area_id + + if target_area_id != area_id: return False return True diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index fe82f693fde1..fc2d6d18a6a6 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1797,6 +1797,7 @@ async def test_chat_log_tts_streaming( assert process_events(events) == snapshot +@pytest.mark.parametrize(("use_satellite_entity"), [True, False]) async def test_acknowledge( hass: HomeAssistant, init_components, @@ -1805,6 +1806,7 @@ async def test_acknowledge( entity_registry: er.EntityRegistry, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, + use_satellite_entity: bool, ) -> None: """Test that acknowledge sound is played when targets are in the same area.""" area_1 = area_registry.async_get_or_create("area_1") @@ -1819,12 +1821,16 @@ async def test_acknowledge( entry = MockConfigEntry() entry.add_to_hass(hass) - satellite = device_registry.async_get_or_create( + + satellite = entity_registry.async_get_or_create("assist_satellite", "test", "1234") + entity_registry.async_update_entity(satellite.entity_id, area_id=area_1.id) + + satellite_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections=set(), identifiers={("demo", "id-1234")}, ) - device_registry.async_update_device(satellite.id, area_id=area_1.id) + device_registry.async_update_device(satellite_device.id, area_id=area_1.id) events: list[assist_pipeline.PipelineEvent] = [] turn_on = async_mock_service(hass, "light", "turn_on") @@ -1837,7 +1843,8 @@ async def test_acknowledge( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input=text, session=mock_chat_session, - device_id=satellite.id, + satellite_id=satellite.entity_id if use_satellite_entity else None, + device_id=satellite_device.id if not use_satellite_entity else None, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1889,7 +1896,8 @@ async def test_acknowledge( ) # 3. Remove satellite device area - device_registry.async_update_device(satellite.id, area_id=None) + entity_registry.async_update_entity(satellite.entity_id, area_id=None) + device_registry.async_update_device(satellite_device.id, area_id=None) _reset() await _run("turn on light 1") @@ -1900,7 +1908,8 @@ async def test_acknowledge( assert len(turn_on) == 1 # Restore - device_registry.async_update_device(satellite.id, area_id=area_1.id) + entity_registry.async_update_entity(satellite.entity_id, area_id=area_1.id) + device_registry.async_update_device(satellite_device.id, area_id=area_1.id) # 4. Check device area instead of entity area light_device = device_registry.async_get_or_create( From 563b58c9aa17297c145574710d54d65ec79ee7ac Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 26 Sep 2025 11:04:02 +0200 Subject: [PATCH 037/103] Bump to home-assistant/wheels@2025.09.1 (#153025) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 984d1e91c8a2..b6a4d0832f7d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -160,7 +160,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.09.0 + uses: home-assistant/wheels@2025.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -221,7 +221,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.09.0 + uses: home-assistant/wheels@2025.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 1e808c965db92c4da1a96420c2b2caeba191959d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 26 Sep 2025 15:46:24 +0200 Subject: [PATCH 038/103] Bump pylamarzocco to 2.1.1 (#153027) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index ec55a7e8c2b1..3bf47df83a40 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.1.0"] + "requirements": ["pylamarzocco==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 596365175db4..c0bb6d785e19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2132,7 +2132,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.1.0 +pylamarzocco==2.1.1 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3dec69b1d28..572503ad36b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1777,7 +1777,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.1.0 +pylamarzocco==2.1.1 # homeassistant.components.lastfm pylast==5.1.0 From 08e81b2ba629852faf42b0f70933bc1f36279cbb Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 26 Sep 2025 13:03:39 +0200 Subject: [PATCH 039/103] Update Home Assistant base image to 2025.09.2 (#153035) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 127d66145ac6..382a7498e43d 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.2 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.2 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.2 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.2 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.2 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From d83502514a28d4699f0eecc8a89242fd3b2cc491 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 26 Sep 2025 19:48:51 +0200 Subject: [PATCH 040/103] Fix Thread flow abort on multiple flows (#153048) --- .../components/thread/config_flow.py | 14 ++- homeassistant/components/thread/manifest.json | 1 + homeassistant/generated/integrations.json | 3 +- tests/components/thread/test_config_flow.py | 87 +++++++++++++++++-- 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index bf202a50c347..42caf5d9e32c 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -5,7 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + DEFAULT_DISCOVERY_UNIQUE_ID, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -18,14 +22,18 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data: None) -> ConfigFlowResult: """Set up by import from async_setup.""" - await self._async_handle_discovery_without_unique_id() + await self.async_set_unique_id( + DEFAULT_DISCOVERY_UNIQUE_ID, raise_on_progress=False + ) return self.async_create_entry(title="Thread", data={}) async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Set up by import from async_setup.""" - await self._async_handle_discovery_without_unique_id() + await self.async_set_unique_id( + DEFAULT_DISCOVERY_UNIQUE_ID, raise_on_progress=False + ) return self.async_create_entry(title="Thread", data={}) async def async_step_zeroconf( diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 868ced022b8b..22d55f57d48d 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -8,5 +8,6 @@ "integration_type": "service", "iot_class": "local_polling", "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], + "single_config_entry": true, "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e260b37afe61..2ce0e314afb5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6807,7 +6807,8 @@ "name": "Thread", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_polling", + "single_config_entry": true }, "tibber": { "name": "Tibber", diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7feefdafedf9..1f9561ac4c7d 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,6 +3,8 @@ from ipaddress import ip_address from unittest.mock import patch +import pytest + from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -56,14 +58,18 @@ async def test_import(hass: HomeAssistant) -> None: assert config_entry.unique_id is None -async def test_import_then_zeroconf(hass: HomeAssistant) -> None: - """Test the import flow.""" +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_single_instance_allowed_zeroconf( + hass: HomeAssistant, + source: str, +) -> None: + """Test zeroconf single instance allowed abort reason.""" with patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "import"} + thread.DOMAIN, context={"source": source} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -77,7 +83,7 @@ async def test_import_then_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 @@ -152,8 +158,45 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_then_import(hass: HomeAssistant) -> None: - """Test the import flow.""" +@pytest.mark.parametrize( + ("first_source", "second_source"), [("import", "user"), ("user", "import")] +) +async def test_import_and_user( + hass: HomeAssistant, + first_source: str, + second_source: str, +) -> None: + """Test single instance allowed for user and import.""" + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": first_source} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": second_source} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_zeroconf_then_import_user( + hass: HomeAssistant, + source: str, +) -> None: + """Test single instance allowed abort reason for import/user flow.""" result = await hass.config_entries.flow.async_init( thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD ) @@ -169,9 +212,37 @@ async def test_zeroconf_then_import(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "import"} + thread.DOMAIN, context={"source": source} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize("source", ["import", "user"]) +async def test_zeroconf_in_progress_then_import_user( + hass: HomeAssistant, + source: str, +) -> None: + """Test priority (import/user) flow with zeroconf flow in progress.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": source} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_setup_entry.call_count == 1 + + flows_in_progress = hass.config_entries.flow.async_progress() + assert len(flows_in_progress) == 0 From 59fdb9f3b5179e1154c183c80b1963e798e09af8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 26 Sep 2025 21:31:47 +0200 Subject: [PATCH 041/103] Update frontend to 20250926.0 (#153049) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 618711c5354a..58a923e2dbeb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250925.1"] + "requirements": ["home-assistant-frontend==20250926.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 981a4b28a098..679f2d951cfb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250925.1 +home-assistant-frontend==20250926.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c0bb6d785e19..cd7a87283888 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250925.1 +home-assistant-frontend==20250926.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 572503ad36b4..0956ef267e5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250925.1 +home-assistant-frontend==20250926.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From 723902e2332547c7ea0692bcf8dbc594f7fb4642 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:05:10 +0000 Subject: [PATCH 042/103] NINA Use better wording for filters (#153050) --- homeassistant/components/nina/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 98ea88d8798c..99acc636bd6f 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -11,7 +11,7 @@ "_r_to_u": "City/county (R-U)", "_v_to_z": "City/county (V-Z)", "slots": "Maximum warnings per city/county", - "headline_filter": "Blacklist regex to filter warning headlines" + "headline_filter": "Headline blocklist" } } }, @@ -34,7 +34,7 @@ "_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]", "slots": "[%key:component::nina::config::step::user::data::slots%]", "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]", - "area_filter": "Whitelist regex to filter warnings based on affected areas" + "area_filter": "Affected area filter" } } }, From 66c17e250acc411b938bbae765b07ab512b05e3b Mon Sep 17 00:00:00 2001 From: SapuSeven Date: Fri, 26 Sep 2025 21:57:01 +0200 Subject: [PATCH 043/103] Add None-check for VeSync fan device.state.display_status (#153055) --- homeassistant/components/vesync/fan.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 0c28faac59f8..5eeb524bc245 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -141,7 +141,9 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): attr["active_time"] = self.device.state.active_time if hasattr(self.device.state, "display_status"): - attr["display_status"] = self.device.state.display_status.value + attr["display_status"] = getattr( + self.device.state.display_status, "value", None + ) if hasattr(self.device.state, "child_lock"): attr["child_lock"] = self.device.state.child_lock From dd01243391f8b4ecf4046b15cd09476c6b3ce07d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 26 Sep 2025 21:36:03 +0200 Subject: [PATCH 044/103] Ensure token validity in lamarzocco (#153058) --- homeassistant/components/lamarzocco/__init__.py | 2 +- homeassistant/components/lamarzocco/coordinator.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 96d4f4c61ac4..2e2c81333058 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -142,7 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - ) coordinators = LaMarzoccoRuntimeData( - LaMarzoccoConfigUpdateCoordinator(hass, entry, device), + LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client), LaMarzoccoSettingsUpdateCoordinator(hass, entry, device), LaMarzoccoScheduleUpdateCoordinator(hass, entry, device), LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index b6379f237ae4..b5fa0ed9028f 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -8,7 +8,7 @@ from datetime import timedelta import logging from typing import Any -from pylamarzocco import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.config_entries import ConfigEntry @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN -SCAN_INTERVAL = timedelta(seconds=15) +SCAN_INTERVAL = timedelta(seconds=60) SETTINGS_UPDATE_INTERVAL = timedelta(hours=8) SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30) STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15) @@ -51,6 +51,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): hass: HomeAssistant, entry: LaMarzoccoConfigEntry, device: LaMarzoccoMachine, + cloud_client: LaMarzoccoCloudClient | None = None, ) -> None: """Initialize coordinator.""" super().__init__( @@ -61,6 +62,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): update_interval=self._default_update_interval, ) self.device = device + self.cloud_client = cloud_client async def _async_update_data(self) -> None: """Do the data update.""" @@ -85,11 +87,17 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): """Class to handle fetching data from the La Marzocco API centrally.""" + cloud_client: LaMarzoccoCloudClient + async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" + # ensure token stays valid; does nothing if token is still valid + await self.cloud_client.async_get_access_token() + if self.device.websocket.connected: return + await self.device.get_dashboard() _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) From 66c6b0f5fc64c9ff65b3acf787f360c64e3950fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 26 Sep 2025 20:37:41 +0000 Subject: [PATCH 045/103] Bump version to 2025.10.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c088e0edd16..de5395948130 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index c2ac2231e925..47e28ac1083e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b2" +version = "2025.10.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 4f0a6ef9a1f9ba741d8bd648f1cb5a71076d429a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 26 Sep 2025 23:28:43 +0200 Subject: [PATCH 046/103] Update Home Assistant base image to 2025.09.3 (#153064) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 382a7498e43d..0499e2bfa2fd 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.2 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.2 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.2 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.2 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.2 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 77f897a7688ea243b127ee2089870b08168cbe66 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 26 Sep 2025 21:30:19 +0000 Subject: [PATCH 047/103] Bump version to 2025.10.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index de5395948130..2ac4965c9806 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 47e28ac1083e..c07ac97d03fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b3" +version = "2025.10.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 4e247a6ebed925e33d04a2cf4d0ae50b2f8aac7e Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 27 Sep 2025 12:29:11 +0200 Subject: [PATCH 048/103] Prevent duplicate entities for Volvo integration (#151779) --- homeassistant/components/volvo/sensor.py | 18 +- tests/components/volvo/__init__.py | 6 + .../xc90_phev_2024/energy_capabilities.json | 33 + .../fixtures/xc90_phev_2024/energy_state.json | 55 + .../fixtures/xc90_phev_2024/statistics.json | 47 + .../fixtures/xc90_phev_2024/vehicle.json | 17 + .../volvo/snapshots/test_sensor.ambr | 1310 +++++++++++++++++ tests/components/volvo/test_binary_sensor.py | 26 + tests/components/volvo/test_sensor.py | 27 + 9 files changed, 1533 insertions(+), 6 deletions(-) create mode 100644 tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json create mode 100644 tests/components/volvo/fixtures/xc90_phev_2024/statistics.json create mode 100644 tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 13614ff28302..f104fabf83b7 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -354,13 +354,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" + + entities: dict[str, VolvoSensor] = {} coordinators = entry.runtime_data.interval_coordinators - async_add_entities( - VolvoSensor(coordinator, description) - for coordinator in coordinators - for description in _DESCRIPTIONS - if description.api_field in coordinator.data - ) + + for coordinator in coordinators: + for description in _DESCRIPTIONS: + if description.key in entities: + continue + + if description.api_field in coordinator.data: + entities[description.key] = VolvoSensor(coordinator, description) + + async_add_entities(entities.values()) class VolvoSensor(VolvoEntity, SensorEntity): diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py index acd608b8d262..39eba5c702c5 100644 --- a/tests/components/volvo/__init__.py +++ b/tests/components/volvo/__init__.py @@ -27,6 +27,12 @@ _MODEL_SPECIFIC_RESPONSES = { "vehicle", ], "xc90_petrol_2019": ["commands", "statistics", "vehicle"], + "xc90_phev_2024": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], } diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json new file mode 100644 index 000000000000..c7a3cdea8c78 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json b/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json new file mode 100644 index 000000000000..43cecce6c43a --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/energy_state.json @@ -0,0 +1,55 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 87.3, + "unit": "percentage", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "electricRange": { + "status": "OK", + "value": 26, + "unit": "miles", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingType": { + "status": "OK", + "value": "NONE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "NO_POWER_AVAILABLE", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 0, + "unit": "minutes", + "updatedAt": "2025-09-05T07:58:14Z" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "ERROR_READING_PROPERTY", + "message": "Failed to retrieve property." + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json b/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json new file mode 100644 index 000000000000..41da31d05199 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/statistics.json @@ -0,0 +1,47 @@ +{ + "averageFuelConsumption": { + "value": 2.0, + "unit": "l/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageEnergyConsumption": { + "value": 19.9, + "unit": "kWh/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageFuelConsumptionAutomatic": { + "value": 0.0, + "unit": "l/100km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageSpeed": { + "value": 47, + "unit": "km/h", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "averageSpeedAutomatic": { + "value": 37, + "unit": "km/h", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "tripMeterManual": { + "value": 5935.8, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "tripMeterAutomatic": { + "value": 23.7, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "distanceToEmptyTank": { + "value": 804, + "unit": "km", + "timestamp": "2025-09-04T18:03:57.437Z" + }, + "distanceToEmptyBattery": { + "value": 43, + "unit": "km", + "timestamp": "2025-09-05T07:58:14.760Z" + } +} diff --git a/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json b/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json new file mode 100644 index 000000000000..63ea7c965f51 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_phev_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL/ELECTRIC", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 18.819, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 9d709a27fc3d..a8c1f10357a7 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -4779,3 +4779,1313 @@ 'state': '178.9', }) # --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_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.volvo_xc90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc90_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC90 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.819', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_power_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging power status', + 'options': list([ + 'fault', + 'power_available_but_not_activated', + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_power_available', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_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.volvo_xc90_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '804', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_estimated_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip automatic average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption_automatic', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption_automatic', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip automatic average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.7', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.9', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47', + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_phev_2024][sensor.volvo_xc90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5935.8', + }) +# --- diff --git a/tests/components/volvo/test_binary_sensor.py b/tests/components/volvo/test_binary_sensor.py index e581b00595c6..3d88b32f7985 100644 --- a/tests/components/volvo/test_binary_sensor.py +++ b/tests/components/volvo/test_binary_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.volvo.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,3 +32,28 @@ async def test_binary_sensor( assert await setup_integration() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + "xc90_phev_2024", + ], +) +async def test_unique_ids( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test binary sensor for unique id's.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await setup_integration() + + assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index 988777cd7739..05571ff8cac1 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.volvo.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,6 +23,7 @@ from tests.common import MockConfigEntry, snapshot_platform "xc40_electric_2024", "xc60_phev_2020", "xc90_petrol_2019", + "xc90_phev_2024", ], ) async def test_sensor( @@ -89,3 +91,28 @@ async def test_charging_power_value( assert await setup_integration() assert hass.states.get("sensor.volvo_ex30_charging_power").state == "0" + + +@pytest.mark.usefixtures("mock_api", "full_model") +@pytest.mark.parametrize( + "full_model", + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + "xc90_phev_2024", + ], +) +async def test_unique_ids( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sensor for unique id's.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert f"Platform {DOMAIN} does not generate unique IDs" not in caplog.text From 2b5f9898559d2c5bc7310513ce807825573ec677 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 28 Sep 2025 22:45:11 +0300 Subject: [PATCH 049/103] Add Shelly EV charger sensors (#152722) --- homeassistant/components/shelly/icons.json | 3 + homeassistant/components/shelly/sensor.py | 36 ++++ homeassistant/components/shelly/strings.json | 12 ++ .../shelly/snapshots/test_sensor.ambr | 182 ++++++++++++++++++ tests/components/shelly/test_sensor.py | 68 +++++++ 5 files changed, 301 insertions(+) diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 832cf2b4c8f2..dfc5cbc2e68d 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -20,6 +20,9 @@ } }, "sensor": { + "charger_state": { + "default": "mdi:ev-station" + }, "detected_objects": { "default": "mdi:account-group" }, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 6e840bc67a68..08a527591e01 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, ) @@ -1489,6 +1490,41 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, role="water_temperature", ), + "number_work_state": RpcSensorDescription( + key="number", + sub_key="value", + translation_key="charger_state", + device_class=SensorDeviceClass.ENUM, + options=[ + "charger_charging", + "charger_end", + "charger_fault", + "charger_free", + "charger_free_fault", + "charger_insert", + "charger_pause", + "charger_wait", + ], + role="work_state", + ), + "number_energy_charge": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + role="energy_charge", + ), + "number_time_charge": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DURATION, + role="time_charge", + ), "presence_num_objects": RpcSensorDescription( key="presence", sub_key="num_objects", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 1a11ecbb4993..294c5937ab09 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -141,6 +141,18 @@ } }, "sensor": { + "charger_state": { + "state": { + "charger_charging": "[%key:common::state::charging%]", + "charger_end": "Charge completed", + "charger_fault": "Error while charging", + "charger_free": "[%key:component::binary_sensor::entity_component::plug::state::off%]", + "charger_free_fault": "Can not release plug", + "charger_insert": "[%key:component::binary_sensor::entity_component::plug::state::on%]", + "charger_pause": "Charging paused by charger", + "charger_wait": "Charging paused by vehicle" + } + }, "detected_objects": { "unit_of_measurement": "objects" }, diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 4b12dddae627..6188d44922c0 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -157,6 +157,188 @@ 'state': '0', }) # --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charger_charging', + 'charger_end', + 'charger_fault', + 'charger_free', + 'charger_free_fault', + 'charger_insert', + 'charger_pause', + 'charger_wait', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_charger_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger state', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_state', + 'unique_id': '123456789ABC-number:200-number_work_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test name Charger state', + 'options': list([ + 'charger_charging', + 'charger_end', + 'charger_fault', + 'charger_free', + 'charger_free_fault', + 'charger_insert', + 'charger_pause', + 'charger_wait', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_name_charger_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charger_charging', + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_session_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session duration', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:202-number_time_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test name Session duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_session_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_session_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:201-number_energy_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Session energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_session_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 1bf2a0e60a95..015afdd36611 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1672,6 +1672,74 @@ async def test_rpc_switch_no_returned_energy_sensor( assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None +async def test_rpc_shelly_ev_sensors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Shelly EV sensors.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": "Charger state", + "meta": { + "ui": { + "titles": { + "charger_charging": "Charging", + "charger_end": "End", + "charger_fault": "Fault", + "charger_free": "Free", + "charger_free_fault": "Free fault", + "charger_insert": "Insert", + "charger_pause": "Pause", + "charger_wait": "Wait", + }, + "view": "label", + } + }, + "options": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault", + ], + "role": "work_state", + } + config["number:201"] = { + "name": "Session energy", + "meta": {"ui": {"unit": "Wh", "view": "label"}}, + "role": "energy_charge", + } + config["number:202"] = { + "name": "Session duration", + "meta": {"ui": {"unit": "min", "view": "label"}}, + "role": "time_charge", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": "charger_charging"} + status["number:201"] = {"value": 5000} + status["number:202"] = {"value": 60} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + for entity in ("charger_state", "session_energy", "session_duration"): + entity_id = f"{SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + async def test_block_friendly_name_sleeping_sensor( hass: HomeAssistant, mock_block_device: Mock, From eb103a8d9a9dd5ef994f9956920d81c7b4ff4c76 Mon Sep 17 00:00:00 2001 From: Christian McHugh Date: Sat, 27 Sep 2025 19:19:57 +0100 Subject: [PATCH 050/103] Fix: Set EPH climate heating as on only when boiler is actively heating (#152914) --- homeassistant/components/ephember/climate.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 8e72457f4a7b..85b21da1dd51 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -3,14 +3,15 @@ from __future__ import annotations from datetime import timedelta +from enum import IntEnum import logging from typing import Any from pyephember2.pyephember2 import ( EphEmber, ZoneMode, + boiler_state, zone_current_temperature, - zone_is_active, zone_is_hotwater, zone_mode, zone_name, @@ -53,6 +54,15 @@ EPH_TO_HA_STATE = { "OFF": HVACMode.OFF, } + +class EPHBoilerStates(IntEnum): + """Boiler states for a zone given by the api.""" + + FIXME = 0 + OFF = 1 + ON = 2 + + HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()} @@ -123,7 +133,7 @@ class EphEmberThermostat(ClimateEntity): @property def hvac_action(self) -> HVACAction: """Return current HVAC action.""" - if zone_is_active(self._zone): + if boiler_state(self._zone) == EPHBoilerStates.ON: return HVACAction.HEATING return HVACAction.IDLE From a01eb48db837d37310d52eaff1b9d05a8767a75f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 27 Sep 2025 14:32:25 +0200 Subject: [PATCH 051/103] Portainer switch terminology to API token (#152958) Co-authored-by: Norbert Rittel --- .../components/portainer/__init__.py | 25 ++++++++++++++++--- .../components/portainer/config_flow.py | 24 ++++++++++-------- .../components/portainer/coordinator.py | 4 +-- .../components/portainer/strings.json | 10 ++++---- tests/components/portainer/conftest.py | 7 +++--- .../components/portainer/test_config_flow.py | 7 +++--- tests/components/portainer/test_init.py | 24 ++++++++++++++++++ 7 files changed, 75 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index b945e60b545c..ad57e66186d6 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -5,7 +5,14 @@ from __future__ import annotations from pyportainer import Portainer from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL, Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_TOKEN, + CONF_HOST, + CONF_URL, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -20,8 +27,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> """Set up Portainer from a config entry.""" client = Portainer( - api_url=entry.data[CONF_HOST], - api_key=entry.data[CONF_API_KEY], + api_url=entry.data[CONF_URL], + api_key=entry.data[CONF_API_TOKEN], session=async_create_clientsession( hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL] ), @@ -39,3 +46,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version < 2: + data = dict(entry.data) + data[CONF_URL] = data.pop(CONF_HOST) + data[CONF_API_TOKEN] = data.pop(CONF_API_KEY) + hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + + return True diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 2fc4f3a722a2..b7cb0ba8b990 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -14,7 +14,7 @@ from pyportainer import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -24,8 +24,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_API_KEY): str, + vol.Required(CONF_URL): str, + vol.Required(CONF_API_TOKEN): str, vol.Optional(CONF_VERIFY_SSL, default=True): bool, } ) @@ -35,9 +35,11 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" client = Portainer( - api_url=data[CONF_HOST], - api_key=data[CONF_API_KEY], - session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]), + api_url=data[CONF_URL], + api_key=data[CONF_API_TOKEN], + session=async_get_clientsession( + hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True) + ), ) try: await client.get_endpoints() @@ -48,19 +50,21 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: except PortainerTimeoutError as err: raise PortainerTimeout from err - _LOGGER.debug("Connected to Portainer API: %s", data[CONF_HOST]) + _LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL]) class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Portainer.""" + VERSION = 2 + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) try: await _validate_input(self.hass, user_input) except CannotConnect: @@ -73,10 +77,10 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_API_KEY]) + await self.async_set_unique_id(user_input[CONF_API_TOKEN]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input + title=user_input[CONF_URL], data=user_input ) return self.async_show_form( diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 988ae319bab1..378f5f342811 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -16,7 +16,7 @@ from pyportainer.models.docker import DockerContainer from pyportainer.models.portainer import Endpoint from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -87,7 +87,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: """Fetch data from Portainer API.""" _LOGGER.debug( - "Fetching data from Portainer API: %s", self.config_entry.data[CONF_HOST] + "Fetching data from Portainer API: %s", self.config_entry.data[CONF_URL] ) try: diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index acdd0d362a3b..083a6763b40f 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -3,16 +3,16 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]", + "api_token": "[%key:common::config_flow::data::api_token%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The host/URL, including the port, of your Portainer instance", - "api_key": "The API key for authenticating with Portainer", + "url": "The URL, including the port, of your Portainer instance", + "api_token": "The API access token for authenticating with Portainer", "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" }, - "description": "You can create an API key in the Portainer UI. Go to **My account > API keys** and select **Add API key**" + "description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**" } }, "error": { diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index d6127c434402..21298da10484 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -8,13 +8,13 @@ from pyportainer.models.portainer import Endpoint import pytest from homeassistant.components.portainer.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from tests.common import MockConfigEntry, load_json_array_fixture MOCK_TEST_CONFIG = { - CONF_HOST: "https://127.0.0.1:9000/", - CONF_API_KEY: "test_api_key", + CONF_URL: "https://127.0.0.1:9000/", + CONF_API_TOKEN: "test_api_token", CONF_VERIFY_SSL: True, } @@ -61,4 +61,5 @@ def mock_config_entry() -> MockConfigEntry: title="Portainer test", data=MOCK_TEST_CONFIG, entry_id="portainer_test_entry_123", + version=2, ) diff --git a/tests/components/portainer/test_config_flow.py b/tests/components/portainer/test_config_flow.py index 50115398c79b..a2806b530418 100644 --- a/tests/components/portainer/test_config_flow.py +++ b/tests/components/portainer/test_config_flow.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -20,8 +20,9 @@ from .conftest import MOCK_TEST_CONFIG from tests.common import MockConfigEntry MOCK_USER_SETUP = { - CONF_HOST: "https://127.0.0.1:9000/", - CONF_API_KEY: "test_api_key", + CONF_URL: "https://127.0.0.1:9000/", + CONF_API_TOKEN: "test_api_token", + CONF_VERIFY_SSL: True, } diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 8c82208752e6..00b4d5940e93 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -9,7 +9,9 @@ from pyportainer.exceptions import ( ) import pytest +from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant from . import setup_integration @@ -36,3 +38,25 @@ async def test_setup_exceptions( mock_portainer_client.get_endpoints.side_effect = exception await setup_integration(hass, mock_config_entry) assert mock_config_entry.state == expected_state + + +async def test_v1_migration(hass: HomeAssistant) -> None: + """Test migration from v1 to v2 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://test_host", + CONF_API_KEY: "test_key", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert CONF_HOST not in entry.data + assert CONF_API_KEY not in entry.data + assert entry.data[CONF_URL] == "http://test_host" + assert entry.data[CONF_API_TOKEN] == "test_key" From a6a6261168ce9236cc2728da159dd7fc586d75f5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 29 Sep 2025 18:29:58 +0200 Subject: [PATCH 052/103] Improve hardware flow strings (#153034) --- .../homeassistant_connect_zbt2/strings.json | 34 ++++++++++++------- .../homeassistant_hardware/strings.json | 16 ++++++--- .../homeassistant_sky_connect/strings.json | 34 ++++++++++++------- .../homeassistant_yellow/strings.json | 17 ++++++---- 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/homeassistant_connect_zbt2/strings.json b/homeassistant/components/homeassistant_connect_zbt2/strings.json index 20d340216e95..1fc7d4d70fbf 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/strings.json +++ b/homeassistant/components/homeassistant_connect_zbt2/strings.json @@ -27,6 +27,12 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "install_thread_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]" + }, + "install_zigbee_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]" + }, "notify_channel_change": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" @@ -69,12 +75,10 @@ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" }, "install_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]" }, "start_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]" }, "otbr_failed": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", @@ -129,14 +133,21 @@ }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", + "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" } }, "config": { "flow_title": "{model}", "step": { + "install_thread_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]" + }, + "install_zigbee_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]" + }, "pick_firmware": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", @@ -158,12 +169,10 @@ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" }, "install_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]" }, "start_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]" }, "otbr_failed": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", @@ -215,9 +224,10 @@ }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", + "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" } }, "exceptions": { diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index a33dae153779..07ed06761fe8 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -23,12 +23,16 @@ "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration." }, "install_otbr_addon": { - "title": "Installing OpenThread Border Router add-on", - "description": "The OpenThread Border Router (OTBR) add-on is being installed." + "title": "Configuring Thread" + }, + "install_thread_firmware": { + "title": "Updating adapter" + }, + "install_zigbee_firmware": { + "title": "Updating adapter" }, "start_otbr_addon": { - "title": "Starting OpenThread Border Router add-on", - "description": "The OpenThread Border Router (OTBR) add-on is now starting." + "title": "Configuring Thread" }, "otbr_failed": { "title": "Failed to set up OpenThread Border Router", @@ -72,7 +76,9 @@ "fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information." }, "progress": { - "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." + "install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.", + "install_otbr_addon": "Installing add-on", + "start_otbr_addon": "Starting add-on" } } }, diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 20d340216e95..c2f02897b459 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -27,6 +27,12 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "install_thread_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]" + }, + "install_zigbee_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]" + }, "notify_channel_change": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" @@ -69,12 +75,10 @@ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" }, "install_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]" }, "start_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]" }, "otbr_failed": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", @@ -129,9 +133,10 @@ }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", + "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" } }, "config": { @@ -158,12 +163,16 @@ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" }, "install_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]" + }, + "install_thread_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]" + }, + "install_zigbee_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]" }, "start_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]" }, "otbr_failed": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", @@ -215,9 +224,10 @@ }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", + "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" } }, "exceptions": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 3d5da55bb92b..f25e2b6d2bd8 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -35,6 +35,12 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "install_thread_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_thread_firmware::title%]" + }, + "install_zigbee_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_firmware::title%]" + }, "notify_channel_change": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" @@ -92,12 +98,10 @@ "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" }, "install_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]" }, "start_otbr_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]" }, "otbr_failed": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", @@ -154,9 +158,10 @@ }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", + "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" } }, "entity": { From ef16327b2be209674c36f2936b574ef87078dca2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 29 Sep 2025 12:06:07 +0200 Subject: [PATCH 053/103] Add `consumed energy` sensor for Shelly `pm1` and `switch` components (#153053) --- homeassistant/components/shelly/sensor.py | 50 +- .../shelly/snapshots/test_devices.ambr | 472 +++++++++++------- .../shelly/snapshots/test_sensor.ambr | 75 ++- tests/components/shelly/test_sensor.py | 78 ++- 4 files changed, 486 insertions(+), 189 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 08a527591e01..ced5f46be3a8 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -122,6 +122,23 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): return self.option_map[attribute_value] +class RpcConsumedEnergySensor(RpcSensor): + """Represent a RPC sensor.""" + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + total_energy = self.status["aenergy"]["total"] + + if not isinstance(total_energy, float): + return None + + if not isinstance(self.attribute_value, float): + return None + + return total_energy - self.attribute_value + + class RpcPresenceSensor(RpcSensor): """Represent a RPC presence sensor.""" @@ -885,7 +902,7 @@ RPC_SENSORS: Final = { "energy": RpcSensorDescription( key="switch", sub_key="aenergy", - name="Energy", + name="Total energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: status["total"], @@ -903,7 +920,22 @@ RPC_SENSORS: Final = { suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + removal_condition=lambda _config, status, key: ( + status[key].get("ret_aenergy") is None + ), + ), + "consumed_energy_switch": RpcSensorDescription( + key="switch", + sub_key="ret_aenergy", + name="Consumed energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + entity_class=RpcConsumedEnergySensor, removal_condition=lambda _config, status, key: ( status[key].get("ret_aenergy") is None ), @@ -922,7 +954,7 @@ RPC_SENSORS: Final = { "energy_pm1": RpcSensorDescription( key="pm1", sub_key="aenergy", - name="Energy", + name="Total energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: status["total"], @@ -933,7 +965,18 @@ RPC_SENSORS: Final = { "ret_energy_pm1": RpcSensorDescription( key="pm1", sub_key="ret_aenergy", - name="Total active returned energy", + name="Returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "consumed_energy_pm1": RpcSensorDescription( + key="pm1", + sub_key="ret_aenergy", + name="Consumed energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda status, _: status["total"], @@ -941,6 +984,7 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + entity_class=RpcConsumedEnergySensor, ), "energy_cct": RpcSensorDescription( key="cct", diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 74c50691ce81..47c952258d51 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -546,65 +546,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_name_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-cover:0-energy', - 'unit_of_measurement': , - }) -# --- -# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_shelly_2pm_gen3_cover[sensor.test_name_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -826,6 +767,65 @@ 'state': '36.4', }) # --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1743,6 +1743,65 @@ 'state': '-52', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_consumed_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_switch_0_consumed_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumed energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-consumed_energy_switch', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_consumed_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Consumed energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_consumed_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1799,65 +1858,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_name_switch_0_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-switch:0-energy', - 'unit_of_measurement': , - }) -# --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name Switch 0 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_switch_0_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2085,6 +2085,65 @@ 'state': '40.6', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_switch_0_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2141,6 +2200,65 @@ 'state': '216.2', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_consumed_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_switch_1_consumed_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumed energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-consumed_energy_switch', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_consumed_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Consumed energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_consumed_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2197,65 +2315,6 @@ 'state': '0.0', }) # --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_name_switch_1_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'shelly', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456789ABC-switch:1-energy', - 'unit_of_measurement': , - }) -# --- -# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name Switch 1 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_name_switch_1_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2483,6 +2542,65 @@ 'state': '40.6', }) # --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_switch_1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 6188d44922c0..3e849287bd73 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -339,7 +339,7 @@ 'state': '5.0', }) # --- -# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-entry] +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_consumed_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -354,7 +354,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_test_switch_0_energy', + 'entity_id': 'sensor.test_name_test_switch_0_consumed_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -372,30 +372,30 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'test switch_0 energy', + 'original_name': 'test switch_0 consumed energy', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC-switch:0-energy', + 'unique_id': '123456789ABC-switch:0-consumed_energy_switch', 'unit_of_measurement': , }) # --- -# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-state] +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_consumed_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Test name test switch_0 energy', + 'friendly_name': 'Test name test switch_0 consumed energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_test_switch_0_energy', + 'entity_id': 'sensor.test_name_test_switch_0_consumed_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1234.56789', + 'state': '1135.80246', }) # --- # name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_returned_energy-entry] @@ -457,3 +457,62 @@ 'state': '98.76543', }) # --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_test_switch_0_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test switch_0 total energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name test switch_0 total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_test_switch_0_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.56789', + }) +# --- diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 015afdd36611..8bca4ce38ab6 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1640,7 +1640,7 @@ async def test_rpc_switch_energy_sensors( monkeypatch.setattr(mock_rpc_device, "status", status) await init_integration(hass, 3) - for entity in ("energy", "returned_energy"): + for entity in ("total_energy", "returned_energy", "consumed_energy"): entity_id = f"{SENSOR_DOMAIN}.test_name_test_switch_0_{entity}" state = hass.states.get(entity_id) @@ -1670,6 +1670,7 @@ async def test_rpc_switch_no_returned_energy_sensor( await init_integration(hass, 3) assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None + assert hass.states.get("sensor.test_name_test_switch_0_consumed_energy") is None async def test_rpc_shelly_ev_sensors( @@ -1864,3 +1865,78 @@ async def test_rpc_presencezone_component( assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_pm1_consumed_energy_sensor( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test energy sensors for switch component.""" + status = { + "sys": {}, + "pm1:0": { + "id": 0, + "voltage": 235.0, + "current": 0.957, + "apower": -220.3, + "freq": 50.0, + "aenergy": {"total": 3000.000}, + "ret_aenergy": {"total": 1000.000}, + }, + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + assert (state := hass.states.get(f"{SENSOR_DOMAIN}.test_name_total_energy")) + assert state.state == "3.0" + + assert (state := hass.states.get(f"{SENSOR_DOMAIN}.test_name_returned_energy")) + assert state.state == "1.0" + + entity_id = f"{SENSOR_DOMAIN}.test_name_consumed_energy" + # consumed energy = total energy - returned energy + assert (state := hass.states.get(entity_id)) + assert state.state == "2.0" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-pm1:0-consumed_energy_pm1" + + +@pytest.mark.parametrize(("key"), ["aenergy", "ret_aenergy"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_pm1_consumed_energy_sensor_non_float_value( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + key: str, +) -> None: + """Test energy sensors for switch component.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_consumed_energy" + status = { + "sys": {}, + "pm1:0": { + "id": 0, + "voltage": 235.0, + "current": 0.957, + "apower": -220.3, + "freq": 50.0, + "aenergy": {"total": 3000.000}, + "ret_aenergy": {"total": 1000.000}, + }, + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + assert (state := hass.states.get(entity_id)) + assert state.state == "2.0" + + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "pm1:0", key, {"total": None} + ) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN From 9a969cea6367353ec6d44850148b2f558dfc9925 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 28 Sep 2025 15:34:56 +0200 Subject: [PATCH 054/103] Ensure togrill detects disconnected devices (#153067) --- .../components/togrill/coordinator.py | 30 +++++++-- tests/components/togrill/conftest.py | 11 +++- tests/components/togrill/test_sensor.py | 61 ++++++++++++++++++- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 391561d477a3..dda205002358 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -139,7 +139,11 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack raise DeviceNotFound("Unable to find device") try: - client = await Client.connect(device, self._notify_callback) + client = await Client.connect( + device, + self._notify_callback, + disconnected_callback=self._disconnected_callback, + ) except BleakError as exc: self.logger.debug("Connection failed", exc_info=True) raise DeviceNotFound("Unable to connect to device") from exc @@ -169,9 +173,6 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack self.client = None async def _get_connected_client(self) -> Client: - if self.client and not self.client.is_connected: - await self.client.disconnect() - self.client = None if self.client: return self.client @@ -196,6 +197,12 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: """Poll the device.""" + if self.client and not self.client.is_connected: + await self.client.disconnect() + self.client = None + self._async_request_refresh_soon() + raise DeviceFailed("Device was disconnected") + client = await self._get_connected_client() try: await client.request(PacketA0Notify) @@ -206,6 +213,17 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack raise DeviceFailed(f"Device failed {exc}") from exc return self.data + @callback + def _async_request_refresh_soon(self) -> None: + self.config_entry.async_create_task( + self.hass, self.async_request_refresh(), eager_start=False + ) + + @callback + def _disconnected_callback(self) -> None: + """Handle Bluetooth device being disconnected.""" + self._async_request_refresh_soon() + @callback def _async_handle_bluetooth_event( self, @@ -213,5 +231,5 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - if not self.client and isinstance(self.last_exception, DeviceNotFound): - self.hass.async_create_task(self.async_refresh()) + if isinstance(self.last_exception, DeviceNotFound): + self._async_request_refresh_soon() diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py index 6b028ca52700..c58bc0698a9f 100644 --- a/tests/components/togrill/conftest.py +++ b/tests/components/togrill/conftest.py @@ -57,9 +57,18 @@ def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mo client_object.mocked_notify = None async def _connect( - address: str, callback: Callable[[Packet], None] | None = None + address: str, + callback: Callable[[Packet], None] | None = None, + disconnected_callback: Callable[[], None] | None = None, ) -> Mock: client_object.mocked_notify = callback + if disconnected_callback: + + def _disconnected_callback(): + client_object.is_connected = False + disconnected_callback() + + client_object.mocked_disconnected_callback = _disconnected_callback return client_object async def _disconnect() -> None: diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py index d7662d483af5..913a295d3795 100644 --- a/tests/components/togrill/test_sensor.py +++ b/tests/components/togrill/test_sensor.py @@ -1,7 +1,8 @@ """Test sensors for ToGrill integration.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch +from habluetooth import BluetoothServiceInfoBleak import pytest from syrupy.assertion import SnapshotAssertion from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify @@ -16,6 +17,16 @@ from tests.common import MockConfigEntry, snapshot_platform from tests.components.bluetooth import inject_bluetooth_service_info +def patch_async_ble_device_from_address( + return_value: BluetoothServiceInfoBleak | None = None, +): + """Patch async_ble_device_from_address to return a mocked BluetoothServiceInfoBleak.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + @pytest.mark.parametrize( "packets", [ @@ -57,3 +68,51 @@ async def test_setup( mock_client.mocked_notify(packet) await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +async def test_device_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the switch set.""" + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + entity_id = "sensor.pro_05_battery" + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" + + with patch_async_ble_device_from_address(): + mock_client.mocked_disconnected_callback() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + +async def test_device_discovered( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the switch set.""" + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + entity_id = "sensor.pro_05_battery" + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" From d8f6f17a4f38a6876c63273f12e2472af2483f00 Mon Sep 17 00:00:00 2001 From: Kyle Worrall <65330257+kylewhirl@users.noreply.github.com> Date: Mon, 29 Sep 2025 05:50:36 -0700 Subject: [PATCH 055/103] Fix for Hue Integration motion aware areas (#153079) Co-authored-by: Marcel van der Veldt Co-authored-by: Joost Lekkerkerker --- .../components/hue/v2/binary_sensor.py | 6 ++++- tests/components/hue/test_binary_sensor.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index da28fd1f6a94..7b7717cbf76b 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -145,7 +145,11 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity): if not self.resource.enabled: # Force None (unknown) if the sensor is set to disabled in Hue return None - return self.resource.motion.value + if not (motion_feature := self.resource.motion): + return None + if motion_feature.motion_report is not None: + return motion_feature.motion_report.motion + return motion_feature.motion # pylint: disable-next=hass-enforce-class-module diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 02b4d93acfed..8fc2043d45aa 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -123,6 +123,29 @@ async def test_binary_sensor_add_update( test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "on" + # NEW: prefer motion_report.motion when present (should turn on even if plain motion is False) + updated_sensor = { + **FAKE_BINARY_SENSOR, + "motion": { + "motion": False, + "motion_report": {"changed": "2025-01-01T00:00:00Z", "motion": True}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + assert hass.states.get(test_entity_id).state == "on" + + # NEW: motion_report False should turn it off (even if plain motion is True) + updated_sensor = { + **FAKE_BINARY_SENSOR, + "motion": { + "motion": True, + "motion_report": {"changed": "2025-01-01T00:00:01Z", "motion": False}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + assert hass.states.get(test_entity_id).state == "off" async def test_grouped_motion_sensor( From eaf264361ffa360f73d973fa09a8dfd45a38a771 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 27 Sep 2025 14:40:29 +0200 Subject: [PATCH 056/103] Fix can exclude optional holidays in workday (#153082) --- .../components/workday/config_flow.py | 1 + tests/components/workday/test_config_flow.py | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 20d9040e527a..f3b139b27c07 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -155,6 +155,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: subdiv=province, years=year, language=language, + categories=[PUBLIC, *user_input.get(CONF_CATEGORY, [])], ) else: diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index c618c5fd8303..b9cbde31e54e 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.workday.const import ( CONF_CATEGORY, CONF_EXCLUDES, CONF_OFFSET, + CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, @@ -702,6 +703,53 @@ async def test_form_with_categories(hass: HomeAssistant) -> None: } +async def test_form_with_categories_can_remove_day(hass: HomeAssistant) -> None: + """Test optional categories, days can be removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "CH", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "FR", + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: ["Berchtoldstag"], + CONF_LANGUAGE: "de", + CONF_CATEGORY: [OPTIONAL], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "CH", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "province": "FR", + "remove_holidays": ["Berchtoldstag"], + "language": "de", + "category": ["optional"], + } + + async def test_options_form_removes_subdiv(hass: HomeAssistant) -> None: """Test we get the form in options when removing a configured subdivision.""" From 54b174998600bab5198e53bd4030787046de43ec Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 27 Sep 2025 13:05:07 +0200 Subject: [PATCH 057/103] Remove redundant code for Alexa Devices (#153083) --- homeassistant/components/alexa_devices/binary_sensor.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 296f4c417f02..010a561fa77e 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -75,13 +75,6 @@ async def async_setup_entry( "detectionState", ) - async_add_entities( - AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in BINARY_SENSORS - for serial_num in coordinator.data - if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) - ) - known_devices: set[str] = set() def _check_device() -> None: From 07d7f4e18d354ef1f2dbbad2b74d87b8d410bd11 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 29 Sep 2025 14:49:38 +0200 Subject: [PATCH 058/103] Add timeout to dnsip (to handle stale connections) (#153086) --- homeassistant/components/dnsip/sensor.py | 21 ++++++-- tests/components/dnsip/__init__.py | 5 ++ tests/components/dnsip/test_sensor.py | 67 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index d093698e26b6..e22155a24e8b 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta from ipaddress import IPv4Address, IPv6Address import logging @@ -88,8 +89,8 @@ class WanIpSensor(SensorEntity): self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname - self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port) - self.resolver.nameservers = [resolver] + self.port = port + self._resolver = resolver self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { @@ -103,14 +104,26 @@ class WanIpSensor(SensorEntity): model=aiodns.__version__, name=name, ) + self.resolver: aiodns.DNSResolver + self.create_dns_resolver() + + def create_dns_resolver(self) -> None: + """Create the DNS resolver.""" + self.resolver = aiodns.DNSResolver(tcp_port=self.port, udp_port=self.port) + self.resolver.nameservers = [self._resolver] async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" + if self.resolver._closed: # noqa: SLF001 + self.create_dns_resolver() + response = None try: - response = await self.resolver.query(self.hostname, self.querytype) + async with asyncio.timeout(10): + response = await self.resolver.query(self.hostname, self.querytype) + except TimeoutError: + await self.resolver.close() except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) - response = None if response: sorted_ips = sort_ips( diff --git a/tests/components/dnsip/__init__.py b/tests/components/dnsip/__init__.py index a0e6b7c81b85..254aad8f1da1 100644 --- a/tests/components/dnsip/__init__.py +++ b/tests/components/dnsip/__init__.py @@ -23,6 +23,7 @@ class RetrieveDNS: self.nameservers = nameservers self._nameservers = ["1.2.3.4"] self.error = error + self._closed = False async def query(self, hostname, qtype) -> list[QueryResult]: """Return information.""" @@ -47,3 +48,7 @@ class RetrieveDNS: @nameservers.setter def nameservers(self, value: list[str]) -> None: self._nameservers = value + + async def close(self) -> None: + """Close the resolver.""" + self._closed = True diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 66cb5cc6ad99..87e03ebceb81 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -171,3 +171,70 @@ async def test_sensor_no_response( state = hass.states.get("sensor.home_assistant_io") assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_timeout( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the DNS IP sensor with timeout.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + dns_mock = RetrieveDNS() + with patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=dns_mock, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_assistant_io") + + assert state.state == "1.1.1.1" + + with ( + patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=dns_mock, + ), + patch( + "homeassistant.components.dnsip.sensor.asyncio.timeout", + side_effect=TimeoutError(), + ), + ): + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Allows 2 retries before going unavailable + state = hass.states.get("sensor.home_assistant_io") + assert state.state == "1.1.1.1" + assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_assistant_io") + assert state.state == STATE_UNAVAILABLE From 6783c4ad831ab094537c034c212da4fba8f629f5 Mon Sep 17 00:00:00 2001 From: Luca Graf Date: Sun, 28 Sep 2025 16:04:22 +0200 Subject: [PATCH 059/103] Ignore gateway device in ViCare integration (#153097) --- homeassistant/components/vicare/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index bcf41223d3fe..f8b74730e576 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -19,6 +19,7 @@ PLATFORMS = [ UNSUPPORTED_DEVICES = [ "Heatbox1", "Heatbox2_SRC", + "E3_TCU10_x07", "E3_TCU41_x04", "E3_FloorHeatingCircuitChannel", "E3_FloorHeatingCircuitDistributorBox", From 2dd0d69bcd53632f73ae54ee7aef97b1da2de83e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 27 Sep 2025 23:24:39 +0200 Subject: [PATCH 060/103] Bump deebot-client to 15.0.0 (#153125) --- homeassistant/components/ecovacs/image.py | 4 +++- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index b1c2f0075f12..5fa00fc5e434 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -69,7 +69,9 @@ class EcovacsMap( await super().async_added_to_hass() async def on_info(event: CachedMapInfoEvent) -> None: - self._attr_extra_state_attributes["map_name"] = event.name + for map_obj in event.maps: + if map_obj.using: + self._attr_extra_state_attributes["map_name"] = map_obj.name async def on_changed(event: MapChangedEvent) -> None: self._attr_image_last_updated = event.when diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 3495126fd15f..8d57eda6f4ca 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==15.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd7a87283888..9179c4b21ee9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==14.0.0 +deebot-client==15.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0956ef267e5a..84750ce86c42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -682,7 +682,7 @@ debugpy==1.8.16 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==14.0.0 +deebot-client==15.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 8466dbf69fa2626504cf39ba40a37d44328fe0e7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 27 Sep 2025 23:22:39 +0200 Subject: [PATCH 061/103] Fix event range in workday calendar (#153128) --- homeassistant/components/workday/calendar.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/workday/calendar.py b/homeassistant/components/workday/calendar.py index b6c7893b142d..82f2942d1f93 100644 --- a/homeassistant/components/workday/calendar.py +++ b/homeassistant/components/workday/calendar.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from holidays import HolidayBase @@ -15,8 +15,6 @@ from . import WorkdayConfigEntry from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS from .entity import BaseWorkdayEntity -CALENDAR_DAYS_AHEAD = 365 - async def async_setup_entry( hass: HomeAssistant, @@ -73,8 +71,10 @@ class WorkdayCalendarEntity(BaseWorkdayEntity, CalendarEntity): def update_data(self, now: datetime) -> None: """Update data.""" event_list = [] - for i in range(CALENDAR_DAYS_AHEAD): - future_date = now.date() + timedelta(days=i) + start_date = date(now.year, 1, 1) + end_number_of_days = date(now.year + 1, 12, 31) - start_date + for i in range(end_number_of_days.days + 1): + future_date = start_date + timedelta(days=i) if self.date_is_workday(future_date): event = CalendarEvent( summary=self._name, From f7265c85d0260a01c21d6d294e49e25fc473aaa4 Mon Sep 17 00:00:00 2001 From: Tom Matheussen Date: Mon, 29 Sep 2025 03:37:35 +0200 Subject: [PATCH 062/103] Fix entities not being created when adding subentries for Satel Integra (#153139) --- homeassistant/components/satel_integra/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index bf387cff96c0..2ffcd243d39b 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -197,6 +197,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo def _close(*_): controller.close() + entry.async_on_unload(entry.add_update_listener(update_listener)) entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -239,3 +240,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bo controller.close() return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: SatelConfigEntry) -> None: + """Handle options update.""" + hass.config_entries.async_schedule_reload(entry.entry_id) From b92e5d71310207cb2d64101273330c019d88bef0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 28 Sep 2025 11:05:15 -0700 Subject: [PATCH 063/103] Add missing translations for Model Context Protocol integration (#153147) --- homeassistant/components/mcp/strings.json | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 780b4818666d..5614609ecd4f 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -9,6 +9,18 @@ "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" } }, + "credentials_choice": { + "title": "Choose how to authenticate with the MCP server", + "description": "You can either use existing credentials from another integration or set up new credentials.", + "menu_options": { + "new_credentials": "Set up new credentials", + "pick_implementation": "Use existing credentials" + }, + "menu_option_descriptions": { + "new_credentials": "You will be guided through setting up a new OAuth Client ID and secret.", + "pick_implementation": "You may use previously entered OAuth credentials." + } + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", "data": { @@ -27,14 +39,21 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_capabilities": "The MCP server does not support a required capability (Tools)", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" } } } From af2888331d7f5eb43c4d0dd0dbf07f3a6de18ac2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 28 Sep 2025 21:55:39 +0200 Subject: [PATCH 064/103] Bump reolink-aio to 0.16.0 (#153161) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 634b8d909e65..c547aee39c2f 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.15.2"] + "requirements": ["reolink-aio==0.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9179c4b21ee9..14ba8380d513 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2701,7 +2701,7 @@ renault-api==0.4.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.15.2 +reolink-aio==0.16.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84750ce86c42..4c6c64258e17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2247,7 +2247,7 @@ renault-api==0.4.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.15.2 +reolink-aio==0.16.0 # homeassistant.components.rflink rflink==0.0.67 From cd6f3a0fe52a14fc4e54c5d33d4738c8cb1098e3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:40:10 +0200 Subject: [PATCH 065/103] Add newly added cpu temperatures to diagnostics in FRITZ!Tools (#153168) --- homeassistant/components/fritz/diagnostics.py | 3 +++ tests/components/fritz/snapshots/test_diagnostics.ambr | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index b9ae9edf04df..e8cad15ec3b9 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -46,6 +46,9 @@ async def async_get_config_entry_diagnostics( } for _, device in avm_wrapper.devices.items() ], + "cpu_temperatures": await hass.async_add_executor_job( + avm_wrapper.fritz_status.get_cpu_temperatures + ), "wan_link_properties": await avm_wrapper.async_get_wan_link_properties(), }, } diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index c2ca866ceb6e..dead09cae4a8 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -12,6 +12,11 @@ }), ]), 'connection_type': 'WANPPPConnection', + 'cpu_temperatures': list([ + 69, + 68, + 67, + ]), 'current_firmware': '7.29', 'discovered_services': list([ 'DeviceInfo1', From 7084bca783ea291cfc43391cb2b0f68cdd926927 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 29 Sep 2025 06:29:25 -0600 Subject: [PATCH 066/103] Correct vesync water tank lifted key (#153173) --- .../components/vesync/binary_sensor.py | 2 +- .../vesync/snapshots/test_binary_sensor.ambr | 633 ++++++++++++++++++ tests/components/vesync/test_binary_sensor.py | 51 ++ 3 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 tests/components/vesync/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/vesync/test_binary_sensor.py diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index 933d2f2599d4..7b72c80ff85e 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -43,7 +43,7 @@ SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( exists_fn=lambda device: rgetattr(device, "state.water_lacks") is not None, ), VeSyncBinarySensorEntityDescription( - key="water_tank_lifted", + key="details.water_tank_lifted", translation_key="water_tank_lifted", is_on=lambda device: device.state.water_tank_lifted, device_class=BinarySensorDeviceClass.PROBLEM, diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..3b851ea989c2 --- /dev/null +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -0,0 +1,633 @@ +# serializer version: 1 +# name: test_sensor_state[Air Purifier 131s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'air-purifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LV-PUR131S', + 'model_id': None, + 'name': 'Air Purifier 131s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 131s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Air Purifier 200s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Core200S', + 'model_id': None, + 'name': 'Air Purifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 200s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Air Purifier 400s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '400s-purifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LAP-C401S-WJP', + 'model_id': None, + 'name': 'Air Purifier 400s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 400s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Air Purifier 600s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-purifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LAP-C601S-WUS', + 'model_id': None, + 'name': 'Air Purifier 600s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Air Purifier 600s][entities] + list([ + ]) +# --- +# name: test_sensor_state[Dimmable Light][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'dimmable-bulb', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESL100', + 'model_id': None, + 'name': 'Dimmable Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Dimmable Light][entities] + list([ + ]) +# --- +# name: test_sensor_state[Dimmer Switch][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'dimmable-switch', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESWD16', + 'model_id': None, + 'name': 'Dimmer Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Dimmer Switch][entities] + list([ + ]) +# --- +# name: test_sensor_state[Humidifier 200s][binary_sensor.humidifier_200s_low_water] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 200s Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_200s_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 200s][binary_sensor.humidifier_200s_water_tank_lifted] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 200s Water tank lifted', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_200s_water_tank_lifted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 200s][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier4321', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Humidifier 200s][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.humidifier_200s_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_lacks', + 'unique_id': '200s-humidifier4321-water_lacks', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.humidifier_200s_water_tank_lifted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water tank lifted', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_tank_lifted', + 'unique_id': '200s-humidifier4321-details.water_tank_lifted', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_state[Humidifier 600S][binary_sensor.humidifier_600s_low_water] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 600S Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_600s_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 600S][binary_sensor.humidifier_600s_water_tank_lifted] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Humidifier 600S Water tank lifted', + }), + 'context': , + 'entity_id': 'binary_sensor.humidifier_600s_water_tank_lifted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_state[Humidifier 600S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Humidifier 600S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.humidifier_600s_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_lacks', + 'unique_id': '600s-humidifier-water_lacks', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.humidifier_600s_water_tank_lifted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water tank lifted', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_tank_lifted', + 'unique_id': '600s-humidifier-details.water_tank_lifted', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_state[Outlet][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'outlet', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'wifi-switch-1.3', + 'model_id': None, + 'name': 'Outlet', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Outlet][entities] + list([ + ]) +# --- +# name: test_sensor_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[SmartTowerFan][entities] + list([ + ]) +# --- +# name: test_sensor_state[Temperature Light][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'tunable-bulb', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESL100CW', + 'model_id': None, + 'name': 'Temperature Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Temperature Light][entities] + list([ + ]) +# --- +# name: test_sensor_state[Wall Switch][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'switch', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'ESWL01', + 'model_id': None, + 'name': 'Wall Switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[Wall Switch][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/test_binary_sensor.py b/tests/components/vesync/test_binary_sensor.py new file mode 100644 index 000000000000..5863270f7f53 --- /dev/null +++ b/tests/components/vesync/test_binary_sensor.py @@ -0,0 +1,51 @@ +"""Tests for the binary sensor module.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ALL_DEVICE_NAMES, mock_devices_response + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) +async def test_sensor_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + device_name: str, +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + # Configure the API devices call for device_name + mock_devices_response(aioclient_mock, device_name) + + # setup platform - only including the named device + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check device registry + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + assert devices == snapshot(name="devices") + + # Check entity registry + entities = [ + entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entity.domain == BINARY_SENSOR_DOMAIN + ] + assert entities == snapshot(name="entities") + + # Check states + for entity in entities: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) From be10f097c7caad5e9addf75aef985fd5c3a9e941 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 29 Sep 2025 14:30:42 +0200 Subject: [PATCH 067/103] Bump aioamazondevices to 6.2.7 (#153185) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/const.py | 2 ++ tests/components/alexa_devices/snapshots/test_services.ambr | 2 ++ 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 14b2ddf90d96..fa5fb5531cc9 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==6.2.6"] + "requirements": ["aioamazondevices==6.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14ba8380d513..ab6696881c46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.1 # homeassistant.components.alexa_devices -aioamazondevices==6.2.6 +aioamazondevices==6.2.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c6c64258e17..c4e195ffc315 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.1 # homeassistant.components.alexa_devices -aioamazondevices==6.2.6 +aioamazondevices==6.2.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 05a6ff587196..8fe407bd1c73 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -13,6 +13,7 @@ TEST_DEVICE_1 = AmazonDevice( capabilities=["AUDIO_PLAYER", "MICROPHONE"], device_family="mine", device_type="echo", + household_device=False, device_owner_customer_id="amazon_ower_id", device_cluster_members=[TEST_DEVICE_1_SN], online=True, @@ -35,6 +36,7 @@ TEST_DEVICE_2 = AmazonDevice( capabilities=["AUDIO_PLAYER", "MICROPHONE"], device_family="mine", device_type="echo", + household_device=True, device_owner_customer_id="amazon_ower_id", device_cluster_members=[TEST_DEVICE_2_SN], online=True, diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index dc15796c32c6..2f6576adb35e 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -16,6 +16,7 @@ 'device_type': 'echo', 'endpoint_id': 'G1234567890123456789012345678A', 'entity_id': '11111111-2222-3333-4444-555555555555', + 'household_device': False, 'online': True, 'sensors': dict({ 'dnd': dict({ @@ -57,6 +58,7 @@ 'device_type': 'echo', 'endpoint_id': 'G1234567890123456789012345678A', 'entity_id': '11111111-2222-3333-4444-555555555555', + 'household_device': False, 'online': True, 'sensors': dict({ 'dnd': dict({ From bb02158d1a2825d9639a0075f322e78468b374f6 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:18:15 +0200 Subject: [PATCH 068/103] Filter out empty integration type in extended analytics (#153188) --- homeassistant/components/analytics/analytics.py | 2 +- tests/common.py | 1 + tests/components/analytics/test_analytics.py | 4 ++-- tests/components/diagnostics/test_init.py | 2 ++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 5795be4e0279..2b67592e2f92 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -551,7 +551,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: for domain, integration_info in integration_inputs.items() if (integration := integrations.get(domain)) is not None and integration.is_built_in - and integration.integration_type in ("device", "hub") + and integration.manifest.get("integration_type") in ("device", "hub") } # Call integrations that implement the analytics platform diff --git a/tests/common.py b/tests/common.py index e43e4bf5fee5..419ba0ad4666 100644 --- a/tests/common.py +++ b/tests/common.py @@ -934,6 +934,7 @@ class MockModule: def mock_manifest(self): """Generate a mock manifest to represent this module.""" return { + "integration_type": "hub", **loader.manifest_from_legacy_module(self.DOMAIN, self), **(self._partial_manifest or {}), } diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 876e34dae75e..be8f38901ee4 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1195,7 +1195,7 @@ async def test_devices_payload_with_entities( # Entity from a different integration entity_registry.async_get_or_create( domain="light", - platform="roomba", + platform="shelly", unique_id="1", device_id=device_entry.id, has_entity_name=True, @@ -1296,7 +1296,7 @@ async def test_devices_payload_with_entities( }, ], }, - "roomba": { + "shelly": { "devices": [], "entities": [ { diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py index fe62efeebacd..e27331811e63 100644 --- a/tests/components/diagnostics/test_init.py +++ b/tests/components/diagnostics/test_init.py @@ -197,6 +197,7 @@ async def test_download_diagnostics( "codeowners": ["test"], "dependencies": [], "domain": "fake_integration", + "integration_type": "hub", "is_built_in": True, "overwrites_built_in": False, "name": "fake_integration", @@ -301,6 +302,7 @@ async def test_download_diagnostics( "codeowners": [], "dependencies": [], "domain": "fake_integration", + "integration_type": "hub", "is_built_in": True, "overwrites_built_in": False, "name": "fake_integration", From d9de96403587f7224ec242c2f3285cb0a2b5cbd6 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 29 Sep 2025 20:08:43 +0200 Subject: [PATCH 069/103] Add hardware Zigbee flow strategy (#153190) --- .../firmware_config_flow.py | 11 + homeassistant/components/zha/config_flow.py | 17 + homeassistant/components/zha/radio_manager.py | 4 + .../test_config_flow.py | 150 +++++++-- tests/components/zha/test_config_flow.py | 305 ++++++++++++++++-- 5 files changed, 428 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 895c7e726184..5e480f8440d2 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -61,6 +61,13 @@ class PickedFirmwareType(StrEnum): ZIGBEE = "zigbee" +class ZigbeeFlowStrategy(StrEnum): + """Zigbee setup strategies that can be picked.""" + + ADVANCED = "advanced" + RECOMMENDED = "recommended" + + class ZigbeeIntegration(StrEnum): """Zigbee integrations that can be picked.""" @@ -73,6 +80,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override _picked_firmware_type: PickedFirmwareType + _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED def __init__(self, *args: Any, **kwargs: Any) -> None: """Instantiate base flow.""" @@ -395,12 +403,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) -> ConfigFlowResult: """Select recommended installation type.""" self._zigbee_integration = ZigbeeIntegration.ZHA + self._zigbee_flow_strategy = ZigbeeFlowStrategy.RECOMMENDED return await self._async_continue_picked_firmware() async def async_step_zigbee_intent_custom( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Select custom installation type.""" + self._zigbee_flow_strategy = ZigbeeFlowStrategy.ADVANCED return await self.async_step_zigbee_integration() async def async_step_zigbee_integration( @@ -521,6 +531,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): "flow_control": "hardware", }, "radio_type": "ezsp", + "flow_strategy": self._zigbee_flow_strategy, }, ) return self._continue_zha_flow(result) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 95c4593089b6..8ca270c0cc2b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -20,6 +20,9 @@ from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon +from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + ZigbeeFlowStrategy, +) from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.config_entries import ( SOURCE_IGNORE, @@ -163,6 +166,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: class BaseZhaFlow(ConfigEntryBaseFlow): """Mixin for common ZHA flow steps and forms.""" + _flow_strategy: ZigbeeFlowStrategy | None = None _hass: HomeAssistant _title: str @@ -373,6 +377,12 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose how to set up the integration from scratch.""" + if self._flow_strategy == ZigbeeFlowStrategy.RECOMMENDED: + # Fast path: automatically form a new network + return await self.async_step_setup_strategy_recommended() + if self._flow_strategy == ZigbeeFlowStrategy.ADVANCED: + # Advanced path: let the user choose + return await self.async_step_setup_strategy_advanced() # Allow onboarding for new users to just create a new network automatically if ( @@ -406,6 +416,12 @@ class BaseZhaFlow(ConfigEntryBaseFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose how to deal with the current radio's settings during migration.""" + if self._flow_strategy == ZigbeeFlowStrategy.RECOMMENDED: + # Fast path: automatically migrate everything + return await self.async_step_migration_strategy_recommended() + if self._flow_strategy == ZigbeeFlowStrategy.ADVANCED: + # Advanced path: let the user choose + return await self.async_step_migration_strategy_advanced() return self.async_show_menu( step_id="choose_migration_strategy", menu_options=[ @@ -867,6 +883,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"]) device_settings = discovery_data["port"] device_path = device_settings[CONF_DEVICE_PATH] + self._flow_strategy = discovery_data.get("flow_strategy") await self._set_unique_id_and_update_ignored_flow( unique_id=f"{name}_{radio_type.name}_{device_path}", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index b2d515d785f2..1a2da153902b 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -28,6 +28,9 @@ from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries from homeassistant.components import usb +from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + ZigbeeFlowStrategy, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.usb import UsbServiceInfo @@ -74,6 +77,7 @@ HARDWARE_DISCOVERY_SCHEMA = vol.Schema( vol.Required("name"): str, vol.Required("port"): DEVICE_SCHEMA, vol.Required("radio_type"): str, + vol.Optional("flow_strategy"): vol.All(str, vol.Coerce(ZigbeeFlowStrategy)), } ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index da81f2bff883..34c6cfb7f804 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -364,8 +364,8 @@ async def consume_progress_flow( return result -async def test_config_flow_recommended(hass: HomeAssistant) -> None: - """Test the config flow with recommended installation type for Zigbee.""" +async def test_config_flow_zigbee_recommended(hass: HomeAssistant) -> None: + """Test flow with recommended Zigbee installation type.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) @@ -418,37 +418,28 @@ async def test_config_flow_recommended(hass: HomeAssistant) -> None: assert zha_flow["context"]["source"] == "hardware" assert zha_flow["step_id"] == "confirm" + progress_zha_flows = hass.config_entries.flow._async_progress_by_handler( + handler="zha", + match_context=None, + ) -@pytest.mark.parametrize( - ("zigbee_integration", "zha_flows"), - [ - ( - "zigbee_integration_zha", - [ - { - "context": { - "confirm_only": True, - "source": "hardware", - "title_placeholders": { - "name": "Some Hardware Name", - }, - "unique_id": "Some Hardware Name_ezsp_/dev/SomeDevice123", - }, - "flow_id": ANY, - "handler": "zha", - "step_id": "confirm", - } - ], - ), - ("zigbee_integration_other", []), - ], -) -async def test_config_flow_zigbee_custom( - hass: HomeAssistant, - zigbee_integration: str, - zha_flows: list[ConfigFlowResult], -) -> None: - """Test the config flow with custom installation type selected for Zigbee.""" + assert len(progress_zha_flows) == 1 + + progress_zha_flow = progress_zha_flows[0] + assert progress_zha_flow.init_data == { + "name": "Some Hardware Name", + "port": { + "path": "/dev/SomeDevice123", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + "flow_strategy": "recommended", + } + + +async def test_config_flow_zigbee_custom_zha(hass: HomeAssistant) -> None: + """Test flow with custom Zigbee installation type and ZHA selected.""" init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) @@ -479,7 +470,7 @@ async def test_config_flow_zigbee_custom( pick_result = await hass.config_entries.flow.async_configure( pick_result["flow_id"], - user_input={"next_step_id": zigbee_integration}, + user_input={"next_step_id": "zigbee_integration_zha"}, ) assert pick_result["type"] is FlowResultType.SHOW_PROGRESS @@ -503,7 +494,98 @@ async def test_config_flow_zigbee_custom( # Ensure a ZHA discovery flow has been created flows = hass.config_entries.flow.async_progress() - assert flows == zha_flows + assert flows == [ + { + "context": { + "confirm_only": True, + "source": "hardware", + "title_placeholders": { + "name": "Some Hardware Name", + }, + "unique_id": "Some Hardware Name_ezsp_/dev/SomeDevice123", + }, + "flow_id": ANY, + "handler": "zha", + "step_id": "confirm", + } + ] + + progress_zha_flows = hass.config_entries.flow._async_progress_by_handler( + handler="zha", + match_context=None, + ) + + assert len(progress_zha_flows) == 1 + + progress_zha_flow = progress_zha_flows[0] + assert progress_zha_flow.init_data == { + "name": "Some Hardware Name", + "port": { + "path": "/dev/SomeDevice123", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + "flow_strategy": "advanced", + } + + +async def test_config_flow_zigbee_custom_other(hass: HomeAssistant) -> None: + """Test flow with custom Zigbee installation type and Other selected.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + with mock_firmware_info( + probe_app_type=ApplicationType.SPINEL, + flash_app_type=ApplicationType.EZSP, + ): + # Pick the menu option: we are flashing the firmware + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_installation_type" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_intent_custom"}, + ) + + assert pick_result["type"] is FlowResultType.MENU + assert pick_result["step_id"] == "zigbee_integration" + + pick_result = await hass.config_entries.flow.async_configure( + pick_result["flow_id"], + user_input={"next_step_id": "zigbee_integration_other"}, + ) + + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_zigbee_firmware" + + create_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=("install_zigbee_firmware",), + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + flows = hass.config_entries.flow.async_progress() + assert flows == [] async def test_config_flow_firmware_index_download_fails_but_not_required( diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index cb0ad5dc6d7e..581d49f7eec9 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1180,9 +1180,8 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: assert probe_mock.await_count == 1 -@pytest.mark.parametrize("onboarded", [True, False]) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -async def test_hardware(onboarded, hass: HomeAssistant) -> None: +async def test_hardware_not_onboarded(hass: HomeAssistant) -> None: """Test hardware flow.""" data = { "name": "Yellow", @@ -1194,33 +1193,12 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: }, } with patch( - "homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded + "homeassistant.components.onboarding.async_is_onboarded", return_value=False ): - result1 = await hass.config_entries.flow.async_init( + result_create = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data ) - - if onboarded: - # Confirm discovery - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "confirm" - - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={}, - ) - - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_setup_strategy" - - result_create = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, - ) await hass.async_block_till_done() - else: - # No need to confirm - result_create = result1 assert result_create["title"] == "Yellow" assert result_create["data"] == { @@ -1233,6 +1211,283 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: } +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_no_flow_strategy(hass: HomeAssistant) -> None: + """Test hardware flow.""" + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + # Confirm discovery + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={}, + ) + + assert result2["type"] is FlowResultType.MENU + assert result2["step_id"] == "choose_setup_strategy" + + result_create = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, + ) + await hass.async_block_till_done() + + assert result_create["title"] == "Yellow" + assert result_create["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_flow_strategy_advanced(hass: HomeAssistant) -> None: + """Test advanced flow strategy for hardware flow.""" + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "advanced", + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + confirm_result = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], + user_input={}, + ) + + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_formation_strategy" + + result_create = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], + user_input={"next_step_id": "form_new_network"}, + ) + await hass.async_block_till_done() + + assert result_create["type"] is FlowResultType.CREATE_ENTRY + assert result_create["title"] == "Yellow" + assert result_create["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_flow_strategy_recommended(hass: HomeAssistant) -> None: + """Test recommended flow strategy for hardware flow.""" + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "recommended", + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + result_create = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + + assert result_create["type"] is FlowResultType.CREATE_ENTRY + assert result_create["title"] == "Yellow" + assert result_create["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_migration_flow_strategy_advanced( + hass: HomeAssistant, + backup: zigpy.backups.NetworkBackup, + mock_app: AsyncMock, +) -> None: + """Test advanced flow strategy for hardware migration flow.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "advanced", + } + with ( + patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + result_confirm = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], user_input={} + ) + + assert result_confirm["type"] is FlowResultType.MENU + assert result_confirm["step_id"] == "choose_formation_strategy" + + result_formation_strategy = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": "form_new_network"}, + ) + await hass.async_block_till_done() + + assert result_formation_strategy["type"] is FlowResultType.ABORT + assert result_formation_strategy["reason"] == "reconfigure_successful" + assert mock_async_unload.call_count == 0 + assert mock_restore_backup.call_count == 0 + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_migration_flow_strategy_recommended( + hass: HomeAssistant, + backup: zigpy.backups.NetworkBackup, + mock_app: AsyncMock, +) -> None: + """Test recommended flow strategy for hardware migration flow.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + data = { + "name": "Yellow", + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + "flow_strategy": "recommended", + } + with ( + patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): + result_hardware = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data + ) + + assert result_hardware["type"] is FlowResultType.FORM + assert result_hardware["step_id"] == "confirm" + + result_confirm = await hass.config_entries.flow.async_configure( + result_hardware["flow_id"], user_input={} + ) + + assert result_confirm["type"] is FlowResultType.ABORT + assert result_confirm["reason"] == "reconfigure_successful" + assert mock_async_unload.mock_calls == [call(entry.entry_id)] + assert mock_restore_backup.call_count == 1 + + @pytest.mark.parametrize( "data", [None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}] ) From abc5c6e2b466fb67676bf28d650c8685d19574c5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Sep 2025 19:22:29 +0200 Subject: [PATCH 070/103] Mark Konnected as Legacy (#153193) --- homeassistant/components/konnected/__init__.py | 15 ++++++++++++++- homeassistant/components/konnected/manifest.json | 2 +- homeassistant/components/konnected/strings.json | 6 ++++++ homeassistant/generated/integrations.json | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index dd4dbc7dbe52..42cd39d1473f 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -35,7 +35,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .config_flow import ( # Loading the config flow file will register the flow @@ -221,6 +221,19 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Konnected platform.""" + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_firmware", + breaks_in_ha_version="2026.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_firmware", + translation_placeholders={ + "kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome", + }, + ) if (cfg := config.get(DOMAIN)) is None: cfg = {} diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json index 7aab6fcd176b..94b852476c10 100644 --- a/homeassistant/components/konnected/manifest.json +++ b/homeassistant/components/konnected/manifest.json @@ -1,6 +1,6 @@ { "domain": "konnected", - "name": "Konnected.io", + "name": "Konnected.io (Legacy)", "codeowners": ["@heythisisnate"], "config_flow": true, "dependencies": ["http"], diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index df92e014f121..4896e4fb767a 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -105,5 +105,11 @@ "abort": { "not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]" } + }, + "issues": { + "deprecated_firmware": { + "title": "Konnected firmware is deprecated", + "description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant." + } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2ce0e314afb5..3289af99fe2d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3346,7 +3346,7 @@ "iot_class": "local_push" }, "konnected": { - "name": "Konnected.io", + "name": "Konnected.io (Legacy)", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" From 584c1fbd9730f71d58371071edcb7efb09ca7748 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 Sep 2025 14:56:42 +0200 Subject: [PATCH 071/103] Revert "Add comment on conversion factor for Carbon monoxide on dependency molecular weight" (#153195) --- homeassistant/util/unit_conversion.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index b2938b249b87..0483878f547c 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -174,9 +174,7 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter): UNIT_CLASS = "carbon_monoxide" _UNIT_CONVERSION: dict[str | None, float] = { CONCENTRATION_PARTS_PER_MILLION: 1, - # concentration (mg/m3) = 0.0409 x concentration (ppm) x molecular weight - # Carbon monoxide molecular weight: 28.01 g/mol - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 0.0409 * 28.01, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.145609, } VALID_UNITS = { CONCENTRATION_PARTS_PER_MILLION, From be942c288895f3ba90955a0ca746a998868a3d64 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 Sep 2025 16:43:13 +0200 Subject: [PATCH 072/103] =?UTF-8?q?Revert=20"Add=20mg/m=C2=B3=20as=20a=20v?= =?UTF-8?q?alid=20UOM=20for=20sensor/number=20Carbon=20Monoxide=20device?= =?UTF-8?q?=20class"=20(#153196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/number/const.py | 7 ++----- .../components/recorder/statistics.py | 5 ----- .../components/recorder/websocket_api.py | 4 ---- homeassistant/components/sensor/const.py | 9 ++------ homeassistant/util/unit_conversion.py | 14 ------------- tests/components/sensor/test_init.py | 1 + tests/util/test_unit_conversion.py | 21 ------------------- 7 files changed, 5 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 07a53c9cb61e..fab3d6f4276a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -124,7 +124,7 @@ class NumberDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppm` (parts per million), mg/m³ + Unit of measurement: `ppm` (parts per million) """ CO2 = "carbon_dioxide" @@ -475,10 +475,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), NumberDeviceClass.BATTERY: {PERCENTAGE}, NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), - NumberDeviceClass.CO: { - CONCENTRATION_PARTS_PER_MILLION, - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - }, + NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent), diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c2a8a6c7607c..2321da45bb9a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -46,7 +46,6 @@ from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, - CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -205,10 +204,6 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **dict.fromkeys( MassVolumeConcentrationConverter.VALID_UNITS, MassVolumeConcentrationConverter ), - **dict.fromkeys( - CarbonMonoxideConcentrationConverter.VALID_UNITS, - CarbonMonoxideConcentrationConverter, - ), **dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter), **dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter), **dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter), diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c65a11cee2ae..4f798fb86d01 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -19,7 +19,6 @@ from homeassistant.util.unit_conversion import ( ApparentPowerConverter, AreaConverter, BloodGlucoseConcentrationConverter, - CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -67,9 +66,6 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS ), - vol.Optional("carbon_monoxide"): vol.In( - CarbonMonoxideConcentrationConverter.VALID_UNITS - ), vol.Optional("concentration"): vol.In( MassVolumeConcentrationConverter.VALID_UNITS ), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index b91bd26d410c..87ddf4445a01 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -51,7 +51,6 @@ from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, - CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -157,7 +156,7 @@ class SensorDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppm` (parts per million), `mg/m³` + Unit of measurement: `ppm` (parts per million) """ CO2 = "carbon_dioxide" @@ -544,7 +543,6 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, - SensorDeviceClass.CO: CarbonMonoxideConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, @@ -586,10 +584,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), SensorDeviceClass.BATTERY: {PERCENTAGE}, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), - SensorDeviceClass.CO: { - CONCENTRATION_PARTS_PER_MILLION, - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - }, + SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent), diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 0483878f547c..dba858c07bff 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -168,20 +168,6 @@ class BaseUnitConverter: return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES) -class CarbonMonoxideConcentrationConverter(BaseUnitConverter): - """Convert carbon monoxide ratio to mass per volume.""" - - UNIT_CLASS = "carbon_monoxide" - _UNIT_CONVERSION: dict[str | None, float] = { - CONCENTRATION_PARTS_PER_MILLION: 1, - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.145609, - } - VALID_UNITS = { - CONCENTRATION_PARTS_PER_MILLION, - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - } - - class DataRateConverter(BaseUnitConverter): """Utility to convert data rate values.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 5d53cfe6d53a..36e8ab4576f7 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -3008,6 +3008,7 @@ def test_device_class_converters_are_complete() -> None: no_converter_device_classes = { SensorDeviceClass.AQI, SensorDeviceClass.BATTERY, + SensorDeviceClass.CO, SensorDeviceClass.CO2, SensorDeviceClass.DATE, SensorDeviceClass.ENUM, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 0d14a30a1b87..d9377779b68e 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -44,7 +44,6 @@ from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, - CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -79,7 +78,6 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { AreaConverter, BloodGlucoseConcentrationConverter, MassVolumeConcentrationConverter, - CarbonMonoxideConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -116,11 +114,6 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, 18, ), - CarbonMonoxideConcentrationConverter: ( - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_MILLION, - 1.145609, - ), ConductivityConverter: ( UnitOfConductivity.MICROSIEMENS_PER_CM, UnitOfConductivity.MILLISIEMENS_PER_CM, @@ -287,20 +280,6 @@ _CONVERTED_VALUE: dict[ UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, ), ], - CarbonMonoxideConcentrationConverter: [ - ( - 1, - CONCENTRATION_PARTS_PER_MILLION, - 1.145609, - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - ), - ( - 120, - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - 104.74778, - CONCENTRATION_PARTS_PER_MILLION, - ), - ], ConductivityConverter: [ # Deprecated to deprecated (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), From 5e2b27699e89ccbebaeeaaf7d02c04ca8c13961e Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Mon, 29 Sep 2025 15:08:42 +0100 Subject: [PATCH 073/103] Handle return result from ebusd being "empty" (#153199) --- homeassistant/components/ebusd/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index 4cb8d92c3917..5c36c311bffe 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -116,7 +116,11 @@ class EbusdData: try: _LOGGER.debug("Opening socket to ebusd %s", name) command_result = ebusdpy.write(self._address, self._circuit, name, value) - if command_result is not None and "done" not in command_result: + if ( + command_result is not None + and "done" not in command_result + and "empty" not in command_result + ): _LOGGER.warning("Write command failed: %s", name) except RuntimeError as err: _LOGGER.error(err) From 51e098e807763716787d897e3a26bf3697a5218f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:17:44 +0200 Subject: [PATCH 074/103] ZHA: rename radio to adapter (#153206) --- homeassistant/components/zha/strings.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 4b28b1c426ed..91be9c3b3b48 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -65,8 +65,8 @@ } }, "maybe_reset_old_radio": { - "title": "Resetting old radio", - "description": "A backup was created earlier and your old radio is being reset as part of the migration." + "title": "Resetting old adapter", + "description": "A backup was created earlier and your old adapter is being reset as part of the migration." }, "choose_formation_strategy": { "title": "Network formation", @@ -135,21 +135,21 @@ "title": "Migrate or re-configure", "description": "Are you migrating to a new radio or re-configuring the current radio?", "menu_options": { - "intent_migrate": "Migrate to a new radio", - "intent_reconfigure": "Re-configure the current radio" + "intent_migrate": "Migrate to a new adapter", + "intent_reconfigure": "Re-configure the current adapter" }, "menu_option_descriptions": { - "intent_migrate": "This will help you migrate your Zigbee network from your old radio to a new one.", - "intent_reconfigure": "This will let you change the serial port for your current Zigbee radio." + "intent_migrate": "This will help you migrate your Zigbee network from your old adapter to a new one.", + "intent_reconfigure": "This will let you change the serial port for your current Zigbee adapter." } }, "intent_migrate": { "title": "[%key:component::zha::options::step::prompt_migrate_or_reconfigure::menu_options::intent_migrate%]", - "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?" + "description": "Before plugging in your new adapter, your old adapter needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?" }, "instruct_unplug": { - "title": "Unplug your old radio", - "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio." + "title": "Unplug your old adapter", + "description": "Your old adapter has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new adapter." }, "choose_serial_port": { "title": "[%key:component::zha::config::step::choose_serial_port::title%]", From 00d667ed51145c94981d59854136c35af871b948 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 29 Sep 2025 19:55:09 +0200 Subject: [PATCH 075/103] Add missing translation strings for added sensor device classes pm4 and reactive energy (#153215) --- homeassistant/components/mqtt/strings.json | 2 ++ homeassistant/components/number/strings.json | 3 +++ homeassistant/components/random/strings.json | 1 + homeassistant/components/scrape/strings.json | 2 ++ homeassistant/components/sensor/strings.json | 3 +++ homeassistant/components/sql/strings.json | 1 + homeassistant/components/template/strings.json | 1 + 7 files changed, 13 insertions(+) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 7f14f26e8792..1f3892fb927d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1235,6 +1235,7 @@ "ozone": "[%key:component::sensor::entity_component::ozone::name%]", "ph": "[%key:component::sensor::entity_component::ph::name%]", "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "power": "[%key:component::sensor::entity_component::power::name%]", @@ -1242,6 +1243,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 1e4290f1d75f..8c94269f069b 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -112,6 +112,9 @@ "pm1": { "name": "[%key:component::sensor::entity_component::pm1::name%]" }, + "pm4": { + "name": "[%key:component::sensor::entity_component::pm4::name%]" + }, "pm10": { "name": "[%key:component::sensor::entity_component::pm10::name%]" }, diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index 450f78f9e83e..bf83da70de10 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -114,6 +114,7 @@ "ozone": "[%key:component::sensor::entity_component::ozone::name%]", "ph": "[%key:component::sensor::entity_component::ph::name%]", "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "power": "[%key:component::sensor::entity_component::power::name%]", diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 91452287ce7e..7faa3ec91dbf 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -171,6 +171,7 @@ "ozone": "[%key:component::sensor::entity_component::ozone::name%]", "ph": "[%key:component::sensor::entity_component::ph::name%]", "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "power": "[%key:component::sensor::entity_component::power::name%]", @@ -178,6 +179,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d721e20b244b..81a67b78adad 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -245,6 +245,9 @@ "pm1": { "name": "PM1" }, + "pm4": { + "name": "PM4" + }, "pm10": { "name": "PM10" }, diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index a70a9812657f..7b4ad1549815 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -125,6 +125,7 @@ "ozone": "[%key:component::sensor::entity_component::ozone::name%]", "ph": "[%key:component::sensor::entity_component::ph::name%]", "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "power": "[%key:component::sensor::entity_component::power::name%]", diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 2f06abe9a22a..6ac73d43870c 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1083,6 +1083,7 @@ "ozone": "[%key:component::sensor::entity_component::ozone::name%]", "ph": "[%key:component::sensor::entity_component::ph::name%]", "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm4": "[%key:component::sensor::entity_component::pm4::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "power": "[%key:component::sensor::entity_component::power::name%]", From c75dca743a3781df21f6c19cb0bada7dbf9b1892 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 Sep 2025 09:21:25 +0000 Subject: [PATCH 076/103] Bump version to 2025.10.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2ac4965c9806..be788d2c6b7a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index c07ac97d03fc..a03b67262eb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b4" +version = "2025.10.0b5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From df69bcecb73315633ad3d08a979443fc135d57c0 Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:59:03 +0100 Subject: [PATCH 077/103] Pihole better logging of update errors (#152077) --- homeassistant/components/pi_hole/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index ae51fe166c4f..7d8dbc508665 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -129,10 +129,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo raise ConfigEntryAuthFailed except HoleError as err: if str(err) == "Authentication failed: Invalid password": - raise ConfigEntryAuthFailed from err - raise UpdateFailed(f"Failed to communicate with API: {err}") from err + raise ConfigEntryAuthFailed( + f"Pi-hole {name} at host {host}, reported an invalid password" + ) from err + raise UpdateFailed( + f"Pi-hole {name} at host {host}, update failed with HoleError: {err}" + ) from err if not isinstance(api.data, dict): - raise ConfigEntryAuthFailed + raise ConfigEntryAuthFailed( + f"Pi-hole {name} at host {host}, returned an unexpected response: {api.data}, assuming authentication failed" + ) coordinator = DataUpdateCoordinator( hass, From b4747ea87b23d2ec81a67369e64538bf95c6d688 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:25:19 -0400 Subject: [PATCH 078/103] Fix Sonos Dialog Select type conversion part II (#152491) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonos/select.py | 22 ++++++------- homeassistant/components/sonos/speaker.py | 23 ++++++++++++++ tests/components/sonos/test_select.py | 38 ++++++++++++++++++++--- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py index 0a56e37e75c0..fa38bf20c9f7 100644 --- a/homeassistant/components/sonos/select.py +++ b/homeassistant/components/sonos/select.py @@ -59,17 +59,12 @@ async def async_setup_entry( for select_data in SELECT_TYPES: if select_data.speaker_model == speaker.model_name.upper(): if ( - state := getattr(speaker.soco, select_data.soco_attribute, None) - ) is not None: - try: - setattr(speaker, select_data.speaker_attribute, int(state)) - features.append(select_data) - except ValueError: - _LOGGER.error( - "Invalid value for %s %s", - select_data.speaker_attribute, - state, - ) + speaker.update_soco_int_attribute( + select_data.soco_attribute, select_data.speaker_attribute + ) + is not None + ): + features.append(select_data) return features async def _async_create_entities(speaker: SonosSpeaker) -> None: @@ -112,8 +107,9 @@ class SonosSelectEntity(SonosEntity, SelectEntity): @soco_error() def poll_state(self) -> None: """Poll the device for the current state.""" - state = getattr(self.soco, self.soco_attribute) - setattr(self.speaker, self.speaker_attribute, state) + self.speaker.update_soco_int_attribute( + self.soco_attribute, self.speaker_attribute + ) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index acf1b08cd36e..c61f047d3e38 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -275,6 +275,29 @@ class SonosSpeaker: """Write states for associated SonosEntity instances.""" async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}") + def update_soco_int_attribute( + self, soco_attribute: str, speaker_attribute: str + ) -> int | None: + """Update an integer attribute from SoCo and set it on the speaker. + + Returns the integer value if successful, otherwise None. Do not call from + async context as it is a blocking function. + """ + value: int | None = None + if (state := getattr(self.soco, soco_attribute, None)) is None: + _LOGGER.error("Missing value for %s", speaker_attribute) + else: + try: + value = int(state) + except (TypeError, ValueError): + _LOGGER.error( + "Invalid value for %s %s", + speaker_attribute, + state, + ) + setattr(self, speaker_attribute, value) + return value + # # Properties # diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py index dbbf28a52d74..0a50da9b9a7d 100644 --- a/tests/components/sonos/test_select.py +++ b/tests/components/sonos/test_select.py @@ -88,6 +88,36 @@ async def test_select_dialog_invalid_level( assert dialog_level_state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("value", "result"), + [ + ("invalid_integer", "Invalid value for dialog_level_enum invalid_integer"), + (None, "Missing value for dialog_level_enum"), + ], +) +async def test_select_dialog_value_error( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, + value: str | None, + result: str, +) -> None: + """Test receiving a value from Sonos that is not convertible to an integer.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = value + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert result in caplog.text + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + @pytest.mark.parametrize( ("result", "option"), [ @@ -149,12 +179,12 @@ async def test_select_dialog_level_event( speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() soco.get_speaker_info.return_value = speaker_info - soco.dialog_level = 0 + soco.dialog_level = "0" await async_setup_sonos() event = create_rendering_control_event(soco) - event.variables[ATTR_DIALOG_LEVEL] = 3 + event.variables[ATTR_DIALOG_LEVEL] = "3" soco.renderingControl.subscribe.return_value._callback(event) await hass.async_block_till_done(wait_background_tasks=True) @@ -175,11 +205,11 @@ async def test_select_dialog_level_poll( speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() soco.get_speaker_info.return_value = speaker_info - soco.dialog_level = 0 + soco.dialog_level = "0" await async_setup_sonos() - soco.dialog_level = 4 + soco.dialog_level = "4" freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) From e982ac1e534425106f7f9f2550a634582612d676 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:05:23 +0800 Subject: [PATCH 079/103] Switchbot Cloud: Fix Roller Shade not work issue (#152528) --- homeassistant/components/switchbot_cloud/cover.py | 8 +++----- homeassistant/components/switchbot_cloud/entity.py | 2 +- tests/components/switchbot_cloud/test_cover.py | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py index 77f0b960d255..e5e7b745cbb5 100644 --- a/homeassistant/components/switchbot_cloud/cover.py +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -109,15 +109,13 @@ class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0)) + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=0) await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.send_api_command( - RollerShadeCommands.SET_POSITION, parameters=str(100) - ) + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=100) await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() @@ -126,7 +124,7 @@ class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover): position: int | None = kwargs.get("position") if position is not None: await self.send_api_command( - RollerShadeCommands.SET_POSITION, parameters=str(100 - position) + RollerShadeCommands.SET_POSITION, parameters=(100 - position) ) await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 5eb96ed3ac8a..376ed47f79f8 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -44,7 +44,7 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): self, command: Commands, command_type: str = "command", - parameters: dict | str = "default", + parameters: dict | str | int = "default", ) -> None: """Send command to device.""" await self._api.send_command( diff --git a/tests/components/switchbot_cloud/test_cover.py b/tests/components/switchbot_cloud/test_cover.py index 0d0daf1bd7b1..e2efffe0bf4b 100644 --- a/tests/components/switchbot_cloud/test_cover.py +++ b/tests/components/switchbot_cloud/test_cover.py @@ -319,7 +319,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "0" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 0 ) await configure_integration(hass) @@ -334,7 +334,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "100" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 100 ) await configure_integration(hass) @@ -349,7 +349,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "50" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 50 ) From bf190609a0db4701ebeb30de7cbd8c6d3cda0d09 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:41:51 -0400 Subject: [PATCH 080/103] Reduce Connect firmware install times by removing unnecessary firmware probing (#153012) --- .../firmware_config_flow.py | 75 +------ .../components/homeassistant_hardware/util.py | 2 +- .../homeassistant_yellow/config_flow.py | 5 +- .../test_config_flow.py | 51 ++--- .../test_config_flow_failures.py | 185 ++---------------- .../test_config_flow.py | 51 ++--- .../homeassistant_yellow/test_config_flow.py | 34 ++-- 7 files changed, 90 insertions(+), 313 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 5e480f8440d2..20b817fe2c50 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -155,34 +155,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): description_placeholders=self._get_translation_placeholders(), ) - async def _probe_firmware_info( - self, - probe_methods: tuple[ApplicationType, ...] = ( - # We probe in order of frequency: Zigbee, Thread, then multi-PAN - ApplicationType.GECKO_BOOTLOADER, - ApplicationType.EZSP, - ApplicationType.SPINEL, - ApplicationType.CPC, - ), - ) -> bool: - """Probe the firmware currently on the device.""" - assert self._device is not None - - self._probed_firmware_info = await probe_silabs_firmware_info( - self._device, - probe_methods=probe_methods, - ) - - return ( - self._probed_firmware_info is not None - and self._probed_firmware_info.firmware_type - in ( - ApplicationType.EZSP, - ApplicationType.SPINEL, - ApplicationType.CPC, - ) - ) - async def _install_firmware_step( self, fw_update_url: str, @@ -236,12 +208,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): expected_installed_firmware_type: ApplicationType, ) -> None: """Install firmware.""" - if not await self._probe_firmware_info(): - raise AbortFlow( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - assert self._device is not None # Keep track of the firmware we're working with, for error messages @@ -250,6 +216,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): # Installing new firmware is only truly required if the wrong type is # installed: upgrading to the latest release of the current firmware type # isn't strictly necessary for functionality. + self._probed_firmware_info = await probe_silabs_firmware_info(self._device) + firmware_install_required = self._probed_firmware_info is None or ( self._probed_firmware_info.firmware_type != expected_installed_firmware_type ) @@ -301,7 +269,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): # Otherwise, fail raise AbortFlow(reason="firmware_download_failed") from err - await async_flash_silabs_firmware( + self._probed_firmware_info = await async_flash_silabs_firmware( hass=self.hass, device=self._device, fw_data=fw_data, @@ -314,15 +282,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): async def _configure_and_start_otbr_addon(self) -> None: """Configure and start the OTBR addon.""" - - # Before we start the addon, confirm that the correct firmware is running - # and populate `self._probed_firmware_info` with the correct information - if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)): - raise AbortFlow( - "unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - otbr_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(otbr_manager) @@ -444,12 +403,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): if self._picked_firmware_type == PickedFirmwareType.ZIGBEE: return await self.async_step_install_zigbee_firmware() - return await self.async_step_prepare_thread_installation() + return await self.async_step_install_thread_firmware() - async def async_step_prepare_thread_installation( + async def async_step_finish_thread_installation( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Prepare for Thread installation by stopping the OTBR addon if needed.""" + """Finish Thread installation by starting the OTBR addon.""" if not is_hassio(self.hass): return self.async_abort( reason="not_hassio_thread", @@ -459,22 +418,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): otbr_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(otbr_manager) - if addon_info.state == AddonState.RUNNING: - # Stop the addon before continuing to flash firmware - await otbr_manager.async_stop_addon() - - return await self.async_step_install_thread_firmware() - - async def async_step_finish_thread_installation( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Finish Thread installation by starting the OTBR addon.""" - otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - if addon_info.state == AddonState.NOT_INSTALLED: return await self.async_step_install_otbr_addon() + if addon_info.state == AddonState.RUNNING: + await otbr_manager.async_stop_addon() + return await self.async_step_start_otbr_addon() async def async_step_pick_firmware_zigbee( @@ -511,12 +460,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None assert self._hardware_name is not None - if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - if self._zigbee_integration == ZigbeeIntegration.OTHER: return self._async_flow_finished() diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index d84f4f75ff70..d3bddad97545 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -42,9 +42,9 @@ class ApplicationType(StrEnum): """Application type running on a device.""" GECKO_BOOTLOADER = "bootloader" - CPC = "cpc" EZSP = "ezsp" SPINEL = "spinel" + CPC = "cpc" ROUTER = "router" @classmethod diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index efc218caeaa7..8339a3562b33 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -27,6 +27,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + probe_silabs_firmware_info, ) from homeassistant.config_entries import ( SOURCE_HARDWARE, @@ -141,8 +142,10 @@ class HomeAssistantYellowConfigFlow( self, data: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + assert self._device is not None + # We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this - await self._probe_firmware_info() + self._probed_firmware_info = await probe_silabs_firmware_info(self._device) # Kick off ZHA hardware discovery automatically if Zigbee firmware is running if ( diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index ff26c246a40f..54f70c57c490 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -72,6 +72,13 @@ async def test_config_flow_zigbee( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -80,16 +87,6 @@ async def test_config_flow_zigbee( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +154,13 @@ async def test_config_flow_thread( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -165,16 +169,6 @@ async def test_config_flow_thread( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -258,6 +252,13 @@ async def test_options_flow( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -270,16 +271,6 @@ async def test_options_flow( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 217c331257e3..b8fd9e5cee88 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -36,171 +36,6 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): """Mock supervisor client in tests.""" -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.usefixtures("addon_store_info") -async def test_config_flow_cannot_probe_firmware_zigbee(hass: HomeAssistant) -> None: - """Test failure case when firmware cannot be probed for zigbee.""" - - with mock_firmware_info( - probe_app_type=None, - ): - # Start the flow - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "zigbee_installation_type" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": "zigbee_intent_recommended"}, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_cannot_probe_after_install_zigbee(hass: HomeAssistant) -> None: - """Test unsupported firmware after firmware install for Zigbee.""" - init_result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert init_result["type"] is FlowResultType.MENU - assert init_result["step_id"] == "pick_firmware" - - with mock_firmware_info( - probe_app_type=ApplicationType.SPINEL, - flash_app_type=ApplicationType.EZSP, - ): - # Pick the menu option: we are flashing the firmware - pick_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - assert pick_result["type"] is FlowResultType.MENU - assert pick_result["step_id"] == "zigbee_installation_type" - - pick_result = await hass.config_entries.flow.async_configure( - pick_result["flow_id"], - user_input={"next_step_id": "zigbee_intent_recommended"}, - ) - - assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_firmware" - assert pick_result["step_id"] == "install_zigbee_firmware" - - with mock_firmware_info( - probe_app_type=None, - flash_app_type=ApplicationType.EZSP, - ): - create_result = await consume_progress_flow( - hass, - flow_id=pick_result["flow_id"], - valid_step_ids=("install_zigbee_firmware",), - ) - - assert create_result["type"] is FlowResultType.ABORT - assert create_result["reason"] == "unsupported_firmware" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.usefixtures("addon_store_info") -async def test_config_flow_cannot_probe_firmware_thread(hass: HomeAssistant) -> None: - """Test failure case when firmware cannot be probed for thread.""" - - with mock_firmware_info( - probe_app_type=None, - ): - # Start the flow - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.usefixtures("addon_installed") -async def test_cannot_probe_after_install_thread(hass: HomeAssistant) -> None: - """Test unsupported firmware after firmware install for thread.""" - init_result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert init_result["type"] is FlowResultType.MENU - assert init_result["step_id"] == "pick_firmware" - - with mock_firmware_info( - probe_app_type=ApplicationType.EZSP, - flash_app_type=ApplicationType.SPINEL, - ): - # Pick the menu option - pick_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - - assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_firmware" - assert pick_result["step_id"] == "install_thread_firmware" - description_placeholders = pick_result["description_placeholders"] - assert description_placeholders is not None - assert description_placeholders["firmware_type"] == "ezsp" - assert description_placeholders["model"] == TEST_HARDWARE_NAME - - with mock_firmware_info( - probe_app_type=None, - flash_app_type=ApplicationType.SPINEL, - ): - # Progress the flow, it is now installing firmware - result = await consume_progress_flow( - hass, - flow_id=pick_result["flow_id"], - valid_step_ids=( - "pick_firmware_thread", - "install_otbr_addon", - "install_thread_firmware", - "start_otbr_addon", - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -217,11 +52,21 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: with mock_firmware_info( is_hassio=False, probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=("install_thread_firmware",), + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_hassio_thread" @@ -245,6 +90,7 @@ async def test_config_flow_thread_addon_info_fails( with mock_firmware_info( probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, ): addon_store_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( @@ -252,6 +98,15 @@ async def test_config_flow_thread_addon_info_fails( user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=("install_thread_firmware",), + ) + # Cannot get addon info assert result["type"] == FlowResultType.ABORT assert result["reason"] == "addon_info_failed" diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index d977a2ba8a14..01478900c60f 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -91,6 +91,13 @@ async def test_config_flow_zigbee( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -99,16 +106,6 @@ async def test_config_flow_zigbee( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -190,6 +187,13 @@ async def test_config_flow_thread( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -198,16 +202,6 @@ async def test_config_flow_thread( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -293,6 +287,13 @@ async def test_options_flow( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -305,16 +306,6 @@ async def test_options_flow( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index df4bee29eab0..3a85ed017cb4 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -362,6 +362,13 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -374,16 +381,6 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=RADIO_DEVICE, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -453,6 +450,13 @@ async def test_firmware_options_flow_thread( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -465,16 +469,6 @@ async def test_firmware_options_flow_thread( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=RADIO_DEVICE, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], From 392ee5ae7eb0b7e4d236c07c68092fc56b46cc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 26 Sep 2025 21:58:17 +0200 Subject: [PATCH 081/103] Use UnitOfTime.DAYS instead of custom unit for LetPot number entity (#153054) --- homeassistant/components/letpot/number.py | 3 ++- homeassistant/components/letpot/strings.json | 3 +-- tests/components/letpot/snapshots/test_number.ambr | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/letpot/number.py b/homeassistant/components/letpot/number.py index a5b9c3df68c1..2061b419ddb5 100644 --- a/homeassistant/components/letpot/number.py +++ b/homeassistant/components/letpot/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.const import PRECISION_WHOLE, EntityCategory +from homeassistant.const import PRECISION_WHOLE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -72,6 +72,7 @@ NUMBERS: tuple[LetPotNumberEntityDescription, ...] = ( LetPotNumberEntityDescription( key="plant_days", translation_key="plant_days", + native_unit_of_measurement=UnitOfTime.DAYS, value_fn=lambda coordinator: coordinator.data.plant_days, set_value_fn=( lambda device_client, serial, value: device_client.set_plant_days( diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 4c46e1ddbb16..3af8c7e3db67 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -54,8 +54,7 @@ "name": "Light brightness" }, "plant_days": { - "name": "Plants age", - "unit_of_measurement": "days" + "name": "Plants age" } }, "select": { diff --git a/tests/components/letpot/snapshots/test_number.ambr b/tests/components/letpot/snapshots/test_number.ambr index 50f6cf64312e..4784cfa695a4 100644 --- a/tests/components/letpot/snapshots/test_number.ambr +++ b/tests/components/letpot/snapshots/test_number.ambr @@ -93,7 +93,7 @@ 'supported_features': 0, 'translation_key': 'plant_days', 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_plant_days', - 'unit_of_measurement': 'days', + 'unit_of_measurement': , }) # --- # name: test_all_entities[number.garden_plants_age-state] @@ -104,7 +104,7 @@ 'min': 0.0, 'mode': , 'step': 1, - 'unit_of_measurement': 'days', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.garden_plants_age', From 4fd10162c9f63657248cbf97e715472f468f373a Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 1 Oct 2025 11:50:01 +0200 Subject: [PATCH 082/103] Improve ZHA multi-pan firmware repair text (#153232) --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 91be9c3b3b48..e36166f074e9 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -584,12 +584,12 @@ }, "issues": { "wrong_silabs_firmware_installed_nabucasa": { - "title": "Zigbee radio with multiprotocol firmware detected", - "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in Step 2 (and Step 2 only) to 'Flash the Silicon Labs radio Zigbee firmware'.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + "title": "Zigbee adapter with multiprotocol firmware detected", + "description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install the Zigbee firmware:\n - Go to Settings > System > Hardware, select the device and select Configure.\n - Select the 'Migrate Zigbee to a new adapter' option and follow the instructions." }, "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", - "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). To run your radio exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee radio manufacturer's instructions for how to do this." + "description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee adapter manufacturer's instructions for how to do this." }, "inconsistent_network_settings": { "title": "Zigbee network settings have changed", From c893552d4a77b8790a61274904809ada949f1acc Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 1 Oct 2025 11:46:08 +0200 Subject: [PATCH 083/103] Replace remaining ZHA "radio" strings with "adapter" (#153234) --- homeassistant/components/zha/strings.json | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index e36166f074e9..71709fdc43dc 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -4,7 +4,7 @@ "step": { "choose_serial_port": { "title": "Select a serial port", - "description": "Select the serial port for your Zigbee radio", + "description": "Select the serial port for your Zigbee adapter", "data": { "path": "Serial device path" } @@ -16,10 +16,10 @@ "description": "Do you want to set up {name}?" }, "manual_pick_radio_type": { - "title": "Select a radio type", - "description": "Pick your Zigbee radio type", + "title": "Select an adapter type", + "description": "Pick your Zigbee adapter type", "data": { - "radio_type": "Radio type" + "radio_type": "Adapter type" } }, "manual_port_config": { @@ -37,8 +37,8 @@ } }, "verify_radio": { - "title": "Radio is not recommended", - "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." + "title": "Adapter is not recommended", + "description": "The adapter you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." }, "choose_setup_strategy": { "title": "Set up Zigbee", @@ -70,11 +70,11 @@ }, "choose_formation_strategy": { "title": "Network formation", - "description": "Choose the network settings for your radio.", + "description": "Choose the network settings for your adapter.", "menu_options": { "form_new_network": "Erase network settings and create a new network", "form_initial_network": "Create a network", - "reuse_settings": "Keep radio network settings", + "reuse_settings": "Keep adapter network settings", "choose_automatic_backup": "Restore an automatic backup", "upload_manual_backup": "Upload a manual backup" }, @@ -101,10 +101,10 @@ } }, "maybe_confirm_ezsp_restore": { - "title": "Overwrite radio IEEE address", - "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", + "title": "Overwrite adapter IEEE address", + "description": "Your backup has a different IEEE address than your adapter. For your network to function properly, the IEEE address of your adapter should also be changed.\n\nThis is a permanent operation.", "data": { - "overwrite_coordinator_ieee": "Permanently replace the radio IEEE address" + "overwrite_coordinator_ieee": "Permanently replace the adapter IEEE address" } } }, @@ -133,7 +133,7 @@ }, "prompt_migrate_or_reconfigure": { "title": "Migrate or re-configure", - "description": "Are you migrating to a new radio or re-configuring the current radio?", + "description": "Are you migrating to a new adapter or re-configuring the current adapter?", "menu_options": { "intent_migrate": "Migrate to a new adapter", "intent_reconfigure": "Re-configure the current adapter" @@ -597,7 +597,7 @@ "step": { "init": { "title": "[%key:component::zha::issues::inconsistent_network_settings::title%]", - "description": "Your Zigbee radio's network settings are inconsistent with the most recent network backup. This usually happens if another Zigbee integration (e.g. Zigbee2MQTT or deCONZ) has overwritten them.\n\n{diff}\n\nIf you did not intentionally change your network settings, restore from the most recent backup: your devices will not work otherwise.", + "description": "Your Zigbee adapter's network settings are inconsistent with the most recent network backup. This usually happens if another Zigbee integration (e.g. Zigbee2MQTT or deCONZ) has overwritten them.\n\n{diff}\n\nIf you did not intentionally change your network settings, restore from the most recent backup: your devices will not work otherwise.", "menu_options": { "use_new_settings": "Keep the new settings", "restore_old_settings": "Restore backup (recommended)" From 037e2bfd31dd1579181ff7c0276b3d2d5cf27c55 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 1 Oct 2025 11:42:50 +0200 Subject: [PATCH 084/103] Fix ZHA unable to select "none" flow control (#153235) --- homeassistant/components/zha/config_flow.py | 4 ++- tests/components/zha/test_config_flow.py | 32 ++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 8ca270c0cc2b..a6b45cbd0863 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -320,7 +320,9 @@ class BaseZhaFlow(ConfigEntryBaseFlow): } ) - if await self._radio_mgr.radio_type.controller.probe(user_input): + if await self._radio_mgr.radio_type.controller.probe( + self._radio_mgr.device_settings + ): return await self.async_step_verify_radio() errors["base"] = "cannot_connect" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 581d49f7eec9..ce1b1f92f379 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -2035,6 +2035,14 @@ async def test_options_flow_creates_backup( @pytest.mark.parametrize( "async_unload_effect", [True, config_entries.OperationNotAllowed()] ) +@pytest.mark.parametrize( + ("input_flow_control", "conf_flow_control"), + [ + ("hardware", "hardware"), + ("software", "software"), + ("none", None), + ], +) @patch( "serial.tools.list_ports.comports", MagicMock( @@ -2047,7 +2055,11 @@ async def test_options_flow_creates_backup( ) @patch("homeassistant.components.zha.async_setup_entry", return_value=True) async def test_options_flow_defaults( - async_setup_entry, async_unload_effect, hass: HomeAssistant + async_setup_entry, + async_unload_effect, + input_flow_control, + conf_flow_control, + hass: HomeAssistant, ) -> None: """Test options flow defaults match radio defaults.""" @@ -2127,7 +2139,9 @@ async def test_options_flow_defaults( "flow_control": "none", } - with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): + with patch( + f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True) + ) as mock_probe: # Change the serial port path result5 = await hass.config_entries.options.async_configure( flow["flow_id"], @@ -2135,9 +2149,19 @@ async def test_options_flow_defaults( # Change everything CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOW_CONTROL: "software", + CONF_FLOW_CONTROL: input_flow_control, }, ) + # verify we passed the correct flow control to the probe function + assert mock_probe.mock_calls == [ + call( + { + "path": "/dev/new_serial_port", + "baudrate": 54321, + "flow_control": conf_flow_control, + } + ) + ] # The radio has been detected, we can move on to creating the config entry assert result5["step_id"] == "choose_migration_strategy" @@ -2164,7 +2188,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOW_CONTROL: "software", + CONF_FLOW_CONTROL: conf_flow_control, }, CONF_RADIO_TYPE: "znp", } From 6d09411c077890b971cf27e5ed9a293ad825601b Mon Sep 17 00:00:00 2001 From: andreimoraru Date: Tue, 30 Sep 2025 14:19:06 +0300 Subject: [PATCH 085/103] Bump yt-dlp to 2025.09.26 (#153252) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 288921b624e6..35977da9924a 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.09.23"], + "requirements": ["yt-dlp[default]==2025.09.26"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index ab6696881c46..0429a43a02c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3217,7 +3217,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.23 +yt-dlp[default]==2025.09.26 # homeassistant.components.zabbix zabbix-utils==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4e195ffc315..d69f7f81c57e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2667,7 +2667,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.23 +yt-dlp[default]==2025.09.26 # homeassistant.components.zamg zamg==0.3.6 From 00f6d26edef9531619d643ba65a896f5ad2d5e83 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:39:32 +0200 Subject: [PATCH 086/103] Add analytics platform to wled (#153258) --- homeassistant/components/wled/analytics.py | 11 ++++++++ tests/components/wled/test_analytics.py | 31 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 homeassistant/components/wled/analytics.py create mode 100644 tests/components/wled/test_analytics.py diff --git a/homeassistant/components/wled/analytics.py b/homeassistant/components/wled/analytics.py new file mode 100644 index 000000000000..d801bfeb31fe --- /dev/null +++ b/homeassistant/components/wled/analytics.py @@ -0,0 +1,11 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications +from homeassistant.core import HomeAssistant + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + return AnalyticsModifications(remove=True) diff --git a/tests/components/wled/test_analytics.py b/tests/components/wled/test_analytics.py new file mode 100644 index 000000000000..7b392c22180a --- /dev/null +++ b/tests/components/wled/test_analytics.py @@ -0,0 +1,31 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.wled import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={(DOMAIN, "test")}, + manufacturer="Test Manufacturer", + ) + + result = await async_devices_payload(hass) + assert DOMAIN not in result["integrations"] From 53a8a250d0098117da98b870a0fbec1495d8e369 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 30 Sep 2025 21:16:37 +0200 Subject: [PATCH 087/103] Replace "Climate name" with "Climate program" in `ecobee` action (#153264) --- homeassistant/components/ecobee/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index b121c178e278..b5cec2858111 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -176,7 +176,7 @@ "description": "Sets the participating sensors for a climate program.", "fields": { "preset_mode": { - "name": "Climate Name", + "name": "Climate program", "description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program." }, "device_ids": { @@ -188,7 +188,7 @@ }, "exceptions": { "invalid_preset": { - "message": "Invalid climate name, available options are: {options}" + "message": "Invalid climate program, available options are: {options}" }, "invalid_sensor": { "message": "Invalid sensor for thermostat, available options are: {options}" From 38f906797017388544a9d3545f4d4792b197debd Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 30 Sep 2025 21:36:04 +0200 Subject: [PATCH 088/103] Portainer fix CONF_VERIFY_SSL (#153269) Co-authored-by: Robert Resch --- homeassistant/components/portainer/__init__.py | 5 +++++ tests/components/portainer/test_init.py | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index ad57e66186d6..79f7c02e4ba8 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -57,4 +57,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) data[CONF_API_TOKEN] = data.pop(CONF_API_KEY) hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + if entry.version < 3: + data = dict(entry.data) + data[CONF_VERIFY_SSL] = True + hass.config_entries.async_update_entry(entry=entry, data=data, version=3) + return True diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 00b4d5940e93..4e661e225055 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -11,7 +11,13 @@ import pytest from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_HOST, CONF_URL +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_TOKEN, + CONF_HOST, + CONF_URL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from . import setup_integration @@ -40,8 +46,8 @@ async def test_setup_exceptions( assert mock_config_entry.state == expected_state -async def test_v1_migration(hass: HomeAssistant) -> None: - """Test migration from v1 to v2 config entry.""" +async def test_migrations(hass: HomeAssistant) -> None: + """Test migration from v1 config entry.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -52,11 +58,14 @@ async def test_v1_migration(hass: HomeAssistant) -> None: version=1, ) entry.add_to_hass(hass) + assert entry.version == 1 + assert CONF_VERIFY_SSL not in entry.data await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.version == 2 + assert entry.version == 3 assert CONF_HOST not in entry.data assert CONF_API_KEY not in entry.data assert entry.data[CONF_URL] == "http://test_host" assert entry.data[CONF_API_TOKEN] == "test_key" + assert entry.data[CONF_VERIFY_SSL] is True From de6d34fec56c9190a983528aced402d412786770 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:38:50 +0200 Subject: [PATCH 089/103] Filter out service type devices in extended analytics (#153271) --- .../components/analytics/analytics.py | 35 ++++++++++++------- tests/components/analytics/test_analytics.py | 26 ++++++++------ 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 2b67592e2f92..6a2943ccd897 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -505,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications() DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications() -async def async_devices_payload(hass: HomeAssistant) -> dict: +async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 """Return detailed information about entities and devices.""" dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) @@ -513,6 +513,8 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: integration_inputs: dict[str, tuple[list[str], list[str]]] = {} integration_configs: dict[str, AnalyticsModifications] = {} + removed_devices: set[str] = set() + # Get device list for device_entry in dev_reg.devices.values(): if not device_entry.primary_config_entry: @@ -525,6 +527,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: if config_entry is None: continue + if device_entry.entry_type is dr.DeviceEntryType.SERVICE: + removed_devices.add(device_entry.id) + continue + integration_domain = config_entry.domain integration_input = integration_inputs.setdefault(integration_domain, ([], [])) @@ -614,11 +620,12 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: device_config = integration_config.devices.get(device_id, device_config) if device_config.remove: + removed_devices.add(device_id) continue device_entry = dev_reg.devices[device_id] - device_id_mapping[device_entry.id] = (integration_domain, len(devices_info)) + device_id_mapping[device_id] = (integration_domain, len(devices_info)) devices_info.append( { @@ -669,7 +676,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: entity_entry = ent_reg.entities[entity_id] - entity_state = hass.states.get(entity_entry.entity_id) + entity_state = hass.states.get(entity_id) entity_info = { # LIMITATION: `assumed_state` can be overridden by users; @@ -690,15 +697,19 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: "unit_of_measurement": entity_entry.unit_of_measurement, } - if ( - ((device_id_ := entity_entry.device_id) is not None) - and ((new_device_id := device_id_mapping.get(device_id_)) is not None) - and (new_device_id[0] == integration_domain) - ): - device_info = devices_info[new_device_id[1]] - device_info["entities"].append(entity_info) - else: - entities_info.append(entity_info) + if (device_id_ := entity_entry.device_id) is not None: + if device_id_ in removed_devices: + # The device was removed, so we remove the entity too + continue + + if ( + new_device_id := device_id_mapping.get(device_id_) + ) is not None and (new_device_id[0] == integration_domain): + device_info = devices_info[new_device_id[1]] + device_info["entities"].append(entity_info) + continue + + entities_info.append(entity_info) return { "version": "home-assistant:1", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index be8f38901ee4..feffc952a49d 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1085,17 +1085,6 @@ async def test_devices_payload_no_entities( "sw_version": "test-sw-version", "via_device": None, }, - { - "entities": [], - "entry_type": "service", - "has_configuration_url": False, - "hw_version": None, - "manufacturer": "test-manufacturer", - "model": None, - "model_id": "test-model-id", - "sw_version": None, - "via_device": None, - }, { "entities": [], "entry_type": None, @@ -1160,6 +1149,13 @@ async def test_devices_payload_with_entities( manufacturer="test-manufacturer", model_id="test-model-id", ) + device_entry_3 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "3")}, + manufacturer="test-manufacturer", + model_id="test-model-id", + entry_type=dr.DeviceEntryType.SERVICE, + ) # First device @@ -1209,6 +1205,14 @@ async def test_devices_payload_with_entities( device_id=device_entry_2.id, ) + # Third device (service type) + entity_registry.async_get_or_create( + domain="light", + platform="hue", + unique_id="4", + device_id=device_entry_3.id, + ) + # Entity without device with unit of measurement and state class entity_registry.async_get_or_create( domain="sensor", From 36ff5c0d45f071b21a6909877ed736913b17b81f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Sep 2025 20:58:34 +0200 Subject: [PATCH 090/103] Bump aioecowitt to 2025.9.2 (#153273) --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index ba3d01ef6af3..d8b8aedbc3d4 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2025.9.1"] + "requirements": ["aioecowitt==2025.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0429a43a02c4..b437ce33dd5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -238,7 +238,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2025.9.1 +aioecowitt==2025.9.2 # homeassistant.components.co2signal aioelectricitymaps==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d69f7f81c57e..d7cc50859e97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -226,7 +226,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2025.9.1 +aioecowitt==2025.9.2 # homeassistant.components.co2signal aioelectricitymaps==1.1.1 From a6b6e4c4b8782302205174728a8afb0fb58f8ad0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Sep 2025 21:29:58 +0200 Subject: [PATCH 091/103] Add Eltako brand (#153276) --- homeassistant/brands/eltako.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/eltako.json diff --git a/homeassistant/brands/eltako.json b/homeassistant/brands/eltako.json new file mode 100644 index 000000000000..ead922aa5b24 --- /dev/null +++ b/homeassistant/brands/eltako.json @@ -0,0 +1,5 @@ +{ + "domain": "eltako", + "name": "Eltako", + "iot_standards": ["matter"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3289af99fe2d..38658433cf31 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1674,6 +1674,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "eltako": { + "name": "Eltako", + "iot_standards": [ + "matter" + ] + }, "elv": { "name": "ELV PCA", "integration_type": "hub", From ed9cfb4c4bf6025ba7aca3d870c27508a3c91df2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Oct 2025 05:43:28 -0400 Subject: [PATCH 092/103] Use hardware bootloader reset methods for firmware config flows (#153277) --- .../homeassistant_connect_zbt2/config_flow.py | 6 ++ .../homeassistant_connect_zbt2/update.py | 3 +- .../firmware_config_flow.py | 5 +- .../homeassistant_hardware/manifest.json | 2 +- .../homeassistant_hardware/update.py | 11 ++- .../components/homeassistant_hardware/util.py | 25 +++++- .../homeassistant_sky_connect/update.py | 3 +- .../homeassistant_yellow/config_flow.py | 3 + .../components/homeassistant_yellow/update.py | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../test_config_flow.py | 77 ++++++++++++++----- .../test_config_flow.py | 5 +- .../homeassistant_hardware/test_update.py | 7 +- .../homeassistant_hardware/test_util.py | 12 ++- .../homeassistant_yellow/test_config_flow.py | 72 ++++++++++++----- 16 files changed, 174 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py index 49243e5a97df..34af7b6168a1 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py +++ b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.homeassistant_hardware import firmware_config_flow from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + ResetTarget, ) from homeassistant.config_entries import ( ConfigEntry, @@ -67,6 +68,11 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): context: ConfigFlowContext + # `rts_dtr` targets older adapters, `baudrate` works for newer ones. The reason we + # try them in this order is that on older adapters `baudrate` entered the ESP32-S3 + # bootloader instead of the MG24 bootloader. + BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE] + async def async_step_install_zigbee_firmware( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_connect_zbt2/update.py b/homeassistant/components/homeassistant_connect_zbt2/update.py index 24ddf4171804..6c8819a7da96 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/update.py +++ b/homeassistant/components/homeassistant_connect_zbt2/update.py @@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import ( from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + ResetTarget, ) from homeassistant.components.update import UpdateDeviceClass from homeassistant.config_entries import ConfigEntry @@ -156,7 +157,7 @@ async def async_setup_entry( class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Connect ZBT-2 firmware update entity.""" - bootloader_reset_type = None + bootloader_reset_methods = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE] def __init__( self, diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 20b817fe2c50..284e7611f2f5 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -39,6 +39,7 @@ from .util import ( FirmwareInfo, OwningAddon, OwningIntegration, + ResetTarget, async_flash_silabs_firmware, get_otbr_addon_manager, guess_firmware_info, @@ -79,6 +80,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Base flow to install firmware.""" ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override + BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override + _picked_firmware_type: PickedFirmwareType _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED @@ -274,7 +277,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): device=self._device, fw_data=fw_data, expected_installed_firmware_type=expected_installed_firmware_type, - bootloader_reset_type=None, + bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS, progress_callback=lambda offset, total: self.async_update_progress( offset / total ), diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 26d227ae922d..510c1fc6d6cf 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "universal-silabs-flasher==0.0.32", + "universal-silabs-flasher==0.0.34", "ha-silabs-firmware-client==0.2.0" ] } diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index 831d9f3f4da7..81c02360bd26 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -22,7 +22,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import FirmwareUpdateCoordinator from .helpers import async_register_firmware_info_callback -from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware +from .util import ( + ApplicationType, + FirmwareInfo, + ResetTarget, + async_flash_silabs_firmware, +) _LOGGER = logging.getLogger(__name__) @@ -81,7 +86,7 @@ class BaseFirmwareUpdateEntity( # Subclasses provide the mapping between firmware types and entity descriptions entity_description: FirmwareUpdateEntityDescription - bootloader_reset_type: str | None = None + bootloader_reset_methods: list[ResetTarget] = [] _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS @@ -268,7 +273,7 @@ class BaseFirmwareUpdateEntity( device=self._current_device, fw_data=fw_data, expected_installed_firmware_type=self.entity_description.expected_firmware_type, - bootloader_reset_type=self.bootloader_reset_type, + bootloader_reset_methods=self.bootloader_reset_methods, progress_callback=self._update_progress, ) finally: diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index d3bddad97545..278cc1915166 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -4,13 +4,16 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import AsyncIterator, Callable, Iterable +from collections.abc import AsyncIterator, Callable, Iterable, Sequence from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from enum import StrEnum import logging -from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.const import ( + ApplicationType as FlasherApplicationType, + ResetTarget as FlasherResetTarget, +) from universal_silabs_flasher.firmware import parse_firmware_image from universal_silabs_flasher.flasher import Flasher @@ -59,6 +62,18 @@ class ApplicationType(StrEnum): return FlasherApplicationType(self.value) +class ResetTarget(StrEnum): + """Methods to reset a device into bootloader mode.""" + + RTS_DTR = "rts_dtr" + BAUDRATE = "baudrate" + YELLOW = "yellow" + + def as_flasher_reset_target(self) -> FlasherResetTarget: + """Convert the reset target enum into one compatible with USF.""" + return FlasherResetTarget(self.value) + + @singleton(OTBR_ADDON_MANAGER_DATA) @callback def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: @@ -342,7 +357,7 @@ async def async_flash_silabs_firmware( device: str, fw_data: bytes, expected_installed_firmware_type: ApplicationType, - bootloader_reset_type: str | None = None, + bootloader_reset_methods: Sequence[ResetTarget] = (), progress_callback: Callable[[int, int], None] | None = None, ) -> FirmwareInfo: """Flash firmware to the SiLabs device.""" @@ -359,7 +374,9 @@ async def async_flash_silabs_firmware( ApplicationType.SPINEL.as_flasher_application_type(), ApplicationType.CPC.as_flasher_application_type(), ), - bootloader_reset=bootloader_reset_type, + bootloader_reset=tuple( + m.as_flasher_reset_target() for m in bootloader_reset_methods + ), ) async with AsyncExitStack() as stack: diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index df69b6d40a23..eab9fc232a43 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -168,7 +168,8 @@ async def async_setup_entry( class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """SkyConnect firmware update entity.""" - bootloader_reset_type = None + # The ZBT-1 does not have a hardware bootloader trigger + bootloader_reset_methods = [] def __init__( self, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 8339a3562b33..821ba48eee76 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -27,6 +27,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + ResetTarget, probe_silabs_firmware_info, ) from homeassistant.config_entries import ( @@ -83,6 +84,8 @@ else: class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): """Mixin for Home Assistant Yellow firmware methods.""" + BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW] + async def async_step_install_zigbee_firmware( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 7a6e2f19b1f0..d86ac93a8489 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.update import ( from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + ResetTarget, ) from homeassistant.components.update import UpdateDeviceClass from homeassistant.config_entries import ConfigEntry @@ -173,7 +174,7 @@ async def async_setup_entry( class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Yellow firmware update entity.""" - bootloader_reset_type = "yellow" # Triggers a GPIO reset + bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset def __init__( self, diff --git a/requirements_all.txt b/requirements_all.txt index b437ce33dd5a..c8e6275b4c45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3054,7 +3054,7 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.32 +universal-silabs-flasher==0.0.34 # homeassistant.components.upb upb-lib==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7cc50859e97..3dc99b198745 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2525,7 +2525,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.32 +universal-silabs-flasher==0.0.34 # homeassistant.components.upb upb-lib==0.6.1 diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index 54f70c57c490..62a34bc1d355 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Home Assistant Connect ZBT-2 config flow.""" from collections.abc import Generator -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, Mock, call, patch import pytest @@ -243,23 +243,18 @@ async def test_options_flow( assert description_placeholders["firmware_type"] == "spinel" assert description_placeholders["model"] == model - async def mock_install_firmware_step( - self, - fw_update_url: str, - fw_type: str, - firmware_name: str, - expected_installed_firmware_type: ApplicationType, - step_id: str, - next_step_id: str, - ) -> ConfigFlowResult: - self._probed_firmware_info = FirmwareInfo( - device=usb_data.device, - firmware_type=expected_installed_firmware_type, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ) - return await getattr(self, f"async_step_{next_step_id}")() + mock_update_client = AsyncMock() + mock_manifest = Mock() + mock_firmware = Mock() + mock_firmware.filename = "zbt2_zigbee_ncp_7.4.4.0.gbl" + mock_firmware.metadata = { + "ezsp_version": "7.4.4.0", + "fw_type": "zbt2_zigbee_ncp", + "metadata_version": 2, + } + mock_manifest.firmwares = [mock_firmware] + mock_update_client.async_update_data.return_value = mock_manifest + mock_update_client.async_fetch_firmware.return_value = b"firmware_data" with ( patch( @@ -267,9 +262,42 @@ async def test_options_flow( return_value=[], ), patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._install_firmware_step", - autospec=True, - side_effect=mock_install_firmware_step, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient", + return_value=mock_update_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ) as flash_mock, + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + side_effect=[ + # First call: probe before installation (returns current SPINEL firmware) + FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="2.4.4.0", + owners=[], + source="probe", + ), + # Second call: probe after installation (returns new EZSP firmware) + FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ], + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), ): pick_result = await hass.config_entries.options.async_configure( @@ -298,6 +326,13 @@ async def test_options_flow( "vid": usb_data.vid, } + # Verify async_flash_silabs_firmware was called with ZBT-2's reset methods + assert flash_mock.call_count == 1 + assert flash_mock.mock_calls[0].kwargs["bootloader_reset_methods"] == [ + "rts_dtr", + "baudrate", + ] + async def test_duplicate_discovery(hass: HomeAssistant) -> None: """Test config flow unique_id deduplication.""" diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 34c6cfb7f804..267fa389d91d 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Home Assistant hardware firmware config flow.""" import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Sequence import contextlib from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch @@ -25,6 +25,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + ResetTarget, ) from homeassistant.config_entries import ( SOURCE_IGNORE, @@ -299,7 +300,7 @@ def mock_firmware_info( device: str, fw_data: bytes, expected_installed_firmware_type: ApplicationType, - bootloader_reset_type: str | None = None, + bootloader_reset_methods: Sequence[ResetTarget] = (), progress_callback: Callable[[int, int], None] | None = None, ) -> FirmwareInfo: await asyncio.sleep(0) diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 3103e5cfc6aa..5f99d64c1b1b 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Sequence import dataclasses import logging from unittest.mock import Mock, patch @@ -29,6 +29,7 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, OwningIntegration, + ResetTarget, ) from homeassistant.components.update import UpdateDeviceClass from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -197,7 +198,7 @@ async def mock_async_setup_update_entities( class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Mock SkyConnect firmware update entity.""" - bootloader_reset_type = None + bootloader_reset_methods = [] def __init__( self, @@ -361,7 +362,7 @@ async def test_update_entity_installation( device: str, fw_data: bytes, expected_installed_firmware_type: ApplicationType, - bootloader_reset_type: str | None = None, + bootloader_reset_methods: Sequence[ResetTarget] = (), progress_callback: Callable[[int, int], None] | None = None, ) -> FirmwareInfo: await asyncio.sleep(0) diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 048bf998d131..e9c20ffb8d60 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -580,7 +580,7 @@ async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: patch( "homeassistant.components.homeassistant_hardware.util.Flasher", return_value=mock_flasher, - ), + ) as flasher_mock, patch( "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), @@ -594,13 +594,17 @@ async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, - bootloader_reset_type=None, + bootloader_reset_methods=(), progress_callback=progress_callback, ) assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)] assert after_flash_info == expected_firmware_info + # Verify Flasher was called with correct bootloader_reset parameter + assert flasher_mock.call_count == 1 + assert flasher_mock.mock_calls[0].kwargs["bootloader_reset"] == () + # Both owning integrations/addons are stopped and restarted assert owner1.temporarily_stop.mock_calls == [ call(hass), @@ -653,7 +657,7 @@ async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) -> device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, - bootloader_reset_type=None, + bootloader_reset_methods=(), ) # Both owning integrations/addons are stopped and restarted @@ -713,7 +717,7 @@ async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, - bootloader_reset_type=None, + bootloader_reset_methods=(), ) # Both owning integrations/addons are stopped and restarted diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 3a85ed017cb4..0cb1b2ab3f4d 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -353,23 +353,18 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: assert description_placeholders["firmware_type"] == "spinel" assert description_placeholders["model"] == "Home Assistant Yellow" - async def mock_install_firmware_step( - self, - fw_update_url: str, - fw_type: str, - firmware_name: str, - expected_installed_firmware_type: ApplicationType, - step_id: str, - next_step_id: str, - ) -> ConfigFlowResult: - self._probed_firmware_info = FirmwareInfo( - device=RADIO_DEVICE, - firmware_type=expected_installed_firmware_type, - firmware_version=fw_version, - owners=[], - source="probe", - ) - return await getattr(self, f"async_step_{next_step_id}")() + mock_update_client = AsyncMock() + mock_manifest = Mock() + mock_firmware = Mock() + mock_firmware.filename = "yellow_zigbee_ncp_7.4.4.0.gbl" + mock_firmware.metadata = { + "ezsp_version": "7.4.4.0", + "fw_type": "yellow_zigbee_ncp", + "metadata_version": 2, + } + mock_manifest.firmwares = [mock_firmware] + mock_update_client.async_update_data.return_value = mock_manifest + mock_update_client.async_fetch_firmware.return_value = b"firmware_data" with ( patch( @@ -377,9 +372,42 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: return_value=[], ), patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step", - autospec=True, - side_effect=mock_install_firmware_step, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient", + return_value=mock_update_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware", + return_value=FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=fw_type, + firmware_version=fw_version, + owners=[], + source="probe", + ), + ) as flash_mock, + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + side_effect=[ + # First call: probe before installation (returns current SPINEL firmware) + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="2.4.4.0", + owners=[], + source="probe", + ), + # Second call: probe after installation (returns new EZSP firmware) + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=fw_type, + firmware_version=fw_version, + owners=[], + source="probe", + ), + ], + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), ): pick_result = await hass.config_entries.options.async_configure( @@ -402,6 +430,10 @@ async def test_firmware_options_flow_zigbee(hass: HomeAssistant) -> None: "firmware_version": fw_version, } + # Verify async_flash_silabs_firmware was called with Yellow's reset method + assert flash_mock.call_count == 1 + assert flash_mock.mock_calls[0].kwargs["bootloader_reset_methods"] == ["yellow"] + @pytest.mark.usefixtures("addon_installed") async def test_firmware_options_flow_thread( From bd10f6ec0836923315042c63ff3eb1b76730af59 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Sep 2025 19:57:24 +0200 Subject: [PATCH 093/103] Require cloud for Aladdin Connect (#153278) Co-authored-by: Paulus Schoutsen Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/aladdin_connect/config_flow.py | 11 ++++ .../components/aladdin_connect/strings.json | 3 +- .../aladdin_connect/test_config_flow.py | 56 ++++++++++++++++--- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index bfc767204541..dab801d47122 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -22,6 +22,17 @@ class OAuth2FlowHandler( VERSION = CONFIG_FLOW_VERSION MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check we have the cloud integration set up.""" + if "cloud" not in self.hass.config.components: + return self.async_abort( + reason="cloud_not_enabled", + description_placeholders={"default_config": "default_config"}, + ) + return await super().async_step_user(user_input) + async def async_step_reauth( self, user_input: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index 7d673efd3cb6..c452ba66865b 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -24,7 +24,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account." + "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.", + "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index d69c588a6490..ee555cf2ebb8 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.aladdin_connect.const import ( OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.config_entries import SOURCE_DHCP +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -23,6 +23,12 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.fixture +def use_cloud(hass: HomeAssistant) -> None: + """Set up the cloud component.""" + hass.config.components.add("cloud") + + @pytest.fixture async def access_token(hass: HomeAssistant) -> str: """Return a valid access token with sub field for unique ID.""" @@ -37,7 +43,7 @@ async def access_token(hass: HomeAssistant) -> str: ) -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -97,7 +103,7 @@ async def test_full_flow( assert result["result"].unique_id == USER_ID -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_dhcp_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -170,7 +176,7 @@ async def test_full_dhcp_flow( assert result["result"].unique_id == USER_ID -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -221,7 +227,7 @@ async def test_duplicate_entry( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_dhcp_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -243,7 +249,7 @@ async def test_duplicate_dhcp_entry( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_flow_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -306,7 +312,7 @@ async def test_flow_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_flow_wrong_account_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -370,3 +376,39 @@ async def test_flow_wrong_account_reauth( # Should abort with wrong account assert result["type"] == "abort" assert result["reason"] == "wrong_account" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check we abort when cloud is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Aladdin Connect reauthentication without cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" From 58cc7c8f84f8f7ba7c7246c26c9e7a1b7d3bc879 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Sep 2025 21:21:21 +0200 Subject: [PATCH 094/103] Add Level brand (#153279) --- homeassistant/brands/level.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/level.json diff --git a/homeassistant/brands/level.json b/homeassistant/brands/level.json new file mode 100644 index 000000000000..89fe23b502bd --- /dev/null +++ b/homeassistant/brands/level.json @@ -0,0 +1,5 @@ +{ + "domain": "level", + "name": "Level", + "iot_standards": ["matter"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 38658433cf31..1dd0f111727e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3482,6 +3482,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "level": { + "name": "Level", + "iot_standards": [ + "matter" + ] + }, "leviton": { "name": "Leviton", "iot_standards": [ From f242e294be9725f9e4b9b44be94cb05c9d1dbba8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Sep 2025 21:27:43 +0200 Subject: [PATCH 095/103] Add Konnected brand (#153280) --- homeassistant/brands/konnected.json | 5 +++++ .../components/konnected_esphome/__init__.py | 1 + .../konnected_esphome/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 19 +++++++++++++++---- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 homeassistant/brands/konnected.json create mode 100644 homeassistant/components/konnected_esphome/__init__.py create mode 100644 homeassistant/components/konnected_esphome/manifest.json diff --git a/homeassistant/brands/konnected.json b/homeassistant/brands/konnected.json new file mode 100644 index 000000000000..6581fe1e476b --- /dev/null +++ b/homeassistant/brands/konnected.json @@ -0,0 +1,5 @@ +{ + "domain": "konnected", + "name": "Konnected", + "integrations": ["konnected", "konnected_esphome"] +} diff --git a/homeassistant/components/konnected_esphome/__init__.py b/homeassistant/components/konnected_esphome/__init__.py new file mode 100644 index 000000000000..376c1b26c780 --- /dev/null +++ b/homeassistant/components/konnected_esphome/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Konnected ESPHome.""" diff --git a/homeassistant/components/konnected_esphome/manifest.json b/homeassistant/components/konnected_esphome/manifest.json new file mode 100644 index 000000000000..0c9827c80e61 --- /dev/null +++ b/homeassistant/components/konnected_esphome/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "konnected_esphome", + "name": "Konnected", + "integration_type": "virtual", + "supported_by": "esphome" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1dd0f111727e..866ed0115fd3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3352,10 +3352,21 @@ "iot_class": "local_push" }, "konnected": { - "name": "Konnected.io (Legacy)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "name": "Konnected", + "integrations": { + "konnected": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Konnected.io (Legacy)" + }, + "konnected_esphome": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "esphome", + "name": "Konnected" + } + } }, "kostal_plenticore": { "name": "Kostal Plenticore Solar Inverter", From 8de200de0b36b4ce3a1b55287c4397a14e1ba14e Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:39:23 +0100 Subject: [PATCH 096/103] Fix Bayesian ConfigFlow templates in 2025.10 (#153289) Co-authored-by: Erik Montnemery --- .../components/bayesian/binary_sensor.py | 7 ++++ .../components/bayesian/test_binary_sensor.py | 41 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index d09e55de77db..6d3dbb7f2448 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -272,6 +272,13 @@ async def async_setup_entry( observations: list[ConfigType] = [ dict(subentry.data) for subentry in config_entry.subentries.values() ] + + for observation in observations: + if observation[CONF_PLATFORM] == CONF_TEMPLATE: + observation[CONF_VALUE_TEMPLATE] = Template( + observation[CONF_VALUE_TEMPLATE], hass + ) + prior: float = config[CONF_PRIOR] probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index b0d81af228cb..a4fe24ca6e43 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_RELOAD, @@ -26,7 +27,7 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import MockConfigEntry, get_fixture_path async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None: @@ -295,6 +296,44 @@ async def test_sensor_value_template(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + await _test_sensor_value_template(hass) + + +async def test_sensor_value_template_config_entry(hass: HomeAssistant) -> None: + """Test sensor on template platform observations.""" + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "Test_Binary", + "prior": 0.2, + "probability_threshold": 0.32, + }, + subentries_data=[ + ConfigSubentryData( + data={ + "platform": "template", + "value_template": "{{states('sensor.test_monitored') == 'off'}}", + "prob_given_true": 0.8, + "prob_given_false": 0.4, + "name": "observation_1", + }, + subentry_type="observation", + title="observation_1", + unique_id=None, + ) + ], + title="Test_Binary", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + await _test_sensor_value_template(hass) + + +async def _test_sensor_value_template(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", "on") state = hass.states.get("binary_sensor.test_binary") From 8abfe424e12a4733b3649139f8bc3ecb9073cf90 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Oct 2025 09:50:30 +0200 Subject: [PATCH 097/103] Update frontend to 20251001.0 (#153300) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 58a923e2dbeb..ec5832d1ec61 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250926.0"] + "requirements": ["home-assistant-frontend==20251001.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 679f2d951cfb..ec3d592cefed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250926.0 +home-assistant-frontend==20251001.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c8e6275b4c45..17e146848c59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250926.0 +home-assistant-frontend==20251001.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3dc99b198745..a3ecebcf8470 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250926.0 +home-assistant-frontend==20251001.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 From c0317f60cc608067c4b23df2fd803184e71bdf37 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:08:50 +0200 Subject: [PATCH 098/103] Add analytics platform to esphome (#153311) --- homeassistant/components/esphome/analytics.py | 11 +++++++ tests/components/esphome/test_analytics.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 homeassistant/components/esphome/analytics.py create mode 100644 tests/components/esphome/test_analytics.py diff --git a/homeassistant/components/esphome/analytics.py b/homeassistant/components/esphome/analytics.py new file mode 100644 index 000000000000..d801bfeb31fe --- /dev/null +++ b/homeassistant/components/esphome/analytics.py @@ -0,0 +1,11 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications +from homeassistant.core import HomeAssistant + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + return AnalyticsModifications(remove=True) diff --git a/tests/components/esphome/test_analytics.py b/tests/components/esphome/test_analytics.py new file mode 100644 index 000000000000..f4de75b2ee0c --- /dev/null +++ b/tests/components/esphome/test_analytics.py @@ -0,0 +1,31 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.esphome import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={(DOMAIN, "test")}, + manufacturer="Test Manufacturer", + ) + + result = await async_devices_payload(hass) + assert DOMAIN not in result["integrations"] From f616e5a4e39159f93c0dfc443632b8ddb80008a9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Oct 2025 10:41:01 +0000 Subject: [PATCH 099/103] Bump version to 2025.10.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index be788d2c6b7a..c8aff43e41f0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index a03b67262eb7..9a6fce7271af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b5" +version = "2025.10.0b6" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From f03b16bdf866c3361ac692e80f63fa568da23cce Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 1 Oct 2025 09:39:08 -0500 Subject: [PATCH 100/103] Bump intents to 2025.10.1 (#153340) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index b3bc9b8c067e..040f6c3a8637 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"] + "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.10.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec3d592cefed..ee05c64cdfbc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20251001.0 -home-assistant-intents==2025.9.24 +home-assistant-intents==2025.10.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index 17e146848c59..c0f6ac93b1a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ holidays==0.81 home-assistant-frontend==20251001.0 # homeassistant.components.conversation -home-assistant-intents==2025.9.24 +home-assistant-intents==2025.10.1 # homeassistant.components.homematicip_cloud homematicip==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3ecebcf8470..b51417378762 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1038,7 +1038,7 @@ holidays==0.81 home-assistant-frontend==20251001.0 # homeassistant.components.conversation -home-assistant-intents==2025.9.24 +home-assistant-intents==2025.10.1 # homeassistant.components.homematicip_cloud homematicip==2.3.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index a9f0aacdae10..c127f5ae51eb 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==3.2.0 \ - home-assistant-intents==2025.9.24 \ + home-assistant-intents==2025.10.1 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 From dde60cdecb5329b66767f5b7c04689d29fcd8619 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 1 Oct 2025 16:49:27 +0200 Subject: [PATCH 101/103] Improve `mac_address_from_name()` function to avoid double discovery of Shelly devices (#153343) --- homeassistant/components/shelly/utils.py | 11 +++++++++-- tests/components/shelly/test_utils.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 962a314f8eb3..0fcec2942614 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -552,8 +552,15 @@ def percentage_to_brightness(percentage: int) -> int: def mac_address_from_name(name: str) -> str | None: """Convert a name to a mac address.""" - mac = name.partition(".")[0].partition("-")[-1] - return mac.upper() if len(mac) == 12 else None + base = name.split(".", 1)[0] + if "-" not in base: + return None + + mac = base.rsplit("-", 1)[-1] + if len(mac) != 12 or not all(char in "0123456789abcdefABCDEF" for char in mac): + return None + + return mac.upper() def get_release_url(gen: int, model: str, beta: bool) -> str | None: diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 0cdd1640e658..ec5bd411ac38 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -34,6 +34,7 @@ from homeassistant.components.shelly.utils import ( get_rpc_channel_name, get_rpc_input_triggers, is_block_momentary_input, + mac_address_from_name, ) from homeassistant.util import dt as dt_util @@ -327,3 +328,17 @@ def test_get_release_url( def test_get_host(host: str, expected: str) -> None: """Test get_host function.""" assert get_host(host) == expected + + +@pytest.mark.parametrize( + ("name", "result"), + [ + ("shelly1pm-AABBCCDDEEFF", "AABBCCDDEEFF"), + ("Shelly Plus 1 [DDEEFF]", None), + ("S11-Schlafzimmer", None), + ("22-Kueche-links", None), + ], +) +def test_mac_address_from_name(name: str, result: str | None) -> None: + """Test mac_address_from_name() function.""" + assert mac_address_from_name(name) == result From 6cd1283b00e16cc814453fd3a4167b07390aafdd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Oct 2025 14:51:37 +0000 Subject: [PATCH 102/103] Bump version to 2025.10.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c8aff43e41f0..22f37e111431 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 9a6fce7271af..70abcea87f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b6" +version = "2025.10.0b7" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 55d5e769b2976de16deb07cdcda6a4ec5064c453 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Oct 2025 15:19:48 +0000 Subject: [PATCH 103/103] Bump version to 2025.10.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 22f37e111431..4d2907f840ed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 70abcea87f90..3606ebd75e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0b7" +version = "2025.10.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3."