From b1ae9c95c9b04a4e204a6662019b38aba40ef1e7 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 23 Sep 2025 12:27:35 -0300 Subject: [PATCH 01/21] Add a switch entity for add-ons (#151431) Co-authored-by: Stefan Agner --- homeassistant/components/hassio/__init__.py | 3 +- .../components/hassio/coordinator.py | 13 + homeassistant/components/hassio/switch.py | 90 +++++ tests/components/hassio/test_switch.py | 320 ++++++++++++++++++ 4 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/hassio/switch.py create mode 100644 tests/components/hassio/test_switch.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 0c15a68742146e..e352f8d0cb361b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -73,6 +73,7 @@ config_flow, diagnostics, sensor, + switch, system_health, update, ) @@ -149,7 +150,7 @@ # If new platforms are added, be sure to import them above # so we do not make other components that depend on hassio # wait for the import of the platforms -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE] CONF_FRONTEND_REPO = "development_repo" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 5532c66d1ae3d5..2a41bbc2bdaf66 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -4,6 +4,7 @@ import asyncio from collections import defaultdict +from copy import deepcopy import logging from typing import TYPE_CHECKING, Any @@ -545,3 +546,15 @@ async def _async_refresh( await super()._async_refresh( log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) + + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: + """Force refresh of addon info data for a specific addon.""" + try: + slug, info = await self._update_addon_info(addon_slug) + if info is not None and DATA_KEY_ADDONS in self.data: + if slug in self.data[DATA_KEY_ADDONS]: + data = deepcopy(self.data) + data[DATA_KEY_ADDONS][slug].update(info) + self.async_set_updated_data(data) + except SupervisorError as err: + _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) diff --git a/homeassistant/components/hassio/switch.py b/homeassistant/components/hassio/switch.py new file mode 100644 index 00000000000000..43fde5190e79ff --- /dev/null +++ b/homeassistant/components/hassio/switch.py @@ -0,0 +1,90 @@ +"""Switch platform for Hass.io addons.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohasupervisor import SupervisorError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS +from .entity import HassioAddonEntity +from .handler import get_supervisor_client + +_LOGGER = logging.getLogger(__name__) + + +ENTITY_DESCRIPTION = SwitchEntityDescription( + key=ATTR_STATE, + name=None, + icon="mdi:puzzle", + entity_registry_enabled_default=False, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Switch set up for Hass.io config entry.""" + coordinator = hass.data[ADDONS_COORDINATOR] + + async_add_entities( + HassioAddonSwitch( + addon=addon, + coordinator=coordinator, + entity_description=ENTITY_DESCRIPTION, + ) + for addon in coordinator.data[DATA_KEY_ADDONS].values() + ) + + +class HassioAddonSwitch(HassioAddonEntity, SwitchEntity): + """Switch for Hass.io add-ons.""" + + @property + def is_on(self) -> bool | None: + """Return true if the add-on is on.""" + addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) + state = addon_data.get(self.entity_description.key) + return state == ATTR_STARTED + + @property + def entity_picture(self) -> str | None: + """Return the icon of the add-on if any.""" + if not self.available: + return None + addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) + if addon_data.get(ATTR_ICON): + return f"/api/hassio/addons/{self._addon_slug}/icon" + return None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + supervisor_client = get_supervisor_client(self.hass) + try: + await supervisor_client.addons.start_addon(self._addon_slug) + except SupervisorError as err: + _LOGGER.error("Failed to start addon %s: %s", self._addon_slug, err) + raise HomeAssistantError(err) from err + + await self.coordinator.force_addon_info_data_refresh(self._addon_slug) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + supervisor_client = get_supervisor_client(self.hass) + try: + await supervisor_client.addons.stop_addon(self._addon_slug) + except SupervisorError as err: + _LOGGER.error("Failed to stop addon %s: %s", self._addon_slug, err) + raise HomeAssistantError(err) from err + + await self.coordinator.force_addon_info_data_refresh(self._addon_slug) diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py new file mode 100644 index 00000000000000..744a277412f746 --- /dev/null +++ b/tests/components/hassio/test_switch.py @@ -0,0 +1,320 @@ +"""The tests for the hassio switch.""" + +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.hassio import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all( + aioclient_mock: AiohttpClientMocker, + addon_installed: AsyncMock, + store_info: AsyncMock, + addon_changelog: AsyncMock, + addon_stats: AsyncMock, + resolution_info: AsyncMock, +) -> None: + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + { + "name": "test-two", + "state": "stopped", + "slug": "test-two", + "installed": True, + "update_available": False, + "icon": True, + "version": "3.1.0", + "version_latest": "3.1.0", + "repository": "core", + "url": "https://github.com", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +@pytest.mark.parametrize( + ("entity_id", "expected", "addon_state"), + [ + ("switch.test", "on", "started"), + ("switch.test_two", "off", "stopped"), + ], +) +async def test_switch_state( + hass: HomeAssistant, + entity_id: str, + expected: str, + addon_state: str, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, +) -> None: + """Test hassio addon switch state.""" + addon_installed.return_value.state = addon_state + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +async def test_switch_turn_on( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, +) -> None: + """Test turning on addon switch.""" + entity_id = "switch.test_two" + addon_installed.return_value.state = "stopped" + + # Mock the start addon API call + aioclient_mock.post("http://127.0.0.1/addons/test-two/start", json={"result": "ok"}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial state is off + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + # Turn on the switch + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + + # Verify the API was called + assert len(aioclient_mock.mock_calls) > 0 + start_call_found = False + for call in aioclient_mock.mock_calls: + if call[1].path == "/addons/test-two/start" and call[0] == "POST": + start_call_found = True + break + assert start_call_found + + +@pytest.mark.parametrize( + ("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)] +) +async def test_switch_turn_off( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, +) -> None: + """Test turning off addon switch.""" + entity_id = "switch.test" + addon_installed.return_value.state = "started" + + # Mock the stop addon API call + aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify initial state is on + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + # Turn off the switch + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) + + # Verify the API was called + assert len(aioclient_mock.mock_calls) > 0 + stop_call_found = False + for call in aioclient_mock.mock_calls: + if call[1].path == "/addons/test/stop" and call[0] == "POST": + stop_call_found = True + break + assert stop_call_found From 3f70084d7ffe356f4450235a7bfe1743c01b3408 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Tue, 23 Sep 2025 10:15:52 -0700 Subject: [PATCH 02/21] Handle ignored and disabled entries correctly in zeroconf discovery for Music Assistant (#152792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/music_assistant/config_flow.py | 6 ++- .../music_assistant/test_config_flow.py | 40 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index 09931040d6a915..3426a08852a99b 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -13,7 +13,7 @@ from music_assistant_models.api import ServerInfoMessage import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -113,6 +113,10 @@ async def async_step_zeroconf( ) if existing_entry: + # If the entry was ignored or disabled, don't make any changes + if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by: + return self.async_abort(reason="already_configured") + # Test connectivity to the current URL first current_url = existing_entry.data[CONF_URL] try: diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index 57eafd72ecf9a0..c9cb465b7c7153 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -362,3 +362,41 @@ async def test_zeroconf_existing_entry_broken_url( # Verify the URL was updated in the config entry updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) assert updated_entry.data[CONF_URL] == "http://discovered-working-url:8095" + + +async def test_zeroconf_existing_entry_ignored( + hass: HomeAssistant, + mock_get_server_info: AsyncMock, +) -> None: + """Test zeroconf flow when existing entry was ignored.""" + # Create an ignored config entry (no URL field) + ignored_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Music Assistant", + data={}, # No URL field for ignored entries + unique_id="1234", + source=SOURCE_IGNORE, + ) + ignored_config_entry.add_to_hass(hass) + + # Mock server info with discovered URL + server_info = ServerInfoMessage.from_json( + await async_load_fixture(hass, "server_info_message.json", DOMAIN) + ) + server_info.base_url = "http://discovered-url:8095" + mock_get_server_info.return_value = server_info + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + # Should abort because entry was ignored (respect user's choice) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # Verify the ignored entry was not modified + ignored_entry = hass.config_entries.async_get_entry(ignored_config_entry.entry_id) + assert ignored_entry.data == {} # Still no URL field + assert ignored_entry.source == SOURCE_IGNORE From 29a42a8e58ef2e2431ff9d9ef3816e0fe17e9d9d Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:52:58 +0200 Subject: [PATCH 03/21] Add analytics platform to automation (#152828) --- .../components/analytics/__init__.py | 2 + .../components/automation/analytics.py | 24 +++++++++++ tests/components/automation/test_analytics.py | 41 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 homeassistant/components/automation/analytics.py create mode 100644 tests/components/automation/test_analytics.py diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 4e805814632c55..230d172ca91a44 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -18,6 +18,7 @@ AnalyticsModifications, DeviceAnalyticsModifications, EntityAnalyticsModifications, + async_devices_payload, ) from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA from .http import AnalyticsDevicesView @@ -27,6 +28,7 @@ "AnalyticsModifications", "DeviceAnalyticsModifications", "EntityAnalyticsModifications", + "async_devices_payload", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/automation/analytics.py b/homeassistant/components/automation/analytics.py new file mode 100644 index 00000000000000..06c9a553d8ae7c --- /dev/null +++ b/homeassistant/components/automation/analytics.py @@ -0,0 +1,24 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import ( + AnalyticsInput, + AnalyticsModifications, + EntityAnalyticsModifications, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + ent_reg = er.async_get(hass) + + entities: dict[str, EntityAnalyticsModifications] = {} + for entity_id in analytics_input.entity_ids: + entity_entry = ent_reg.entities[entity_id] + if entity_entry.capabilities is not None: + entities[entity_id] = EntityAnalyticsModifications(capabilities=None) + + return AnalyticsModifications(entities=entities) diff --git a/tests/components/automation/test_analytics.py b/tests/components/automation/test_analytics.py new file mode 100644 index 00000000000000..803103d0245ca0 --- /dev/null +++ b/tests/components/automation/test_analytics.py @@ -0,0 +1,41 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.automation import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + entity_registry.async_get_or_create( + domain="automation", + platform="automation", + unique_id="automation1", + suggested_object_id="automation1", + capabilities={"id": "automation1"}, + ) + + result = await async_devices_payload(hass) + assert result["integrations"][DOMAIN]["entities"] == [ + { + "assumed_state": None, + "capabilities": None, + "domain": "automation", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": [ + "capabilities", + ], + "original_device_class": None, + "unit_of_measurement": None, + }, + ] From 014881d9850b2ebb6629a3a8579c75e14bfa597a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 23 Sep 2025 19:53:12 +0200 Subject: [PATCH 04/21] Fix error handling in subscription info retrieval and update tests (#148397) Co-authored-by: Joost Lekkerkerker --- .../components/cloud/subscription.py | 6 ++++- tests/components/cloud/test_subscription.py | 27 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index c1b8fc095c3dda..980823243bcd77 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -25,7 +25,11 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo return await cloud.payments.subscription_info() except PaymentsApiError as exception: _LOGGER.error("Failed to fetch subscription information - %s", exception) - + except TimeoutError: + _LOGGER.error( + "A timeout of %s was reached while trying to fetch subscription information", + REQUEST_TIMEOUT, + ) return None diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index ba45e6bca57daf..45c199421d6df4 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -1,6 +1,7 @@ """Test cloud subscription functions.""" -from unittest.mock import AsyncMock, Mock +import asyncio +from unittest.mock import AsyncMock, Mock, patch from hass_nabucasa import Cloud, payments_api import pytest @@ -30,19 +31,35 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: ) -async def test_fetching_subscription_with_timeout_error( +async def test_fetching_subscription_with_api_error( aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, mocked_cloud: Cloud, ) -> None: - """Test that we handle timeout error.""" + """Test that we handle API errors.""" mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError( - "Timeout reached while calling API" + "There was an error with the API" ) assert await async_subscription_info(mocked_cloud) is None assert ( - "Failed to fetch subscription information - Timeout reached while calling API" + "Failed to fetch subscription information - There was an error with the API" + in caplog.text + ) + + +async def test_fetching_subscription_with_timeout_error( + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, + mocked_cloud: Cloud, +) -> None: + """Test that we handle timeout error.""" + mocked_cloud.payments.subscription_info = lambda: asyncio.sleep(1) + with patch("homeassistant.components.cloud.subscription.REQUEST_TIMEOUT", 0): + assert await async_subscription_info(mocked_cloud) is None + + assert ( + "A timeout of 0 was reached while trying to fetch subscription information" in caplog.text ) From f00ab80d17e1c4c41d04144c377057bd6c933acf Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:53:53 +0200 Subject: [PATCH 05/21] Add analytics platform to template (#152824) --- .../components/template/analytics.py | 43 +++++++ tests/components/template/test_analytics.py | 105 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 homeassistant/components/template/analytics.py create mode 100644 tests/components/template/test_analytics.py diff --git a/homeassistant/components/template/analytics.py b/homeassistant/components/template/analytics.py new file mode 100644 index 00000000000000..e4db2c5c70a63b --- /dev/null +++ b/homeassistant/components/template/analytics.py @@ -0,0 +1,43 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import ( + AnalyticsInput, + AnalyticsModifications, + EntityAnalyticsModifications, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers import entity_registry as er + +FILTERED_PLATFORM_CAPABILITY: dict[str, str] = { + Platform.FAN: "preset_modes", + Platform.SELECT: "options", +} + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + ent_reg = er.async_get(hass) + + entities: dict[str, EntityAnalyticsModifications] = {} + for entity_id in analytics_input.entity_ids: + platform = split_entity_id(entity_id)[0] + if platform not in FILTERED_PLATFORM_CAPABILITY: + continue + + entity_entry = ent_reg.entities[entity_id] + if entity_entry.capabilities is not None: + filtered_capability = FILTERED_PLATFORM_CAPABILITY[platform] + if filtered_capability not in entity_entry.capabilities: + continue + + capabilities = dict(entity_entry.capabilities) + capabilities[filtered_capability] = len(capabilities[filtered_capability]) + + entities[entity_id] = EntityAnalyticsModifications( + capabilities=capabilities + ) + + return AnalyticsModifications(entities=entities) diff --git a/tests/components/template/test_analytics.py b/tests/components/template/test_analytics.py new file mode 100644 index 00000000000000..33a0373bd170d8 --- /dev/null +++ b/tests/components/template/test_analytics.py @@ -0,0 +1,105 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.template import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + entity_registry.async_get_or_create( + domain=Platform.FAN, + platform="template", + unique_id="fan1", + suggested_object_id="my_fan", + capabilities={"options": ["a", "b", "c"], "preset_modes": ["auto", "eco"]}, + ) + entity_registry.async_get_or_create( + domain=Platform.SELECT, + platform="template", + unique_id="select1", + suggested_object_id="my_select", + capabilities={"not_filtered": "xyz", "options": ["a", "b", "c"]}, + ) + entity_registry.async_get_or_create( + domain=Platform.SELECT, + platform="template", + unique_id="select2", + suggested_object_id="my_select", + capabilities={"not_filtered": "xyz"}, + ) + entity_registry.async_get_or_create( + domain=Platform.LIGHT, + platform="template", + unique_id="light1", + suggested_object_id="my_light", + capabilities={"not_filtered": "abc"}, + ) + + result = await async_devices_payload(hass) + assert result["integrations"][DOMAIN]["entities"] == [ + { + "assumed_state": None, + "capabilities": { + "options": ["a", "b", "c"], + "preset_modes": 2, + }, + "domain": "fan", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": [ + "capabilities", + ], + "original_device_class": None, + "unit_of_measurement": None, + }, + { + "assumed_state": None, + "capabilities": { + "not_filtered": "xyz", + "options": 3, + }, + "domain": "select", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": [ + "capabilities", + ], + "original_device_class": None, + "unit_of_measurement": None, + }, + { + "assumed_state": None, + "capabilities": { + "not_filtered": "xyz", + }, + "domain": "select", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, + { + "assumed_state": None, + "capabilities": { + "not_filtered": "abc", + }, + "domain": "light", + "entity_category": None, + "has_entity_name": False, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, + ] From a78c909b342764220861ad6bc234b187463e621a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:35:47 +0200 Subject: [PATCH 06/21] Rename cover property in tuya (#152822) --- homeassistant/components/tuya/cover.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index be75ff9d69447b..8b02d0adbda61b 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -260,11 +260,10 @@ def __init__( self._motor_reverse_mode_enum = enum_type @property - def _is_motor_forward(self) -> bool: - """Check if the cover direction should be reversed based on motor_reverse_mode. - - If the motor is "forward" (=default) then the positions need to be reversed. - """ + def _is_position_reversed(self) -> bool: + """Check if the cover position and direction should be reversed.""" + # The default is True + # Having motor_reverse_mode == "back" cancels the inversion return not ( self._motor_reverse_mode_enum and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back" @@ -281,7 +280,7 @@ def current_cover_position(self) -> int | None: return round( self._current_position.remap_value_to( - position, 0, 100, reverse=self._is_motor_forward + position, 0, 100, reverse=self._is_position_reversed ) ) @@ -335,7 +334,7 @@ def open_cover(self, **kwargs: Any) -> None: "code": self._set_position.dpcode, "value": round( self._set_position.remap_value_from( - 100, 0, 100, reverse=self._is_motor_forward + 100, 0, 100, reverse=self._is_position_reversed ), ), } @@ -361,7 +360,7 @@ def close_cover(self, **kwargs: Any) -> None: "code": self._set_position.dpcode, "value": round( self._set_position.remap_value_from( - 0, 0, 100, reverse=self._is_motor_forward + 0, 0, 100, reverse=self._is_position_reversed ), ), } @@ -384,7 +383,7 @@ def set_cover_position(self, **kwargs: Any) -> None: kwargs[ATTR_POSITION], 0, 100, - reverse=self._is_motor_forward, + reverse=self._is_position_reversed, ) ), } @@ -417,7 +416,7 @@ def set_cover_tilt_position(self, **kwargs: Any) -> None: kwargs[ATTR_TILT_POSITION], 0, 100, - reverse=self._is_motor_forward, + reverse=self._is_position_reversed, ) ), } From 5d543d2185d70c7b342828d43e1936ce866a34d3 Mon Sep 17 00:00:00 2001 From: Sarah Seidman Date: Tue, 23 Sep 2025 14:36:06 -0400 Subject: [PATCH 07/21] Bump pydroplet version to 2.3.3 (#152832) --- homeassistant/components/droplet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json index bd5f1ba2a0bbb4..f4a03ebfb21afc 100644 --- a/homeassistant/components/droplet/manifest.json +++ b/homeassistant/components/droplet/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/droplet", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["pydroplet==2.3.2"], + "requirements": ["pydroplet==2.3.3"], "zeroconf": ["_droplet._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e49466c857c198..ce8df02b5b57be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1954,7 +1954,7 @@ pydrawise==2025.9.0 pydroid-ipcam==3.0.0 # homeassistant.components.droplet -pydroplet==2.3.2 +pydroplet==2.3.3 # homeassistant.components.ebox pyebox==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14866be0ea96c9..2f461dba07935b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ pydrawise==2025.9.0 pydroid-ipcam==3.0.0 # homeassistant.components.droplet -pydroplet==2.3.2 +pydroplet==2.3.3 # homeassistant.components.ecoforest pyecoforest==0.4.0 From a2a726de34a448bf78f0d31cf2bbf2370d2c400c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:54:52 +0200 Subject: [PATCH 08/21] Rename function arguments in modbus (#152814) --- homeassistant/components/modbus/light.py | 8 ++++---- homeassistant/components/modbus/modbus.py | 24 +++++++++++++---------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 4c27ffb456b68c..36b8f4415b8242 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -117,7 +117,7 @@ async def async_set_brightness(self, brightness: int) -> None: conv_brightness = self._convert_brightness_to_modbus(brightness) await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, address=self._brightness_address, value=conv_brightness, use_call=CALL_TYPE_WRITE_REGISTER, @@ -133,7 +133,7 @@ async def async_set_color_temp(self, color_temp_kelvin: int) -> None: conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, address=self._color_temp_address, value=conv_color_temp_kelvin, use_call=CALL_TYPE_WRITE_REGISTER, @@ -150,7 +150,7 @@ async def _async_update(self) -> None: if self._brightness_address: brightness_result = await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, value=1, address=self._brightness_address, use_call=CALL_TYPE_REGISTER_HOLDING, @@ -167,7 +167,7 @@ async def _async_update(self) -> None: if self._color_temp_address: color_result = await self._hub.async_pb_call( - unit=self._device_address, + device_address=self._device_address, value=1, address=self._color_temp_address, use_call=CALL_TYPE_REGISTER_HOLDING, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1f797c82a08967..26992404e38f2a 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -370,11 +370,17 @@ async def async_close(self) -> None: _LOGGER.info(f"modbus {self.name} communication closed") async def low_level_pb_call( - self, slave: int | None, address: int, value: int | list[int], use_call: str + self, + device_address: int | None, + address: int, + value: int | list[int], + use_call: str, ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs: dict[str, Any] = ( - {DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1} + {DEVICE_ID: device_address} + if device_address is not None + else {DEVICE_ID: 1} ) entry = self._pb_request[use_call] @@ -386,28 +392,26 @@ async def low_level_pb_call( try: result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: - error = f"Error: device: {slave} address: {address} -> {exception_error!s}" + error = f"Error: device: {device_address} address: {address} -> {exception_error!s}" self._log_error(error) return None if not result: - error = ( - f"Error: device: {slave} address: {address} -> pymodbus returned None" - ) + error = f"Error: device: {device_address} address: {address} -> pymodbus returned None" self._log_error(error) return None if not hasattr(result, entry.attr): - error = f"Error: device: {slave} address: {address} -> {result!s}" + error = f"Error: device: {device_address} address: {address} -> {result!s}" self._log_error(error) return None if result.isError(): - error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" + error = f"Error: device: {device_address} address: {address} -> pymodbus returned isError True" self._log_error(error) return None return result async def async_pb_call( self, - unit: int | None, + device_address: int | None, address: int, value: int | list[int], use_call: str, @@ -415,7 +419,7 @@ async def async_pb_call( """Convert async to sync pymodbus call.""" if not self._client: return None - result = await self.low_level_pb_call(unit, address, value, use_call) + result = await self.low_level_pb_call(device_address, address, value, use_call) if self._msg_wait: await asyncio.sleep(self._msg_wait) return result From 2ab051b7169ca2bdb897f07e6a03e822a9b070e2 Mon Sep 17 00:00:00 2001 From: andreimoraru Date: Tue, 23 Sep 2025 22:03:53 +0300 Subject: [PATCH 09/21] Bump yt-dlp to 2025.09.23 (#152818) --- 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 beb22dd0858bf2..288921b624e67d 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.05"], + "requirements": ["yt-dlp[default]==2025.09.23"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index ce8df02b5b57be..83da8573a51a66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3204,7 +3204,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.05 +yt-dlp[default]==2025.09.23 # homeassistant.components.zabbix zabbix-utils==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f461dba07935b..698e558de6ed75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2657,7 +2657,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.05 +yt-dlp[default]==2025.09.23 # homeassistant.components.zamg zamg==0.3.6 From ca186925af33897bcfc060e63e57afc263dd2e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 23 Sep 2025 21:28:24 +0200 Subject: [PATCH 10/21] Add Matter Thermostat OutdoorTemperature sensor (#152632) --- homeassistant/components/matter/sensor.py | 19 +++++++ homeassistant/components/matter/strings.json | 3 + .../matter/fixtures/nodes/thermostat.json | 1 + .../matter/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ tests/components/matter/test_sensor.py | 20 +++++++ 5 files changed, 99 insertions(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index f5f1fe0e73e08b..b8249e9efa3aec 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -152,6 +152,8 @@ clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, } +TEMPERATURE_SCALING_FACTOR = 100 + async def async_setup_entry( hass: HomeAssistant, @@ -1141,6 +1143,23 @@ def _update_from_device(self) -> None: device_type=(device_types.Thermostat,), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThermostatOutdoorTemperature", + translation_key="outdoor_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + device_to_ha=lambda x: ( + None if x is None else x / TEMPERATURE_SCALING_FACTOR + ), + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.Thermostat.Attributes.OutdoorTemperature,), + device_type=(device_types.Thermostat, device_types.RoomAirConditioner), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterOperationalStateSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7dae7638d8d623..85ad6527653da2 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -485,6 +485,9 @@ "apparent_current": { "name": "Apparent current" }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, "reactive_current": { "name": "Reactive current" }, diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index a7abff41331b92..bb42b8926b9d33 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -317,6 +317,7 @@ "1/64/65529": [], "1/64/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/513/0": 2830, + "1/513/1": 1250, "1/513/3": null, "1/513/4": null, "1/513/5": null, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 2567ce2e936b78..911ea0049952f3 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6847,6 +6847,62 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.longan_link_hvac_outdoor_temperature', + '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': 'Outdoor temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- # name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 2254c021c6aec7..2414bafc80d03c 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -233,6 +233,26 @@ async def test_eve_thermo_sensor( assert state.state == "18.0" +@pytest.mark.parametrize("node_fixture", ["thermostat"]) +async def test_thermostat_outdoor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test OutdoorTemperature.""" + # OutdoorTemperature + state = hass.states.get("sensor.longan_link_hvac_outdoor_temperature") + assert state + assert state.state == "12.5" + + set_node_attribute(matter_node, 1, 513, 1, -550) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.longan_link_hvac_outdoor_temperature") + assert state + assert state.state == "-5.5" + + @pytest.mark.parametrize("node_fixture", ["pressure_sensor"]) async def test_pressure_sensor( hass: HomeAssistant, From 874ca1323bba054f309c70a07967132853ec7bd8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:00:31 -0400 Subject: [PATCH 11/21] Simplified ZHA adapter migration and setup flow (#152389) --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/api.py | 2 +- homeassistant/components/zha/config_flow.py | 317 ++++++-- homeassistant/components/zha/radio_manager.py | 163 ++-- .../repairs/network_settings_inconsistent.py | 2 +- homeassistant/components/zha/strings.json | 87 +- .../homeassistant_connect_zbt2/conftest.py | 2 +- .../homeassistant_hardware/conftest.py | 2 +- .../homeassistant_sky_connect/conftest.py | 2 +- .../homeassistant_yellow/conftest.py | 2 +- .../homeassistant_yellow/test_init.py | 20 +- tests/components/zha/test_config_flow.py | 757 ++++++++++++------ tests/components/zha/test_radio_manager.py | 50 +- 13 files changed, 995 insertions(+), 413 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e446f32cf08d94..c3406181ff8b43 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -134,7 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - async with radio_mgr.connect_zigpy_app() as app: + async with radio_mgr.create_zigpy_app(connect=False) as app: for dev in app.devices.values(): dev_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(dev.ieee))}, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 60960a3e9fc9d4..9dbd00273b6196 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -56,7 +56,7 @@ async def async_get_last_network_settings( radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - async with radio_mgr.connect_zigpy_app() as app: + async with radio_mgr.create_zigpy_app(connect=False) as app: try: settings = max(app.backups, key=lambda b: b.backup_time) except ValueError: diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index b98e53f98d8d53..cb0b26d6ac0a4d 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod import collections from contextlib import suppress import json @@ -13,6 +14,7 @@ from zha.application.const import RadioType import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import CannotWriteNetworkSettings, DestructiveWriteNetworkSettings from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file @@ -21,7 +23,6 @@ from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.config_entries import ( SOURCE_IGNORE, - SOURCE_ZEROCONF, ConfigEntry, ConfigEntryBaseFlow, ConfigEntryState, @@ -32,6 +33,7 @@ ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig @@ -40,6 +42,7 @@ from homeassistant.util import dt as dt_util from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN +from .helpers import get_zha_gateway from .radio_manager import ( DEVICE_SCHEMA, HARDWARE_DISCOVERY_SCHEMA, @@ -49,12 +52,22 @@ ) CONF_MANUAL_PATH = "Enter Manually" -SUPPORTED_PORT_SETTINGS = ( - CONF_BAUDRATE, - CONF_FLOW_CONTROL, -) DECONZ_DOMAIN = "deconz" +# The ZHA config flow takes different branches depending on if you are migrating to a +# new adapter via discovery or setting it up from scratch + +# For the fast path, we automatically migrate everything and restore the most recent backup +MIGRATION_STRATEGY_RECOMMENDED = "migration_strategy_recommended" +MIGRATION_STRATEGY_ADVANCED = "migration_strategy_advanced" + +# Similarly, setup follows the same approach: we create a new network +SETUP_STRATEGY_RECOMMENDED = "setup_strategy_recommended" +SETUP_STRATEGY_ADVANCED = "setup_strategy_advanced" + +# For the advanced paths, we allow users to pick how to form a network: form a brand new +# network, use the settings currently on the stick, restore from a database backup, or +# restore from a JSON backup FORMATION_STRATEGY = "formation_strategy" FORMATION_FORM_NEW_NETWORK = "form_new_network" FORMATION_FORM_INITIAL_NETWORK = "form_initial_network" @@ -170,24 +183,35 @@ def hass(self, hass: HomeAssistant) -> None: self._hass = hass self._radio_mgr.hass = hass - async def _async_create_radio_entry(self) -> ConfigFlowResult: - """Create a config entry with the current flow state.""" + async def _get_config_entry_data(self) -> dict: + """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 - device_settings = self._radio_mgr.device_settings.copy() - device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job( - usb.get_serial_by_id, self._radio_mgr.device_path - ) + 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_RADIO_TYPE: self._radio_mgr.radio_type.name, + } - return self.async_create_entry( - title=self._title, - data={ - CONF_DEVICE: DEVICE_SCHEMA(device_settings), - CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, - }, - ) + @abstractmethod + async def _async_create_radio_entry(self) -> ConfigFlowResult: + """Create a config entry with the current flow state.""" async def async_step_choose_serial_port( self, user_input: dict[str, Any] | None = None @@ -288,43 +312,44 @@ async def async_step_manual_port_config( if user_input is not None: self._title = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH] - self._radio_mgr.device_settings = user_input.copy() + self._radio_mgr.device_settings = DEVICE_SCHEMA( + { + CONF_DEVICE_PATH: self._radio_mgr.device_path, + CONF_BAUDRATE: user_input[CONF_BAUDRATE], + # `None` shows up as the empty string in the frontend + CONF_FLOW_CONTROL: ( + user_input[CONF_FLOW_CONTROL] + if user_input[CONF_FLOW_CONTROL] != "none" + else None + ), + } + ) if await self._radio_mgr.radio_type.controller.probe(user_input): return await self.async_step_verify_radio() errors["base"] = "cannot_connect" - schema = { - vol.Required( - CONF_DEVICE_PATH, default=self._radio_mgr.device_path or vol.UNDEFINED - ): str - } - - source = self.context.get("source") - for ( - param, - value, - ) in DEVICE_SCHEMA.schema.items(): - if param not in SUPPORTED_PORT_SETTINGS: - continue - - if source == SOURCE_ZEROCONF and param == CONF_BAUDRATE: - value = 115200 - param = vol.Required(CONF_BAUDRATE, default=value) - elif ( - self._radio_mgr.device_settings is not None - and param in self._radio_mgr.device_settings - ): - param = vol.Required( - str(param), default=self._radio_mgr.device_settings[param] - ) - - schema[param] = value + device_settings = self._radio_mgr.device_settings or {} return self.async_show_form( step_id="manual_port_config", - data_schema=vol.Schema(schema), + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICE_PATH, + default=self._radio_mgr.device_path or vol.UNDEFINED, + ): str, + vol.Required( + CONF_BAUDRATE, + default=device_settings.get(CONF_BAUDRATE) or 115200, + ): int, + vol.Required( + CONF_FLOW_CONTROL, + default=device_settings.get(CONF_FLOW_CONTROL) or "none", + ): vol.In(["hardware", "software", "none"]), + } + ), errors=errors, ) @@ -333,10 +358,15 @@ async def async_step_verify_radio( ) -> ConfigFlowResult: """Add a warning step to dissuade the use of deprecated radios.""" assert self._radio_mgr.radio_type is not None + await self._radio_mgr.async_read_backups_from_database() # Skip this step if we are using a recommended radio if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: - return await self.async_step_choose_formation_strategy() + # ZHA disables the single instance check and will decide at runtime if we + # are migrating or setting up from scratch + if self.hass.config_entries.async_entries(DOMAIN): + return await self.async_step_choose_migration_strategy() + return await self.async_step_choose_setup_strategy() return self.async_show_form( step_id="verify_radio", @@ -348,6 +378,91 @@ async def async_step_verify_radio( }, ) + async def async_step_choose_setup_strategy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose how to set up the integration from scratch.""" + + # Allow onboarding for new users to just create a new network automatically + if ( + not onboarding.async_is_onboarded(self.hass) + and not self.hass.config_entries.async_entries(DOMAIN) + and not self._radio_mgr.backups + ): + return await self.async_step_setup_strategy_recommended() + + return self.async_show_menu( + step_id="choose_setup_strategy", + menu_options=[ + SETUP_STRATEGY_RECOMMENDED, + SETUP_STRATEGY_ADVANCED, + ], + ) + + async def async_step_setup_strategy_recommended( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Recommended setup strategy: form a brand-new network.""" + return await self.async_step_form_new_network() + + async def async_step_setup_strategy_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Advanced setup strategy: let the user choose.""" + return await self.async_step_choose_formation_strategy() + + async def async_step_choose_migration_strategy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose how to deal with the current radio's settings during migration.""" + return self.async_show_menu( + step_id="choose_migration_strategy", + menu_options=[ + MIGRATION_STRATEGY_RECOMMENDED, + MIGRATION_STRATEGY_ADVANCED, + ], + ) + + async def async_step_migration_strategy_recommended( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Recommended migration strategy: automatically migrate everything.""" + + # Assume the most recent backup is the correct one + self._radio_mgr.chosen_backup = self._radio_mgr.backups[0] + return await self.async_step_maybe_reset_old_radio() + + async def async_step_maybe_reset_old_radio( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Erase the old radio's network settings before migration.""" + + # Like in the options flow, pull the correct settings from the config entry + config_entries = self.hass.config_entries.async_entries(DOMAIN) + + if config_entries: + assert len(config_entries) == 1 + config_entry = config_entries[0] + + # Create a radio manager to connect to the old stick to reset it + temp_radio_mgr = ZhaRadioManager() + temp_radio_mgr.hass = self.hass + temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][ + CONF_DEVICE_PATH + ] + temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE] + temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + + await temp_radio_mgr.async_reset_adapter() + + return await self.async_step_maybe_confirm_ezsp_restore() + + async def async_step_migration_strategy_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Advanced migration strategy: let the user choose.""" + return await self.async_step_choose_formation_strategy() + async def async_step_choose_formation_strategy( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -434,7 +549,7 @@ async def async_step_upload_manual_backup( except ValueError: errors["base"] = "invalid_backup_json" else: - return await self.async_step_maybe_confirm_ezsp_restore() + return await self.async_step_maybe_reset_old_radio() return self.async_show_form( step_id="upload_manual_backup", @@ -474,7 +589,7 @@ async def async_step_choose_automatic_backup( index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP]) self._radio_mgr.chosen_backup = self._radio_mgr.backups[index] - return await self.async_step_maybe_confirm_ezsp_restore() + return await self.async_step_maybe_reset_old_radio() return self.async_show_form( step_id="choose_automatic_backup", @@ -491,16 +606,37 @@ async def async_step_maybe_confirm_ezsp_restore( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm restore for EZSP radios that require permanent IEEE writes.""" - call_step_2 = await self._radio_mgr.async_restore_backup_step_1() - if not call_step_2: - return await self._async_create_radio_entry() - if user_input is not None: - await self._radio_mgr.async_restore_backup_step_2( - user_input[OVERWRITE_COORDINATOR_IEEE] + if user_input[OVERWRITE_COORDINATOR_IEEE]: + # On confirmation, overwrite destructively + try: + await self._radio_mgr.restore_backup(overwrite_ieee=True) + except CannotWriteNetworkSettings as exc: + return self.async_abort( + reason="cannot_restore_backup", + description_placeholders={"error": str(exc)}, + ) + + return await self._async_create_radio_entry() + + # On rejection, explain why we can't restore + return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm") + + # On first attempt, just try to restore nondestructively + try: + await self._radio_mgr.restore_backup() + except DestructiveWriteNetworkSettings: + # Restore cannot happen automatically, we need to ask for permission + pass + except CannotWriteNetworkSettings as exc: + return self.async_abort( + reason="cannot_restore_backup", + description_placeholders={"error": str(exc)}, ) + else: return await self._async_create_radio_entry() + # If it fails, show the form return self.async_show_form( step_id="maybe_confirm_ezsp_restore", data_schema=vol.Schema( @@ -548,24 +684,22 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a ZHA config flow start.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return await self.async_step_choose_serial_port(user_input) async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" + self._set_confirm_only() - # Don't permit discovery if ZHA is already set up - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") + zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. - if user_input is not None or not onboarding.async_is_onboarded(self.hass): + if user_input is not None or ( + not onboarding.async_is_onboarded(self.hass) and not zha_config_entries + ): # Probe the radio type if we don't have one yet if self._radio_mgr.radio_type is None: probe_result = await self._radio_mgr.detect_radio_type() @@ -686,11 +820,13 @@ async def async_step_zeroconf( self._title = title self._radio_mgr.device_path = device_path self._radio_mgr.radio_type = radio_type - self._radio_mgr.device_settings = { - CONF_DEVICE_PATH: device_path, - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } + self._radio_mgr.device_settings = DEVICE_SCHEMA( + { + CONF_DEVICE_PATH: device_path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + ) return await self.async_step_confirm() @@ -721,6 +857,30 @@ async def async_step_hardware( return await self.async_step_confirm() + async def _async_create_radio_entry(self) -> ConfigFlowResult: + """Create a config entry with the current flow state.""" + + # ZHA is still single instance only, even though we use discovery to allow for + # migrating to a new radio + zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) + data = await self._get_config_entry_data() + + if len(zha_config_entries) == 1: + return self.async_update_reload_and_abort( + entry=zha_config_entries[0], + title=self._title, + data=data, + reload_even_if_entry_is_unchanged=True, + reason="reconfigure_successful", + ) + if not zha_config_entries: + return self.async_create_entry( + title=self._title, + data=data, + ) + # This should never be reached + return self.async_abort(reason="single_instance_allowed") + class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): """Handle an options flow.""" @@ -738,8 +898,20 @@ async def async_step_init( ) -> ConfigFlowResult: """Launch the options flow.""" if user_input is not None: - # OperationNotAllowed: ZHA is not running + # Perform a backup first + try: + zha_gateway = get_zha_gateway(self.hass) + except ValueError: + pass + else: + # The backup itself will be stored in `zigbee.db`, which the radio + # manager will read when the class is initialized + application_controller = zha_gateway.application_controller + await application_controller.backups.create_backup(load_devices=True) + + # Then unload the integration with suppress(OperationNotAllowed): + # OperationNotAllowed: ZHA is not running await self.hass.config_entries.async_unload(self.config_entry.entry_id) return await self.async_step_prompt_migrate_or_reconfigure() @@ -790,18 +962,11 @@ async def async_step_instruct_unplug( async def _async_create_radio_entry(self): """Re-implementation of the base flow's final step to update the config.""" - device_settings = self._radio_mgr.device_settings.copy() - device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job( - usb.get_serial_by_id, self._radio_mgr.device_path - ) # Avoid creating both `.options` and `.data` by directly writing `data` here self.hass.config_entries.async_update_entry( entry=self.config_entry, - data={ - CONF_DEVICE: device_settings, - CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, - }, + data=await self._get_config_entry_data(), options=self.config_entry.options, ) diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 6a5d39bc3dbe71..b2d515d785f2cf 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -1,4 +1,4 @@ -"""Config flow for ZHA.""" +"""ZHA radio manager.""" from __future__ import annotations @@ -29,6 +29,7 @@ from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import repairs @@ -40,22 +41,17 @@ ) from .helpers import get_zha_data -# Only the common radio types will be autoprobed, ordered by new device popularity. -# XBee takes too long to probe since it scans through all possible bauds and likely has -# very few users to begin with. -AUTOPROBE_RADIOS = ( - RadioType.ezsp, - RadioType.znp, - RadioType.deconz, - RadioType.zigate, -) - RECOMMENDED_RADIOS = ( RadioType.ezsp, RadioType.znp, RadioType.deconz, ) +# Only the common radio types will be autoprobed, ordered by new device popularity. +# XBee takes too long to probe since it scans through all possible bauds and likely has +# very few users to begin with. +AUTOPROBE_RADIOS = RECOMMENDED_RADIOS + CONNECT_DELAY_S = 1.0 RETRY_DELAY_S = 1.0 @@ -158,22 +154,38 @@ def from_config_entry( return mgr + @property + def zigpy_database_path(self) -> str: + """Path to `zigbee.db`.""" + config = get_zha_data(self.hass).yaml_config + + return config.get( + CONF_DATABASE, + self.hass.config.path(DEFAULT_DATABASE_NAME), + ) + @contextlib.asynccontextmanager - async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]: + async def create_zigpy_app( + self, *, connect: bool = True + ) -> AsyncIterator[ControllerApplication]: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None config = get_zha_data(self.hass).yaml_config app_config = config.get(CONF_ZIGPY, {}).copy() - database_path = config.get( - CONF_DATABASE, - self.hass.config.path(DEFAULT_DATABASE_NAME), - ) + database_path: str | None = self.zigpy_database_path # Don't create `zigbee.db` if it doesn't already exist - if not await self.hass.async_add_executor_job(os.path.exists, database_path): - database_path = None + try: + if database_path is not None and not await self.hass.async_add_executor_job( + os.path.exists, database_path + ): + database_path = None + except OSError as error: + raise HomeAssistantError( + f"Could not read the ZHA database {database_path}: {error}" + ) from error app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings @@ -185,22 +197,45 @@ async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]: ) try: + if connect: + try: + await app.connect() + except OSError as error: + raise HomeAssistantError( + f"Failed to connect to Zigbee adapter: {error}" + ) from error + yield app finally: await app.shutdown() await asyncio.sleep(CONNECT_DELAY_S) async def restore_backup( - self, backup: zigpy.backups.NetworkBackup, **kwargs: Any + self, + backup: zigpy.backups.NetworkBackup | None = None, + *, + overwrite_ieee: bool = False, + **kwargs: Any, ) -> None: """Restore the provided network backup, passing through kwargs.""" + if backup is None: + backup = self.chosen_backup + + assert backup is not None + if self.current_settings is not None and self.current_settings.supersedes( - self.chosen_backup + backup ): return - async with self.connect_zigpy_app() as app: - await app.connect() + if overwrite_ieee: + backup = _allow_overwrite_ezsp_ieee(backup) + + async with self.create_zigpy_app() as app: + await app.can_write_network_settings( + network_info=backup.network_info, + node_info=backup.node_info, + ) await app.backups.restore_backup(backup, **kwargs) @staticmethod @@ -242,15 +277,27 @@ async def detect_radio_type(self) -> ProbeResult: return ProbeResult.PROBING_FAILED + async def _async_read_backups_from_database( + self, + ) -> list[zigpy.backups.NetworkBackup]: + """Read the list of backups from the database, internal.""" + async with self.create_zigpy_app(connect=False) as app: + backups = app.backups.backups.copy() + backups.sort(reverse=True, key=lambda b: b.backup_time) + + return backups + + async def async_read_backups_from_database(self) -> None: + """Read the list of backups from the database.""" + self.backups = await self._async_read_backups_from_database() + async def async_load_network_settings( self, *, create_backup: bool = False ) -> zigpy.backups.NetworkBackup | None: """Connect to the radio and load its current network settings.""" backup = None - async with self.connect_zigpy_app() as app: - await app.connect() - + async with self.create_zigpy_app() as app: # Check if the stick has any settings and load them try: await app.load_network_info() @@ -273,66 +320,20 @@ async def async_load_network_settings( async def async_form_network(self) -> None: """Form a brand-new network.""" - async with self.connect_zigpy_app() as app: - await app.connect() + + # When forming a new network, we delete the ZHA database to prevent old devices + # from appearing in an unusable state + with suppress(OSError): + await self.hass.async_add_executor_job(os.remove, self.zigpy_database_path) + + async with self.create_zigpy_app() as app: await app.form_network() async def async_reset_adapter(self) -> None: """Reset the current adapter.""" - async with self.connect_zigpy_app() as app: - await app.connect() + async with self.create_zigpy_app() as app: await app.reset_network_info() - async def async_restore_backup_step_1(self) -> bool: - """Prepare restoring backup. - - Returns True if async_restore_backup_step_2 should be called. - """ - assert self.chosen_backup is not None - - if self.radio_type != RadioType.ezsp: - await self.restore_backup(self.chosen_backup) - return False - - # We have no way to partially load network settings if no network is formed - if self.current_settings is None: - # Since we are going to be restoring the backup anyways, write it to the - # radio without overwriting the IEEE but don't take a backup with these - # temporary settings - temp_backup = _prevent_overwrite_ezsp_ieee(self.chosen_backup) - await self.restore_backup(temp_backup, create_new=False) - await self.async_load_network_settings() - - assert self.current_settings is not None - - metadata = self.current_settings.network_info.metadata["ezsp"] - - if ( - self.current_settings.node_info.ieee == self.chosen_backup.node_info.ieee - or metadata["can_rewrite_custom_eui64"] - or not metadata["can_burn_userdata_custom_eui64"] - ): - # No point in prompting the user if the backup doesn't have a new IEEE - # address or if there is no way to overwrite the IEEE address a second time - await self.restore_backup(self.chosen_backup) - - return False - - return True - - async def async_restore_backup_step_2(self, overwrite_ieee: bool) -> None: - """Restore backup and optionally overwrite IEEE.""" - assert self.chosen_backup is not None - - backup = self.chosen_backup - - if overwrite_ieee: - backup = _allow_overwrite_ezsp_ieee(backup) - - # If the user declined to overwrite the IEEE *and* we wrote the backup to - # their empty radio above, restoring it again would be redundant. - await self.restore_backup(backup) - class ZhaMultiPANMigrationHelper: """Helper class for automatic migration when upgrading the firmware of a radio. @@ -442,9 +443,7 @@ async def async_finish_migration(self) -> None: # Restore the backup, permanently overwriting the device IEEE address for retry in range(MIGRATION_RETRIES): try: - if await self._radio_mgr.async_restore_backup_step_1(): - await self._radio_mgr.async_restore_backup_step_2(True) - + await self._radio_mgr.restore_backup(overwrite_ieee=True) break except OSError as err: if retry >= MIGRATION_RETRIES - 1: diff --git a/homeassistant/components/zha/repairs/network_settings_inconsistent.py b/homeassistant/components/zha/repairs/network_settings_inconsistent.py index ef38ebc3d47a95..609dda5100be14 100644 --- a/homeassistant/components/zha/repairs/network_settings_inconsistent.py +++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py @@ -136,7 +136,7 @@ async def async_step_use_new_settings( self, user_input: dict[str, str] | None = None ) -> FlowResult: """Step to use the new settings found on the radio.""" - async with self._radio_mgr.connect_zigpy_app() as app: + async with self._radio_mgr.create_zigpy_app(connect=False) as app: app.backups.add_backup(self._new_state) await self.hass.config_entries.async_reload(self._entry_id) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 096fd591fb7ae5..4b28b1c426ed3f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -24,17 +24,50 @@ }, "manual_port_config": { "title": "Serial port settings", - "description": "Enter the serial port settings", + "description": "ZHA was not able to automatically detect serial port settings for your adapter. This usually is an issue with the firmware or permissions.\n\nIf you are using firmware with nonstandard settings, enter the serial port settings", "data": { "path": "Serial device path", - "baudrate": "Port speed", - "flow_control": "Data flow control" + "baudrate": "Serial port speed", + "flow_control": "Serial port flow control" + }, + "data_description": { + "path": "Path to the serial port or `socket://` TCP address", + "baudrate": "Baudrate to use when communicating with the serial port, usually 115200 or 460800", + "flow_control": "Check your adapter's documentation for the correct option, usually `None` or `Hardware`" } }, "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})." }, + "choose_setup_strategy": { + "title": "Set up Zigbee", + "description": "Choose how you want to set up Zigbee. Automatic setup is recommended unless you are restoring your network from a backup or setting up an adapter with nonstandard settings.", + "menu_options": { + "setup_strategy_recommended": "Set up automatically (recommended)", + "setup_strategy_advanced": "Advanced setup" + }, + "menu_option_descriptions": { + "setup_strategy_recommended": "This is the quickest option to create a new network and get started.", + "setup_strategy_advanced": "This will let you restore from a backup." + } + }, + "choose_migration_strategy": { + "title": "Migrate to a new adapter", + "description": "Choose how you want to migrate your Zigbee network backup from your old adapter to a new one.", + "menu_options": { + "migration_strategy_recommended": "Migrate automatically (recommended)", + "migration_strategy_advanced": "Advanced migration" + }, + "menu_option_descriptions": { + "migration_strategy_recommended": "This is the quickest option to migrate to a new adapter.", + "migration_strategy_advanced": "This will let you restore a specific network backup or upload your own." + } + }, + "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." + }, "choose_formation_strategy": { "title": "Network formation", "description": "Choose the network settings for your radio.", @@ -44,6 +77,13 @@ "reuse_settings": "Keep radio network settings", "choose_automatic_backup": "Restore an automatic backup", "upload_manual_backup": "Upload a manual backup" + }, + "menu_option_descriptions": { + "form_new_network": "This will create a new Zigbee network.", + "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]", + "reuse_settings": "This will let ZHA import the settings from a stick that was used with other software, migrating some of the network automatically.", + "choose_automatic_backup": "This will let you change your adapter's network settings back to a previous state, in case you have changed them.", + "upload_manual_backup": "This will let you upload a backup JSON file from ZHA or the Zigbee2MQTT `coordinator_backup.json` file." } }, "choose_automatic_backup": { @@ -76,8 +116,12 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "This device is not a ZHA device", "usb_probe_failed": "Failed to probe the USB device", + "cannot_resolve_path": "Could not resolve device path: {path}", "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", - "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA" + "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA", + "cannot_restore_backup": "The adapter you are restoring to does not properly support backup restoration. Please upgrade the firmware.\n\nError: {error}", + "cannot_restore_backup_no_ieee_confirm": "The adapter you are restoring to has outdated firmware and cannot write the adapter IEEE address multiple times. Please upgrade the firmware or confirm permanent overwrite in the previous step.", + "reconfigure_successful": "ZHA has successfully migrated from your old adapter to the new one. Give your Zigbee network a few minutes to stabilize.\n\nIf you no longer need the old adapter, you can now unplug it." } }, "options": { @@ -85,7 +129,7 @@ "step": { "init": { "title": "Reconfigure ZHA", - "description": "ZHA will be stopped. Do you wish to continue?" + "description": "A backup will be performed and ZHA will be stopped. Do you wish to continue?" }, "prompt_migrate_or_reconfigure": { "title": "Migrate or re-configure", @@ -93,6 +137,10 @@ "menu_options": { "intent_migrate": "Migrate to a new radio", "intent_reconfigure": "Re-configure the current radio" + }, + "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": { @@ -130,6 +178,18 @@ "title": "[%key:component::zha::config::step::verify_radio::title%]", "description": "[%key:component::zha::config::step::verify_radio::description%]" }, + "choose_migration_strategy": { + "title": "[%key:component::zha::config::step::choose_migration_strategy::title%]", + "description": "[%key:component::zha::config::step::choose_migration_strategy::description%]", + "menu_options": { + "migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_recommended%]", + "migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_advanced%]" + }, + "menu_option_descriptions": { + "migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_recommended%]", + "migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_advanced%]" + } + }, "choose_formation_strategy": { "title": "[%key:component::zha::config::step::choose_formation_strategy::title%]", "description": "[%key:component::zha::config::step::choose_formation_strategy::description%]", @@ -139,6 +199,13 @@ "reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::reuse_settings%]", "choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::choose_automatic_backup%]", "upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::upload_manual_backup%]" + }, + "menu_option_descriptions": { + "form_new_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]", + "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]", + "reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::reuse_settings%]", + "choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::choose_automatic_backup%]", + "upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::upload_manual_backup%]" } }, "choose_automatic_backup": { @@ -168,10 +235,12 @@ "invalid_backup_json": "[%key:component::zha::config::error::invalid_backup_json%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]", "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]", - "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]" + "cannot_resolve_path": "[%key:component::zha::config::abort::cannot_resolve_path%]", + "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]", + "cannot_restore_backup": "[%key:component::zha::config::abort::cannot_restore_backup%]", + "cannot_restore_backup_no_ieee_confirm": "[%key:component::zha::config::abort::cannot_restore_backup_no_ieee_confirm%]" } }, "config_panel": { @@ -532,6 +601,10 @@ "menu_options": { "use_new_settings": "Keep the new settings", "restore_old_settings": "Restore backup (recommended)" + }, + "menu_option_descriptions": { + "use_new_settings": "This will keep the new settings written to the stick. Only choose this option if you have intentionally changed settings.", + "restore_old_settings": "This will restore your network settings back to the last working state." } } } diff --git a/tests/components/homeassistant_connect_zbt2/conftest.py b/tests/components/homeassistant_connect_zbt2/conftest.py index d6b8fa09a3f532..2a4d349debe3b2 100644 --- a/tests/components/homeassistant_connect_zbt2/conftest.py +++ b/tests/components/homeassistant_connect_zbt2/conftest.py @@ -27,7 +27,7 @@ def mock_zha(): with ( patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index ddf18305b2a48c..9da3371bfae166 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -27,7 +27,7 @@ def mock_probe(config: dict[str, Any]) -> dict[str, Any]: side_effect=mock_probe, ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 89ec292d87966d..e71a86384c1301 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -27,7 +27,7 @@ def mock_zha(): with ( patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index 7247c7da4e2a07..ef89f5ba330523 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -27,7 +27,7 @@ def mock_probe(config: dict[str, Any]) -> dict[str, Any]: side_effect=mock_probe, ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ), patch( diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 00e3383cf77a86..7bff7f10c6589c 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -71,10 +71,16 @@ async def test_setup_entry( if num_entries > 0: zha_flows = hass.config_entries.flow.async_progress_by_handler("zha") assert len(zha_flows) == 1 - assert zha_flows[0]["step_id"] == "choose_formation_strategy" + assert zha_flows[0]["step_id"] == "choose_setup_strategy" - await hass.config_entries.flow.async_configure( + setup_result = await hass.config_entries.flow.async_configure( zha_flows[0]["flow_id"], + user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED}, + ) + assert setup_result["step_id"] == "choose_formation_strategy" + + await hass.config_entries.flow.async_configure( + setup_result["flow_id"], user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() @@ -117,10 +123,16 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None: # Finish setting up ZHA zha_flows = hass.config_entries.flow.async_progress_by_handler("zha") assert len(zha_flows) == 1 - assert zha_flows[0]["step_id"] == "choose_formation_strategy" + assert zha_flows[0]["step_id"] == "choose_setup_strategy" - await hass.config_entries.flow.async_configure( + setup_result = await hass.config_entries.flow.async_configure( zha_flows[0]["flow_id"], + user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED}, + ) + assert setup_result["step_id"] == "choose_formation_strategy" + + await hass.config_entries.flow.async_configure( + setup_result["flow_id"], user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index ff939180fbb0ec..70419a4b503ea0 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,7 +1,6 @@ """Tests for ZHA config flow.""" from collections.abc import Callable, Coroutine, Generator -import copy from datetime import timedelta from ipaddress import ip_address import json @@ -16,7 +15,11 @@ import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device -from zigpy.exceptions import NetworkNotFormed +from zigpy.exceptions import ( + CannotWriteNetworkSettings, + DestructiveWriteNetworkSettings, + NetworkNotFormed, +) import zigpy.types from homeassistant import config_entries @@ -29,7 +32,7 @@ DOMAIN, EZSP_OVERWRITE_EUI64, ) -from homeassistant.components.zha.radio_manager import ProbeResult +from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USB, @@ -268,11 +271,11 @@ async def test_zeroconf_discovery( ) assert result_confirm["type"] is FlowResultType.MENU - assert result_confirm["step_id"] == "choose_formation_strategy" + assert result_confirm["step_id"] == "choose_setup_strategy" result_form = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -322,11 +325,11 @@ async def test_legacy_zeroconf_discovery_zigate( ) assert result_confirm["type"] is FlowResultType.MENU - assert result_confirm["step_id"] == "choose_formation_strategy" + assert result_confirm["step_id"] == "choose_setup_strategy" result_form = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -426,9 +429,9 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( flow["flow_id"], user_input={} ) - # Config will fail - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + # Now prompts to migrate instead of aborting + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "choose_setup_strategy" @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) @@ -456,12 +459,12 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result2["step_id"] == "choose_setup_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -477,56 +480,6 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: } -@patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) -async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: - """Test zigate usb flow -- radio detected.""" - discovery_info = UsbServiceInfo( - device="/dev/ttyZIGBEE", - pid="0403", - vid="6015", - serial_number="1234", - description="zigate radio", - manufacturer="test", - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USB}, data=discovery_info - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result2["step_id"] == "verify_radio" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "choose_formation_strategy" - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "zigate radio" - assert result4["data"] == { - "device": { - "path": "/dev/ttyZIGBEE", - "baudrate": 115200, - "flow_control": None, - }, - CONF_RADIO_TYPE: "zigate", - } - - @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", AsyncMock(return_value=ProbeResult.PROBING_FAILED), @@ -574,13 +527,170 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: description="zigbee radio", manufacturer="test", ) - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USB}, data=discovery_info ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={}, + ) + + # When we have an existing config entry, we migrate + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_migration_strategy" + + +@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_migration_strategy_recommended( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test automatic migration.""" + 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) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup: + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "reconfigure_successful" + mock_restore_backup.assert_called_once() + + +@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_migration_strategy_recommended_cannot_write( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test recommended migration with a write failure.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}, + CONF_RADIO_TYPE: "ezsp", + }, + ).add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ): + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=CannotWriteNetworkSettings("test error"), + ) as mock_restore_backup: + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert mock_restore_backup.call_count == 1 + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "cannot_restore_backup" + assert "test error" in result_recommended["description_placeholders"]["error"] + + +@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_multiple_zha_entries_aborts(hass: HomeAssistant, mock_app) -> None: + """Test flow aborts if there are multiple ZHA config entries.""" + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB2"}} + ).add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED}, + ) + + result_reuse = await hass.config_entries.flow.async_configure( + result_recommended["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + ) + + assert result_reuse["type"] is FlowResultType.ABORT + assert result_reuse["reason"] == "single_instance_allowed" @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -751,13 +861,19 @@ async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> N domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={}, + ) + + # When we have an existing config entry, we migrate + assert confirm_result["type"] is FlowResultType.MENU + assert confirm_result["step_id"] == "choose_migration_strategy" @patch( @@ -779,12 +895,12 @@ async def test_user_flow(hass: HomeAssistant) -> None: }, ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" with patch("homeassistant.components.zha.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -875,19 +991,6 @@ async def test_pick_radio_flow(hass: HomeAssistant, radio_type) -> None: assert result["step_id"] == "manual_port_config" -async def test_user_flow_existing_config_entry(hass: HomeAssistant) -> None: - """Test if config entry already exists.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - assert result["type"] is FlowResultType.ABORT - - @patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=False) @patch(f"zigpy_deconz.{PROBE_FUNCTION_PATH}", return_value=False) @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=False) @@ -956,7 +1059,11 @@ async def test_user_port_config_fail(probe_mock, hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, + user_input={ + CONF_DEVICE_PATH: "/dev/ttyUSB33", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: "none", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual_port_config" @@ -981,11 +1088,11 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, + user_input={"next_step_id": config_flow.SETUP_STRATEGY_RECOMMENDED}, ) await hass.async_block_till_done() @@ -1026,21 +1133,21 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: result1["flow_id"], user_input={}, ) - else: - # No need to confirm - result2 = result1 - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result2["type"] is FlowResultType.MENU + assert result2["step_id"] == "choose_setup_strategy" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() + 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 result3["title"] == "Yellow" - assert result3["data"] == { + assert result_create["title"] == "Yellow" + assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: "hardware", @@ -1050,30 +1157,6 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: } -async def test_hardware_already_setup(hass: HomeAssistant) -> None: - """Test hardware flow -- already setup.""" - - MockConfigEntry( - domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} - ).add_to_hass(hass) - - data = { - "name": "Yellow", - "radio_type": "efr32", - "port": { - "path": "/dev/ttyAMA1", - "baudrate": 115200, - "flow_control": "hardware", - }, - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_HARDWARE}, data=data - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - @pytest.mark.parametrize( "data", [None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}] ) @@ -1110,7 +1193,7 @@ def test_prevent_overwrite_ezsp_ieee() -> None: @pytest.fixture -def pick_radio( +def advanced_pick_radio( hass: HomeAssistant, ) -> Generator[RadioPicker]: """Fixture for the first step of the config flow (where a radio is picked).""" @@ -1132,9 +1215,17 @@ async def wrapper(radio_type: RadioType) -> tuple[ConfigFlowResult, ListPortInfo ) assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "choose_formation_strategy" + assert result["step_id"] == "choose_setup_strategy" + + advanced_strategy_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": config_flow.SETUP_STRATEGY_ADVANCED}, + ) + + assert advanced_strategy_result["type"] == FlowResultType.MENU + assert advanced_strategy_result["step_id"] == "choose_formation_strategy" - return result, port + return advanced_strategy_result p1 = patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) p2 = patch("homeassistant.components.zha.async_setup_entry") @@ -1144,12 +1235,12 @@ async def wrapper(radio_type: RadioType) -> tuple[ConfigFlowResult, ListPortInfo async def test_strategy_no_network_settings( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test formation strategy when no network settings are present.""" mock_app.load_network_info = MagicMock(side_effect=NetworkNotFormed()) - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) assert ( config_flow.FORMATION_REUSE_SETTINGS not in result["data_schema"].schema["next_step_id"].container @@ -1157,10 +1248,10 @@ async def test_strategy_no_network_settings( async def test_formation_strategy_form_new_network( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network.""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1175,12 +1266,12 @@ async def test_formation_strategy_form_new_network( async def test_formation_strategy_form_initial_network( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network, with no previous settings on the radio.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": config_flow.FORMATION_FORM_INITIAL_NETWORK}, @@ -1231,10 +1322,10 @@ async def test_onboarding_auto_formation_new_hardware( async def test_formation_strategy_reuse_settings( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test reusing existing network settings.""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1265,12 +1356,12 @@ def test_parse_uploaded_backup(process_mock) -> None: @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_non_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on non-EZSP coordinators.""" - result, _port = await pick_radio(RadioType.znp) + result = await advanced_pick_radio(RadioType.znp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1300,13 +1391,13 @@ async def test_formation_strategy_restore_manual_backup_non_ezsp( @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, backup, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (overwrite IEEE).""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1317,39 +1408,53 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "upload_manual_backup" - with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + None, + ], + ) as mock_restore_backup, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, - ) + # The radio requires user confirmation for restore + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "maybe_confirm_ezsp_restore" - allow_overwrite_ieee_mock.assert_called_once() - mock_app.backups.restore_backup.assert_called_once() + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert mock_restore_backup.call_count == 1 + assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True + @patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") async def test_formation_strategy_restore_manual_backup_ezsp( allow_overwrite_ieee_mock, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (don't overwrite IEEE).""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1360,37 +1465,48 @@ async def test_formation_strategy_restore_manual_backup_ezsp( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "upload_manual_backup" - backup = zigpy.backups.NetworkBackup() - - with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + None, + ], + ) as mock_restore_backup, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False}, - ) + # The radio requires user confirmation for restore + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "maybe_confirm_ezsp_restore" - allow_overwrite_ieee_mock.assert_not_called() - mock_app.backups.restore_backup.assert_called_once_with(backup) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + # We do not accept + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False}, + ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert result4["type"] is FlowResultType.ABORT + assert result4["reason"] == "cannot_restore_backup_no_ieee_confirm" + assert mock_restore_backup.call_count == 0 async def test_formation_strategy_restore_manual_backup_invalid_upload( - pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test restoring a manual backup but an invalid file is uploaded.""" - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1439,7 +1555,10 @@ def test_format_backup_choice() -> None: ) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_formation_strategy_restore_automatic_backup_ezsp( - pick_radio: RadioPicker, mock_app: AsyncMock, make_backup, hass: HomeAssistant + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + make_backup, + hass: HomeAssistant, ) -> None: """Test restoring an automatic backup (EZSP radio).""" mock_app.backups.backups = [ @@ -1450,7 +1569,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, _port = await pick_radio(RadioType.ezsp) + result = await advanced_pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": (config_flow.FORMATION_CHOOSE_AUTOMATIC_BACKUP)}, @@ -1467,18 +1586,10 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( }, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "maybe_confirm_ezsp_restore" - - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, - ) - mock_app.backups.restore_backup.assert_called_once() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"][CONF_RADIO_TYPE] == "ezsp" + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_RADIO_TYPE] == "ezsp" @patch( @@ -1489,7 +1600,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( @pytest.mark.parametrize("is_advanced", [True, False]) async def test_formation_strategy_restore_automatic_backup_non_ezsp( is_advanced, - pick_radio: RadioPicker, + advanced_pick_radio: RadioPicker, mock_app: AsyncMock, make_backup, hass: HomeAssistant, @@ -1503,7 +1614,7 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, _port = await pick_radio(RadioType.znp) + result = await advanced_pick_radio(RadioType.znp) with patch( "homeassistant.config_entries.ConfigFlow.show_advanced_options", @@ -1543,54 +1654,51 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( assert result3["data"][CONF_RADIO_TYPE] == "znp" -@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") -async def test_ezsp_restore_without_settings_change_ieee( - allow_overwrite_ieee_mock, - pick_radio: RadioPicker, - mock_app: AsyncMock, - backup, - hass: HomeAssistant, +@patch("homeassistant.components.zha.async_setup_entry", return_value=True) +async def test_options_flow_creates_backup( + async_setup_entry, hass: HomeAssistant, mock_app ) -> None: - """Test a manual backup on EZSP coordinators without settings (no IEEE write).""" - # Fail to load settings - with patch.object( - mock_app, "load_network_info", MagicMock(side_effect=NetworkNotFormed()) - ): - result, _port = await pick_radio(RadioType.ezsp) - - # Set the network state, it'll be picked up later after the load "succeeds" - mock_app.state.node_info = backup.node_info - mock_app.state.network_info = copy.deepcopy(backup.network_info) - mock_app.state.network_info.network_key.tx_counter += 10000 - mock_app.state.network_info.metadata["ezsp"] = {} + """Test options flow creates a backup.""" + 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) - # Include the overwrite option, just in case someone uploads a backup with it - backup.network_info.metadata["ezsp"] = {EZSP_OVERWRITE_EUI64: True} + zha_gateway = MagicMock() + zha_gateway.application_controller = mock_app - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, - ) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "upload_manual_backup" + assert entry.state is ConfigEntryState.LOADED with patch( - "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", - return_value=backup, + "homeassistant.components.zha.config_flow.get_zha_gateway", + return_value=zha_gateway, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, - ) + flow = await hass.config_entries.options.async_init(entry.entry_id) - # We wrote settings when connecting - allow_overwrite_ieee_mock.assert_not_called() - mock_app.backups.restore_backup.assert_called_once_with(backup, create_new=False) + assert flow["step_id"] == "init" - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"][CONF_RADIO_TYPE] == "ezsp" + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload", return_value=True + ) as mock_async_unload: + result = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) + + mock_app.backups.create_backup.assert_called_once_with(load_devices=True) + mock_async_unload.assert_called_once_with(entry.entry_id) + + assert result["step_id"] == "prompt_migrate_or_reconfigure" @pytest.mark.parametrize( @@ -1677,7 +1785,16 @@ async def test_options_flow_defaults( # The defaults match our current settings assert result4["step_id"] == "manual_port_config" - assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] + assert entry.data[CONF_DEVICE] == { + "path": "/dev/ttyUSB0", + "baudrate": 12345, + "flow_control": None, + } + assert result4["data_schema"]({}) == { + "path": "/dev/ttyUSB0", + "baudrate": 12345, + "flow_control": "none", + } with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): # Change the serial port path @@ -1692,18 +1809,24 @@ async def test_options_flow_defaults( ) # The radio has been detected, we can move on to creating the config entry - assert result5["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_migration_strategy" async_setup_entry.assert_not_called() result6 = await hass.config_entries.options.async_configure( - result1["flow_id"], + result5["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED}, + ) + await hass.async_block_till_done() + + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result6["type"] is FlowResultType.CREATE_ENTRY - assert result6["data"] == {} + assert result7["type"] is FlowResultType.CREATE_ENTRY + assert result7["data"] == {} # The updated entry contains correct settings assert entry.data == { @@ -1784,14 +1907,23 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: # The defaults match our current settings assert result4["step_id"] == "manual_port_config" - assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] + assert entry.data[CONF_DEVICE] == { + "path": "socket://localhost:5678", + "baudrate": 12345, + "flow_control": None, + } + assert result4["data_schema"]({}) == { + "path": "socket://localhost:5678", + "baudrate": 12345, + "flow_control": "none", + } with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): result5 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) - assert result5["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_migration_strategy" @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) @@ -2061,3 +2193,174 @@ async def test_migration_ti_cc_to_znp( assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type + + +@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_migration_resets_old_radio( + hass: HomeAssistant, backup, mock_app +) -> None: + """Test that the old radio is reset during migration.""" + 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: "ezsp", + }, + ) + entry.add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + mock_temp_radio_mgr = AsyncMock() + mock_temp_radio_mgr.async_reset_adapter = AsyncMock() + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.config_flow.ZhaRadioManager", + side_effect=[ZhaRadioManager(), mock_temp_radio_mgr], + ), + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + assert result_recommended["type"] is FlowResultType.ABORT + assert result_recommended["reason"] == "reconfigure_successful" + + # We reset the old radio + assert mock_temp_radio_mgr.async_reset_adapter.call_count == 1 + + # It should be configured with the old radio's settings + assert mock_temp_radio_mgr.radio_type == RadioType.ezsp + assert mock_temp_radio_mgr.device_path == "/dev/ttyUSB0" + assert mock_temp_radio_mgr.device_settings == { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +@patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=True) +async def test_config_flow_serial_resolution_oserror( + probe_mock, hass: HomeAssistant +) -> 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}, + ) + + 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", + 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}, + ) + + assert setup_result["type"] is FlowResultType.ABORT + assert setup_result["reason"] == "cannot_resolve_path" + assert setup_result["description_placeholders"] == {"path": "/dev/ttyUSB33"} + + +@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") +async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_write_fail( + allow_overwrite_ieee_mock, + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + backup, + hass: HomeAssistant, +) -> None: + """Test restoring a manual backup on EZSP coordinators (overwrite IEEE) with a write failure.""" + advanced_strategy_result = await advanced_pick_radio(RadioType.ezsp) + + upload_backup_result = await hass.config_entries.flow.async_configure( + advanced_strategy_result["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, + ) + await hass.async_block_till_done() + + assert upload_backup_result["type"] is FlowResultType.FORM + assert upload_backup_result["step_id"] == "upload_manual_backup" + + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + CannotWriteNetworkSettings("Failed to write settings"), + ], + ) as mock_restore_backup, + ): + confirm_restore_result = await hass.config_entries.flow.async_configure( + upload_backup_result["flow_id"], + user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, + ) + + assert mock_restore_backup.call_count == 1 + assert not mock_restore_backup.mock_calls[0].kwargs.get("overwrite_ieee") + mock_restore_backup.reset_mock() + + # The radio requires user confirmation for restore + assert confirm_restore_result["type"] is FlowResultType.FORM + assert confirm_restore_result["step_id"] == "maybe_confirm_ezsp_restore" + + final_result = await hass.config_entries.flow.async_configure( + confirm_restore_result["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) + + assert final_result["type"] is FlowResultType.ABORT + assert final_result["reason"] == "cannot_restore_backup" + assert ( + "Failed to write settings" in final_result["description_placeholders"]["error"] + ) + + assert mock_restore_backup.call_count == 1 + assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 59494dd0d09361..c8086cc49d934d 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -16,6 +16,7 @@ from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry @@ -88,7 +89,7 @@ def com_port(device="/dev/ttyUSB1234"): @pytest.fixture -def mock_connect_zigpy_app() -> Generator[MagicMock]: +def mock_create_zigpy_app() -> Generator[MagicMock]: """Mock the radio connection.""" mock_connect_app = MagicMock() @@ -98,7 +99,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock]: ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app", return_value=mock_connect_app, ): yield mock_connect_app @@ -107,7 +108,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock]: @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migrate_matching_port( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -167,7 +168,7 @@ async def test_migrate_matching_port( @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_migrate_matching_port_usb( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -214,7 +215,7 @@ async def test_migrate_matching_port_usb( async def test_migrate_matching_port_config_entry_not_loaded( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -268,13 +269,13 @@ async def test_migrate_matching_port_config_entry_not_loaded( @patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.async_restore_backup_step_1", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", side_effect=OSError, ) async def test_migrate_matching_port_retry( mock_restore_backup_step_1, hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -331,7 +332,7 @@ async def test_migrate_matching_port_retry( async def test_migrate_non_matching_port( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test automatic migration.""" # Set up the config entry @@ -379,7 +380,7 @@ async def test_migrate_non_matching_port( async def test_migrate_initiate_failure( hass: HomeAssistant, - mock_connect_zigpy_app, + mock_create_zigpy_app, ) -> None: """Test retries with failure.""" # Set up the config entry @@ -416,7 +417,7 @@ async def test_migrate_initiate_failure( } mock_load_info = AsyncMock(side_effect=OSError()) - mock_connect_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info + mock_create_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info migration_helper = radio_manager.ZhaMultiPANMigrationHelper(hass, config_entry) @@ -484,3 +485,32 @@ async def test_detect_radio_type_failure_no_detect( ): assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED assert radio_manager.radio_type is None + + +async def test_load_network_settings_oserror( + radio_manager: ZhaRadioManager, hass: HomeAssistant +) -> None: + """Test that OSError during network settings loading is handled.""" + radio_manager.device_path = "/dev/ttyZigbee" + radio_manager.radio_type = RadioType.ezsp + radio_manager.device_settings = {"database": "/test/db/path"} + + with ( + patch("os.path.exists", side_effect=OSError("Test error")), + pytest.raises(HomeAssistantError, match="Could not read the ZHA database"), + ): + await radio_manager.async_load_network_settings() + + +async def test_create_zigpy_app_connect_oserror( + radio_manager: ZhaRadioManager, hass: HomeAssistant, mock_app +) -> None: + """Test that OSError during zigpy app connection is handled.""" + radio_manager.radio_type = RadioType.ezsp + radio_manager.device_settings = {CONF_DEVICE_PATH: "/dev/ttyZigbee"} + + mock_app.connect.side_effect = OSError("Test error") + + with pytest.raises(HomeAssistantError, match="Failed to connect to Zigbee adapter"): + async with radio_manager.create_zigpy_app(): + pytest.fail("Should not be reached") From 15cc28e6c1edae5d152b6bb348d2ba872cdfcd6c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 23 Sep 2025 21:03:04 +0100 Subject: [PATCH 12/21] Move first probe firmware to firmware progress in hardware flow (#152819) --- .../firmware_config_flow.py | 195 ++++++++++-------- 1 file changed, 107 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 6df3e697fefeb6..6ea568890f9899 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -88,7 +88,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.addon_install_task: asyncio.Task | None = None self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None - self.firmware_install_task: asyncio.Task | None = None + self.firmware_install_task: asyncio.Task[None] | None = None self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: @@ -184,91 +184,17 @@ async def _install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: - assert self._device is not None - + """Show progress dialog for installing firmware.""" if not self.firmware_install_task: - # Keep track of the firmware we're working with, for error messages - self.installing_firmware_name = firmware_name - - # 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. - firmware_install_required = self._probed_firmware_info is None or ( - self._probed_firmware_info.firmware_type - != expected_installed_firmware_type - ) - - session = async_get_clientsession(self.hass) - client = FirmwareUpdateClient(fw_update_url, session) - - try: - manifest = await client.async_update_data() - fw_manifest = next( - fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) - ) - except (StopIteration, TimeoutError, ClientError, ManifestMissing): - _LOGGER.warning( - "Failed to fetch firmware update manifest", exc_info=True - ) - - # Not having internet access should not prevent setup - if not firmware_install_required: - _LOGGER.debug( - "Skipping firmware upgrade due to index download failure" - ) - return self.async_show_progress_done(next_step_id=next_step_id) - - return self.async_show_progress_done( - next_step_id="firmware_download_failed" - ) - - if not firmware_install_required: - assert self._probed_firmware_info is not None - - # Make sure we do not downgrade the firmware - fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) - fw_version = fw_metadata.get_public_version() - probed_fw_version = Version(self._probed_firmware_info.firmware_version) - - if probed_fw_version >= fw_version: - _LOGGER.debug( - "Not downgrading firmware, installed %s is newer than available %s", - probed_fw_version, - fw_version, - ) - return self.async_show_progress_done(next_step_id=next_step_id) - - try: - fw_data = await client.async_fetch_firmware(fw_manifest) - except (TimeoutError, ClientError, ValueError): - _LOGGER.warning("Failed to fetch firmware update", exc_info=True) - - # If we cannot download new firmware, we shouldn't block setup - if not firmware_install_required: - _LOGGER.debug( - "Skipping firmware upgrade due to image download failure" - ) - return self.async_show_progress_done(next_step_id=next_step_id) - - # Otherwise, fail - return self.async_show_progress_done( - next_step_id="firmware_download_failed" - ) - self.firmware_install_task = self.hass.async_create_task( - async_flash_silabs_firmware( - hass=self.hass, - device=self._device, - fw_data=fw_data, - expected_installed_firmware_type=expected_installed_firmware_type, - bootloader_reset_type=None, - progress_callback=lambda offset, total: self.async_update_progress( - offset / total - ), + self._install_firmware( + fw_update_url, + fw_type, + firmware_name, + expected_installed_firmware_type, ), - f"Flash {firmware_name} firmware", + f"Install {firmware_name} firmware", ) - if not self.firmware_install_task.done(): return self.async_show_progress( step_id=step_id, @@ -282,12 +208,102 @@ async def _install_firmware_step( try: await self.firmware_install_task + except AbortFlow as err: + return self.async_show_progress_done( + next_step_id=err.reason, + ) except HomeAssistantError: _LOGGER.exception("Failed to flash firmware") return self.async_show_progress_done(next_step_id="firmware_install_failed") + finally: + self.firmware_install_task = None return self.async_show_progress_done(next_step_id=next_step_id) + async def _install_firmware( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + 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 + self.installing_firmware_name = firmware_name + + # 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. + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type != expected_installed_firmware_type + ) + + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + _LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug("Skipping firmware upgrade due to index download failure") + return + + raise AbortFlow(reason="firmware_download_failed") from err + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError) as err: + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug("Skipping firmware upgrade due to image download failure") + return + + # Otherwise, fail + raise AbortFlow(reason="firmware_download_failed") from err + + await async_flash_silabs_firmware( + hass=self.hass, + device=self._device, + fw_data=fw_data, + expected_installed_firmware_type=expected_installed_firmware_type, + bootloader_reset_type=None, + progress_callback=lambda offset, total: self.async_update_progress( + offset / total + ), + ) + async def _configure_and_start_otbr_addon(self) -> None: """Configure and start the OTBR addon.""" @@ -353,6 +369,15 @@ async def async_step_firmware_install_failed( }, ) + async def async_step_unsupported_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when unsupported firmware is detected.""" + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + async def async_step_zigbee_installation_type( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -406,12 +431,6 @@ async def async_step_zigbee_integration_other( async def _async_continue_picked_firmware(self) -> ConfigFlowResult: """Continue to the picked firmware step.""" - if not await self._probe_firmware_info(): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - if self._picked_firmware_type == PickedFirmwareType.ZIGBEE: return await self.async_step_install_zigbee_firmware() From 20293e2a114d9443424c4bcb17bc7f757b105a90 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 23 Sep 2025 22:05:38 +0200 Subject: [PATCH 13/21] Bump aiohasupervisor to 0.3.3b0 (#152835) Co-authored-by: Claude --- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/components/hassio/strings.json | 4 +++ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../backup_done_with_addon_folder_errors.json | 27 ++++++++++++------- tests/components/hassio/test_backup.py | 3 +++ 9 files changed, 31 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 197ca8d67f8476..cf78eaea05d18a 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.3.2"], + "requirements": ["aiohasupervisor==0.3.3b0"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 94c40732f4d160..d93fff8d06d6bb 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -250,6 +250,10 @@ "unsupported_os_version": { "title": "Unsupported system - Home Assistant OS version", "description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more." + }, + "unsupported_home_assistant_core_version": { + "title": "Unsupported system - Home Assistant Core version", + "description": "System is unsupported because the Home Assistant Core version in use is not supported. For troubleshooting information, select Learn more." } }, "entity": { diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6c5e88984d5e2..facfb507fb5a4b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 aiodns==3.5.0 -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 aiohttp==3.12.15 diff --git a/pyproject.toml b/pyproject.toml index c81dd7e00f3cbd..366482ec7fc344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.2", + "aiohasupervisor==0.3.3b0", "aiohttp==3.12.15", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", diff --git a/requirements.txt b/requirements.txt index 8ba1d7be73634a..0f161b69c20268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.5.0 -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 aiohttp==3.12.15 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 83da8573a51a66..3ed99db0740fb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioguardian==2022.07.0 aioharmony==0.5.3 # homeassistant.components.hassio -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 # homeassistant.components.home_connect aiohomeconnect==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 698e558de6ed75..1125d5db7d1e78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.5.3 # homeassistant.components.hassio -aiohasupervisor==0.3.2 +aiohasupervisor==0.3.3b0 # homeassistant.components.home_connect aiohomeconnect==0.19.0 diff --git a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json index 183a38a60db370..e13bf364e9a3d1 100644 --- a/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json +++ b/tests/components/hassio/fixtures/backup_done_with_addon_folder_errors.json @@ -19,7 +19,8 @@ "done": true, "errors": [], "created": "2025-05-14T08:56:22.807078+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_store_addons", @@ -57,7 +58,8 @@ } ], "created": "2025-05-14T08:56:22.844160+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_addon_save", @@ -74,9 +76,11 @@ } ], "created": "2025-05-14T08:56:22.850376+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null } - ] + ], + "extra": null }, { "name": "backup_store_folders", @@ -119,7 +123,8 @@ } ], "created": "2025-05-14T08:56:22.858385+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_folder_save", @@ -136,7 +141,8 @@ } ], "created": "2025-05-14T08:56:22.859973+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null }, { "name": "backup_folder_save", @@ -153,10 +159,13 @@ } ], "created": "2025-05-14T08:56:22.860792+00:00", - "child_jobs": [] + "child_jobs": [], + "extra": null } - ] + ], + "extra": null } - ] + ], + "extra": null } } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index fb791b38fc55af..0d9b0defe8319e 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -268,6 +268,7 @@ errors=[], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) TEST_JOB_DONE = supervisor_jobs.Job( name="backup_manager_partial_backup", @@ -279,6 +280,7 @@ errors=[], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( name="backup_manager_partial_restore", @@ -299,6 +301,7 @@ ], created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], + extra=None, ) From 3bac6b86dfdc51e0b062f9b0b941290978afdb24 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 23 Sep 2025 22:06:06 +0200 Subject: [PATCH 14/21] Fix multiple_here_travel_time_entries issue description (#152839) --- homeassistant/components/here_travel_time/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index 95fd77d5fa98bb..ec457bf7099d41 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -111,7 +111,7 @@ "issues": { "multiple_here_travel_time_entries": { "title": "More than one HERE Travel Time integration detected", - "description": "HERE deprecated the previous free tier. You have change to the Base Plan which has 5000 instead of 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost." + "description": "HERE deprecated the previous free tier. The new Base Plan has only 5000 instead of the previous 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost." } } } From 3bc2ea7b5f5da2cf606bd9e168c1e690916c627d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 23 Sep 2025 22:07:24 +0200 Subject: [PATCH 15/21] Use DOMAIN not MODBUS_DOMAIN (#152823) --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/modbus.py | 2 +- homeassistant/components/modbus/validators.py | 2 +- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_binary_sensor.py | 4 ++-- tests/components/modbus/test_climate.py | 4 ++-- tests/components/modbus/test_cover.py | 4 ++-- tests/components/modbus/test_fan.py | 6 +++--- tests/components/modbus/test_init.py | 2 +- tests/components/modbus/test_light.py | 6 +++--- tests/components/modbus/test_sensor.py | 4 ++-- tests/components/modbus/test_switch.py | 6 +++--- 13 files changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index d933eed82cd2d2..1847c4fb738cd3 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -148,7 +148,7 @@ DEFAULT_HVAC_ON_VALUE, DEFAULT_SCAN_INTERVAL, DEFAULT_TEMP_UNIT, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, RTUOVERTCP, SERIAL, TCP, diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index dafc604e7812a6..9eab4299b18d13 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -159,6 +159,7 @@ class DataType(str, Enum): DEFAULT_HVAC_ON_VALUE = 1 DEFAULT_HVAC_OFF_VALUE = 0 MODBUS_DOMAIN = "modbus" +DOMAIN = "modbus" ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 26992404e38f2a..89cdb7d47e4483 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -56,7 +56,7 @@ CONF_STOPBITS, DEFAULT_HUB, DEVICE_ID, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, PLATFORMS, RTUOVERTCP, SERIAL, diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f8f1a7450eba8f..fba0736c64ddff 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -36,7 +36,7 @@ CONF_VIRTUAL_COUNT, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, PLATFORMS, SERIAL, DataType, diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index f7bd4b13a1b71b..a57c2cfdcc586e 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -11,7 +11,7 @@ from pymodbus.exceptions import ModbusException import pytest -from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP +from homeassistant.components.modbus.const import DOMAIN, TCP from homeassistant.const import ( CONF_ADDRESS, CONF_HOST, diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 758b1fd7a7acbe..a8acb5f4674b2a 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -13,7 +13,7 @@ CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -439,7 +439,7 @@ async def test_no_discovery_info_binary_sensor( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 409d864949c826..14bc46042f69ff 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -84,7 +84,7 @@ CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, - MODBUS_DOMAIN, + DOMAIN, DataType, ) from homeassistant.const import ( @@ -1695,7 +1695,7 @@ async def test_no_discovery_info_climate( assert await async_setup_component( hass, CLIMATE_DOMAIN, - {CLIMATE_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {CLIMATE_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert CLIMATE_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index a244ce80399ae5..9f3a64c27e5909 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -16,7 +16,7 @@ CONF_STATE_OPENING, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -305,7 +305,7 @@ async def test_no_discovery_info_cover( assert await async_setup_component( hass, COVER_DOMAIN, - {COVER_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {COVER_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert COVER_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 2afc6314048be2..c9796bbaf3c1be 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -17,7 +17,7 @@ CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -270,7 +270,7 @@ async def test_fan_service_turn( ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( @@ -354,7 +354,7 @@ async def test_no_discovery_info_fan( assert await async_setup_component( hass, FAN_DOMAIN, - {FAN_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {FAN_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert FAN_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index aa0ef1dcca7fc3..a0c38e37ce5819 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -64,7 +64,7 @@ CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, DEVICE_ID, - MODBUS_DOMAIN as DOMAIN, + DOMAIN, RTUOVERTCP, SERIAL, SERVICE_STOP, diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 56b6d0ef3b411a..9b8eed7437f8c4 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -22,7 +22,7 @@ CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -311,7 +311,7 @@ async def test_light_service_turn( ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID} @@ -535,7 +535,7 @@ async def test_no_discovery_info_light( assert await async_setup_component( hass, LIGHT_DOMAIN, - {LIGHT_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {LIGHT_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert LIGHT_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 868e8a8baada7e..ef9c6b5b8cd460 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -23,7 +23,7 @@ CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, - MODBUS_DOMAIN, + DOMAIN, DataType, ) from homeassistant.components.sensor import ( @@ -1482,7 +1482,7 @@ async def test_no_discovery_info_sensor( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index fc994c70d4985d..f9763e80307a23 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -19,7 +19,7 @@ CONF_STATE_ON, CONF_VERIFY, CONF_WRITE_TYPE, - MODBUS_DOMAIN, + DOMAIN, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -349,7 +349,7 @@ async def test_switch_service_turn( mock_modbus, ) -> None: """Run test for service turn_on/turn_off.""" - assert MODBUS_DOMAIN in hass.config.components + assert DOMAIN in hass.config.components assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( @@ -520,7 +520,7 @@ async def test_no_discovery_info_switch( assert await async_setup_component( hass, SWITCH_DOMAIN, - {SWITCH_DOMAIN: {CONF_PLATFORM: MODBUS_DOMAIN}}, + {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SWITCH_DOMAIN in hass.config.components From 60bf298ca6df7363cdfd0b7f79e97d0985ca5249 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:25:56 -0400 Subject: [PATCH 16/21] File add read_file action with Response (#139216) --- .../components/default_config/manifest.json | 1 + homeassistant/components/file/__init__.py | 11 ++ homeassistant/components/file/const.py | 4 + homeassistant/components/file/icons.json | 7 + homeassistant/components/file/services.py | 88 +++++++++++ homeassistant/components/file/services.yaml | 14 ++ homeassistant/components/file/strings.json | 31 ++++ homeassistant/package_constraints.txt | 1 + tests/components/file/conftest.py | 13 ++ tests/components/file/fixtures/file_read.json | 1 + .../file/fixtures/file_read.not_json | 1 + .../file/fixtures/file_read.not_yaml | 4 + tests/components/file/fixtures/file_read.yaml | 5 + .../file/fixtures/file_read_list.yaml | 4 + .../file/snapshots/test_services.ambr | 39 +++++ tests/components/file/test_services.py | 147 ++++++++++++++++++ 16 files changed, 371 insertions(+) create mode 100644 homeassistant/components/file/icons.json create mode 100644 homeassistant/components/file/services.py create mode 100644 homeassistant/components/file/services.yaml create mode 100644 tests/components/file/fixtures/file_read.json create mode 100644 tests/components/file/fixtures/file_read.not_json create mode 100644 tests/components/file/fixtures/file_read.not_yaml create mode 100644 tests/components/file/fixtures/file_read.yaml create mode 100644 tests/components/file/fixtures/file_read_list.yaml create mode 100644 tests/components/file/snapshots/test_services.ambr create mode 100644 tests/components/file/test_services.py diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 3d845066251e90..7aa037ac047892 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -9,6 +9,7 @@ "conversation", "dhcp", "energy", + "file", "go2rtc", "history", "homeassistant_alerts", diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 59a08715b8e4a2..8f49fb097756d1 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -7,11 +7,22 @@ from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import async_register_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the file component.""" + async_register_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a file component entry.""" diff --git a/homeassistant/components/file/const.py b/homeassistant/components/file/const.py index 0fa9f8a421bd22..2504610bf5a235 100644 --- a/homeassistant/components/file/const.py +++ b/homeassistant/components/file/const.py @@ -6,3 +6,7 @@ DEFAULT_NAME = "File" FILE_ICON = "mdi:file" + +SERVICE_READ_FILE = "read_file" +ATTR_FILE_NAME = "file_name" +ATTR_FILE_ENCODING = "file_encoding" diff --git a/homeassistant/components/file/icons.json b/homeassistant/components/file/icons.json new file mode 100644 index 00000000000000..826048974cc43a --- /dev/null +++ b/homeassistant/components/file/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "read_file": { + "service": "mdi:file" + } + } +} diff --git a/homeassistant/components/file/services.py b/homeassistant/components/file/services.py new file mode 100644 index 00000000000000..3db7bb2c922d5c --- /dev/null +++ b/homeassistant/components/file/services.py @@ -0,0 +1,88 @@ +"""File Service calls.""" + +from collections.abc import Callable +import json + +import voluptuous as vol +import yaml + +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for File integration.""" + + if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE): + hass.services.async_register( + DOMAIN, + SERVICE_READ_FILE, + read_file, + schema=vol.Schema( + { + vol.Required(ATTR_FILE_NAME): cv.string, + vol.Required(ATTR_FILE_ENCODING): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + +ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = { + "json": (json.loads, json.JSONDecodeError), + "yaml": (yaml.safe_load, yaml.YAMLError), +} + + +def read_file(call: ServiceCall) -> dict: + """Handle read_file service call.""" + file_name = call.data[ATTR_FILE_NAME] + file_encoding = call.data[ATTR_FILE_ENCODING].lower() + + if not call.hass.config.is_allowed_path(file_name): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": file_name}, + ) + + if file_encoding not in ENCODING_LOADERS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_file_encoding", + translation_placeholders={ + "filename": file_name, + "encoding": file_encoding, + }, + ) + + try: + with open(file_name, encoding="utf-8") as file: + file_content = file.read() + except FileNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"filename": file_name}, + ) from err + except OSError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_read_error", + translation_placeholders={"filename": file_name}, + ) from err + + loader, error_type = ENCODING_LOADERS[file_encoding] + try: + data = loader(file_content) + except error_type as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_decoding", + translation_placeholders={"filename": file_name, "encoding": file_encoding}, + ) from err + + return {"data": data} diff --git a/homeassistant/components/file/services.yaml b/homeassistant/components/file/services.yaml new file mode 100644 index 00000000000000..18dafe88205eb6 --- /dev/null +++ b/homeassistant/components/file/services.yaml @@ -0,0 +1,14 @@ +# Describes the format for available file services +read_file: + fields: + file_name: + example: "www/my_file.json" + selector: + text: + file_encoding: + example: "JSON" + selector: + select: + options: + - "JSON" + - "YAML" diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 02f8c42755b675..66666b3dd7d255 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -64,6 +64,37 @@ }, "write_access_failed": { "message": "Write access to {filename} failed: {exc}." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "unsupported_file_encoding": { + "message": "Cannot read {filename}, unsupported file encoding {encoding}." + }, + "file_decoding": { + "message": "Cannot read file {filename} as {encoding}." + }, + "file_not_found": { + "message": "File {filename} not found." + }, + "file_read_error": { + "message": "Error reading {filename}." + } + }, + "services": { + "read_file": { + "name": "Read file", + "description": "Reads a file and returns the contents.", + "fields": { + "file_name": { + "name": "File name", + "description": "Name of the file to read." + }, + "file_encoding": { + "name": "File encoding", + "description": "Encoding of the file (JSON, YAML.)" + } + } } } } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index facfb507fb5a4b..227b9e3b918842 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,6 +31,7 @@ ciso8601==2.3.3 cronsim==2.6 cryptography==45.0.7 dbus-fast==2.44.3 +file-read-backwards==2.0.0 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/tests/components/file/conftest.py b/tests/components/file/conftest.py index 5345a0d38d0b99..2e167310111c6e 100644 --- a/tests/components/file/conftest.py +++ b/tests/components/file/conftest.py @@ -5,7 +5,9 @@ import pytest +from homeassistant.components.file import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component @pytest.fixture @@ -30,3 +32,14 @@ def mock_is_allowed_path(hass: HomeAssistant, is_allowed: bool) -> Generator[Mag hass.config, "is_allowed_path", return_value=is_allowed ) as allowed_path_mock: yield allowed_path_mock + + +@pytest.fixture +async def setup_ha_file_integration(hass: HomeAssistant): + """Set up Home Assistant and load File integration.""" + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {}}, + ) + await hass.async_block_till_done() diff --git a/tests/components/file/fixtures/file_read.json b/tests/components/file/fixtures/file_read.json new file mode 100644 index 00000000000000..5f7453316207d5 --- /dev/null +++ b/tests/components/file/fixtures/file_read.json @@ -0,0 +1 @@ +{ "key": "value", "key1": "value1" } diff --git a/tests/components/file/fixtures/file_read.not_json b/tests/components/file/fixtures/file_read.not_json new file mode 100644 index 00000000000000..07967a9afa2fd1 --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_json @@ -0,0 +1 @@ +{ "key": "value", "key1": value1 } diff --git a/tests/components/file/fixtures/file_read.not_yaml b/tests/components/file/fixtures/file_read.not_yaml new file mode 100644 index 00000000000000..a7e5ad397dc5b4 --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_yaml @@ -0,0 +1,4 @@ +test: + - element: "X" + - element: "Y" + unexpected: "Z" diff --git a/tests/components/file/fixtures/file_read.yaml b/tests/components/file/fixtures/file_read.yaml new file mode 100644 index 00000000000000..cb2a2c9b1f96a3 --- /dev/null +++ b/tests/components/file/fixtures/file_read.yaml @@ -0,0 +1,5 @@ +mylist: + - name: list_item_1 + id: 1 + - name: list_item_2 + id: 2 diff --git a/tests/components/file/fixtures/file_read_list.yaml b/tests/components/file/fixtures/file_read_list.yaml new file mode 100644 index 00000000000000..3e4271b394193f --- /dev/null +++ b/tests/components/file/fixtures/file_read_list.yaml @@ -0,0 +1,4 @@ +- name: list_item_1 + id: 1 +- name: list_item_2 + id: 2 diff --git a/tests/components/file/snapshots/test_services.ambr b/tests/components/file/snapshots/test_services.ambr new file mode 100644 index 00000000000000..daa7c3990faedb --- /dev/null +++ b/tests/components/file/snapshots/test_services.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_read_file[tests/components/file/fixtures/file_read.json-json] + dict({ + 'data': dict({ + 'key': 'value', + 'key1': 'value1', + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read.yaml-yaml] + dict({ + 'data': dict({ + 'mylist': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read_list.yaml-yaml] + dict({ + 'data': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }) +# --- diff --git a/tests/components/file/test_services.py b/tests/components/file/test_services.py new file mode 100644 index 00000000000000..9b7198b9967e8d --- /dev/null +++ b/tests/components/file/test_services.py @@ -0,0 +1,147 @@ +"""The tests for the notify file platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.file import DOMAIN +from homeassistant.components.file.services import ( + ATTR_FILE_ENCODING, + ATTR_FILE_NAME, + SERVICE_READ_FILE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.json", "json"), + ("tests/components/file/fixtures/file_read.yaml", "yaml"), + ("tests/components/file/fixtures/file_read_list.yaml", "yaml"), + ], +) +async def test_read_file( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, + snapshot: SnapshotAssertion, +) -> None: + """Test reading files in supported formats.""" + result = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_read_file_disallowed_path( + hass: HomeAssistant, + setup_ha_file_integration, +) -> None: + """Test reading in a disallowed path generates error.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "json", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert sve.value.translation_key == "no_access_to_path" + assert sve.value.translation_domain == DOMAIN + + +async def test_read_file_bad_encoding_option( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if an invalid encoding is specified.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "invalid", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert "invalid" in str(sve.value) + assert sve.value.translation_key == "unsupported_file_encoding" + assert sve.value.translation_domain == DOMAIN + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.not_json", "json"), + ("tests/components/file/fixtures/file_read.not_yaml", "yaml"), + ], +) +async def test_read_file_decoding_error( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, +) -> None: + """Test decoding errors are handled correctly.""" + with pytest.raises(HomeAssistantError) as hae: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) + assert file_encoding in str(hae.value) + assert hae.value.translation_key == "file_decoding" + assert hae.value.translation_domain == DOMAIN + + +async def test_read_file_dne( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if file does not exist.""" + file_name = "tests/components/file/fixtures/file_dne.yaml" + + with pytest.raises(HomeAssistantError) as hae: + _ = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "yaml", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) From 2008a73657a2572f28c275fc5ef6f9906db03ff7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 22:52:09 +0200 Subject: [PATCH 17/21] Add support for Hue MotionAware sensors (#152811) Co-authored-by: Franck Nijhof --- homeassistant/components/hue/event.py | 31 ++- .../components/hue/v2/binary_sensor.py | 131 +++++++++++- homeassistant/components/hue/v2/device.py | 11 +- homeassistant/components/hue/v2/sensor.py | 69 ++++++- .../components/hue/fixtures/v2_resources.json | 194 ++++++++++++++++++ tests/components/hue/test_binary_sensor.py | 98 ++++++++- tests/components/hue/test_event.py | 4 +- tests/components/hue/test_sensor_v2.py | 50 ++++- 8 files changed, 569 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 4cffbb73a38a71..c13cccd48e6e8d 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -6,6 +6,7 @@ from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.bell_button import BellButton from aiohue.v2.models.button import Button from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection @@ -39,19 +40,27 @@ async def async_setup_entry( @callback def async_add_entity( event_type: EventType, - resource: Button | RelativeRotary, + resource: Button | RelativeRotary | BellButton, ) -> None: """Add entity from Hue resource.""" if isinstance(resource, RelativeRotary): async_add_entities( [HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)] ) + elif isinstance(resource, BellButton): + async_add_entities( + [HueBellButtonEventEntity(bridge, api.sensors.bell_button, resource)] + ) else: async_add_entities( [HueButtonEventEntity(bridge, api.sensors.button, resource)] ) - for controller in (api.sensors.button, api.sensors.relative_rotary): + for controller in ( + api.sensors.button, + api.sensors.relative_rotary, + api.sensors.bell_button, + ): # add all current items in controller for item in controller: async_add_entity(EventType.RESOURCE_ADDED, item) @@ -67,6 +76,8 @@ def async_add_entity( class HueButtonEventEntity(HueBaseEntity, EventEntity): """Representation of a Hue Event entity from a button resource.""" + resource: Button | BellButton + entity_description = EventEntityDescription( key="button", device_class=EventDeviceClass.BUTTON, @@ -91,7 +102,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: } @callback - def _handle_event(self, event_type: EventType, resource: Button) -> None: + def _handle_event( + self, event_type: EventType, resource: Button | BellButton + ) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: if resource.button is None or resource.button.button_report is None: @@ -102,6 +115,18 @@ def _handle_event(self, event_type: EventType, resource: Button) -> None: super()._handle_event(event_type, resource) +class HueBellButtonEventEntity(HueButtonEventEntity): + """Representation of a Hue Event entity from a bell_button resource.""" + + resource: Button | BellButton + + entity_description = EventEntityDescription( + key="bell_button", + device_class=EventDeviceClass.DOORBELL, + has_entity_name=True, + ) + + class HueRotaryEventEntity(HueBaseEntity, EventEntity): """Representation of a Hue Event entity from a RelativeRotary resource.""" diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 17584a0f5cb08e..da28fd1f6a94ef 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -13,13 +13,18 @@ from aiohue.v2.controllers.sensors import ( CameraMotionController, ContactController, + GroupedMotionController, MotionController, + SecurityAreaMotionController, TamperController, ) from aiohue.v2.models.camera_motion import CameraMotion from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus +from aiohue.v2.models.grouped_motion import GroupedMotion from aiohue.v2.models.motion import Motion +from aiohue.v2.models.resource import ResourceTypes +from aiohue.v2.models.security_area_motion import SecurityAreaMotion from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( @@ -29,21 +34,54 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueConfigEntry +from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN from .entity import HueBaseEntity -type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +type SensorType = ( + CameraMotion + | Contact + | Motion + | EntertainmentConfiguration + | Tamper + | GroupedMotion + | SecurityAreaMotion +) type ControllerType = ( CameraMotionController | ContactController | MotionController | EntertainmentConfigurationController | TamperController + | GroupedMotionController + | SecurityAreaMotionController ) +def _resource_valid(resource: SensorType, controller: ControllerType) -> bool: + """Return True if the resource is valid.""" + if isinstance(resource, GroupedMotion): + # filter out GroupedMotion sensors that are not linked to a valid group/parent + if resource.owner.rtype not in ( + ResourceTypes.ROOM, + ResourceTypes.ZONE, + ResourceTypes.SERVICE_GROUP, + ): + return False + # guard against GroupedMotion without parent (should not happen, but just in case) + if not (parent := controller.get_parent(resource.id)): + return False + # filter out GroupedMotion sensors that have only one member, because Hue creates one + # default grouped Motion sensor per zone/room, which is not useful to expose in HA + if len(parent.children) <= 1: + return False + # default/other checks can go here (none for now) + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: HueConfigEntry, @@ -59,11 +97,17 @@ def register_items(controller: ControllerType, sensor_class: SensorType): @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: - """Add Hue Binary Sensor.""" + """Add Hue Binary Sensor from resource added callback.""" + if not _resource_valid(resource, controller): + return async_add_entities([make_binary_sensor_entity(resource)]) # add all current items in controller - async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller) + async_add_entities( + make_binary_sensor_entity(sensor) + for sensor in controller + if _resource_valid(sensor, controller) + ) # register listener for new sensors config_entry.async_on_unload( @@ -78,6 +122,8 @@ def async_add_sensor(event_type: EventType, resource: SensorType) -> None: register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) register_items(api.sensors.contact, HueContactSensor) register_items(api.sensors.tamper, HueTamperSensor) + register_items(api.sensors.grouped_motion, HueGroupedMotionSensor) + register_items(api.sensors.security_area_motion, HueMotionAwareSensor) # pylint: disable-next=hass-enforce-class-module @@ -102,6 +148,83 @@ def is_on(self) -> bool | None: return self.resource.motion.value +# pylint: disable-next=hass-enforce-class-module +class HueGroupedMotionSensor(HueMotionSensor): + """Representation of a Hue Grouped Motion sensor.""" + + controller: GroupedMotionController + resource: GroupedMotion + + def __init__( + self, + bridge: HueBridge, + controller: GroupedMotionController, + resource: GroupedMotion, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the GroupedMotion sensor to the parent the sensor is associated with + # which can either be a special ServiceGroup or a Zone/Room + parent = self.controller.get_parent(resource.id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, parent.id)}, + ) + + +# pylint: disable-next=hass-enforce-class-module +class HueMotionAwareSensor(HueMotionSensor): + """Representation of a Motion sensor based on Hue Motion Aware. + + Note that we only create sensors for the SecurityAreaMotion resource + and not for the ConvenienceAreaMotion resource, because the latter + does not have a state when it's not directly controlling lights. + The SecurityAreaMotion resource is always available with a state, allowing + Home Assistant users to actually use it as a motion sensor in their HA automations. + """ + + controller: SecurityAreaMotionController + resource: SecurityAreaMotion + + entity_description = BinarySensorEntityDescription( + key="motion_sensor", + device_class=BinarySensorDeviceClass.MOTION, + has_entity_name=False, + ) + + @property + def name(self) -> str: + """Return sensor name.""" + return self.controller.get_motion_area_configuration(self.resource.id).name + + def __init__( + self, + bridge: HueBridge, + controller: SecurityAreaMotionController, + resource: SecurityAreaMotion, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the MotionAware sensor to the group the sensor is associated with + self._motion_area_configuration = self.controller.get_motion_area_configuration( + resource.id + ) + group_id = self._motion_area_configuration.group.rid + self.group = self.bridge.api.groups[group_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + # subscribe to updates of the MotionAreaConfiguration to update the name + self.async_on_remove( + self.bridge.api.config.subscribe( + self._handle_event, self._motion_area_configuration.id + ) + ) + + # pylint: disable-next=hass-enforce-class-module class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 62dbe94021712a..e6bded7a7f7b62 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -9,6 +9,7 @@ from aiohue.v2.controllers.groups import Room, Zone from aiohue.v2.models.device import Device from aiohue.v2.models.resource import ResourceTypes +from aiohue.v2.models.service_group import ServiceGroup from homeassistant.const import ( ATTR_CONNECTIONS, @@ -39,16 +40,16 @@ async def async_setup_devices(bridge: HueBridge): dev_controller = api.devices @callback - def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry: + def add_device(hue_resource: Device | Room | Zone | ServiceGroup) -> dr.DeviceEntry: """Register a Hue device in device registry.""" - if isinstance(hue_resource, (Room, Zone)): + if isinstance(hue_resource, (Room, Zone, ServiceGroup)): # Register a Hue Room/Zone as service in HA device registry. return dev_reg.async_get_or_create( config_entry_id=entry.entry_id, entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, hue_resource.id)}, name=hue_resource.metadata.name, - model=hue_resource.type.value.title(), + model=hue_resource.type.value.replace("_", " ").title(), manufacturer=api.config.bridge_device.product_data.manufacturer_name, via_device=(DOMAIN, api.config.bridge_device.id), suggested_area=hue_resource.metadata.name @@ -85,7 +86,7 @@ def remove_device(hue_device_id: str) -> None: @callback def handle_device_event( - evt_type: EventType, hue_resource: Device | Room | Zone + evt_type: EventType, hue_resource: Device | Room | Zone | ServiceGroup ) -> None: """Handle event from Hue controller.""" if evt_type == EventType.RESOURCE_DELETED: @@ -101,6 +102,7 @@ def handle_device_event( known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] + known_devices += [add_device(sg) for sg in api.config.service_group] # Check for nodes that no longer exist and remove them for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): @@ -111,3 +113,4 @@ def handle_device_event( entry.async_on_unload(dev_controller.subscribe(handle_device_event)) entry.async_on_unload(api.groups.room.subscribe(handle_device_event)) entry.async_on_unload(api.groups.zone.subscribe(handle_device_event)) + entry.async_on_unload(api.config.service_group.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 1eec4eaa6b9ba4..0c92b0c8b3ef91 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -9,13 +9,16 @@ from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( DevicePowerController, + GroupedLightLevelController, LightLevelController, SensorsController, TemperatureController, ZigbeeConnectivityController, ) from aiohue.v2.models.device_power import DevicePower +from aiohue.v2.models.grouped_light_level import GroupedLightLevel from aiohue.v2.models.light_level import LightLevel +from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.temperature import Temperature from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity @@ -27,20 +30,50 @@ ) from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN from .entity import HueBaseEntity -type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity +type SensorType = ( + DevicePower | LightLevel | Temperature | ZigbeeConnectivity | GroupedLightLevel +) type ControllerType = ( DevicePowerController | LightLevelController | TemperatureController | ZigbeeConnectivityController + | GroupedLightLevelController ) +def _resource_valid( + resource: SensorType, controller: ControllerType, api: HueBridgeV2 +) -> bool: + """Return True if the resource is valid.""" + if isinstance(resource, GroupedLightLevel): + # filter out GroupedLightLevel sensors that are not linked to a valid group/parent + if resource.owner.rtype not in ( + ResourceTypes.ROOM, + ResourceTypes.ZONE, + ResourceTypes.SERVICE_GROUP, + ): + return False + # guard against GroupedLightLevel without parent (should not happen, but just in case) + parent_id = resource.owner.rid + parent = api.groups.get(parent_id) or api.config.get(parent_id) + if not parent: + return False + # filter out GroupedLightLevel sensors that have only one member, because Hue creates one + # default grouped LightLevel sensor per zone/room, which is not useful to expose in HA + if len(parent.children) <= 1: + return False + # default/other checks can go here (none for now) + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: HueConfigEntry, @@ -58,10 +91,16 @@ def register_items(controller: ControllerType, sensor_class: SensorType): @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Sensor.""" + if not _resource_valid(resource, controller, api): + return async_add_entities([make_sensor_entity(resource)]) # add all current items in controller - async_add_entities(make_sensor_entity(sensor) for sensor in controller) + async_add_entities( + make_sensor_entity(sensor) + for sensor in controller + if _resource_valid(sensor, controller, api) + ) # register listener for new sensors config_entry.async_on_unload( @@ -75,6 +114,7 @@ def async_add_sensor(event_type: EventType, resource: SensorType) -> None: register_items(ctrl_base.light_level, HueLightLevelSensor) register_items(ctrl_base.device_power, HueBatterySensor) register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor) + register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor) # pylint: disable-next=hass-enforce-class-module @@ -140,6 +180,31 @@ def extra_state_attributes(self) -> dict[str, Any]: } +# pylint: disable-next=hass-enforce-class-module +class HueGroupedLightLevelSensor(HueLightLevelSensor): + """Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource.""" + + controller: GroupedLightLevelController + resource: GroupedLightLevel + + def __init__( + self, + bridge: HueBridge, + controller: GroupedLightLevelController, + resource: GroupedLightLevel, + ) -> None: + """Initialize the sensor.""" + super().__init__(bridge, controller, resource) + # link the GroupedLightLevel sensor to the parent the sensor is associated with + # which can either be a special ServiceGroup or a Zone/Room + api = self.bridge.api + parent_id = resource.owner.rid + parent = api.groups.get(parent_id) or api.config.get(parent_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, parent.id)}, + ) + + # pylint: disable-next=hass-enforce-class-module class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 3d718f24c5052e..321ffa20508e21 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2363,5 +2363,199 @@ "sensitivity_max": 4 }, "type": "motion" + }, + { + "id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T08:13:42.394Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "convenience_area_motion" + }, + { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "owner": { + "rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "rtype": "motion_area_configuration" + }, + "enabled": true, + "motion": { + "motion": false, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-23T05:54:08.166Z", + "motion": false + } + }, + "sensitivity": { + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "security_area_motion" + }, + { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "name": "Motion Aware Sensor 1", + "group": { + "rid": "6ddc9066-7e7d-4a03-a773-c73937968296", + "rtype": "room" + }, + "participants": [ + { + "resource": { + "rid": "a17253ed-168d-471a-8e59-01a101441511", + "rtype": "motion_area_candidate" + }, + "status": { + "health": "healthy" + } + } + ], + "services": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "rtype": "security_area_motion" + } + ], + "health": "healthy", + "enabled": true, + "type": "motion_area_configuration" + }, + { + "id": "9f8e7d6c-5b4a-3e2d-1c0b-9a8f7e6d5c4b", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "state": "no_update", + "problems": [], + "type": "device_software_update" + }, + { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "light": { + "light_level_report": { + "changed": "2023-09-23T06:19:38.865Z", + "light_level": 0 + } + }, + "type": "grouped_light_level" + }, + { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "owner": { + "rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "rtype": "service_group" + }, + "enabled": true, + "motion": { + "motion_report": { + "changed": "2023-09-23T08:20:51.384Z", + "motion": false + } + }, + "type": "grouped_motion" + }, + { + "id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f", + "id_v1": "/sensors/75", + "owner": { + "rid": "3ff06175-29e8-44a8-8fe7-af591b0025da", + "rtype": "device" + }, + "relative_rotary": { + "last_event": { + "action": "start", + "rotation": { + "direction": "clock_wise", + "steps": 30, + "duration": 400 + } + }, + "rotary_report": { + "updated": "2023-09-21T10:00:03.276Z", + "action": "start", + "rotation": { + "direction": "counter_clock_wise", + "steps": 45, + "duration": 400 + } + } + }, + "type": "relative_rotary" + }, + { + "id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + "children": [ + { + "rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345", + "rtype": "convenience_area_motion" + }, + { + "rid": "5f317b69-9da0-4b4f-84f2-7ca07b9fe346", + "rtype": "security_area_motion" + } + ], + "services": [ + { + "rid": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "rtype": "grouped_motion" + }, + { + "rid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "rtype": "grouped_light_level" + } + ], + "metadata": { + "name": "Sensor group" + }, + "type": "service_group" + }, + { + "id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b", + "name": "Test clip resource", + "type": "clip" + }, + { + "id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c", + "type": "matter", + "enabled": true, + "max_fabrics": 5, + "has_qr_code": false + }, + { + "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", + "time": { + "time_zone": "UTC", + "time": "2023-09-23T10:30:00Z" + }, + "type": "time" + }, + { + "id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e", + "status": "ready", + "type": "zigbee_device_discovery" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index b9c21a5231f681..02b4d93acfede5 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -19,8 +19,7 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 5 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 5 + # 7 binary_sensors should be created from test data # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -81,6 +80,20 @@ async def test_binary_sensors( assert sensor.name == "Test Camera Motion" assert sensor.attributes["device_class"] == "motion" + # test grouped motion sensor + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Sensor group Motion" + assert sensor.attributes["device_class"] == "motion" + + # test motion aware sensor + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Motion Aware Sensor 1" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update( hass: HomeAssistant, mock_bridge_v2: Mock @@ -110,3 +123,84 @@ 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" + + +async def test_grouped_motion_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedMotionSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test grouped motion sensor exists and has correct state + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of grouped motion sensor works on incoming event + updated_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "motion": { + "motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True} + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "on" + + # test disabled grouped motion sensor == state unknown + disabled_sensor = { + "id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "type": "grouped_motion", + "enabled": False, + } + mock_bridge_v2.api.emit_event("update", disabled_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.sensor_group_motion") + assert sensor.state == "unknown" + + +async def test_motion_aware_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueMotionAwareSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR) + + # test motion aware sensor exists and has correct state + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.state == "off" + assert sensor.attributes["device_class"] == "motion" + + # test update of motion aware sensor works on incoming event + updated_sensor = { + "id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f", + "type": "security_area_motion", + "motion": { + "motion": True, + "motion_valid": True, + "motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": True}, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor.state == "on" + + # test name update when motion area configuration name changes + updated_config = { + "id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b", + "type": "motion_area_configuration", + "name": "Updated Motion Area", + } + mock_bridge_v2.api.emit_event("update", updated_config) + await hass.async_block_till_done() + # The entity name is derived from the motion area configuration name + # but the entity ID doesn't change - we just verify the sensor still exists + sensor = hass.states.get("binary_sensor.motion_aware_sensor_1") + assert sensor is not None + assert sensor.name == "Updated Motion Area" diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index 88b441656874ce..73ae1e5d1d525c 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -17,8 +17,8 @@ async def test_event( """Test event entity for Hue integration.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, Platform.EVENT) - # 7 entities should be created from test data - assert len(hass.states.async_all()) == 7 + # 8 entities should be created from test data + assert len(hass.states.async_all()) == 8 # pick one of the remote buttons state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 7c5afae33719b6..e7b90c2015dcf1 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -27,8 +27,8 @@ async def test_sensors( await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 6 entities should be created from test data - assert len(hass.states.async_all()) == 6 + # 7 entities should be created from test data + assert len(hass.states.async_all()) == 7 # test temperature sensor sensor = hass.states.get("sensor.hue_motion_sensor_temperature") @@ -59,6 +59,16 @@ async def test_sensors( assert sensor.attributes["unit_of_measurement"] == "%" assert sensor.attributes["battery_state"] == "normal" + # test grouped light level sensor + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert sensor.state == "0" + assert sensor.attributes["friendly_name"] == "Sensor group Illuminance" + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "lx" + assert sensor.attributes["light_level"] == 0 + # test disabled zigbee_connectivity sensor entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" entity_entry = entity_registry.async_get(entity_id) @@ -139,3 +149,39 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2: Mock) -> N test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "22.5" + + +async def test_grouped_light_level_sensor( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test HueGroupedLightLevelSensor functionality.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.SENSOR) + + # test grouped light level sensor exists and has correct state + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert sensor is not None + assert ( + sensor.state == "0" + ) # Light level 0 translates to 10^((0-1)/10000) ≈ 0 lux (rounded) + assert sensor.attributes["device_class"] == "illuminance" + assert sensor.attributes["light_level"] == 0 + + # test update of grouped light level sensor works on incoming event + updated_sensor = { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "type": "grouped_light_level", + "light": { + "light_level": 30000, + "light_level_report": { + "changed": "2023-09-23T08:20:51.384Z", + "light_level": 30000, + }, + }, + } + mock_bridge_v2.api.emit_event("update", updated_sensor) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.sensor_group_illuminance") + assert ( + sensor.state == "999" + ) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux From 911f901d9d7f3454e7449229f98070e73e00c85d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 15:54:31 -0500 Subject: [PATCH 18/21] Bump aioesphomeapi to 41.9.0 (#152841) --- 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 269b3874237f5c..4835ead2049443 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.6.0", + "aioesphomeapi==41.9.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3ed99db0740fb8..02510fcb97ef4c 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.6.0 +aioesphomeapi==41.9.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1125d5db7d1e78..f0934cdb36f35e 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.6.0 +aioesphomeapi==41.9.0 # homeassistant.components.flo aioflo==2021.11.0 From 9ba7dda864185313ab8557f0a33628e1f8e5c8d0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 22:55:11 +0200 Subject: [PATCH 19/21] Rename logbook integration to "Activity" in user-facing strings (#150950) --- homeassistant/components/logbook/manifest.json | 2 +- homeassistant/components/logbook/strings.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logbook/manifest.json b/homeassistant/components/logbook/manifest.json index b6b68a1489ea4e..5a84fdb85e58cb 100644 --- a/homeassistant/components/logbook/manifest.json +++ b/homeassistant/components/logbook/manifest.json @@ -1,6 +1,6 @@ { "domain": "logbook", - "name": "Logbook", + "name": "Activity", "codeowners": ["@home-assistant/core"], "dependencies": ["frontend", "http", "recorder"], "documentation": "https://www.home-assistant.io/integrations/logbook", diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index 5a38b57a9b7664..8c725a764c67e6 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -1,9 +1,9 @@ { - "title": "Logbook", + "title": "Activity", "services": { "log": { "name": "Log", - "description": "Creates a custom entry in the logbook.", + "description": "Tracks a custom activity.", "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", @@ -11,15 +11,15 @@ }, "message": { "name": "Message", - "description": "Message of the logbook entry." + "description": "Message of the activity." }, "entity_id": { "name": "Entity ID", - "description": "Entity to reference in the logbook entry." + "description": "Entity to reference in the activity." }, "domain": { "name": "Domain", - "description": "Determines which icon is used in the logbook entry. The icon illustrates the integration domain related to this logbook entry." + "description": "Determines which icon is used in the activity. The icon illustrates the integration domain related to this activity." } } } From ff47839c614ef36c3d2c5ed8d89cd5b1341cba71 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 22:56:16 +0200 Subject: [PATCH 20/21] Fix support for new Hue bulbs with very wide color temperature support (#152834) --- homeassistant/components/hue/v2/group.py | 6 +++- homeassistant/components/hue/v2/helpers.py | 11 +++--- homeassistant/components/hue/v2/light.py | 40 ++++++++++++++-------- tests/components/hue/test_light_v2.py | 2 +- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index eb57d99956a638..c9d7bf6408bf24 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -162,7 +162,11 @@ async def async_turn_on(self, **kwargs: Any) -> None: """Turn the grouped_light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) + color_temp = normalize_hue_colortemp( + kwargs.get(ATTR_COLOR_TEMP_KELVIN), + color_util.color_temperature_kelvin_to_mired(self.max_color_temp_kelvin), + color_util.color_temperature_kelvin_to_mired(self.min_color_temp_kelvin), + ) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index 384d2a305960d9..12c0d6d10e89ae 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -23,11 +23,12 @@ def normalize_hue_transition(transition: float | None) -> float | None: return transition -def normalize_hue_colortemp(colortemp_k: int | None) -> int | None: +def normalize_hue_colortemp( + colortemp_k: int | None, min_mireds: int, max_mireds: int +) -> int | None: """Return color temperature within Hue's ranges.""" if colortemp_k is None: return None - colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k) - # Hue only accepts a range between 153..500 - colortemp = min(colortemp, 500) - return max(colortemp, 153) + colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k) + # Hue only accepts a range between min_mireds..max_mireds + return min(max(colortemp_mireds, min_mireds), max_mireds) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index d83cdaa8009380..e22d2c09f43e3a 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -40,8 +40,8 @@ normalize_hue_transition, ) -FALLBACK_MIN_KELVIN = 6500 -FALLBACK_MAX_KELVIN = 2000 +FALLBACK_MIN_MIREDS = 153 # hue default for most lights +FALLBACK_MAX_MIREDS = 500 # hue default for most lights FALLBACK_KELVIN = 5800 # halfway # HA 2025.4 replaced the deprecated effect "None" with HA default "off" @@ -177,25 +177,31 @@ def color_temp_kelvin(self) -> int | None: # return a fallback value to prevent issues with mired->kelvin conversions return FALLBACK_KELVIN + @property + def max_color_temp_mireds(self) -> int: + """Return the warmest color_temp in mireds (so highest number) that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_maximum + # return a fallback value if the light doesn't provide limits + return FALLBACK_MAX_MIREDS + + @property + def min_color_temp_mireds(self) -> int: + """Return the coldest color_temp in mireds (so lowest number) that this light supports.""" + if color_temp := self.resource.color_temperature: + return color_temp.mirek_schema.mirek_minimum + # return a fallback value if the light doesn't provide limits + return FALLBACK_MIN_MIREDS + @property def max_color_temp_kelvin(self) -> int: """Return the coldest color_temp_kelvin that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_util.color_temperature_mired_to_kelvin( - color_temp.mirek_schema.mirek_minimum - ) - # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MAX_KELVIN + return color_util.color_temperature_mired_to_kelvin(self.min_color_temp_mireds) @property def min_color_temp_kelvin(self) -> int: """Return the warmest color_temp_kelvin that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_util.color_temperature_mired_to_kelvin( - color_temp.mirek_schema.mirek_maximum - ) - # return a fallback value to prevent issues with mired->kelvin conversions - return FALLBACK_MIN_KELVIN + return color_util.color_temperature_mired_to_kelvin(self.max_color_temp_mireds) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -220,7 +226,11 @@ async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN)) + color_temp = normalize_hue_colortemp( + kwargs.get(ATTR_COLOR_TEMP_KELVIN), + self.min_color_temp_mireds, + self.max_color_temp_mireds, + ) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) if self._last_brightness and brightness is None: # The Hue bridge sets the brightness to 1% when turning on a bulb diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 13cfe3995de9b0..a5e7d24c86e816 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -178,7 +178,7 @@ async def test_light_turn_on_service( blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 6 - assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 + assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 454 # test enable an effect await hass.services.async_call( From a0be737925d65036d5cffbbf63f63f4a8e0ae34b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 23 Sep 2025 16:19:04 -0500 Subject: [PATCH 21/21] Auto select first active wake word (#152562) --- homeassistant/components/esphome/select.py | 15 +++++++ .../esphome/test_assist_satellite.py | 19 ++++++++- tests/components/esphome/test_select.py | 41 ++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 4ecde9c5113743..65494e06a3630b 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -194,6 +194,21 @@ def async_satellite_config_updated( self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)] option = self._attr_current_option + + if ( + (self._wake_word_index == 0) + and (len(config.active_wake_words) == 1) + and (option in (None, NO_WAKE_WORD)) + ): + option = next( + ( + wake_word + for wake_word, wake_word_id in self._wake_words.items() + if wake_word_id == config.active_wake_words[0] + ), + None, + ) + if ( (option is None) or ((wake_word_id := self._wake_words.get(option)) is None) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 525f56603ad5be..d6643c17d45669 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1887,10 +1887,10 @@ async def wrapper(*args, **kwargs): assert satellite is not None assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"] - # No wake word should be selected by default + # First wake word should be selected by default state = hass.states.get("select.test_wake_word") assert state is not None - assert state.state == NO_WAKE_WORD + assert state.state == "Hey Jarvis" # Changing the select should set the active wake word await hass.services.async_call( @@ -1955,6 +1955,21 @@ async def wrapper(*args, **kwargs): # Only primary wake word remains assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + # Remove the primary wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": NO_WAKE_WORD}, + blocking=True, + ) + await hass.async_block_till_done() + + async with asyncio.timeout(1): + await configuration_set.wait() + + # No active wake word remain + assert not satellite.async_get_configuration().active_wake_words + async def test_secondary_pipeline( hass: HomeAssistant, diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index db41b164c2decd..7de4dcd6aca3be 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -186,7 +186,7 @@ async def test_wake_word_select_no_active_wake_words( mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: - """Test wake word select uses first available wake word if none are active.""" + """Test wake word select has no wake word selected if none are active.""" device_config = AssistSatelliteConfiguration( available_wake_words=[ AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), @@ -215,3 +215,42 @@ async def test_wake_word_select_no_active_wake_words( state = hass.states.get(entity_id) assert state is not None assert state.state == NO_WAKE_WORD + + +async def test_wake_word_select_first_active_wake_word( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test wake word select uses first available wake word if one is active.""" + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + ], + active_wake_words=["okay_nabu"], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # First wake word should be selected + state = hass.states.get("select.test_wake_word") + assert state is not None + assert state.state == "Okay Nabu" + + # Second wake word should not be selected + state_2 = hass.states.get("select.test_wake_word_2") + assert state_2 is not None + assert state_2.state == NO_WAKE_WORD