From dfd61f85c241a9465d8eac094ed955b76939c884 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 16 Feb 2026 15:29:20 +0000 Subject: [PATCH 01/39] Add reauth to Mastodon (#163148) --- homeassistant/components/mastodon/__init__.py | 8 +- .../components/mastodon/config_flow.py | 85 ++++++++++++++--- .../components/mastodon/manifest.json | 2 +- .../components/mastodon/quality_scale.yaml | 5 +- .../components/mastodon/strings.json | 17 +++- tests/components/mastodon/test_config_flow.py | 94 ++++++++++++++++++- tests/components/mastodon/test_init.py | 18 +++- 7 files changed, 203 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 8e4910d937aa1..15d9aec63335e 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -9,6 +9,7 @@ Mastodon, MastodonError, MastodonNotFoundError, + MastodonUnauthorizedError, ) from homeassistant.const import ( @@ -18,7 +19,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -48,6 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> entry, ) + except MastodonUnauthorizedError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error except MastodonError as ex: raise ConfigEntryNotReady("Failed to connect") from ex diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 6cc82fd50f159..83803cf695d02 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from mastodon.Mastodon import ( @@ -43,6 +44,15 @@ ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), } ) +REAUTH_SCHEMA = vol.Schema( + { + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) + +EXAMPLE_URL = "https://mastodon.social" def base_url_from_url(url: str) -> str: @@ -50,18 +60,26 @@ def base_url_from_url(url: str) -> str: return str(URL(url).origin()) +def remove_email_link(account_name: str) -> str: + """Remove email link from account name.""" + + # Replaces the @ with a HTML entity to prevent mailto links. + return account_name.replace("@", "@") + + class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 MINOR_VERSION = 2 + base_url: str + client_id: str + client_secret: str + access_token: str + def check_connection( self, - base_url: str, - client_id: str, - client_secret: str, - access_token: str, ) -> tuple[ InstanceV2 | Instance | None, Account | None, @@ -70,10 +88,10 @@ def check_connection( """Check connection to the Mastodon instance.""" try: client = create_mastodon_client( - base_url, - client_id, - client_secret, - access_token, + self.base_url, + self.client_id, + self.client_secret, + self.access_token, ) try: instance = client.instance_v2() @@ -117,12 +135,13 @@ async def async_step_user( if user_input: user_input[CONF_BASE_URL] = base_url_from_url(user_input[CONF_BASE_URL]) + self.base_url = user_input[CONF_BASE_URL] + self.client_id = user_input[CONF_CLIENT_ID] + self.client_secret = user_input[CONF_CLIENT_SECRET] + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( - self.check_connection, - user_input[CONF_BASE_URL], - user_input[CONF_CLIENT_ID], - user_input[CONF_CLIENT_SECRET], - user_input[CONF_ACCESS_TOKEN], + self.check_connection ) if not errors: @@ -137,5 +156,43 @@ async def async_step_user( return self.show_user_form( user_input, errors, - description_placeholders={"example_url": "https://mastodon.social"}, + description_placeholders={"example_url": EXAMPLE_URL}, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.base_url = entry_data[CONF_BASE_URL] + self.client_id = entry_data[CONF_CLIENT_ID] + self.client_secret = entry_data[CONF_CLIENT_SECRET] + self.access_token = entry_data[CONF_ACCESS_TOKEN] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + if user_input: + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection + ) + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(slugify(name)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN]}, + ) + account_name = self._get_reauth_entry().title + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + description_placeholders={ + "account_name": remove_email_link(account_name), + }, ) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 157b2986c4d9a..3105e07128e77 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["Mastodon.py==2.1.2"] } diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index ff3d4ad3db0bb..f386cf98ccba7 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -34,10 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: - status: todo - comment: | - Waiting to move to oAuth. + reauthentication-flow: done test-coverage: done # Gold devices: done diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index b069e09b7abdf..f51e33477d145 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "You have to use the same account that was used to configure the integration." }, "error": { "network_error": "The Mastodon instance was not found.", @@ -9,6 +12,15 @@ "unknown": "Unknown error occurred when connecting to the Mastodon instance." }, "step": { + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "access_token": "[%key:component::mastodon::config::step::user::data_description::access_token%]" + }, + "description": "Please reauthenticate {account_name} with Mastodon." + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", @@ -69,6 +81,9 @@ } }, "exceptions": { + "auth_failed": { + "message": "Authentication failed, please reauthenticate with Mastodon." + }, "idempotency_key_too_short": { "message": "Idempotency key must be at least 4 characters long." }, diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py index 5f1014c31d3d4..0ec3647da1de0 100644 --- a/tests/components/mastodon/test_config_flow.py +++ b/tests/components/mastodon/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the Mastodon config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from mastodon.Mastodon import ( MastodonNetworkError, @@ -204,3 +204,95 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "token2" + + +async def test_reauth_flow_wrong_account( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with wrong account.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.mastodon.config_flow.construct_mastodon_username", + return_value="BAD_USERNAME", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MastodonNetworkError, "network_error"), + (MastodonUnauthorizedError, "unauthorized_error"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow errors.""" + mock_config_entry.add_to_hass(hass) + mock_mastodon_client.account_verify_credentials.side_effect = exception + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_mastodon_client.account_verify_credentials.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/mastodon/test_init.py b/tests/components/mastodon/test_init.py index b4808792f6634..af6786a72883f 100644 --- a/tests/components/mastodon/test_init.py +++ b/tests/components/mastodon/test_init.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock -from mastodon.Mastodon import MastodonNotFoundError +from mastodon.Mastodon import MastodonNotFoundError, MastodonUnauthorizedError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.mastodon.config_flow import MastodonConfigFlow @@ -33,18 +34,27 @@ async def test_device_info( assert device_entry == snapshot +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (MastodonNotFoundError, ConfigEntryState.SETUP_RETRY), + (MastodonUnauthorizedError, ConfigEntryState.SETUP_ERROR), + ], +) async def test_initialization_failure( hass: HomeAssistant, mock_mastodon_client: AsyncMock, mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, ) -> None: """Test initialization failure.""" - mock_mastodon_client.instance_v1.side_effect = MastodonNotFoundError - mock_mastodon_client.instance_v2.side_effect = MastodonNotFoundError + mock_mastodon_client.instance_v1.side_effect = exception + mock_mastodon_client.instance_v2.side_effect = exception await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is expected_state async def test_setup_integration_fallback_to_instance_v1( From fdc264cf711ea9c49a24873c4a1831cd5b872c51 Mon Sep 17 00:00:00 2001 From: doggyben <30413475+doggyben@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:35:26 -0500 Subject: [PATCH 02/39] Change Facebook notify tag from ACCOUNT_UPDATE to HUMAN_AGENT (#162890) --- homeassistant/components/facebook/notify.py | 2 +- tests/components/facebook/test_notify.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 674da78ead2dd..ba998e79e3adf 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -77,7 +77,7 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: "recipient": recipient, "message": body_message, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } resp = requests.post( BASE_URL, diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index db9cd86e086ba..4e889b26dc006 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -34,7 +34,7 @@ async def test_send_simple_message( "recipient": {"phone_number": target[0]}, "message": {"text": message}, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } assert mock.last_request.json() == expected_body @@ -62,7 +62,7 @@ async def test_send_multiple_message( "recipient": {"phone_number": target}, "message": {"text": message}, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } assert request.json() == expected_body @@ -94,7 +94,7 @@ async def test_send_message_attachment( "recipient": {"phone_number": target[0]}, "message": data, "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", + "tag": "HUMAN_AGENT", } assert mock.last_request.json() == expected_body From a308b84f15eecd3e3f6567ebe15fda2beec1ff11 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:39:28 +0100 Subject: [PATCH 03/39] Use hardware/usb domain constant in tests (#162934) --- .../test_config_flow.py | 13 +++-- .../test_hardware.py | 3 +- .../homeassistant_connect_zbt2/test_init.py | 4 +- .../test_config_flow.py | 7 ++- .../homeassistant_hardware/test_helpers.py | 24 ++++----- .../homeassistant_hardware/test_switch.py | 6 ++- .../homeassistant_hardware/test_update.py | 3 +- .../homeassistant_hardware/test_util.py | 11 ++-- .../test_config_flow.py | 13 +++-- .../test_hardware.py | 3 +- .../homeassistant_sky_connect/test_init.py | 4 +- tests/components/otbr/test_config_flow.py | 5 +- .../otbr/test_homeassistant_hardware.py | 5 +- tests/components/usb/test_init.py | 51 ++++++++++--------- .../zha/test_homeassistant_hardware.py | 5 +- tests/components/zha/test_init.py | 9 ++-- 16 files changed, 97 insertions(+), 69 deletions(-) diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index d50d02172f1ce..43ac3e8a41eb6 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -6,6 +6,9 @@ import pytest from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -18,7 +21,7 @@ FirmwareInfo, ResetTarget, ) -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -442,8 +445,8 @@ async def test_duplicate_discovery_updates_usb_path(hass: HomeAssistant) -> None async def test_firmware_callback_auto_creates_entry(hass: HomeAssistant) -> None: """Test that firmware notification triggers import flow that auto-creates config entry.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 @@ -499,8 +502,8 @@ async def test_firmware_callback_auto_creates_entry(hass: HomeAssistant) -> None async def test_firmware_callback_updates_existing_entry(hass: HomeAssistant) -> None: """Test that firmware notification updates existing config entry device path.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) # Create existing config entry with old device path config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_connect_zbt2/test_hardware.py b/tests/components/homeassistant_connect_zbt2/test_hardware.py index 030a2610d647f..a0bd22e1c932d 100644 --- a/tests/components/homeassistant_connect_zbt2/test_hardware.py +++ b/tests/components/homeassistant_connect_zbt2/test_hardware.py @@ -1,6 +1,7 @@ """Test the Home Assistant Connect ZBT-2 hardware platform.""" from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.usb import DOMAIN as USB_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -24,7 +25,7 @@ async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info ) -> None: """Test we can get the board info.""" - assert await async_setup_component(hass, "usb", {}) + assert await async_setup_component(hass, USB_DOMAIN, {}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) # Setup the config entry diff --git a/tests/components/homeassistant_connect_zbt2/test_init.py b/tests/components/homeassistant_connect_zbt2/test_init.py index 42f5f8ac5a5e4..09a89ab13fab7 100644 --- a/tests/components/homeassistant_connect_zbt2/test_init.py +++ b/tests/components/homeassistant_connect_zbt2/test_init.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant @@ -65,7 +65,7 @@ async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("force_usb_polling_watcher") async def test_usb_device_reactivity(hass: HomeAssistant) -> None: """Test setting up USB monitoring.""" - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, USB_DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index e3dd3d70eed27..be6459dc0a802 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -16,7 +16,10 @@ import pytest from yarl import URL -from homeassistant.components.homeassistant_hardware.const import Z2M_EMBER_DOCS_URL +from homeassistant.components.homeassistant_hardware.const import ( + DOMAIN, + Z2M_EMBER_DOCS_URL, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -195,7 +198,7 @@ async def mock_test_firmware_platform( mock_integration(hass, mock_module) mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) with mock_config_flow(TEST_DOMAIN, FakeFirmwareConfigFlow): yield diff --git a/tests/components/homeassistant_hardware/test_helpers.py b/tests/components/homeassistant_hardware/test_helpers.py index 540d2ca7afddd..5ebe955f4e930 100644 --- a/tests/components/homeassistant_hardware/test_helpers.py +++ b/tests/components/homeassistant_hardware/test_helpers.py @@ -6,7 +6,7 @@ import pytest -from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT +from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT, DOMAIN from homeassistant.components.homeassistant_hardware.helpers import ( async_firmware_update_context, async_is_firmware_update_in_progress, @@ -20,7 +20,7 @@ ApplicationType, FirmwareInfo, ) -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -47,7 +47,7 @@ async def test_dispatcher_registration(hass: HomeAssistant) -> None: """Test HardwareInfoDispatcher registration.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) # Mock provider 1 with a synchronous method to pull firmware info provider1_config_entry = MockConfigEntry( @@ -123,7 +123,7 @@ async def test_dispatcher_iter_error_handling( ) -> None: """Test HardwareInfoDispatcher ignoring errors from firmware info providers.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) provider1_config_entry = MockConfigEntry( domain="zha", @@ -163,7 +163,7 @@ async def test_dispatcher_callback_error_handling( ) -> None: """Test HardwareInfoDispatcher ignoring errors from firmware info callbacks.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) provider1_config_entry = MockConfigEntry( domain="zha", unique_id="some_unique_id1", @@ -193,7 +193,7 @@ async def test_dispatcher_callback_error_handling( async def test_firmware_update_tracking(hass: HomeAssistant) -> None: """Test firmware update tracking API.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) device_path = "/dev/ttyUSB0" @@ -225,7 +225,7 @@ async def test_firmware_update_tracking(hass: HomeAssistant) -> None: async def test_firmware_update_context_manager(hass: HomeAssistant) -> None: """Test firmware update progress context manager.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) device_path = "/dev/ttyUSB0" @@ -263,7 +263,7 @@ async def test_firmware_update_context_manager(hass: HomeAssistant) -> None: async def test_dispatcher_callback_self_unregister(hass: HomeAssistant) -> None: """Test callbacks can unregister themselves during notification.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) called_callbacks = [] unregister_funcs = {} @@ -304,8 +304,8 @@ async def test_firmware_callback_no_usb_device( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test firmware notification when usb_device_from_path returns None.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) with ( patch( @@ -335,8 +335,8 @@ async def test_firmware_callback_no_hardware_domain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test firmware notification when no hardware domain is found for device.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) # Create a USB device that doesn't match any hardware integration usb_device = USBDevice( diff --git a/tests/components/homeassistant_hardware/test_switch.py b/tests/components/homeassistant_hardware/test_switch.py index a856aa33f0f2a..c145fb411cd53 100644 --- a/tests/components/homeassistant_hardware/test_switch.py +++ b/tests/components/homeassistant_hardware/test_switch.py @@ -7,6 +7,8 @@ import pytest +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -122,8 +124,8 @@ async def mock_switch_config_entry( mock_firmware_client, ) -> AsyncGenerator[ConfigEntry]: """Set up a mock config entry for testing.""" - await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await async_setup_component(hass, DOMAIN, {}) mock_integration( hass, diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index cd2298dfb437f..f04cd33cb89b7 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -15,6 +15,7 @@ DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -232,7 +233,7 @@ async def mock_update_config_entry( ) -> AsyncGenerator[ConfigEntry]: """Set up a mock Home Assistant Hardware firmware update entity.""" await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) mock_integration( hass, diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index bc650b4cc7a52..95644478ed8f1 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -15,6 +15,7 @@ AddonManager, AddonState, ) +from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_provider, ) @@ -72,7 +73,7 @@ async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo( device="/dev/missing", @@ -86,7 +87,7 @@ async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: """Test guessing the firmware via OTBR and ZHA.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) # One instance of ZHA and two OTBRs zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") @@ -553,7 +554,7 @@ async def test_probe_silabs_firmware_type( async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: """Test async_flash_silabs_firmware.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() @@ -687,7 +688,7 @@ async def test_async_flash_silabs_firmware_flash_failure( hass: HomeAssistant, side_effect: Exception, expected_error_msg: str ) -> None: """Test async_flash_silabs_firmware flash failure.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() @@ -748,7 +749,7 @@ async def test_async_flash_silabs_firmware_flash_failure( async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None: """Test async_flash_silabs_firmware probe failure.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index b0d58473a6786..3ec09b358e798 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -6,6 +6,9 @@ import pytest from homeassistant.components.hassio import AddonInfo, AddonState +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -23,7 +26,7 @@ FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -446,8 +449,8 @@ async def test_firmware_callback_auto_creates_entry( hass: HomeAssistant, ) -> None: """Test that firmware notification triggers import flow that auto-creates config entry.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "usb"}, data=usb_data @@ -556,8 +559,8 @@ async def test_firmware_callback_updates_existing_entry( usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test that firmware notification updates existing config entry device path.""" - await async_setup_component(hass, "homeassistant_hardware", {}) - await async_setup_component(hass, "usb", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) + await async_setup_component(hass, USB_DOMAIN, {}) # Create existing config entry with old device path config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index 2a594ebcdad31..2df7076ab745d 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -1,6 +1,7 @@ """Test the Home Assistant SkyConnect hardware platform.""" from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.usb import DOMAIN as USB_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,7 +38,7 @@ async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info ) -> None: """Test we can get the board info.""" - assert await async_setup_component(hass, "usb", {}) + assert await async_setup_component(hass, USB_DOMAIN, {}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) # Setup the config entry diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index f027a6d2fb87f..37039a968fb38 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -18,7 +18,7 @@ SERIAL_NUMBER, VID, ) -from homeassistant.components.usb import USBDevice +from homeassistant.components.usb import DOMAIN as USB_DOMAIN, USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant @@ -126,7 +126,7 @@ async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("force_usb_polling_watcher") async def test_usb_device_reactivity(hass: HomeAssistant) -> None: """Test setting up USB monitoring.""" - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, USB_DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 6df5681d9e1cd..a45bff9b21212 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -10,6 +10,9 @@ import python_otbr_api from homeassistant.components import otbr +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_callback, ) @@ -1010,7 +1013,7 @@ async def test_hassio_discovery_reload( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) aioclient_mock.get( "http://core-openthread-border-router:8081/node/dataset/active", text="" diff --git a/tests/components/otbr/test_homeassistant_hardware.py b/tests/components/otbr/test_homeassistant_hardware.py index 7f831656d06e0..606c0a008e43e 100644 --- a/tests/components/otbr/test_homeassistant_hardware.py +++ b/tests/components/otbr/test_homeassistant_hardware.py @@ -4,6 +4,9 @@ import pytest +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_callback, ) @@ -174,7 +177,7 @@ async def test_hardware_firmware_info_provider_notification( ) otbr.add_to_hass(hass) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) callback = Mock() async_register_firmware_info_callback(hass, DEVICE_PATH, callback) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 3f4dc07c6aa5c..0880577b571a9 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -10,6 +10,7 @@ from homeassistant import config_entries from homeassistant.components import usb +from homeassistant.components.usb import DOMAIN from homeassistant.components.usb.models import USBDevice from homeassistant.components.usb.utils import scan_serial_ports, usb_device_from_path from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP @@ -84,7 +85,7 @@ def async_register_callback(callback): ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -159,7 +160,7 @@ def scan_serial_ports() -> list: patch_scanned_serial_ports(side_effect=scan_serial_ports) as mock_ports, patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -197,7 +198,7 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() with patch_scanned_serial_ports(return_value=[]): @@ -233,7 +234,7 @@ async def test_discovered_by_websocket_scan( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -272,7 +273,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -312,7 +313,7 @@ async def test_most_targeted_matcher_wins( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -351,7 +352,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -394,7 +395,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -433,7 +434,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -476,7 +477,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -520,7 +521,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -558,7 +559,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -594,7 +595,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -631,7 +632,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -667,7 +668,7 @@ async def test_discovered_by_websocket_no_vid_pid( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -703,7 +704,7 @@ async def test_non_matching_discovered_by_scanner_after_started( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -738,7 +739,7 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -785,7 +786,7 @@ def async_register_callback(callback): ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -897,7 +898,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -933,7 +934,7 @@ async def test_initial_scan_callback( patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) cancel_1 = usb.async_register_initial_scan_callback(hass, mock_callback_1) assert len(mock_callback_1.mock_calls) == 0 @@ -968,7 +969,7 @@ async def test_cancel_initial_scan_callback( patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) cancel = usb.async_register_initial_scan_callback(hass, mock_callback) assert len(mock_callback.mock_calls) == 0 @@ -1071,7 +1072,7 @@ async def test_cp2102n_ordering_on_macos( patch_scanned_serial_ports(return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -1122,7 +1123,7 @@ async def test_register_port_event_callback( with ( patch_scanned_serial_ports(return_value=[]), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) cancel2 = usb.async_register_port_event_callback(hass, mock_callback2) @@ -1217,7 +1218,7 @@ async def test_register_port_event_callback_failure( with ( patch_scanned_serial_ports(return_value=[]), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) usb.async_register_port_event_callback(hass, mock_callback1) usb.async_register_port_event_callback(hass, mock_callback2) @@ -1490,7 +1491,7 @@ async def async_step_confirm(self, user_input=None): mock_config_flow("test1", TestFlow), mock_config_flow("test2", TestFlow), ): - assert await async_setup_component(hass, "usb", {"usb": {}}) + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() diff --git a/tests/components/zha/test_homeassistant_hardware.py b/tests/components/zha/test_homeassistant_hardware.py index 067126cec79a1..0e038fff113c8 100644 --- a/tests/components/zha/test_homeassistant_hardware.py +++ b/tests/components/zha/test_homeassistant_hardware.py @@ -5,6 +5,9 @@ import pytest from zigpy.application import ControllerApplication +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_callback, ) @@ -102,7 +105,7 @@ async def test_hardware_firmware_info_provider_notification( """Test that the ZHA gateway provides hardware and firmware information.""" config_entry.add_to_hass(hass) - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) callback = MagicMock() async_register_firmware_info_callback(hass, "/dev/ttyUSB0", callback) diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 081cd333d8b4c..50a5db9710032 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -13,6 +13,9 @@ from zigpy.device import Device from zigpy.exceptions import TransientConnectionError +from homeassistant.components.homeassistant_hardware import ( + DOMAIN as HOMEASSISTANT_HARDWARE_DOMAIN, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_is_firmware_update_in_progress, async_register_firmware_update_in_progress, @@ -330,7 +333,7 @@ async def test_setup_no_firmware_update_in_progress( mock_zigpy_connect: ControllerApplication, ) -> None: """Test that ZHA setup proceeds normally when no firmware update is in progress.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) config_entry.add_to_hass(hass) device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] @@ -345,7 +348,7 @@ async def test_setup_firmware_update_in_progress( config_entry: MockConfigEntry, ) -> None: """Test that ZHA setup is blocked when firmware update is in progress.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) config_entry.add_to_hass(hass) device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] @@ -362,7 +365,7 @@ async def test_setup_firmware_update_in_progress_prevents_silabs_warning( mock_zigpy_connect: ControllerApplication, ) -> None: """Test firmware update in progress prevents silabs firmware warning on setup failure.""" - await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, HOMEASSISTANT_HARDWARE_DOMAIN, {}) config_entry.add_to_hass(hass) device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] From 0292a8cd7e242b78484ff6fbb7f5f276d1673e0a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 17 Feb 2026 01:44:40 +1000 Subject: [PATCH 04/39] Add quality scale to Advantage Air integration (#160476) Co-authored-by: Claude Opus 4.6 Co-authored-by: Joost Lekkerkerker --- .../advantage_air/quality_scale.yaml | 108 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/advantage_air/quality_scale.yaml diff --git a/homeassistant/components/advantage_air/quality_scale.yaml b/homeassistant/components/advantage_air/quality_scale.yaml new file mode 100644 index 0000000000000..9c87ce4213ed5 --- /dev/null +++ b/homeassistant/components/advantage_air/quality_scale.yaml @@ -0,0 +1,108 @@ +rules: + # Bronze + action-setup: + status: todo + comment: https://developers.home-assistant.io/blog/2025/09/25/entity-services-api-changes/ + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: | + Move coordinator from __init__.py to coordinator.py. + Consider using entity descriptions for binary_sensor and switch. + Consider simplifying climate supported features flow. + config-flow-test-coverage: + status: todo + comment: | + Add mock_setup_entry common fixture. + Test unique_id of the entry in happy flow. + Split duplicate entry test from happy flow, use mock_config_entry. + Error flow should end in CREATE_ENTRY to test recovery. + Add data_description for ip_address (and port) to strings.json - tests fail with: + "Translation not found for advantage_air: config.step.user.data_description.ip_address" + config-flow: + status: todo + comment: Data descriptions missing + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: + status: done + comment: Consider extending coordinator to access API via coordinator and remove extra dataclass. + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to be set. + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: MyZone temp entity should be unavailable when MyZone is disabled rather than returning None. + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Integration connects to local device without authentication. + test-coverage: + status: todo + comment: | + Patch the library instead of mocking at integration level. + Split binary sensor tests into multiple tests (enable entities etc). + Split tests into Creation (right entities with right values), Actions (right library calls), and Other behaviors. + + # Gold + devices: + status: todo + comment: Consider making every zone its own device for better naming and room assignment. Breaking change to split cover entities to separate devices. + diagnostics: done + discovery-update-info: + status: exempt + comment: Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices, not discoverable. + discovery: + status: exempt + comment: Check mDNS, DHCP, SSDP confirmed not feasible. Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: AC zones are static per unit and configured on the device itself. + entity-category: done + entity-device-class: + status: todo + comment: Consider using UPDATE device class for app update binary sensor instead of custom. + entity-disabled-by-default: done + entity-translations: todo + exception-translations: + status: todo + comment: UpdateFailed in the coordinator + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not raise repair issues. + stale-devices: + status: exempt + comment: Zones are part of the AC unit, not separate removable devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f3d7acaf65360..5624038936860 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -125,7 +125,6 @@ class Rule: "adax", "adguard", "ads", - "advantage_air", "aemet", "aftership", "agent_dvr", From be228dbe47871a8bc85827833f47da4a5d0f7658 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 16 Feb 2026 16:45:47 +0100 Subject: [PATCH 05/39] Fix title for onedrive for business (#163134) --- .../onedrive_for_business/config_flow.py | 17 ++++++------- .../onedrive_for_business/conftest.py | 24 ------------------- .../onedrive_for_business/test_config_flow.py | 12 +++++----- .../onedrive_for_business/test_init.py | 3 +-- 4 files changed, 16 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/onedrive_for_business/config_flow.py b/homeassistant/components/onedrive_for_business/config_flow.py index ae1d9f6b681d4..c9b3c0473175a 100644 --- a/homeassistant/components/onedrive_for_business/config_flow.py +++ b/homeassistant/components/onedrive_for_business/config_flow.py @@ -8,7 +8,7 @@ from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException -from onedrive_personal_sdk.models.items import AppRoot +from onedrive_personal_sdk.models.items import Drive import voluptuous as vol from homeassistant.config_entries import ( @@ -38,7 +38,7 @@ class OneDriveForBusinessConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): DOMAIN = DOMAIN client: OneDriveClient - approot: AppRoot + drive: Drive @property def logger(self) -> logging.Logger: @@ -102,8 +102,7 @@ async def get_access_token() -> str: ) try: - self.approot = await self.client.get_approot() - drive = await self.client.get_drive() + self.drive = await self.client.get_drive() except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") @@ -111,7 +110,7 @@ async def get_access_token() -> str: self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(drive.id) + await self.async_set_unique_id(self.drive.id) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_drive") @@ -147,9 +146,11 @@ async def async_step_select_folder( errors["base"] = "folder_creation_error" if not errors: title = ( - f"{self.approot.created_by.user.display_name}'s OneDrive" - if self.approot.created_by.user - and self.approot.created_by.user.display_name + f"{self.drive.owner.user.display_name}'s OneDrive ({self.drive.owner.user.email})" + if self.drive.owner + and self.drive.owner.user + and self.drive.owner.user.display_name + and self.drive.owner.user.email else "OneDrive" ) return self.async_create_entry( diff --git a/tests/components/onedrive_for_business/conftest.py b/tests/components/onedrive_for_business/conftest.py index 9ad609c8cc41a..0f30419688a2b 100644 --- a/tests/components/onedrive_for_business/conftest.py +++ b/tests/components/onedrive_for_business/conftest.py @@ -7,7 +7,6 @@ from onedrive_personal_sdk.const import DriveState, DriveType from onedrive_personal_sdk.models.items import ( - AppRoot, Drive, DriveQuota, File, @@ -99,27 +98,6 @@ def mock_onedrive_client_init() -> Generator[MagicMock]: yield onedrive_client -@pytest.fixture -def mock_approot() -> AppRoot: - """Return a mocked approot.""" - return AppRoot( - id="id", - child_count=0, - size=0, - name="name", - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - created_by=IdentitySet( - user=User( - display_name="John Doe", - id="id", - email="john@doe.com", - ) - ), - ) - - @pytest.fixture def mock_drive() -> Drive: """Return a mocked drive.""" @@ -199,7 +177,6 @@ def mock_metadata_file() -> File: @pytest.fixture(autouse=True) def mock_onedrive_client( mock_onedrive_client_init: MagicMock, - mock_approot: AppRoot, mock_drive: Drive, mock_folder: Folder, mock_backup_file: File, @@ -207,7 +184,6 @@ def mock_onedrive_client( ) -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" client = mock_onedrive_client_init.return_value - client.get_approot.return_value = mock_approot client.create_folder.return_value = mock_folder client.list_drive_items.return_value = [mock_backup_file, mock_metadata_file] client.get_drive_item.return_value = mock_folder diff --git a/tests/components/onedrive_for_business/test_config_flow.py b/tests/components/onedrive_for_business/test_config_flow.py index ce42892a67931..1c470e1f4a772 100644 --- a/tests/components/onedrive_for_business/test_config_flow.py +++ b/tests/components/onedrive_for_business/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock from onedrive_personal_sdk.exceptions import OneDriveException -from onedrive_personal_sdk.models.items import Drive +from onedrive_personal_sdk.models.items import Drive, IdentitySet import pytest from homeassistant import config_entries @@ -104,7 +104,7 @@ async def test_full_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 - assert result["title"] == "John Doe's OneDrive" + assert result["title"] == "John Doe's OneDrive (john@doe.com)" assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" @@ -119,11 +119,11 @@ async def test_full_flow_with_owner_not_found( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_onedrive_client: MagicMock, - mock_approot: MagicMock, + mock_drive: Drive, ) -> None: """Ensure we get a default title if the drive's owner can't be read.""" - mock_approot.created_by.user = None + mock_drive.owner = IdentitySet(user=None) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -194,7 +194,7 @@ async def test_error_during_folder_creation( result["flow_id"], {CONF_FOLDER_PATH: "myFolder"} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "John Doe's OneDrive" + assert result["title"] == "John Doe's OneDrive (john@doe.com)" assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" @@ -220,7 +220,7 @@ async def test_flow_errors( ) -> None: """Test errors during flow.""" - mock_onedrive_client.get_approot.side_effect = exception + mock_onedrive_client.get_drive.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/onedrive_for_business/test_init.py b/tests/components/onedrive_for_business/test_init.py index 5f80ef4f1320e..613f023e4c929 100644 --- a/tests/components/onedrive_for_business/test_init.py +++ b/tests/components/onedrive_for_business/test_init.py @@ -8,7 +8,7 @@ NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import AppRoot, Folder +from onedrive_personal_sdk.models.items import Folder import pytest from homeassistant.components.onedrive_for_business.const import ( @@ -72,7 +72,6 @@ async def test_get_integration_folder_creation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, - mock_approot: AppRoot, mock_folder: Folder, ) -> None: """Test faulty integration folder creation.""" From 97df38f1daa90c13e3b280a2f8fa9c7a5745ad3f Mon Sep 17 00:00:00 2001 From: On Freund Date: Mon, 16 Feb 2026 10:47:24 -0500 Subject: [PATCH 06/39] Add MTA New York City Transit integration (#156846) Co-authored-by: Claude Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/mta/__init__.py | 28 ++ homeassistant/components/mta/config_flow.py | 151 ++++++ homeassistant/components/mta/const.py | 11 + homeassistant/components/mta/coordinator.py | 110 +++++ homeassistant/components/mta/manifest.json | 12 + .../components/mta/quality_scale.yaml | 88 ++++ homeassistant/components/mta/sensor.py | 147 ++++++ homeassistant/components/mta/strings.json | 65 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/mta/__init__.py | 1 + tests/components/mta/conftest.py | 92 ++++ .../components/mta/snapshots/test_sensor.ambr | 445 ++++++++++++++++++ tests/components/mta/test_config_flow.py | 161 +++++++ tests/components/mta/test_init.py | 29 ++ tests/components/mta/test_sensor.py | 30 ++ 19 files changed, 1385 insertions(+) create mode 100644 homeassistant/components/mta/__init__.py create mode 100644 homeassistant/components/mta/config_flow.py create mode 100644 homeassistant/components/mta/const.py create mode 100644 homeassistant/components/mta/coordinator.py create mode 100644 homeassistant/components/mta/manifest.json create mode 100644 homeassistant/components/mta/quality_scale.yaml create mode 100644 homeassistant/components/mta/sensor.py create mode 100644 homeassistant/components/mta/strings.json create mode 100644 tests/components/mta/__init__.py create mode 100644 tests/components/mta/conftest.py create mode 100644 tests/components/mta/snapshots/test_sensor.ambr create mode 100644 tests/components/mta/test_config_flow.py create mode 100644 tests/components/mta/test_init.py create mode 100644 tests/components/mta/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index f81e1b94719cd..c53d65b595732 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind +/homeassistant/components/mta/ @OnFreund +/tests/components/mta/ @OnFreund /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys /homeassistant/components/music_assistant/ @music-assistant @arturpragacz diff --git a/homeassistant/components/mta/__init__.py b/homeassistant/components/mta/__init__.py new file mode 100644 index 0000000000000..bfa04ab9b8805 --- /dev/null +++ b/homeassistant/components/mta/__init__.py @@ -0,0 +1,28 @@ +"""The MTA New York City Transit integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN as DOMAIN +from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Set up MTA from a config entry.""" + coordinator = MTADataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mta/config_flow.py b/homeassistant/components/mta/config_flow.py new file mode 100644 index 0000000000000..b1f8d51cf4387 --- /dev/null +++ b/homeassistant/components/mta/config_flow.py @@ -0,0 +1,151 @@ +"""Config flow for MTA New York City Transit integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MTAConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for MTA.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + self.stops: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self.data[CONF_LINE] = user_input[CONF_LINE] + return await self.async_step_stop() + + lines = sorted(LINE_TO_FEED.keys()) + line_options = [SelectOptionDict(value=line, label=line) for line in lines] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LINE): SelectSelector( + SelectSelectorConfig( + options=line_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_stop( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the stop step.""" + errors: dict[str, str] = {} + + if user_input is not None: + stop_id = user_input[CONF_STOP_ID] + self.data[CONF_STOP_ID] = stop_id + stop_name = self.stops.get(stop_id, stop_id) + self.data[CONF_STOP_NAME] = stop_name + + unique_id = f"{self.data[CONF_LINE]}_{stop_id}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops) + try: + await self._async_test_connection() + except MTAFeedError: + errors["base"] = "cannot_connect" + else: + title = f"{self.data[CONF_LINE]} Line - {stop_name}" + return self.async_create_entry( + title=title, + data=self.data, + ) + + try: + self.stops = await self._async_get_stops(self.data[CONF_LINE]) + except MTAFeedError: + _LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="cannot_connect") + + if not self.stops: + _LOGGER.error("No stops found for line %s", self.data[CONF_LINE]) + return self.async_abort(reason="no_stops") + + stop_options = [ + SelectOptionDict(value=stop_id, label=stop_name) + for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1]) + ] + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP_ID): SelectSelector( + SelectSelectorConfig( + options=stop_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + description_placeholders={"line": self.data[CONF_LINE]}, + ) + + async def _async_get_stops(self, line: str) -> dict[str, str]: + """Get stops for a line from the library.""" + feed_id = SubwayFeed.get_feed_id_for_route(line) + session = aiohttp_client.async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + stops_list = await subway_feed.get_stops(route_id=line) + + stops = {} + for stop in stops_list: + stop_id = stop["stop_id"] + stop_name = stop["stop_name"] + # Add direction label (stop_id always ends in N or S) + direction = stop_id[-1] + stops[stop_id] = f"{stop_name} ({direction} direction)" + + return stops + + async def _async_test_connection(self) -> None: + """Test connection to MTA feed.""" + feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE]) + session = aiohttp_client.async_get_clientsession(self.hass) + + subway_feed = SubwayFeed(feed_id=feed_id, session=session) + await subway_feed.get_arrivals( + route_id=self.data[CONF_LINE], + stop_id=self.data[CONF_STOP_ID], + max_arrivals=1, + ) diff --git a/homeassistant/components/mta/const.py b/homeassistant/components/mta/const.py new file mode 100644 index 0000000000000..4088401e8bc60 --- /dev/null +++ b/homeassistant/components/mta/const.py @@ -0,0 +1,11 @@ +"""Constants for the MTA New York City Transit integration.""" + +from datetime import timedelta + +DOMAIN = "mta" + +CONF_LINE = "line" +CONF_STOP_ID = "stop_id" +CONF_STOP_NAME = "stop_name" + +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/mta/coordinator.py b/homeassistant/components/mta/coordinator.py new file mode 100644 index 0000000000000..fd1edee882e46 --- /dev/null +++ b/homeassistant/components/mta/coordinator.py @@ -0,0 +1,110 @@ +"""Data update coordinator for MTA New York City Transit.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging + +from pymta import MTAFeedError, SubwayFeed + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MTAArrival: + """Represents a single train arrival.""" + + arrival_time: datetime + minutes_until: int + route_id: str + destination: str + + +@dataclass +class MTAData: + """Data for MTA arrivals.""" + + arrivals: list[MTAArrival] + + +type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator] + + +class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]): + """Class to manage fetching MTA data.""" + + config_entry: MTAConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None: + """Initialize.""" + self.line = config_entry.data[CONF_LINE] + self.stop_id = config_entry.data[CONF_STOP_ID] + + self.feed_id = SubwayFeed.get_feed_id_for_route(self.line) + session = async_get_clientsession(hass) + self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session) + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> MTAData: + """Fetch data from MTA.""" + _LOGGER.debug( + "Fetching data for line=%s, stop=%s, feed=%s", + self.line, + self.stop_id, + self.feed_id, + ) + + try: + library_arrivals = await self.subway_feed.get_arrivals( + route_id=self.line, + stop_id=self.stop_id, + max_arrivals=3, + ) + except MTAFeedError as err: + raise UpdateFailed(f"Error fetching MTA data: {err}") from err + + now = dt_util.now() + arrivals: list[MTAArrival] = [] + + for library_arrival in library_arrivals: + # Convert UTC arrival time to local time + arrival_time = dt_util.as_local(library_arrival.arrival_time) + + minutes_until = int((arrival_time - now).total_seconds() / 60) + + _LOGGER.debug( + "Stop %s: arrival_time=%s, minutes_until=%d, route=%s", + library_arrival.stop_id, + arrival_time, + minutes_until, + library_arrival.route_id, + ) + + arrivals.append( + MTAArrival( + arrival_time=arrival_time, + minutes_until=minutes_until, + route_id=library_arrival.route_id, + destination=library_arrival.destination, + ) + ) + + _LOGGER.debug("Returning %d arrivals", len(arrivals)) + + return MTAData(arrivals=arrivals) diff --git a/homeassistant/components/mta/manifest.json b/homeassistant/components/mta/manifest.json new file mode 100644 index 0000000000000..b1d82533df6f5 --- /dev/null +++ b/homeassistant/components/mta/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mta", + "name": "MTA New York City Transit", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mta", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["pymta"], + "quality_scale": "silver", + "requirements": ["py-nymta==0.3.4"] +} diff --git a/homeassistant/components/mta/quality_scale.yaml b/homeassistant/components/mta/quality_scale.yaml new file mode 100644 index 0000000000000..2cd98e9f45a1c --- /dev/null +++ b/homeassistant/components/mta/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not explicitly subscribe to events in async_added_to_hass. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: No physical devices. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration tracks a single configured stop, not dynamically discovered devices. + entity-category: + status: exempt + comment: All entities are primary entities without specific categories. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: N/A + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs needed currently. + stale-devices: + status: exempt + comment: Integration tracks a single configured stop per entry, devices cannot become stale. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/mta/sensor.py b/homeassistant/components/mta/sensor.py new file mode 100644 index 0000000000000..5f352caa7d290 --- /dev/null +++ b/homeassistant/components/mta/sensor.py @@ -0,0 +1,147 @@ +"""Sensor platform for MTA New York City Transit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN +from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class MTASensorEntityDescription(SensorEntityDescription): + """Describes an MTA sensor entity.""" + + arrival_index: int + value_fn: Callable[[MTAArrival], datetime | str] + + +SENSOR_DESCRIPTIONS: tuple[MTASensorEntityDescription, ...] = ( + MTASensorEntityDescription( + key="next_arrival", + translation_key="next_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=0, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="next_arrival_route", + translation_key="next_arrival_route", + arrival_index=0, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="next_arrival_destination", + translation_key="next_arrival_destination", + arrival_index=0, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="second_arrival", + translation_key="second_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=1, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="second_arrival_route", + translation_key="second_arrival_route", + arrival_index=1, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="second_arrival_destination", + translation_key="second_arrival_destination", + arrival_index=1, + value_fn=lambda arrival: arrival.destination, + ), + MTASensorEntityDescription( + key="third_arrival", + translation_key="third_arrival", + device_class=SensorDeviceClass.TIMESTAMP, + arrival_index=2, + value_fn=lambda arrival: arrival.arrival_time, + ), + MTASensorEntityDescription( + key="third_arrival_route", + translation_key="third_arrival_route", + arrival_index=2, + value_fn=lambda arrival: arrival.route_id, + ), + MTASensorEntityDescription( + key="third_arrival_destination", + translation_key="third_arrival_destination", + arrival_index=2, + value_fn=lambda arrival: arrival.destination, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MTAConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MTA sensor based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + MTASensor(coordinator, entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity): + """Sensor for MTA train arrivals.""" + + _attr_has_entity_name = True + entity_description: MTASensorEntityDescription + + def __init__( + self, + coordinator: MTADataUpdateCoordinator, + entry: MTAConfigEntry, + description: MTASensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + line = entry.data[CONF_LINE] + stop_id = entry.data[CONF_STOP_ID] + stop_name = entry.data.get(CONF_STOP_NAME, stop_id) + + self._attr_unique_id = f"{entry.unique_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"{line} Line - {stop_name} ({stop_id})", + manufacturer="MTA", + model="Subway", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> datetime | str | None: + """Return the state of the sensor.""" + arrivals = self.coordinator.data.arrivals + if len(arrivals) <= self.entity_description.arrival_index: + return None + + return self.entity_description.value_fn( + arrivals[self.entity_description.arrival_index] + ) diff --git a/homeassistant/components/mta/strings.json b/homeassistant/components/mta/strings.json new file mode 100644 index 0000000000000..4f3b3be7d9329 --- /dev/null +++ b/homeassistant/components/mta/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_stops": "No stops found for this line. The line may not be currently running." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "stop": { + "data": { + "stop_id": "Stop and direction" + }, + "data_description": { + "stop_id": "Select the stop and direction you want to track" + }, + "description": "Choose a stop on the {line} line. The direction is included with each stop.", + "title": "Select stop and direction" + }, + "user": { + "data": { + "line": "Line" + }, + "data_description": { + "line": "The subway line to track" + }, + "description": "Choose the subway line you want to track.", + "title": "Select subway line" + } + } + }, + "entity": { + "sensor": { + "next_arrival": { + "name": "Next arrival" + }, + "next_arrival_destination": { + "name": "Next arrival destination" + }, + "next_arrival_route": { + "name": "Next arrival route" + }, + "second_arrival": { + "name": "Second arrival" + }, + "second_arrival_destination": { + "name": "Second arrival destination" + }, + "second_arrival_route": { + "name": "Second arrival route" + }, + "third_arrival": { + "name": "Third arrival" + }, + "third_arrival_destination": { + "name": "Third arrival destination" + }, + "third_arrival_route": { + "name": "Third arrival route" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 463fd28ec96c9..04902a57f0252 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -444,6 +444,7 @@ "motionmount", "mpd", "mqtt", + "mta", "mullvad", "music_assistant", "mutesync", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a18fbe6822c9e..e111bae54b2e3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4348,6 +4348,12 @@ } } }, + "mta": { + "name": "MTA New York City Transit", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mullvad": { "name": "Mullvad VPN", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 068635d57f4f1..bc9fde4c04797 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1851,6 +1851,9 @@ py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.mta +py-nymta==0.3.4 + # homeassistant.components.schluter py-schluter==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f79d9c975eeb0..1dff39369c2b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,6 +1600,9 @@ py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.mta +py-nymta==0.3.4 + # homeassistant.components.ecovacs py-sucks==0.9.11 diff --git a/tests/components/mta/__init__.py b/tests/components/mta/__init__.py new file mode 100644 index 0000000000000..70fa60764d048 --- /dev/null +++ b/tests/components/mta/__init__.py @@ -0,0 +1 @@ +"""Tests for the MTA New York City Transit integration.""" diff --git a/tests/components/mta/conftest.py b/tests/components/mta/conftest.py new file mode 100644 index 0000000000000..fdbd91b461151 --- /dev/null +++ b/tests/components/mta/conftest.py @@ -0,0 +1,92 @@ +"""Test helpers for MTA tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from pymta import Arrival +import pytest + +from homeassistant.components.mta.const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain="mta", + data={ + CONF_LINE: "1", + CONF_STOP_ID: "127N", + CONF_STOP_NAME: "Times Sq - 42 St (N direction)", + }, + unique_id="1_127N", + entry_id="01J0000000000000000000000", + title="1 Line - Times Sq - 42 St (N direction)", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.mta.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_subway_feed() -> Generator[MagicMock]: + """Create a mock SubwayFeed for both coordinator and config flow.""" + # Fixed arrival times: 5, 10, and 15 minutes after test frozen time (2023-10-21 00:00:00 UTC) + mock_arrivals = [ + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 5, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 10, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + Arrival( + arrival_time=datetime(2023, 10, 21, 0, 15, 0, tzinfo=UTC), + route_id="1", + stop_id="127N", + destination="Van Cortlandt Park - 242 St", + ), + ] + + mock_stops = [ + { + "stop_id": "127N", + "stop_name": "Times Sq - 42 St", + "stop_sequence": 1, + }, + { + "stop_id": "127S", + "stop_name": "Times Sq - 42 St", + "stop_sequence": 2, + }, + ] + + with ( + patch( + "homeassistant.components.mta.coordinator.SubwayFeed", autospec=True + ) as mock_feed, + patch( + "homeassistant.components.mta.config_flow.SubwayFeed", + new=mock_feed, + ), + ): + mock_instance = mock_feed.return_value + mock_feed.get_feed_id_for_route.return_value = "1" + mock_instance.get_arrivals.return_value = mock_arrivals + mock_instance.get_stops.return_value = mock_stops + + yield mock_feed diff --git a/tests/components/mta/snapshots/test_sensor.ambr b/tests/components/mta/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..8d75b80ca2d87 --- /dev/null +++ b/tests/components/mta/snapshots/test_sensor.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival', + 'unique_id': '1_127N-next_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:05:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival_destination', + 'unique_id': '1_127N-next_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_arrival_route', + 'unique_id': '1_127N-next_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Next arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_next_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Second arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival', + 'unique_id': '1_127N-second_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:10:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Second arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival_destination', + 'unique_id': '1_127N-second_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Second arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Second arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'second_arrival_route', + 'unique_id': '1_127N-second_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Second arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_second_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Third arrival', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival', + 'unique_id': '1_127N-third_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:15:00+00:00', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Third arrival destination', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival_destination', + 'unique_id': '1_127N-third_arrival_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival destination', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Van Cortlandt Park - 242 St', + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Third arrival route', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Third arrival route', + 'platform': 'mta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'third_arrival_route', + 'unique_id': '1_127N-third_arrival_route', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1 Line - Times Sq - 42 St (N direction) (127N) Third arrival route', + }), + 'context': , + 'entity_id': 'sensor.1_line_times_sq_42_st_n_direction_127n_third_arrival_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/mta/test_config_flow.py b/tests/components/mta/test_config_flow.py new file mode 100644 index 0000000000000..048ef444cd3a8 --- /dev/null +++ b/tests/components/mta/test_config_flow.py @@ -0,0 +1,161 @@ +"""Test the MTA config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pymta import MTAFeedError + +from homeassistant.components.mta.const import ( + CONF_LINE, + CONF_STOP_ID, + CONF_STOP_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the complete config flow.""" + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # Select line + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LINE: "1"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "stop" + assert result["errors"] == {} + + # Select stop and complete + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127N"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1 Line - Times Sq - 42 St (N direction)" + assert result["data"] == { + CONF_LINE: "1", + CONF_STOP_ID: "127N", + CONF_STOP_NAME: "Times Sq - 42 St (N direction)", + } + assert result["result"].unique_id == "1_127N" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127N"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_connection_error( + hass: HomeAssistant, + mock_subway_feed: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle connection errors and can recover.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_arrivals.side_effect = MTAFeedError("Connection error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127S"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Test recovery - reset mock to succeed + mock_instance.get_arrivals.side_effect = None + mock_instance.get_arrivals.return_value = [] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STOP_ID: "127S"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_get_stops( + hass: HomeAssistant, mock_subway_feed: MagicMock +) -> None: + """Test we abort when we cannot get stops.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_stops.side_effect = MTAFeedError("Feed error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_form_no_stops_found( + hass: HomeAssistant, mock_subway_feed: MagicMock +) -> None: + """Test we abort when no stops are found.""" + mock_instance = mock_subway_feed.return_value + mock_instance.get_stops.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: "1"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_stops" diff --git a/tests/components/mta/test_init.py b/tests/components/mta/test_init.py new file mode 100644 index 0000000000000..05751187ce716 --- /dev/null +++ b/tests/components/mta/test_init.py @@ -0,0 +1,29 @@ +"""Test the MTA New York City Transit init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.mta.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_subway_feed: MagicMock, +) -> None: + """Test setting up and unloading an entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert DOMAIN in hass.config_entries.async_domains() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/mta/test_sensor.py b/tests/components/mta/test_sensor.py new file mode 100644 index 0000000000000..29d59dd67d781 --- /dev/null +++ b/tests/components/mta/test_sensor.py @@ -0,0 +1,30 @@ +"""Test the MTA sensor platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-10-21") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_subway_feed: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity.""" + await hass.config.async_set_time_zone("UTC") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From fed9ed615e35d6a2bb784103a6f3cd365dc03e7b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:47:53 +0100 Subject: [PATCH 07/39] Rename DOMAIN aliases in tests (#163176) --- tests/components/ecovacs/test_lawn_mower.py | 4 ++-- tests/components/ecovacs/test_number.py | 4 ++-- tests/components/ecovacs/test_switch.py | 6 +++--- tests/components/litterrobot/test_sensor.py | 18 ++++++++---------- tests/components/litterrobot/test_switch.py | 18 +++++++++--------- tests/components/litterrobot/test_time.py | 6 +++--- tests/components/litterrobot/test_update.py | 12 ++++++------ tests/components/nibe_heatpump/test_button.py | 4 ++-- tests/components/nibe_heatpump/test_climate.py | 18 +++++++++--------- tests/components/nibe_heatpump/test_number.py | 8 ++++---- tests/components/openhome/test_update.py | 6 +++--- tests/components/smlight/test_update.py | 12 ++++++------ 12 files changed, 57 insertions(+), 59 deletions(-) diff --git a/tests/components/ecovacs/test_lawn_mower.py b/tests/components/ecovacs/test_lawn_mower.py index bab1495e16c1f..2c5b8c530c758 100644 --- a/tests/components/ecovacs/test_lawn_mower.py +++ b/tests/components/ecovacs/test_lawn_mower.py @@ -12,7 +12,7 @@ from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.components.lawn_mower import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as LAWN_MOWER_DOMAIN, SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, @@ -108,7 +108,7 @@ async def test_mover_services( for test in tests: device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + LAWN_MOWER_DOMAIN, test.service_name, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 02628554519eb..f7a1705c95541 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -18,7 +18,7 @@ from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.components.number import ( ATTR_VALUE, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform @@ -139,7 +139,7 @@ async def test_number_entities( device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: test_case.set_value}, blocking=True, diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index ef79865d354cb..f62b5a5afdb3c 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -33,7 +33,7 @@ from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.components.switch import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -196,7 +196,7 @@ async def test_switch_entities( device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -205,7 +205,7 @@ async def test_switch_entities( device._execute_command.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index b6ce4d609544a..5fcb49e1b581d 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.litterrobot.sensor import icon_for_gauge_level from homeassistant.components.sensor import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -24,7 +24,7 @@ async def test_waste_drawer_sensor( hass: HomeAssistant, mock_account: MagicMock ) -> None: """Tests the waste drawer sensor entity was set up.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SENSOR_DOMAIN) sensor = hass.states.get(WASTE_DRAWER_ENTITY_ID) assert sensor @@ -36,9 +36,7 @@ async def test_sleep_time_sensor_with_sleep_disabled( hass: HomeAssistant, mock_account_with_sleep_disabled_robot: MagicMock ) -> None: """Tests the sleep mode start time sensor where sleep mode is disabled.""" - await setup_integration( - hass, mock_account_with_sleep_disabled_robot, PLATFORM_DOMAIN - ) + await setup_integration(hass, mock_account_with_sleep_disabled_robot, SENSOR_DOMAIN) sensor = hass.states.get(SLEEP_START_TIME_ENTITY_ID) assert sensor @@ -79,7 +77,7 @@ async def test_litter_robot_sensor( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock ) -> None: """Tests Litter-Robot sensors.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SENSOR_DOMAIN) sensor = hass.states.get(SLEEP_START_TIME_ENTITY_ID) assert sensor.state == "2022-09-19T04:00:00+00:00" @@ -109,7 +107,7 @@ async def test_feeder_robot_sensor( hass: HomeAssistant, mock_account_with_feederrobot: MagicMock ) -> None: """Tests Feeder-Robot sensors.""" - await setup_integration(hass, mock_account_with_feederrobot, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_feederrobot, SENSOR_DOMAIN) sensor = hass.states.get("sensor.test_food_level") assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE @@ -133,7 +131,7 @@ async def test_pet_weight_sensor( hass: HomeAssistant, mock_account_with_pet: MagicMock ) -> None: """Tests pet weight sensors.""" - await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_pet, SENSOR_DOMAIN) sensor = hass.states.get("sensor.kitty_weight") assert sensor.state == "9.1" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS @@ -144,7 +142,7 @@ async def test_pet_visits_today_sensor( hass: HomeAssistant, mock_account_with_pet: MagicMock ) -> None: """Tests pet visits today sensors.""" - await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_pet, SENSOR_DOMAIN) sensor = hass.states.get("sensor.kitty_visits_today") assert sensor.state == "2" @@ -153,6 +151,6 @@ async def test_litterhopper_sensor( hass: HomeAssistant, mock_account_with_litterhopper: MagicMock ) -> None: """Tests LitterHopper sensors.""" - await setup_integration(hass, mock_account_with_litterhopper, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterhopper, SENSOR_DOMAIN) sensor = hass.states.get("sensor.test_hopper_status") assert sensor.state == "enabled" diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index 3991bdbbab0df..dea0b63496ab3 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -7,7 +7,7 @@ from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.switch import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -25,7 +25,7 @@ async def test_switch( hass: HomeAssistant, mock_account: MagicMock, entity_registry: er.EntityRegistry ) -> None: """Tests the switch entity was set up.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SWITCH_DOMAIN) state = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID) assert state @@ -51,7 +51,7 @@ async def test_on_off_commands( updated_field: str, ) -> None: """Test sending commands to the switch.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SWITCH_DOMAIN) robot: Robot = mock_account.robots[0] state = hass.states.get(entity_id) @@ -61,7 +61,7 @@ async def test_on_off_commands( services = ((SERVICE_TURN_ON, STATE_ON, "1"), (SERVICE_TURN_OFF, STATE_OFF, "0")) for count, (service, new_state, new_value) in enumerate(services): - await hass.services.async_call(PLATFORM_DOMAIN, service, data, blocking=True) + await hass.services.async_call(SWITCH_DOMAIN, service, data, blocking=True) robot._update_data({updated_field: new_value}, partial=True) assert getattr(robot, robot_command).call_count == count + 1 @@ -73,7 +73,7 @@ async def test_feeder_robot_switch( hass: HomeAssistant, mock_account_with_feederrobot: MagicMock ) -> None: """Tests Feeder-Robot switches.""" - await setup_integration(hass, mock_account_with_feederrobot, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_feederrobot, SWITCH_DOMAIN) robot: FeederRobot = mock_account_with_feederrobot.robots[0] gravity_mode_switch = "switch.test_gravity_mode" @@ -85,7 +85,7 @@ async def test_feeder_robot_switch( services = ((SERVICE_TURN_ON, STATE_ON, True), (SERVICE_TURN_OFF, STATE_OFF, False)) for count, (service, new_state, new_value) in enumerate(services): - await hass.services.async_call(PLATFORM_DOMAIN, service, data, blocking=True) + await hass.services.async_call(SWITCH_DOMAIN, service, data, blocking=True) robot._update_data({"state": {"info": {"gravity": new_value}}}, partial=True) assert robot.set_gravity_mode.call_count == count + 1 @@ -114,16 +114,16 @@ async def test_litterrobot_4_deprecated_switch( """Test switch deprecation issue.""" entity_uid = "LR4C010001-night_light_mode_enabled" if preexisting_entity: - suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{PLATFORM_DOMAIN}.", "") + suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{SWITCH_DOMAIN}.", "") entity_registry.async_get_or_create( - PLATFORM_DOMAIN, + SWITCH_DOMAIN, DOMAIN, entity_uid, suggested_object_id=suggested_id, disabled_by=disabled_by, ) - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SWITCH_DOMAIN) assert ( entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) is not None diff --git a/tests/components/litterrobot/test_time.py b/tests/components/litterrobot/test_time.py index f77263d949313..75dfc9e5ca4d1 100644 --- a/tests/components/litterrobot/test_time.py +++ b/tests/components/litterrobot/test_time.py @@ -8,7 +8,7 @@ from pylitterbot import LitterRobot3 import pytest -from homeassistant.components.time import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.time import DOMAIN as TIME_DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -22,7 +22,7 @@ async def test_sleep_mode_start_time( hass: HomeAssistant, mock_account: MagicMock ) -> None: """Tests the sleep mode start time.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, TIME_DOMAIN) entity = hass.states.get(SLEEP_START_TIME_ENTITY_ID) assert entity @@ -30,7 +30,7 @@ async def test_sleep_mode_start_time( robot: LitterRobot3 = mock_account.robots[0] await hass.services.async_call( - PLATFORM_DOMAIN, + TIME_DOMAIN, "set_value", {ATTR_ENTITY_ID: SLEEP_START_TIME_ENTITY_ID, "time": time(23, 0)}, blocking=True, diff --git a/tests/components/litterrobot/test_update.py b/tests/components/litterrobot/test_update.py index f7d7492dec80e..dccfec0b29e88 100644 --- a/tests/components/litterrobot/test_update.py +++ b/tests/components/litterrobot/test_update.py @@ -10,7 +10,7 @@ ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, ATTR_RELEASE_URL, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, ) @@ -40,7 +40,7 @@ async def test_robot_with_no_update( robot.get_latest_firmware = AsyncMock(return_value=None) entry = await setup_integration( - hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN + hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN ) state = hass.states.get(ENTITY_ID) @@ -63,7 +63,7 @@ async def test_robot_with_update( robot.has_firmware_update = AsyncMock(return_value=True) robot.get_latest_firmware = AsyncMock(return_value=NEW_FIRMWARE) - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN) state = hass.states.get(ENTITY_ID) assert state @@ -77,7 +77,7 @@ async def test_robot_with_update( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM_DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, @@ -87,7 +87,7 @@ async def test_robot_with_update( robot.update_firmware = AsyncMock(return_value=True) await hass.services.async_call( - PLATFORM_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) await hass.async_block_till_done() assert robot.update_firmware.call_count == 1 @@ -101,7 +101,7 @@ async def test_robot_with_update_already_in_progress( robot._update_data({"isFirmwareUpdateTriggered": True}, partial=True) entry = await setup_integration( - hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN + hass, mock_account_with_litterrobot_4, UPDATE_DOMAIN ) state = hass.states.get(ENTITY_ID) diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 4f2bab7ad0a51..d13e9511d4472 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -8,7 +8,7 @@ from nibe.heatpump import Model import pytest -from homeassistant.components.button import DOMAIN as PLATFORM_DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( ATTR_ENTITY_ID, STATE_UNAVAILABLE, @@ -67,7 +67,7 @@ async def test_reset_button( # Press button await hass.services.async_call( - PLATFORM_DOMAIN, + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 039113892c102..b64bc9036d938 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -19,7 +19,7 @@ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -164,7 +164,7 @@ async def test_set_temperature_supported_cooling( ) await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -181,7 +181,7 @@ async def test_set_temperature_supported_cooling( mock_connection.write_coil.reset_mock() await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -199,7 +199,7 @@ async def test_set_temperature_supported_cooling( with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -209,7 +209,7 @@ async def test_set_temperature_supported_cooling( ) await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -255,7 +255,7 @@ async def test_set_temperature_unsupported_cooling( # Set temperature to heat await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -272,7 +272,7 @@ async def test_set_temperature_unsupported_cooling( # Attempt to set temperature to cool should raise ServiceValidationError with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -324,7 +324,7 @@ async def test_set_hvac_mode( ) await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: entity_id, @@ -364,7 +364,7 @@ async def test_set_invalid_hvac_mode( await async_add_model(hass, model) with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: entity_id, diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index 6e004a0554ef2..2881ac62a3319 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -11,7 +11,7 @@ from homeassistant.components.number import ( ATTR_VALUE, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -95,7 +95,7 @@ async def test_set_value( # Write value await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, @@ -158,7 +158,7 @@ async def test_set_value_fail( # Write value with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, @@ -192,7 +192,7 @@ async def test_set_value_same( # Write value await hass.services.async_call( - PLATFORM_DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, diff --git a/tests/components/openhome/test_update.py b/tests/components/openhome/test_update.py index 354ed26af6446..15dadc2757903 100644 --- a/tests/components/openhome/test_update.py +++ b/tests/components/openhome/test_update.py @@ -10,7 +10,7 @@ ATTR_LATEST_VERSION, ATTR_RELEASE_SUMMARY, ATTR_RELEASE_URL, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, ) @@ -148,7 +148,7 @@ async def test_update_available(hass: HomeAssistant) -> None: ) await hass.services.async_call( - PLATFORM_DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.friendly_name"}, blocking=True, @@ -166,7 +166,7 @@ async def test_firmware_update_not_required(hass: HomeAssistant) -> None: with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM_DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.friendly_name"}, blocking=True, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 6949ccb3c97f4..acd9cfe197e39 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -16,7 +16,7 @@ ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, ATTR_UPDATE_PERCENTAGE, - DOMAIN as PLATFORM, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform @@ -113,7 +113,7 @@ async def test_update_firmware( assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -167,7 +167,7 @@ async def test_update_zigbee2_firmware( assert state.attributes[ATTR_LATEST_VERSION] == "20240716" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -212,7 +212,7 @@ async def test_update_legacy_firmware_v2( assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -253,7 +253,7 @@ async def test_update_firmware_failed( assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, @@ -300,7 +300,7 @@ async def test_update_reboot_timeout( ), ): await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: entity_id}, blocking=False, From aab4f575805562224c2c4df6094255ca548174e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Mon, 16 Feb 2026 16:49:24 +0100 Subject: [PATCH 08/39] Add missing native_unit_of_measurement in WLED (#157802) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/wled/quality_scale.yaml | 4 +--- homeassistant/components/wled/strings.json | 3 ++- tests/components/wled/snapshots/test_sensor.ambr | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wled/quality_scale.yaml b/homeassistant/components/wled/quality_scale.yaml index 11a59bcc6d97f..749e38d759227 100644 --- a/homeassistant/components/wled/quality_scale.yaml +++ b/homeassistant/components/wled/quality_scale.yaml @@ -57,9 +57,7 @@ rules: comment: | This integration has a fixed single device. entity-category: done - entity-device-class: - status: todo - comment: Led count could receive unit of measurement + entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: done diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9719406472e63..aa4303c670941 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -89,7 +89,8 @@ "name": "Free memory" }, "info_leds_count": { - "name": "LED count" + "name": "LED count", + "unit_of_measurement": "LEDs" }, "info_leds_max_power": { "name": "Max current" diff --git a/tests/components/wled/snapshots/test_sensor.ambr b/tests/components/wled/snapshots/test_sensor.ambr index d9430bb4fa988..7cfe508527ffa 100644 --- a/tests/components/wled/snapshots/test_sensor.ambr +++ b/tests/components/wled/snapshots/test_sensor.ambr @@ -195,13 +195,14 @@ 'supported_features': 0, 'translation_key': 'info_leds_count', 'unique_id': 'aabbccddeeff_info_leds_count', - 'unit_of_measurement': None, + 'unit_of_measurement': 'LEDs', }) # --- # name: test_snapshots[sensor.wled_rgb_light_led_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light LED count', + 'unit_of_measurement': 'LEDs', }), 'context': , 'entity_id': 'sensor.wled_rgb_light_led_count', From cbc2928c4aba06ab452728128cdfca038212e1f0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:53:22 +0100 Subject: [PATCH 09/39] Rename devolo test variables and aliases (#163175) --- .../devolo_home_network/test_binary_sensor.py | 14 ++--- .../devolo_home_network/test_button.py | 34 ++++++----- .../test_device_tracker.py | 20 ++++--- .../devolo_home_network/test_image.py | 18 +++--- .../devolo_home_network/test_init.py | 44 ++++++++++---- .../devolo_home_network/test_sensor.py | 48 +++++++-------- .../devolo_home_network/test_switch.py | 60 +++++++++---------- .../devolo_home_network/test_update.py | 36 +++++------ 8 files changed, 153 insertions(+), 121 deletions(-) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index e793c509b1367..de994ca90b728 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN as PLATFORM +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.devolo_home_network.const import LONG_UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE @@ -34,7 +34,7 @@ async def test_binary_sensor_setup( assert entry.state is ConfigEntryState.LOADED assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_connected_to_router" + f"{BINARY_SENSOR_DOMAIN}.{device_name}_connected_to_router" ).disabled @@ -49,13 +49,13 @@ async def test_update_attached_to_router( """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_connected_to_router" + entity_id = f"{BINARY_SENSOR_DOMAIN}.{device_name}_connected_to_router" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( @@ -65,7 +65,7 @@ async def test_update_attached_to_router( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -77,6 +77,6 @@ async def test_update_attached_to_router( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index 8a8028454ea51..cf68d1887c599 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.button import DOMAIN as PLATFORM, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID @@ -31,15 +31,17 @@ async def test_button_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" + f"{BUTTON_DOMAIN}.{device_name}_identify_device_with_a_blinking_led" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_start_plc_pairing" + f"{BUTTON_DOMAIN}.{device_name}_start_plc_pairing" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_restart_device" + f"{BUTTON_DOMAIN}.{device_name}_restart_device" + ).disabled + assert not entity_registry.async_get( + f"{BUTTON_DOMAIN}.{device_name}_start_wps" ).disabled - assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_start_wps").disabled @pytest.mark.parametrize( @@ -80,23 +82,23 @@ async def test_button( """Test a button.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{BUTTON_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate button press await hass.services.async_call( - PLATFORM, + BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state.state == "2023-01-13T12:00:00+00:00" api = getattr(mock_device, api_name) assert getattr(api, trigger_method).call_count == 1 @@ -106,9 +108,9 @@ async def test_button( getattr(api, trigger_method).side_effect = DeviceUnavailable with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, + BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -117,7 +119,7 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None """Test setting unautherized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_start_wps" + entity_id = f"{BUTTON_DOMAIN}.{device_name}_start_wps" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -126,9 +128,9 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, + BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index cb92b8bc3d90b..8358b2d5d5665 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.device_tracker import DOMAIN as PLATFORM +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.devolo_home_network.const import ( DOMAIN, LONG_UPDATE_INTERVAL, @@ -34,14 +34,16 @@ async def test_device_tracker( snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" - state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" + entity_id = ( + f"{DEVICE_TRACKER_DOMAIN}.{STATION.mac_address.lower().replace(':', '_')}" + ) entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() freezer.tick(LONG_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_wifi_connected_station = AsyncMock( @@ -51,7 +53,7 @@ async def test_device_tracker( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_NOT_HOME @@ -63,7 +65,7 @@ async def test_device_tracker( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -74,10 +76,12 @@ async def test_restoring_clients( entity_registry: er.EntityRegistry, ) -> None: """Test restoring existing device_tracker entities.""" - state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" + entity_id = ( + f"{DEVICE_TRACKER_DOMAIN}.{STATION.mac_address.lower().replace(':', '_')}" + ) entry = configure_integration(hass) entity_registry.async_get_or_create( - PLATFORM, + DEVICE_TRACKER_DOMAIN, DOMAIN, f"{STATION.mac_address}", config_entry=entry, @@ -90,6 +94,6 @@ async def test_restoring_clients( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_NOT_HOME diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index 54a8af3af6eb4..9d109857ac157 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -9,7 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL -from homeassistant.components.image import DOMAIN as PLATFORM +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -37,7 +37,7 @@ async def test_image_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" + f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" ).disabled @@ -53,18 +53,18 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" + entity_id = f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state.name == "Mock Title Guest Wi-Fi credentials as QR code" assert state.state == dt_util.utcnow().isoformat() - assert entity_registry.async_get(state_key) == snapshot + assert entity_registry.async_get(entity_id) == snapshot client = await hass_client() - resp = await client.get(f"/api/image_proxy/{state_key}") + resp = await client.get(f"/api/image_proxy/{entity_id}") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == snapshot @@ -75,7 +75,7 @@ async def test_guest_wifi_qr( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -87,11 +87,11 @@ async def test_guest_wifi_qr( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == dt_util.utcnow().isoformat() client = await hass_client() - resp = await client.get(f"/api/image_proxy/{state_key}") + resp = await client.get(f"/api/image_proxy/{entity_id}") assert resp.status == HTTPStatus.OK assert await resp.read() != body diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 9c609334718dc..973ee1cdd7dcd 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -6,14 +6,14 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.button import DOMAIN as BUTTON -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.devolo_home_network.const import DOMAIN -from homeassistant.components.image import DOMAIN as IMAGE -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.switch import DOMAIN as SWITCH -from homeassistant.components.update import DOMAIN as UPDATE +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -90,13 +90,37 @@ async def test_device( [ ( "mock_device", - (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ( + BINARY_SENSOR_DOMAIN, + BUTTON_DOMAIN, + DEVICE_TRACKER_DOMAIN, + IMAGE_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, + UPDATE_DOMAIN, + ), ), ( "mock_repeater_device", - (BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ( + BUTTON_DOMAIN, + DEVICE_TRACKER_DOMAIN, + IMAGE_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, + UPDATE_DOMAIN, + ), + ), + ( + "mock_nonwifi_device", + ( + BINARY_SENSOR_DOMAIN, + BUTTON_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, + UPDATE_DOMAIN, + ), ), - ("mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH, UPDATE)), ], ) async def test_platforms( diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index d01eb9f9e380d..d23c172f7c7ff 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -13,7 +13,7 @@ LONG_UPDATE_INTERVAL, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.sensor import DOMAIN as PLATFORM +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -39,28 +39,28 @@ async def test_sensor_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_connected_wi_fi_clients" + f"{SENSOR_DOMAIN}.{device_name}_connected_wi_fi_clients" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_connected_plc_devices" + f"{SENSOR_DOMAIN}.{device_name}_connected_plc_devices" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks" + f"{SENSOR_DOMAIN}.{device_name}_neighboring_wi_fi_networks" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[2].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[2].user_device_name}" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[2].user_device_name}" + f"{SENSOR_DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[2].user_device_name}" ).disabled assert entity_registry.async_get( - f"{PLATFORM}.{device_name}_last_restart_of_the_device" + f"{SENSOR_DOMAIN}.{device_name}_last_restart_of_the_device" ).disabled @@ -109,12 +109,12 @@ async def test_sensor( """Test state change of a sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{SENSOR_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate device failure setattr(mock_device.device, get_method, AsyncMock(side_effect=DeviceUnavailable)) @@ -123,7 +123,7 @@ async def test_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -133,7 +133,7 @@ async def test_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == expected_state @@ -148,15 +148,15 @@ async def test_update_plc_phyrates( """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key_downlink = f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" - state_key_uplink = f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + entity_id_downlink = f"{SENSOR_DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + entity_id_uplink = f"{SENSOR_DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key_downlink) == snapshot - assert entity_registry.async_get(state_key_downlink) == snapshot - assert hass.states.get(state_key_downlink) == snapshot - assert entity_registry.async_get(state_key_downlink) == snapshot + assert hass.states.get(entity_id_downlink) == snapshot + assert entity_registry.async_get(entity_id_downlink) == snapshot + assert hass.states.get(entity_id_downlink) == snapshot + assert entity_registry.async_get(entity_id_downlink) == snapshot # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( @@ -166,11 +166,11 @@ async def test_update_plc_phyrates( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key_downlink) + state = hass.states.get(entity_id_downlink) assert state is not None assert state.state == STATE_UNAVAILABLE - state = hass.states.get(state_key_uplink) + state = hass.states.get(entity_id_uplink) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -180,11 +180,11 @@ async def test_update_plc_phyrates( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key_downlink) + state = hass.states.get(entity_id_downlink) assert state is not None assert state.state == str(PLCNET.data_rates[0].rx_rate) - state = hass.states.get(state_key_uplink) + state = hass.states.get(entity_id_uplink) assert state is not None assert state.state == str(PLCNET.data_rates[0].tx_rate) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 1ab2a1c354b62..2d4cb2f191ca2 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -13,7 +13,7 @@ DOMAIN, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.switch import DOMAIN as PLATFORM +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -47,10 +47,10 @@ async def test_switch_setup( assert entry.state is ConfigEntryState.LOADED assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_enable_guest_wi_fi" + f"{SWITCH_DOMAIN}.{device_name}_enable_guest_wi_fi" ).disabled assert not entity_registry.async_get( - f"{PLATFORM}.{device_name}_enable_leds" + f"{SWITCH_DOMAIN}.{device_name}_enable_leds" ).disabled @@ -87,13 +87,13 @@ async def test_update_enable_guest_wifi( """Test state change of a enable_guest_wifi switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_enable_guest_wi_fi" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_enable_guest_wi_fi" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( @@ -103,7 +103,7 @@ async def test_update_enable_guest_wifi( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON @@ -112,10 +112,10 @@ async def test_update_enable_guest_wifi( enabled=False ) await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OFF mock_device.device.async_set_wifi_guest_access.assert_called_once_with(False) @@ -130,10 +130,10 @@ async def test_update_enable_guest_wifi( enabled=True ) await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON mock_device.device.async_set_wifi_guest_access.assert_called_once_with(True) @@ -151,9 +151,9 @@ async def test_update_enable_guest_wifi( HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -168,13 +168,13 @@ async def test_update_enable_leds( """Test state change of a enable_leds switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_enable_leds" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_enable_leds" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot # Emulate state change mock_device.device.async_get_led_setting.return_value = True @@ -182,17 +182,17 @@ async def test_update_enable_leds( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON # Switch off mock_device.device.async_get_led_setting.return_value = False await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OFF mock_device.device.async_set_led_setting.assert_called_once_with(False) @@ -205,10 +205,10 @@ async def test_update_enable_leds( # Switch on mock_device.device.async_get_led_setting.return_value = True await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON mock_device.device.async_set_led_setting.assert_called_once_with(True) @@ -226,9 +226,9 @@ async def test_update_enable_leds( HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -251,12 +251,12 @@ async def test_device_failure( """Test device failure.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None api = getattr(mock_device.device, get_method) @@ -265,7 +265,7 @@ async def test_device_failure( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -283,12 +283,12 @@ async def test_auth_failed( """Test setting unautherized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_{name}" + entity_id = f"{SWITCH_DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None setattr(mock_device.device, set_method, AsyncMock()) @@ -297,7 +297,7 @@ async def test_auth_failed( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() @@ -314,7 +314,7 @@ async def test_auth_failed( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True ) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 034d1bad7f640..59cebe5cc3dd2 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -10,7 +10,7 @@ DOMAIN, FIRMWARE_UPDATE_INTERVAL, ) -from homeassistant.components.update import DOMAIN as PLATFORM, SERVICE_INSTALL +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -36,7 +36,9 @@ async def test_update_setup( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_firmware").disabled + assert not entity_registry.async_get( + f"{UPDATE_DOMAIN}.{device_name}_firmware" + ).disabled async def test_update_firmware( @@ -50,18 +52,18 @@ async def test_update_firmware( """Test updating a device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot - assert entity_registry.async_get(state_key) == snapshot + assert hass.states.get(entity_id) == snapshot + assert entity_registry.async_get(entity_id) == snapshot await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) assert mock_device.device.async_start_firmware_update.call_count == 1 @@ -77,7 +79,7 @@ async def test_update_firmware( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_OFF @@ -96,12 +98,12 @@ async def test_device_failure_check( """Test device failure during check.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable @@ -109,7 +111,7 @@ async def test_device_failure_check( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(state_key) + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -121,7 +123,7 @@ async def test_device_failure_update( """Test device failure when starting update.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -131,9 +133,9 @@ async def test_device_failure_update( # Emulate update start with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -142,7 +144,7 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None """Test updating unauthorized triggers the reauth flow.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_firmware" + entity_id = f"{UPDATE_DOMAIN}.{device_name}_firmware" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -151,9 +153,9 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None with pytest.raises(HomeAssistantError): assert await hass.services.async_call( - PLATFORM, + UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: state_key}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) await hass.async_block_till_done() From 2684f4b5556375169c4222f6484f6b7d7fa60bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Mon, 16 Feb 2026 17:03:14 +0100 Subject: [PATCH 10/39] Update quality scale of WLED integration to platinum (#162680) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/wled/manifest.json | 1 + .../components/wled/quality_scale.yaml | 19 ++++++++----------- script/hassfest/quality_scale.py | 1 - 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 326008ae1af4b..977479a8b1906 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/wled", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "platinum", "requirements": ["wled==0.21.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/quality_scale.yaml b/homeassistant/components/wled/quality_scale.yaml index 749e38d759227..c3185f05dce51 100644 --- a/homeassistant/components/wled/quality_scale.yaml +++ b/homeassistant/components/wled/quality_scale.yaml @@ -24,10 +24,11 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: todo + docs-installation-parameters: done + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done @@ -41,17 +42,13 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done - docs-known-limitations: - status: todo - comment: | - Analog RGBCCT Strip are poor supported by HA. - See: https://github.com/home-assistant/core/issues/123614 - docs-supported-devices: todo + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5624038936860..e7b0cafd0914f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2064,7 +2064,6 @@ class Rule: "wirelesstag", "withings", "wiz", - "wled", "wmspro", "wolflink", "workday", From 09b122e670d9e5e83d848c8346f2600a8626d829 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 16 Feb 2026 17:12:47 +0100 Subject: [PATCH 11/39] KNX Sensor: set device and state class for YAML entities based on DPT (#159465) --- homeassistant/components/knx/schema.py | 36 ++++++---- homeassistant/components/knx/sensor.py | 14 ++-- .../knx/storage/entity_store_schema.py | 61 ++-------------- homeassistant/components/knx/validation.py | 70 ++++++++++++++++++- tests/components/knx/test_sensor.py | 59 +++++++++++++--- 5 files changed, 157 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index e5db0e650bdd5..62b7e35047e5e 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -22,7 +22,7 @@ ) from homeassistant.components.number import NumberMode from homeassistant.components.sensor import ( - CONF_STATE_CLASS, + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, ) @@ -64,6 +64,7 @@ NumberConf, SceneConf, ) +from .dpt import get_supported_dpts from .validation import ( backwards_compatible_xknx_climate_enum_member, dpt_base_type_validator, @@ -74,6 +75,7 @@ string_type_validator, sync_state_validator, validate_number_attributes, + validate_sensor_attributes, ) @@ -143,6 +145,13 @@ def select_options_sub_validator(entity_config: OrderedDict) -> OrderedDict: return entity_config +def _sensor_attribute_sub_validator(config: dict) -> dict: + """Validate that state_class is compatible with device_class and unit_of_measurement.""" + transcoder: type[DPTBase] = DPTBase.parse_transcoder(config[CONF_TYPE]) # type: ignore[assignment] # already checked in sensor_type_validator + dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] + return validate_sensor_attributes(dpt_metadata, config) + + ######### # EVENT ######### @@ -848,17 +857,20 @@ class SensorSchema(KNXPlatformSchema): CONF_SYNC_STATE = CONF_SYNC_STATE DEFAULT_NAME = "KNX Sensor" - ENTITY_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Required(CONF_TYPE): sensor_type_validator, - vol.Required(CONF_STATE_ADDRESS): ga_list_validator, - vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, - } + ENTITY_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean, + vol.Optional(CONF_SENSOR_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Required(CONF_TYPE): sensor_type_validator, + vol.Required(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ), + _sensor_attribute_sub_validator, ) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 0d5480858026d..92da35973e156 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -213,18 +213,22 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: value_type=config[CONF_TYPE], ), ) + dpt_string = self._device.sensor_value.dpt_class.dpt_number_str() + dpt_info = get_supported_dpts()[dpt_string] + if device_class := config.get(CONF_DEVICE_CLASS): self._attr_device_class = device_class else: - self._attr_device_class = try_parse_enum( - SensorDeviceClass, self._device.ha_device_class() - ) + self._attr_device_class = dpt_info["sensor_device_class"] + + self._attr_state_class = ( + config.get(CONF_STATE_CLASS) or dpt_info["sensor_state_class"] + ) + self._attr_native_unit_of_measurement = dpt_info["unit"] self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK] self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) self._attr_unique_id = str(self._device.sensor_value.group_address_state) - self._attr_native_unit_of_measurement = self._device.unit_of_measurement() - self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index cef993ca355a1..c1b5d77c63f39 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -13,9 +13,7 @@ ) from homeassistant.components.sensor import ( CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, - DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, - STATE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, ) @@ -52,7 +50,7 @@ SceneConf, ) from ..dpt import get_supported_dpts -from ..validation import validate_number_attributes +from ..validation import validate_number_attributes, validate_sensor_attributes from .const import ( CONF_ALWAYS_CALLBACK, CONF_COLOR, @@ -684,62 +682,11 @@ class ConfClimateFanSpeedMode(StrEnum): ) -def _validate_sensor_attributes(config: dict) -> dict: +def _sensor_attribute_sub_validator(config: dict) -> dict: """Validate that state_class is compatible with device_class and unit_of_measurement.""" dpt = config[CONF_GA_SENSOR][CONF_DPT] dpt_metadata = get_supported_dpts()[dpt] - state_class = config.get( - CONF_SENSOR_STATE_CLASS, - dpt_metadata["sensor_state_class"], - ) - device_class = config.get( - CONF_DEVICE_CLASS, - dpt_metadata["sensor_device_class"], - ) - unit_of_measurement = config.get( - CONF_UNIT_OF_MEASUREMENT, - dpt_metadata["unit"], - ) - if ( - state_class - and device_class - and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None - and state_class not in state_classes - ): - raise vol.Invalid( - f"State class '{state_class}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", - path=[CONF_SENSOR_STATE_CLASS], - ) - if ( - device_class - and (d_c_units := SENSOR_DEVICE_CLASS_UNITS.get(device_class)) is not None - and unit_of_measurement not in d_c_units - ): - raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " - f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", - path=( - [CONF_DEVICE_CLASS] - if CONF_DEVICE_CLASS in config - else [CONF_UNIT_OF_MEASUREMENT] - ), - ) - if ( - state_class - and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None - and unit_of_measurement not in s_c_units - ): - raise vol.Invalid( - f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " - f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", - path=( - [CONF_SENSOR_STATE_CLASS] - if CONF_SENSOR_STATE_CLASS in config - else [CONF_UNIT_OF_MEASUREMENT] - ), - ) - return config + return validate_sensor_attributes(dpt_metadata, config) SENSOR_KNX_SCHEMA = AllSerializeFirst( @@ -788,7 +735,7 @@ def _validate_sensor_attributes(config: dict) -> dict: ), }, ), - _validate_sensor_attributes, + _sensor_attribute_sub_validator, ) KNX_SCHEMA_FOR_PLATFORM = { diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 280ffc6b96785..f218dec0faea4 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -14,11 +14,17 @@ from homeassistant.components.number import ( DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, ) +from homeassistant.components.sensor import ( + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, + DEVICE_CLASS_STATE_CLASSES, + DEVICE_CLASS_UNITS, + STATE_CLASS_UNITS, +) from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT from homeassistant.helpers import config_validation as cv from .const import NumberConf -from .dpt import get_supported_dpts +from .dpt import DPTInfo, get_supported_dpts def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: @@ -219,3 +225,65 @@ def validate_number_attributes( ) return config + + +def validate_sensor_attributes( + dpt_info: DPTInfo, config: dict[str, Any] +) -> dict[str, Any]: + """Validate that state_class is compatible with device_class and unit_of_measurement. + + Works for both, UI and YAML configuration schema since they + share same names for all tested attributes. + """ + state_class = config.get( + CONF_SENSOR_STATE_CLASS, + dpt_info["sensor_state_class"], + ) + device_class = config.get( + CONF_DEVICE_CLASS, + dpt_info["sensor_device_class"], + ) + unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, + dpt_info["unit"], + ) + if ( + state_class + and device_class + and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None + and state_class not in state_classes + ): + raise vol.Invalid( + f"State class '{state_class}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}", + path=[CONF_SENSOR_STATE_CLASS], + ) + if ( + device_class + and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None + and unit_of_measurement not in d_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", + path=( + [CONF_DEVICE_CLASS] + if CONF_DEVICE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + if ( + state_class + and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None + and unit_of_measurement not in s_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. " + f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}", + path=( + [CONF_SENSOR_STATE_CLASS] + if CONF_SENSOR_STATE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + return config diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index ff42d78fd2cc8..9fb3b85b9f2ad 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -1,5 +1,6 @@ """Test KNX sensor.""" +import logging from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -11,6 +12,11 @@ CONF_SYNC_STATE, ) from homeassistant.components.knx.schema import SensorSchema +from homeassistant.components.sensor import ( + CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State @@ -42,13 +48,18 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: # StateUpdater initialize state await knx.assert_read("1/1/1") await knx.receive_response("1/1/1", (0, 40)) - state = hass.states.get("sensor.test") - assert state.state == "40" + knx.assert_state( + "sensor.test", + "40", + # default values for DPT type "current" + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement="mA", + ) # update from KNX await knx.receive_write("1/1/1", (0x03, 0xE8)) - state = hass.states.get("sensor.test") - assert state.state == "1000" + knx.assert_state("sensor.test", "1000") # don't answer to GroupValueRead requests await knx.receive_read("1/1/1") @@ -172,6 +183,38 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: assert len(events) == 6 +async def test_sensor_yaml_attribute_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + knx: KNXTestKit, +) -> None: + """Test creating a sensor with invalid unit, state_class or device_class.""" + with caplog.at_level(logging.ERROR): + await knx.setup_integration( + { + SensorSchema.PLATFORM: { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "1/1/1", + CONF_TYPE: "9.001", # temperature 2 byte float + CONF_SENSOR_STATE_CLASS: "total_increasing", # invalid for temperature + } + } + ) + assert len(caplog.messages) == 2 + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'knx': State class 'total_increasing' is not valid for device class" + in record.message + ) + + record = caplog.records[1] + assert record.levelname == "ERROR" + assert "Setup failed for 'knx': Invalid config." in record.message + + assert hass.states.get("sensor.test") is None + + @pytest.mark.parametrize( ("knx_config", "response_payload", "expected_state"), [ @@ -186,8 +229,8 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: (0, 0), { "state": "0.0", - "device_class": "temperature", - "state_class": "measurement", + "device_class": SensorDeviceClass.TEMPERATURE, + "state_class": SensorStateClass.MEASUREMENT, "unit_of_measurement": "°C", }, ), @@ -206,8 +249,8 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: (1, 2, 3, 4), { "state": "16909060", - "device_class": "energy", - "state_class": "total_increasing", + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL_INCREASING, }, ), ], From 80fccaec567257dd92a23b2a558b9345927bfde2 Mon Sep 17 00:00:00 2001 From: Perchun Pak Date: Mon, 16 Feb 2026 17:15:04 +0100 Subject: [PATCH 12/39] minecraft_server: do not use mcstatus' internal objects (#163101) --- tests/components/minecraft_server/const.py | 28 +++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 0fb8e99413301..4447a6fe37e8d 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -5,16 +5,13 @@ BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion, + JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion, - RawJavaResponse, - RawJavaResponsePlayer, - RawJavaResponsePlayers, - RawJavaResponseVersion, ) from homeassistant.components.minecraft_server.api import MinecraftServerData @@ -24,26 +21,19 @@ TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" -TEST_JAVA_STATUS_RESPONSE_RAW = RawJavaResponse( - description="Dummy MOTD", - players=RawJavaResponsePlayers( +TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( + raw={"foo": "bar"}, + players=JavaStatusPlayers( online=3, max=10, sample=[ - RawJavaResponsePlayer(id="1", name="Player 1"), - RawJavaResponsePlayer(id="2", name="Player 2"), - RawJavaResponsePlayer(id="3", name="Player 3"), + JavaStatusPlayer(id="1", name="Player 1"), + JavaStatusPlayer(id="2", name="Player 2"), + JavaStatusPlayer(id="3", name="Player 3"), ], ), - version=RawJavaResponseVersion(name="Dummy Version", protocol=123), - favicon="Dummy Icon", -) - -TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( - raw=TEST_JAVA_STATUS_RESPONSE_RAW, - players=JavaStatusPlayers.build(TEST_JAVA_STATUS_RESPONSE_RAW["players"]), - version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), - motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), + version=JavaStatusVersion(name="Dummy Version", protocol=123), + motd=Motd.parse("Dummy MOTD", bedrock=False), icon=None, enforces_secure_chat=False, latency=5, From 9e14a643c0f3bed7206264dfe408d128e3ead9cc Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 16 Feb 2026 16:15:29 +0000 Subject: [PATCH 13/39] Add Mastodon reconfigure flow (#163178) --- .../components/mastodon/config_flow.py | 51 ++++++++ .../components/mastodon/quality_scale.yaml | 5 +- .../components/mastodon/strings.json | 14 +++ tests/components/mastodon/test_config_flow.py | 111 ++++++++++++++++++ 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 83803cf695d02..963df3d219392 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -51,6 +51,19 @@ ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), } ) +STEP_RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required( + CONF_CLIENT_ID, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_CLIENT_SECRET, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_ACCESS_TOKEN, + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + } +) EXAMPLE_URL = "https://mastodon.social" @@ -196,3 +209,41 @@ async def async_step_reauth_confirm( "account_name": remove_email_link(account_name), }, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + + reconfigure_entry = self._get_reconfigure_entry() + + if user_input: + self.base_url = reconfigure_entry.data[CONF_BASE_URL] + self.client_id = user_input[CONF_CLIENT_ID] + self.client_secret = user_input[CONF_CLIENT_SECRET] + self.access_token = user_input[CONF_ACCESS_TOKEN] + instance, account, errors = await self.hass.async_add_executor_job( + self.check_connection + ) + if not errors: + name = construct_mastodon_username(instance, account) + await self.async_set_unique_id(slugify(name)) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={ + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET], + CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN], + }, + ) + account_name = reconfigure_entry.title + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE_SCHEMA, + errors=errors, + description_placeholders={ + "account_name": remove_email_link(account_name), + }, + ) diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index f386cf98ccba7..70491d57e6962 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -64,10 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: | - Waiting to move to OAuth. + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index f51e33477d145..9b07630a3c33f 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -4,6 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "wrong_account": "You have to use the same account that was used to configure the integration." }, "error": { @@ -21,6 +22,19 @@ }, "description": "Please reauthenticate {account_name} with Mastodon." }, + "reconfigure": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]", + "client_id": "[%key:component::mastodon::config::step::user::data::client_id%]", + "client_secret": "[%key:component::mastodon::config::step::user::data::client_secret%]" + }, + "data_description": { + "access_token": "[%key:component::mastodon::config::step::user::data_description::access_token%]", + "client_id": "[%key:component::mastodon::config::step::user::data_description::client_id%]", + "client_secret": "[%key:component::mastodon::config::step::user::data_description::client_secret%]" + }, + "description": "Reconfigure {account_name} with Mastodon." + }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", diff --git a/tests/components/mastodon/test_config_flow.py b/tests/components/mastodon/test_config_flow.py index 0ec3647da1de0..56f399b85dbc4 100644 --- a/tests/components/mastodon/test_config_flow.py +++ b/tests/components/mastodon/test_config_flow.py @@ -296,3 +296,114 @@ async def test_reauth_flow_exceptions( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id2", + CONF_CLIENT_SECRET: "client_secret2", + CONF_ACCESS_TOKEN: "access_token2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_BASE_URL] == "https://mastodon.social" + assert mock_config_entry.data[CONF_CLIENT_ID] == "client_id2" + assert mock_config_entry.data[CONF_CLIENT_SECRET] == "client_secret2" + assert mock_config_entry.data[CONF_ACCESS_TOKEN] == "access_token2" + + +async def test_reconfigure_flow_wrong_account( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow with wrong account.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.mastodon.config_flow.construct_mastodon_username", + return_value="WRONG_USERNAME", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MastodonNetworkError, "network_error"), + (MastodonUnauthorizedError, "unauthorized_error"), + (Exception, "unknown"), + ], +) +async def test_reconfigure_flow_exceptions( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + mock_mastodon_client.account_verify_credentials.side_effect = exception + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": error} + + mock_mastodon_client.account_verify_credentials.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLIENT_ID: "client_id", + CONF_CLIENT_SECRET: "client_secret", + CONF_ACCESS_TOKEN: "access_token", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 19aaaf6cc6a0ef8c67d8e1fbc1f64030d45daf72 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Mon, 16 Feb 2026 17:32:22 +0100 Subject: [PATCH 14/39] Add Lux to homee units (#163180) --- homeassistant/components/homee/const.py | 1 + tests/components/homee/fixtures/sensors.json | 81 ++++++++----- .../homee/snapshots/test_sensor.ambr | 112 +++++++++++++----- tests/components/homee/test_sensor.py | 6 +- 4 files changed, 138 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 718baf346ae7b..c542de0a0aa3d 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -31,6 +31,7 @@ "n/a": None, "text": None, "%": PERCENTAGE, + "Lux": LIGHT_LUX, "lx": LIGHT_LUX, "klx": LIGHT_LUX, "1/min": REVOLUTIONS_PER_MINUTE, diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index 1c743195a20b1..0c811b03a3c65 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -111,7 +111,7 @@ "current_value": 175.0, "target_value": 175.0, "last_value": 66.0, - "unit": "lx", + "unit": "Lux", "step_value": 1.0, "editable": 0, "type": 11, @@ -126,6 +126,27 @@ { "id": 6, "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 65000, + "current_value": 175.0, + "target_value": 175.0, + "last_value": 66.0, + "unit": "lx", + "step_value": 1.0, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, "instance": 2, "minimum": 1, "maximum": 100, @@ -145,7 +166,7 @@ "name": "" }, { - "id": 7, + "id": 8, "node_id": 1, "instance": 1, "minimum": 0, @@ -166,7 +187,7 @@ "name": "" }, { - "id": 8, + "id": 9, "node_id": 1, "instance": 2, "minimum": 0, @@ -187,7 +208,7 @@ "name": "" }, { - "id": 9, + "id": 10, "node_id": 1, "instance": 0, "minimum": 0, @@ -208,7 +229,7 @@ "name": "" }, { - "id": 10, + "id": 11, "node_id": 1, "instance": 0, "minimum": 0, @@ -229,7 +250,7 @@ "name": "" }, { - "id": 11, + "id": 12, "node_id": 1, "instance": 0, "minimum": -40, @@ -250,7 +271,7 @@ "name": "" }, { - "id": 12, + "id": 13, "node_id": 1, "instance": 0, "minimum": 0, @@ -271,7 +292,7 @@ "name": "" }, { - "id": 13, + "id": 14, "node_id": 1, "instance": 0, "minimum": 0, @@ -292,7 +313,7 @@ "name": "" }, { - "id": 14, + "id": 15, "node_id": 1, "instance": 0, "minimum": -64, @@ -313,7 +334,7 @@ "name": "" }, { - "id": 15, + "id": 16, "node_id": 1, "instance": 0, "minimum": 0, @@ -334,7 +355,7 @@ "name": "" }, { - "id": 16, + "id": 17, "node_id": 1, "instance": 0, "minimum": 0, @@ -355,7 +376,7 @@ "name": "" }, { - "id": 17, + "id": 18, "node_id": 1, "instance": 0, "minimum": 0, @@ -376,7 +397,7 @@ "name": "" }, { - "id": 18, + "id": 19, "node_id": 1, "instance": 0, "minimum": 0, @@ -397,7 +418,7 @@ "name": "" }, { - "id": 19, + "id": 20, "node_id": 1, "instance": 0, "minimum": 0, @@ -418,7 +439,7 @@ "name": "" }, { - "id": 20, + "id": 21, "node_id": 1, "instance": 0, "minimum": -64, @@ -439,7 +460,7 @@ "name": "" }, { - "id": 21, + "id": 22, "node_id": 1, "instance": 0, "minimum": 0, @@ -460,7 +481,7 @@ "name": "" }, { - "id": 22, + "id": 23, "node_id": 1, "instance": 0, "minimum": 0, @@ -481,7 +502,7 @@ "name": "" }, { - "id": 23, + "id": 24, "node_id": 1, "instance": 0, "minimum": -50, @@ -502,7 +523,7 @@ "name": "" }, { - "id": 24, + "id": 25, "node_id": 1, "instance": 0, "minimum": 0, @@ -523,7 +544,7 @@ "name": "" }, { - "id": 25, + "id": 26, "node_id": 1, "instance": 0, "minimum": 0, @@ -544,7 +565,7 @@ "name": "" }, { - "id": 26, + "id": 27, "node_id": 1, "instance": 0, "minimum": 0, @@ -565,7 +586,7 @@ "name": "" }, { - "id": 27, + "id": 28, "node_id": 1, "instance": 0, "minimum": 0, @@ -586,7 +607,7 @@ "name": "" }, { - "id": 28, + "id": 29, "node_id": 1, "instance": 0, "minimum": 0, @@ -607,7 +628,7 @@ "name": "" }, { - "id": 29, + "id": 30, "node_id": 1, "instance": 0, "minimum": 0, @@ -628,7 +649,7 @@ "name": "" }, { - "id": 30, + "id": 31, "node_id": 1, "instance": 1, "minimum": 0, @@ -649,7 +670,7 @@ "name": "" }, { - "id": 31, + "id": 32, "node_id": 1, "instance": 2, "minimum": 0, @@ -670,7 +691,7 @@ "name": "" }, { - "id": 32, + "id": 33, "node_id": 1, "instance": 0, "minimum": 0, @@ -691,7 +712,7 @@ "name": "" }, { - "id": 33, + "id": 34, "node_id": 1, "instance": 0, "minimum": 0, @@ -712,7 +733,7 @@ "name": "" }, { - "id": 34, + "id": 35, "node_id": 1, "instance": 0, "minimum": -50, @@ -740,7 +761,7 @@ } }, { - "id": 35, + "id": 36, "node_id": 1, "instance": 0, "minimum": -50, diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index 5772bdc128b6c..ca8f66c89f25f 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -90,7 +90,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_instance', - 'unique_id': '00055511EECC-1-7', + 'unique_id': '00055511EECC-1-8', 'unit_of_measurement': , }) # --- @@ -147,7 +147,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_instance', - 'unique_id': '00055511EECC-1-8', + 'unique_id': '00055511EECC-1-9', 'unit_of_measurement': , }) # --- @@ -201,7 +201,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'dawn', - 'unique_id': '00055511EECC-1-10', + 'unique_id': '00055511EECC-1-11', 'unit_of_measurement': 'lx', }) # --- @@ -258,7 +258,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_temperature', - 'unique_id': '00055511EECC-1-11', + 'unique_id': '00055511EECC-1-12', 'unit_of_measurement': , }) # --- @@ -426,7 +426,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'exhaust_motor_revs', - 'unique_id': '00055511EECC-1-12', + 'unique_id': '00055511EECC-1-13', 'unit_of_measurement': 'rpm', }) # --- @@ -482,7 +482,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_temperature', - 'unique_id': '00055511EECC-1-34', + 'unique_id': '00055511EECC-1-35', 'unit_of_measurement': , }) # --- @@ -539,7 +539,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'floor_temperature', - 'unique_id': '00055511EECC-1-35', + 'unique_id': '00055511EECC-1-36', 'unit_of_measurement': , }) # --- @@ -593,7 +593,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': '00055511EECC-1-22', + 'unique_id': '00055511EECC-1-23', 'unit_of_measurement': '%', }) # --- @@ -720,6 +720,60 @@ 'state': '175.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Illuminance 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 1', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '175.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -754,7 +808,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'brightness_instance', - 'unique_id': '00055511EECC-1-6', + 'unique_id': '00055511EECC-1-7', 'unit_of_measurement': 'lx', }) # --- @@ -808,7 +862,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_humidity', - 'unique_id': '00055511EECC-1-13', + 'unique_id': '00055511EECC-1-14', 'unit_of_measurement': '%', }) # --- @@ -865,7 +919,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indoor_temperature', - 'unique_id': '00055511EECC-1-14', + 'unique_id': '00055511EECC-1-15', 'unit_of_measurement': , }) # --- @@ -919,7 +973,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'intake_motor_revs', - 'unique_id': '00055511EECC-1-15', + 'unique_id': '00055511EECC-1-16', 'unit_of_measurement': 'rpm', }) # --- @@ -975,7 +1029,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'level', - 'unique_id': '00055511EECC-1-16', + 'unique_id': '00055511EECC-1-17', 'unit_of_measurement': , }) # --- @@ -1029,7 +1083,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_quality', - 'unique_id': '00055511EECC-1-17', + 'unique_id': '00055511EECC-1-18', 'unit_of_measurement': None, }) # --- @@ -1167,7 +1221,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'operating_hours', - 'unique_id': '00055511EECC-1-18', + 'unique_id': '00055511EECC-1-19', 'unit_of_measurement': , }) # --- @@ -1221,7 +1275,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_humidity', - 'unique_id': '00055511EECC-1-19', + 'unique_id': '00055511EECC-1-20', 'unit_of_measurement': '%', }) # --- @@ -1278,7 +1332,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'outdoor_temperature', - 'unique_id': '00055511EECC-1-20', + 'unique_id': '00055511EECC-1-21', 'unit_of_measurement': , }) # --- @@ -1332,7 +1386,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'position', - 'unique_id': '00055511EECC-1-21', + 'unique_id': '00055511EECC-1-22', 'unit_of_measurement': '%', }) # --- @@ -1391,7 +1445,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'up_down', - 'unique_id': '00055511EECC-1-28', + 'unique_id': '00055511EECC-1-29', 'unit_of_measurement': None, }) # --- @@ -1453,7 +1507,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': '00055511EECC-1-23', + 'unique_id': '00055511EECC-1-24', 'unit_of_measurement': , }) # --- @@ -1510,7 +1564,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_current', - 'unique_id': '00055511EECC-1-25', + 'unique_id': '00055511EECC-1-26', 'unit_of_measurement': , }) # --- @@ -1567,7 +1621,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy', - 'unique_id': '00055511EECC-1-24', + 'unique_id': '00055511EECC-1-25', 'unit_of_measurement': , }) # --- @@ -1624,7 +1678,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_power', - 'unique_id': '00055511EECC-1-26', + 'unique_id': '00055511EECC-1-27', 'unit_of_measurement': , }) # --- @@ -1681,7 +1735,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_voltage', - 'unique_id': '00055511EECC-1-27', + 'unique_id': '00055511EECC-1-28', 'unit_of_measurement': , }) # --- @@ -1735,7 +1789,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv', - 'unique_id': '00055511EECC-1-29', + 'unique_id': '00055511EECC-1-30', 'unit_of_measurement': None, }) # --- @@ -1790,7 +1844,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_instance', - 'unique_id': '00055511EECC-1-30', + 'unique_id': '00055511EECC-1-31', 'unit_of_measurement': , }) # --- @@ -1847,7 +1901,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage_instance', - 'unique_id': '00055511EECC-1-31', + 'unique_id': '00055511EECC-1-32', 'unit_of_measurement': , }) # --- @@ -1907,7 +1961,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed', - 'unique_id': '00055511EECC-1-32', + 'unique_id': '00055511EECC-1-33', 'unit_of_measurement': , }) # --- @@ -1965,7 +2019,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'window_position', - 'unique_id': '00055511EECC-1-33', + 'unique_id': '00055511EECC-1-34', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 0e7bde2e76b5f..0059b4ceedb78 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -49,7 +49,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[27] + attribute = mock_homee.nodes[0].attributes[28] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -79,7 +79,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[32] + attribute = mock_homee.nodes[0].attributes[33] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( @@ -137,7 +137,7 @@ async def test_entity_update_action( blocking=True, ) - mock_homee.update_attribute.assert_called_once_with(1, 23) + mock_homee.update_attribute.assert_called_once_with(1, 24) async def test_sensor_snapshot( From d370a730c230f690551829ddd04156da07a2eaf4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:51:12 +0100 Subject: [PATCH 15/39] Mark update method type hints as mandatory (#163182) --- pylint/plugins/hass_enforce_type_hints.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 47416da20ccde..2a8d246943993 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2751,10 +2751,12 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="auto_update", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="installed_version", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="device_class", @@ -2764,30 +2766,37 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="in_progress", return_type=["bool", None], + mandatory=True, ), TypeHintMatch( function_name="latest_version", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="release_summary", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="release_url", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="UpdateEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="title", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="update_percentage", return_type=["int", "float", None], + mandatory=True, ), TypeHintMatch( function_name="install", @@ -2795,11 +2804,13 @@ class ClassTypeHintMatch: kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="release_notes", return_type=["str", None], has_async_counterpart=True, + mandatory=True, ), ], ), From 6c433d0809c87aa048a8ea75b086d5a651bc13e8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:53:38 +0100 Subject: [PATCH 16/39] Improve type hints in roomba vacuum (#163184) --- homeassistant/components/roomba/vacuum.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index d955c7a7ecf74..6abc1d52398c4 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -125,7 +125,7 @@ def __init__(self, roomba, blid) -> None: self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 @property - def activity(self): + def activity(self) -> VacuumActivity: """Return the state of the vacuum cleaner.""" clean_mission_status = self.vacuum_state.get("cleanMissionStatus", {}) cycle = clean_mission_status.get("cycle") @@ -213,7 +213,7 @@ async def async_start(self) -> None: else: await self.hass.async_add_executor_job(self.vacuum.send_command, "start") - async def async_stop(self, **kwargs): + async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "stop") @@ -221,7 +221,7 @@ async def async_pause(self) -> None: """Pause the cleaning cycle.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "pause") - async def async_return_to_base(self, **kwargs): + async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" if self.state == VacuumActivity.CLEANING: await self.async_pause() @@ -231,11 +231,16 @@ async def async_return_to_base(self, **kwargs): await asyncio.sleep(1) await self.hass.async_add_executor_job(self.vacuum.send_command, "dock") - async def async_locate(self, **kwargs): + async def async_locate(self, **kwargs: Any) -> None: """Located vacuum.""" await self.hass.async_add_executor_job(self.vacuum.send_command, "find") - async def async_send_command(self, command, params=None, **kwargs): + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: """Send raw command.""" _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) await self.hass.async_add_executor_job( @@ -270,7 +275,7 @@ class RoombaVacuumCarpetBoost(RoombaVacuum): _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST @property - def fan_speed(self): + def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" fan_speed = None carpet_boost = self.vacuum_state.get("carpetBoost") @@ -284,7 +289,7 @@ def fan_speed(self): fan_speed = FAN_SPEED_ECO return fan_speed - async def async_set_fan_speed(self, fan_speed, **kwargs): + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" if fan_speed.capitalize() in FAN_SPEEDS: fan_speed = fan_speed.capitalize() @@ -329,7 +334,7 @@ def __init__(self, roomba, blid) -> None: ] @property - def fan_speed(self): + def fan_speed(self) -> str: """Return the fan speed of the vacuum cleaner.""" # Mopping behavior and spray amount as fan speed rank_overlap = self.vacuum_state.get("rankOverlap", {}) @@ -345,7 +350,7 @@ def fan_speed(self): pad_wetness_value = pad_wetness.get("disposable") return f"{behavior}-{pad_wetness_value}" - async def async_set_fan_speed(self, fan_speed, **kwargs): + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" try: split = fan_speed.split("-", 1) From 977ee1a9d16d4b8b607823421f8db19f513d890f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Feb 2026 17:59:51 +0100 Subject: [PATCH 17/39] Add snapshot testing to SleepIQ (#163179) --- .../sleepiq/snapshots/test_sensor.ambr | 213 ++++++++++++++++++ tests/components/sleepiq/test_sensor.py | 95 +------- 2 files changed, 224 insertions(+), 84 deletions(-) create mode 100644 tests/components/sleepiq/snapshots/test_sensor.ambr diff --git a/tests/components/sleepiq/snapshots/test_sensor.ambr b/tests/components/sleepiq/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..2bf892e2277bf --- /dev/null +++ b/tests/components/sleepiq/snapshots/test_sensor.ambr @@ -0,0 +1,213 @@ +# serializer version: 1 +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleepnumber_test_bed_sleeper_r_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed Sleeper R Pressure', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed Sleeper R Pressure', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '43219_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed Sleeper R Pressure', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeper_r_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1400', + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_sleepnumber-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleepnumber_test_bed_sleeper_r_sleepnumber', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed Sleeper R SleepNumber', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed Sleeper R SleepNumber', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '43219_sleep_number', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeper_r_sleepnumber-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed Sleeper R SleepNumber', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeper_r_sleepnumber', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleepnumber_test_bed_sleeperl_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed SleeperL Pressure', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed SleeperL Pressure', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed SleeperL Pressure', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeperl_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_sleepnumber-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sleepnumber_test_bed_sleeperl_sleepnumber', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SleepNumber Test Bed SleeperL SleepNumber', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bed', + 'original_name': 'SleepNumber Test Bed SleeperL SleepNumber', + 'platform': 'sleepiq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765_sleep_number', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sleepnumber_test_bed_sleeperl_sleepnumber-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SleepNumber Test Bed SleeperL SleepNumber', + 'icon': 'mdi:bed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sleepnumber_test_bed_sleeperl_sleepnumber', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py index eb558850fb300..f177ef6670b16 100644 --- a/tests/components/sleepiq/test_sensor.py +++ b/tests/components/sleepiq/test_sensor.py @@ -1,96 +1,23 @@ """The tests for SleepIQ sensor platform.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - BED_NAME, - BED_NAME_LOWER, - SLEEPER_L_ID, - SLEEPER_L_NAME, - SLEEPER_L_NAME_LOWER, - SLEEPER_R_ID, - SLEEPER_R_NAME, - SLEEPER_R_NAME_LOWER, - setup_platform, -) - - -async def test_sleepnumber_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq -) -> None: - """Test the SleepIQ sleepnumber for a bed with two sides.""" - entry = await setup_platform(hass, SENSOR_DOMAIN) - - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_sleepnumber" - ) - assert state.state == "40" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} SleepNumber" - ) +from .conftest import setup_platform - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_sleepnumber" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_L_ID}_sleep_number" +from tests.common import snapshot_platform - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_sleepnumber" - ) - assert state.state == "80" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} SleepNumber" - ) - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_sleepnumber" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_R_ID}_sleep_number" - - -async def test_pressure_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq, + snapshot: SnapshotAssertion, ) -> None: - """Test the SleepIQ pressure for a bed with two sides.""" + """Test the SleepIQ sleepnumber for a bed with two sides.""" entry = await setup_platform(hass, SENSOR_DOMAIN) - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_pressure" - ) - assert state.state == "1000" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Pressure" - ) - - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_pressure" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_L_ID}_pressure" - - state = hass.states.get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_pressure" - ) - assert state.state == "1400" - assert state.attributes.get(ATTR_ICON) == "mdi:bed" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Pressure" - ) - - entry = entity_registry.async_get( - f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_pressure" - ) - assert entry - assert entry.unique_id == f"{SLEEPER_R_ID}_pressure" + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From a5c1ed593c02760f040267b101c28cfe7817b371 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:06:40 +0100 Subject: [PATCH 18/39] Improve type hints in atag water_heater (#163192) --- homeassistant/components/atag/water_heater.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index 286857f17eb54..a409c3cecfa82 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -37,15 +37,15 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self.coordinator.atag.dhw.temperature @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation.""" operation = self.coordinator.atag.dhw.current_operation - return operation if operation in self.operation_list else STATE_OFF + return operation if operation in OPERATION_LIST else STATE_OFF async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -53,7 +53,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: self.async_write_ha_state() @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the setpoint if water demand, otherwise return base temp (comfort level).""" return self.coordinator.atag.dhw.target_temperature From d85040058f2ccaa1751c36852e20383b24391e68 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:07:10 +0100 Subject: [PATCH 19/39] Improve type hints in aosmith water_heater (#163191) --- homeassistant/components/aosmith/water_heater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index d29b00955b6e1..3f88fdd497dae 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -120,7 +120,7 @@ def current_operation(self) -> str: return MODE_AOSMITH_TO_HA.get(self.device.status.current_mode, STATE_OFF) @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return True if away mode is on.""" return self.device.status.current_mode == AOSmithOperationMode.VACATION From 66d8a5bc510dbaf20399746fad1dd11c387f57d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:08:46 +0100 Subject: [PATCH 20/39] Improve type hints in econet water_heater (#163193) --- homeassistant/components/econet/water_heater.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index f93ad7f8872e1..876d9270bc914 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -136,12 +136,12 @@ def target_temperature(self) -> int: return self.water_heater.set_point @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self.water_heater.set_point_limits[0] @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self.water_heater.set_point_limits[1] From 168dd36d6678f9a22db8e5841ed79cfe8ac1b576 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:20:38 +0100 Subject: [PATCH 21/39] Mark vacuum method type hints as mandatory (#163185) --- pylint/plugins/hass_enforce_type_hints.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 2a8d246943993..5a0d5d0d218dc 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2830,64 +2830,77 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="state", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="activity", return_type=["VacuumActivity", None], + mandatory=True, ), TypeHintMatch( function_name="battery_level", return_type=["int", None], + mandatory=True, ), TypeHintMatch( function_name="battery_icon", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="fan_speed", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="fan_speed_list", return_type="list[str]", + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="VacuumEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="stop", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="start", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="pause", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="return_to_base", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="clean_spot", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="locate", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_fan_speed", @@ -2897,6 +2910,7 @@ class ClassTypeHintMatch: kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="send_command", @@ -2907,6 +2921,7 @@ class ClassTypeHintMatch: kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), From 5bb7699df040122b5a6d7a6c6d3eda61a296f11a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:35:09 +0100 Subject: [PATCH 22/39] Mark water_heater method type hints as mandatory (#163190) --- pylint/plugins/hass_enforce_type_hints.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 5a0d5d0d218dc..278023c4f3cd1 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2941,72 +2941,88 @@ class ClassTypeHintMatch: TypeHintMatch( function_name="current_operation", return_type=["str", None], + mandatory=True, ), TypeHintMatch( function_name="current_temperature", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="is_away_mode_on", return_type=["bool", None], + mandatory=True, ), TypeHintMatch( function_name="max_temp", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="min_temp", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="operation_list", return_type=["list[str]", None], + mandatory=True, ), TypeHintMatch( function_name="precision", return_type="float", + mandatory=True, ), TypeHintMatch( function_name="supported_features", return_type="WaterHeaterEntityFeature", + mandatory=True, ), TypeHintMatch( function_name="target_temperature", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="target_temperature_high", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="target_temperature_low", return_type=["float", None], + mandatory=True, ), TypeHintMatch( function_name="temperature_unit", return_type="str", + mandatory=True, ), TypeHintMatch( function_name="set_temperature", kwargs_type="Any", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="set_operation_mode", arg_types={1: "str"}, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_away_mode_on", return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="turn_away_mode_off", return_type=None, has_async_counterpart=True, + mandatory=True, ), ], ), From 66dc566d3a91b8b01e07a7aca7fa016b0935513a Mon Sep 17 00:00:00 2001 From: James <38914183+barneyonline@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:44:38 +1100 Subject: [PATCH 23/39] Add zone temperature support to Daikin integration (#152642) --- homeassistant/components/daikin/climate.py | 214 ++++++++++- homeassistant/components/daikin/const.py | 2 + homeassistant/components/daikin/strings.json | 23 ++ homeassistant/components/daikin/switch.py | 3 +- tests/components/daikin/conftest.py | 109 ++++++ tests/components/daikin/test_zone_climate.py | 353 +++++++++++++++++++ 6 files changed, 701 insertions(+), 3 deletions(-) create mode 100644 tests/components/daikin/conftest.py create mode 100644 tests/components/daikin/test_zone_climate.py diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 648a65c0d30be..e5ddf4c6a38c1 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence import logging from typing import Any +from pydaikin.daikin_base import Appliance + from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, @@ -21,6 +24,7 @@ ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -29,12 +33,19 @@ ATTR_STATE_OFF, ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, + DOMAIN, + ZONE_NAME_UNCONFIGURED, ) from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) +type DaikinZone = Sequence[str | int] + +DAIKIN_ZONE_TEMP_HEAT = "lztemp_h" +DAIKIN_ZONE_TEMP_COOL = "lztemp_c" + HA_STATE_TO_DAIKIN = { HVACMode.FAN_ONLY: "fan", @@ -78,6 +89,70 @@ } DAIKIN_ATTR_ADVANCED = "adv" +ZONE_TEMPERATURE_WINDOW = 2 + + +def _zone_error( + translation_key: str, placeholders: dict[str, str] | None = None +) -> HomeAssistantError: + """Return a Home Assistant error with Daikin translation info.""" + return HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + +def _zone_is_configured(zone: DaikinZone) -> bool: + """Return True if the Daikin zone represents a configured zone.""" + if not zone: + return False + return zone[0] != ZONE_NAME_UNCONFIGURED + + +def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]: + """Return the decoded zone temperature lists.""" + try: + heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1] + cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1] + except AttributeError: + return ([], []) + return (list(heating or []), list(cooling or [])) + + +def _supports_zone_temperature_control(device: Appliance) -> bool: + """Return True if the device exposes zone temperature settings.""" + zones = device.zones + if not zones: + return False + heating, cooling = _zone_temperature_lists(device) + return bool( + heating + and cooling + and len(heating) >= len(zones) + and len(cooling) >= len(zones) + ) + + +def _system_target_temperature(device: Appliance) -> float | None: + """Return the system target temperature when available.""" + target = device.target_temperature + if target is None: + return None + try: + return float(target) + except TypeError, ValueError: + return None + + +def _zone_temperature_from_list(values: list[str], zone_id: int) -> float | None: + """Return the parsed temperature for a zone from a Daikin list.""" + if zone_id >= len(values): + return None + try: + return float(values[zone_id]) + except TypeError, ValueError: + return None async def async_setup_entry( @@ -86,8 +161,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api = entry.runtime_data - async_add_entities([DaikinClimate(daikin_api)]) + coordinator = entry.runtime_data + entities: list[ClimateEntity] = [DaikinClimate(coordinator)] + if _supports_zone_temperature_control(coordinator.device): + zones = coordinator.device.zones or [] + entities.extend( + DaikinZoneClimate(coordinator, zone_id) + for zone_id, zone in enumerate(zones) + if _zone_is_configured(zone) + ) + async_add_entities(entities) def format_target_temperature(target_temperature: float) -> str: @@ -284,3 +367,130 @@ async def async_turn_off(self) -> None: {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) await self.coordinator.async_refresh() + + +class DaikinZoneClimate(DaikinEntity, ClimateEntity): + """Representation of a Daikin zone temperature controller.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = 1 + + def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: + """Initialize the zone climate entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{self.device.mac}-zone{zone_id}-temperature" + zone_name = self.device.zones[self._zone_id][0] + self._attr_name = f"{zone_name} temperature" + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the hvac modes (mirrors the main unit).""" + return [self.hvac_mode] + + @property + def hvac_mode(self) -> HVACMode: + """Return the current HVAC mode.""" + daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + return HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) + + @property + def target_temperature(self) -> float | None: + """Return the zone target temperature for the active mode.""" + heating, cooling = _zone_temperature_lists(self.device) + mode = self.hvac_mode + if mode == HVACMode.HEAT: + return _zone_temperature_from_list(heating, self._zone_id) + if mode == HVACMode.COOL: + return _zone_temperature_from_list(cooling, self._zone_id) + return None + + @property + def min_temp(self) -> float: + """Return the minimum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().min_temp + return target - ZONE_TEMPERATURE_WINDOW + + @property + def max_temp(self) -> float: + """Return the maximum selectable temperature.""" + target = _system_target_temperature(self.device) + if target is None: + return super().max_temp + return target + ZONE_TEMPERATURE_WINDOW + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and _supports_zone_temperature_control(self.device) + and _system_target_temperature(self.device) is not None + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional metadata.""" + return {"zone_id": self._zone_id} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the zone temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="zone_temperature_missing", + ) + zones = self.device.zones + if not zones or not _supports_zone_temperature_control(self.device): + raise _zone_error("zone_parameters_unavailable") + + try: + zone = zones[self._zone_id] + except (IndexError, TypeError) as err: + raise _zone_error( + "zone_missing", + { + "zone_id": str(self._zone_id), + "max_zone": str(len(zones) - 1), + }, + ) from err + + if not _zone_is_configured(zone): + raise _zone_error("zone_inactive", {"zone_id": str(self._zone_id)}) + + temperature_value = float(temperature) + target = _system_target_temperature(self.device) + if target is None: + raise _zone_error("zone_parameters_unavailable") + + mode = self.hvac_mode + if mode == HVACMode.HEAT: + zone_key = DAIKIN_ZONE_TEMP_HEAT + elif mode == HVACMode.COOL: + zone_key = DAIKIN_ZONE_TEMP_COOL + else: + raise _zone_error("zone_hvac_mode_unsupported") + + zone_value = str(round(temperature_value)) + try: + await self.device.set_zone(self._zone_id, zone_key, zone_value) + except (AttributeError, KeyError, NotImplementedError, TypeError) as err: + raise _zone_error("zone_set_failed") from err + + await self.coordinator.async_request_refresh() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Disallow changing HVAC mode via zone climate.""" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="zone_hvac_read_only", + ) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index f093569ea54df..27f0b9ba57d32 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -24,4 +24,6 @@ KEY_MAC = "mac" KEY_IP = "ip" +ZONE_NAME_UNCONFIGURED = "-" + TIMEOUT_SEC = 120 diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 53645b1e7bd41..b3326454d375b 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -57,5 +57,28 @@ "name": "Power" } } + }, + "exceptions": { + "zone_hvac_mode_unsupported": { + "message": "Zone temperature can only be changed when the main climate mode is heat or cool." + }, + "zone_hvac_read_only": { + "message": "Zone HVAC mode is controlled by the main climate entity." + }, + "zone_inactive": { + "message": "Zone {zone_id} is not active. Enable the zone on your Daikin device first." + }, + "zone_missing": { + "message": "Zone {zone_id} does not exist. Available zones are 0-{max_zone}." + }, + "zone_parameters_unavailable": { + "message": "This device does not expose the required zone temperature parameters." + }, + "zone_set_failed": { + "message": "Failed to set zone temperature. The device may not support this operation." + }, + "zone_temperature_missing": { + "message": "Provide a temperature value when adjusting a zone." + } } } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 20a56ac321cd6..20d27e7d3ea37 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import ZONE_NAME_UNCONFIGURED from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -28,7 +29,7 @@ async def async_setup_entry( switches.extend( DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone[0] != "-" + if zone[0] != ZONE_NAME_UNCONFIGURED ) if daikin_api.device.support_advanced_modes: # It isn't possible to find out from the API responses if a specific diff --git a/tests/components/daikin/conftest.py b/tests/components/daikin/conftest.py new file mode 100644 index 0000000000000..f3ef384add0d9 --- /dev/null +++ b/tests/components/daikin/conftest.py @@ -0,0 +1,109 @@ +"""Fixtures for Daikin tests.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator +import re +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +import urllib.parse + +import pytest + +type ZoneDefinition = list[str | int] +type ZoneDevice = MagicMock + + +def _decode_zone_values(value: str) -> list[str]: + """Decode a semicolon separated list into zone values.""" + return re.findall(r"[^;]+", urllib.parse.unquote(value)) + + +def configure_zone_device( + zone_device: ZoneDevice, + *, + zones: list[ZoneDefinition], + target_temperature: float | None = 22, + mode: str = "hot", + heating_values: str | None = None, + cooling_values: str | None = None, +) -> None: + """Configure a mocked zone-capable Daikin device for a test.""" + zone_device.target_temperature = target_temperature + zone_device.zones = zones + zone_device._mode = mode + + encoded_zone_temperatures = ";".join(str(zone[2]) for zone in zones) + zone_device.values = { + "name": "Daikin Test", + "model": "TESTMODEL", + "ver": "1_0_0", + "zone_name": ";".join(str(zone[0]) for zone in zones), + "zone_onoff": ";".join(str(zone[1]) for zone in zones), + "lztemp_h": ( + encoded_zone_temperatures if heating_values is None else heating_values + ), + "lztemp_c": ( + encoded_zone_temperatures if cooling_values is None else cooling_values + ), + } + + +@pytest.fixture +def zone_device() -> Generator[ZoneDevice]: + """Return a mocked zone-capable Daikin device and patch its factory.""" + device = MagicMock(name="DaikinZoneDevice") + device.mac = "001122334455" + device.fan_rate = [] + device.swing_modes = [] + device.support_away_mode = False + device.support_advanced_modes = False + device.support_fan_rate = False + device.support_swing_mode = False + device.support_outside_temperature = False + device.support_energy_consumption = False + device.support_humidity = False + device.support_compressor_frequency = False + device.compressor_frequency = 0 + device.inside_temperature = 21.0 + device.outside_temperature = 13.0 + device.humidity = 40 + device.current_total_power_consumption = 0.0 + device.last_hour_cool_energy_consumption = 0.0 + device.last_hour_heat_energy_consumption = 0.0 + device.today_energy_consumption = 0.0 + device.today_total_energy_consumption = 0.0 + + configure_zone_device(device, zones=[["Living", "1", 22]]) + + def _represent(key: str) -> tuple[None, list[str] | str]: + dynamic_values: dict[str, Callable[[], list[str] | str]] = { + "lztemp_h": lambda: _decode_zone_values(device.values["lztemp_h"]), + "lztemp_c": lambda: _decode_zone_values(device.values["lztemp_c"]), + "mode": lambda: device._mode, + "f_rate": lambda: "auto", + "f_dir": lambda: "3d", + "en_hol": lambda: "off", + "adv": lambda: "", + "htemp": lambda: str(device.inside_temperature), + "otemp": lambda: str(device.outside_temperature), + } + return (None, dynamic_values.get(key, lambda: "")()) + + async def _set(values: dict[str, Any]) -> None: + if mode := values.get("mode"): + device._mode = mode + + device.represent = MagicMock(side_effect=_represent) + device.update_status = AsyncMock() + device.set = AsyncMock(side_effect=_set) + device.set_zone = AsyncMock() + device.set_holiday = AsyncMock() + device.set_advanced_mode = AsyncMock() + device.set_streamer = AsyncMock() + + with patch( + "homeassistant.components.daikin.DaikinFactory", + new=AsyncMock(return_value=device), + ): + yield device diff --git a/tests/components/daikin/test_zone_climate.py b/tests/components/daikin/test_zone_climate.py new file mode 100644 index 0000000000000..168d0bd5f5b1f --- /dev/null +++ b/tests/components/daikin/test_zone_climate.py @@ -0,0 +1,353 @@ +"""Tests for Daikin zone climate entities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.daikin.const import DOMAIN, KEY_MAC +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_HOST, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import ZoneDevice, configure_zone_device + +from tests.common import MockConfigEntry + +HOST = "127.0.0.1" + + +async def _async_setup_daikin( + hass: HomeAssistant, zone_device: ZoneDevice +) -> MockConfigEntry: + """Set up a Daikin config entry with a mocked library device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=zone_device.mac, + data={CONF_HOST: HOST, KEY_MAC: zone_device.mac}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def _zone_entity_id( + entity_registry: er.EntityRegistry, zone_device: ZoneDevice, zone_id: int +) -> str | None: + """Return the entity id for a zone climate unique id.""" + return entity_registry.async_get_entity_id( + CLIMATE_DOMAIN, + DOMAIN, + f"{zone_device.mac}-zone{zone_id}-temperature", + ) + + +async def _async_set_zone_temperature( + hass: HomeAssistant, entity_id: str, temperature: float +) -> None: + """Call `climate.set_temperature` for a zone climate.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: temperature, + }, + blocking=True, + ) + + +async def test_setup_entry_adds_zone_climates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Configured zones create zone climate entities.""" + configure_zone_device( + zone_device, zones=[["-", "0", 0], ["Living", "1", 22], ["Office", "1", 21]] + ) + + await _async_setup_daikin(hass, zone_device) + + assert _zone_entity_id(entity_registry, zone_device, 0) is None + assert _zone_entity_id(entity_registry, zone_device, 1) is not None + assert _zone_entity_id(entity_registry, zone_device, 2) is not None + + +async def test_setup_entry_skips_zone_climates_without_support( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zone temperature lists skip zone climate entities.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + zone_device.values["lztemp_h"] = "" + zone_device.values["lztemp_c"] = "" + + await _async_setup_daikin(hass, zone_device) + + assert _zone_entity_id(entity_registry, zone_device, 0) is None + + +@pytest.mark.parametrize( + ("mode", "expected_zone_key"), + [("hot", "lztemp_h"), ("cool", "lztemp_c")], +) +async def test_zone_climate_sets_temperature_for_active_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, + mode: str, + expected_zone_key: str, +) -> None: + """Setting temperature updates the active mode zone value.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22], ["Office", "1", 21]], + mode=mode, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + await _async_set_zone_temperature(hass, entity_id, 23) + + zone_device.set_zone.assert_awaited_once_with(0, expected_zone_key, "23") + + +async def test_zone_climate_rejects_out_of_range_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Service validation rejects values outside the allowed range.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=22, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(ServiceValidationError) as err: + await _async_set_zone_temperature(hass, entity_id, 30) + + assert err.value.translation_key == "temp_out_of_range" + + +async def test_zone_climate_unavailable_without_target_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Zones are unavailable if system target temperature is missing.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=None, + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_zone_climate_zone_inactive_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Inactive zones raise a translated error during service calls.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.zones[0][0] = "-" + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_inactive" + + +async def test_zone_climate_zone_missing_after_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zones raise a translated error during service calls.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22], ["Office", "1", 22]], + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 1) + assert entity_id is not None + zone_device.zones = [["Living", "1", 22]] + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_missing" + + +async def test_zone_climate_parameters_unavailable( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Missing zone parameter lists make the zone entity unavailable.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.values["lztemp_h"] = "" + zone_device.values["lztemp_c"] = "" + + await async_update_entity(hass, entity_id) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_zone_climate_hvac_modes_read_only( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Changing HVAC mode through a zone climate is blocked.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + assert err.value.translation_key == "zone_hvac_read_only" + + +async def test_zone_climate_set_temperature_requires_heat_or_cool( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Setting temperature in unsupported modes raises a translated error.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="auto", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_hvac_mode_unsupported" + + +async def test_zone_climate_properties( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Zone climate exposes expected state attributes.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + target_temperature=24, + mode="cool", + heating_values="20", + cooling_values="18", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_TEMPERATURE] == 18.0 + assert state.attributes[ATTR_MIN_TEMP] == 22.0 + assert state.attributes[ATTR_MAX_TEMP] == 26.0 + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.COOL] + assert state.attributes["zone_id"] == 0 + + +async def test_zone_climate_target_temperature_inactive_mode( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """In non-heating/cooling modes, zone target temperature is None.""" + configure_zone_device( + zone_device, + zones=[["Living", "1", 22]], + mode="auto", + heating_values="bad", + cooling_values="19", + ) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.HEAT_COOL + assert state.attributes[ATTR_TEMPERATURE] is None + + +async def test_zone_climate_set_zone_failed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zone_device: ZoneDevice, +) -> None: + """Service call surfaces backend zone update errors.""" + configure_zone_device(zone_device, zones=[["Living", "1", 22]]) + await _async_setup_daikin(hass, zone_device) + entity_id = _zone_entity_id(entity_registry, zone_device, 0) + assert entity_id is not None + zone_device.set_zone = AsyncMock(side_effect=NotImplementedError) + + with pytest.raises(HomeAssistantError) as err: + await _async_set_zone_temperature(hass, entity_id, 21) + + assert err.value.translation_key == "zone_set_failed" From e6c5e72470b209521ddb03b056c8a78243d2f67f Mon Sep 17 00:00:00 2001 From: wollew Date: Mon, 16 Feb 2026 18:57:45 +0100 Subject: [PATCH 24/39] add upper and lower shutter of Velux dualrollershutters as entities (#162998) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/velux/cover.py | 90 ++++++++- homeassistant/components/velux/strings.json | 8 + tests/components/velux/__init__.py | 5 +- tests/components/velux/conftest.py | 23 ++- .../velux/snapshots/test_cover.ambr | 156 +++++++++++++++ tests/components/velux/test_cover.py | 180 +++++++++++++++++- 6 files changed, 452 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index e56fc2e54d2de..334dab34cea73 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -2,11 +2,13 @@ from __future__ import annotations +from enum import StrEnum from typing import Any -from pyvlx import ( +from pyvlx.opening_device import ( Awning, Blind, + DualRollerShutter, GarageDoor, Gate, OpeningDevice, @@ -43,6 +45,23 @@ async def async_setup_entry( for node in pyvlx.nodes: if isinstance(node, Blind): entities.append(VeluxBlind(node, config_entry.entry_id)) + elif isinstance(node, DualRollerShutter): + # add three entities, one for each part and the "dual" control + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.DUAL + ) + ) + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.UPPER + ) + ) + entities.append( + VeluxDualRollerShutter( + node, config_entry.entry_id, VeluxDualRollerPart.LOWER + ) + ) elif isinstance(node, OpeningDevice): entities.append(VeluxCover(node, config_entry.entry_id)) @@ -54,9 +73,6 @@ class VeluxCover(VeluxEntity, CoverEntity): node: OpeningDevice - # Do not name the "main" feature of the device (position control) - _attr_name = None - # Features common to all covers _attr_supported_features = ( CoverEntityFeature.OPEN @@ -125,6 +141,72 @@ async def async_stop_cover(self, **kwargs: Any) -> None: await self.node.stop(wait_for_completion=False) +class VeluxDualRollerPart(StrEnum): + """Enum for the parts of a dual roller shutter.""" + + UPPER = "upper" + LOWER = "lower" + DUAL = "dual" + + +class VeluxDualRollerShutter(VeluxCover): + """Representation of a Velux dual roller shutter cover.""" + + node: DualRollerShutter + _attr_device_class = CoverDeviceClass.SHUTTER + + def __init__( + self, node: DualRollerShutter, config_entry_id: str, part: VeluxDualRollerPart + ) -> None: + """Initialize VeluxDualRollerShutter.""" + super().__init__(node, config_entry_id) + if part == VeluxDualRollerPart.DUAL: + self._attr_name = None + else: + self._attr_unique_id = f"{self._attr_unique_id}_{part}" + self._attr_translation_key = f"dual_roller_shutter_{part}" + self.part = part + + @property + def current_cover_position(self) -> int: + """Return the current position of the cover.""" + if self.part == VeluxDualRollerPart.UPPER: + return 100 - self.node.position_upper_curtain.position_percent + if self.part == VeluxDualRollerPart.LOWER: + return 100 - self.node.position_lower_curtain.position_percent + return 100 - self.node.position.position_percent + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + if self.part == VeluxDualRollerPart.UPPER: + return self.node.position_upper_curtain.closed + if self.part == VeluxDualRollerPart.LOWER: + return self.node.position_lower_curtain.closed + return self.node.position.closed + + @wrap_pyvlx_call_exceptions + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.node.close(curtain=self.part, wait_for_completion=False) + + @wrap_pyvlx_call_exceptions + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.node.open(curtain=self.part, wait_for_completion=False) + + @wrap_pyvlx_call_exceptions + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position_percent = 100 - kwargs[ATTR_POSITION] + + await self.node.set_position( + Position(position_percent=position_percent), + curtain=self.part, + wait_for_completion=False, + ) + + class VeluxBlind(VeluxCover): """Representation of a Velux blind cover.""" diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 13abb8a0f78cb..98745106b3dbc 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -45,6 +45,14 @@ "rain_sensor": { "name": "Rain sensor" } + }, + "cover": { + "dual_roller_shutter_lower": { + "name": "Lower shutter" + }, + "dual_roller_shutter_upper": { + "name": "Upper shutter" + } } }, "exceptions": { diff --git a/tests/components/velux/__init__.py b/tests/components/velux/__init__.py index 931469d213e29..cd190d3ce7b44 100644 --- a/tests/components/velux/__init__.py +++ b/tests/components/velux/__init__.py @@ -15,8 +15,9 @@ async def update_callback_entity( ) -> None: """Simulate an update triggered by the pyvlx lib for a Velux node.""" - callback = mock_velux_node.register_device_updated_cb.call_args[0][0] - await callback(mock_velux_node) + for c in mock_velux_node.register_device_updated_cb.call_args_list: + callback = c[0][0] + await callback(mock_velux_node) await hass.async_block_till_done() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index c14911bec0037..2c84ca77af34c 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -4,7 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pyvlx import Blind, Light, OnOffLight, OnOffSwitch, Scene, Window +from pyvlx import Light, OnOffLight, OnOffSwitch, Scene +from pyvlx.opening_device import Blind, DualRollerShutter, Window from homeassistant.components.velux import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform @@ -69,6 +70,22 @@ def mock_window() -> AsyncMock: return window +# a dual roller shutter +@pytest.fixture +def mock_dual_roller_shutter() -> AsyncMock: + """Create a mock Velux dual roller shutter.""" + cover = AsyncMock(spec=DualRollerShutter, autospec=True) + cover.name = "Test Dual Roller Shutter" + cover.serial_number = "987654321" + cover.is_opening = False + cover.is_closing = False + cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) + cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) + cover.position = MagicMock(position_percent=30, closed=False) + cover.pyvlx = MagicMock() + return cover + + # a blind @pytest.fixture def mock_blind() -> AsyncMock: @@ -137,6 +154,8 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.is_opening = False cover.is_closing = False cover.position = MagicMock(position_percent=30, closed=False) + cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) + cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) cover.pyvlx = MagicMock() return cover @@ -149,6 +168,7 @@ def mock_pyvlx( mock_onoff_switch: AsyncMock, mock_window: AsyncMock, mock_blind: AsyncMock, + mock_dual_roller_shutter: AsyncMock, request: pytest.FixtureRequest, ) -> Generator[MagicMock]: """Create the library mock and patch PyVLX in both component and config_flow. @@ -164,6 +184,7 @@ def mock_pyvlx( pyvlx.nodes = [request.getfixturevalue(request.param)] else: pyvlx.nodes = [ + mock_dual_roller_shutter, mock_light, mock_onoff_light, mock_onoff_switch, diff --git a/tests/components/velux/snapshots/test_cover.ambr b/tests/components/velux/snapshots/test_cover.ambr index e6e9fa5f4f7fd..2e2d0fae52c76 100644 --- a/tests/components/velux/snapshots/test_cover.ambr +++ b/tests/components/velux/snapshots/test_cover.ambr @@ -104,6 +104,162 @@ 'state': 'open', }) # --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_dualrollershutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_DualRollerShutter', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test DualRollerShutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_dualrollershutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_lower_shutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_dualrollershutter_lower_shutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower shutter', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lower shutter', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'dual_roller_shutter_lower', + 'unique_id': 'serial_DualRollerShutter_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_lower_shutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test DualRollerShutter Lower shutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_dualrollershutter_lower_shutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_upper_shutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_dualrollershutter_upper_shutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper shutter', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upper shutter', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'dual_roller_shutter_upper', + 'unique_id': 'serial_DualRollerShutter_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_upper_shutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test DualRollerShutter Upper shutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_dualrollershutter_upper_shutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_cover_entity_setup[mock_cover_type-GarageDoor][cover.test_garagedoor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index 39df7d25ad09a..a2620aac31dc4 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -4,7 +4,14 @@ import pytest from pyvlx.exception import PyVLXException -from pyvlx.opening_device import Awning, GarageDoor, Gate, RollerShutter, Window +from pyvlx.opening_device import ( + Awning, + DualRollerShutter, + GarageDoor, + Gate, + RollerShutter, + Window, +) from homeassistant.components.cover import ( ATTR_POSITION, @@ -64,7 +71,9 @@ async def test_blind_entity_setup( @pytest.mark.usefixtures("mock_cover_type") @pytest.mark.parametrize( - "mock_cover_type", [Awning, GarageDoor, Gate, RollerShutter, Window], indirect=True + "mock_cover_type", + [Awning, DualRollerShutter, GarageDoor, Gate, RollerShutter, Window], + indirect=True, ) @pytest.mark.parametrize( "mock_pyvlx", @@ -103,7 +112,13 @@ async def test_cover_device_association( assert entry.device_id is not None device_entry = device_registry.async_get(entry.device_id) assert device_entry is not None - assert (DOMAIN, entry.unique_id) in device_entry.identifiers + + # For dual roller shutters, the unique_id is suffixed with "_upper" or "_lower", + # so remove that suffix to get the domain_id for device registry lookup + domain_id = entry.unique_id + if entry.unique_id.endswith("_upper") or entry.unique_id.endswith("_lower"): + domain_id = entry.unique_id.rsplit("_", 1)[0] + assert (DOMAIN, domain_id) in device_entry.identifiers assert device_entry.via_device_id is not None via_device_entry = device_registry.async_get(device_entry.via_device_id) assert via_device_entry is not None @@ -220,6 +235,165 @@ async def test_window_current_position_and_opening_closing_states( assert state.state == STATE_CLOSING +# Dual roller shutter command tests +async def test_dual_roller_shutter_open_close_services( + hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock +) -> None: + """Verify open/close services map to device calls with correct part.""" + + dual_entity_id = "cover.test_dual_roller_shutter" + upper_entity_id = "cover.test_dual_roller_shutter_upper_shutter" + lower_entity_id = "cover.test_dual_roller_shutter_lower_shutter" + + # Open upper part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": upper_entity_id}, blocking=True + ) + mock_dual_roller_shutter.open.assert_awaited_with( + curtain="upper", wait_for_completion=False + ) + + # Open lower part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": lower_entity_id}, blocking=True + ) + mock_dual_roller_shutter.open.assert_awaited_with( + curtain="lower", wait_for_completion=False + ) + + # Open dual + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": dual_entity_id}, blocking=True + ) + mock_dual_roller_shutter.open.assert_awaited_with( + curtain="dual", wait_for_completion=False + ) + + # Close upper part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": upper_entity_id}, blocking=True + ) + mock_dual_roller_shutter.close.assert_awaited_with( + curtain="upper", wait_for_completion=False + ) + + # Close lower part + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": lower_entity_id}, blocking=True + ) + mock_dual_roller_shutter.close.assert_awaited_with( + curtain="lower", wait_for_completion=False + ) + + # Close dual + await hass.services.async_call( + COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": dual_entity_id}, blocking=True + ) + mock_dual_roller_shutter.close.assert_awaited_with( + curtain="dual", wait_for_completion=False + ) + + +async def test_dual_shutter_set_cover_position_inversion( + hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock +) -> None: + """HA position is inverted for device's Position.""" + + entity_id = "cover.test_dual_roller_shutter" + # Call with position 30 (=70% for device) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_POSITION: 30}, + blocking=True, + ) + + # Expect device Position 70% + args, kwargs = mock_dual_roller_shutter.set_position.await_args + position_obj = args[0] + assert position_obj.position_percent == 70 + assert kwargs.get("wait_for_completion") is False + assert kwargs.get("curtain") == "dual" + + entity_id = "cover.test_dual_roller_shutter_upper_shutter" + # Call with position 30 (=70% for device) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_POSITION: 30}, + blocking=True, + ) + + # Expect device Position 70% + args, kwargs = mock_dual_roller_shutter.set_position.await_args + position_obj = args[0] + assert position_obj.position_percent == 70 + assert kwargs.get("wait_for_completion") is False + assert kwargs.get("curtain") == "upper" + + entity_id = "cover.test_dual_roller_shutter_lower_shutter" + # Call with position 30 (=70% for device) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_POSITION: 30}, + blocking=True, + ) + + # Expect device Position 70% + args, kwargs = mock_dual_roller_shutter.set_position.await_args + position_obj = args[0] + assert position_obj.position_percent == 70 + assert kwargs.get("wait_for_completion") is False + assert kwargs.get("curtain") == "lower" + + +async def test_dual_roller_shutter_position_tests( + hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock +) -> None: + """Validate current_position and open/closed state.""" + + entity_id_dual = "cover.test_dual_roller_shutter" + entity_id_lower = "cover.test_dual_roller_shutter_lower_shutter" + entity_id_upper = "cover.test_dual_roller_shutter_upper_shutter" + + # device position is inverted (100 - x) + mock_dual_roller_shutter.position.position_percent = 29 + mock_dual_roller_shutter.position_upper_curtain.position_percent = 28 + mock_dual_roller_shutter.position_lower_curtain.position_percent = 27 + await update_callback_entity(hass, mock_dual_roller_shutter) + state = hass.states.get(entity_id_dual) + assert state is not None + assert state.attributes.get("current_position") == 71 + assert state.state == STATE_OPEN + + state = hass.states.get(entity_id_upper) + assert state is not None + assert state.attributes.get("current_position") == 72 + assert state.state == STATE_OPEN + + state = hass.states.get(entity_id_lower) + assert state is not None + assert state.attributes.get("current_position") == 73 + assert state.state == STATE_OPEN + + mock_dual_roller_shutter.position.closed = True + mock_dual_roller_shutter.position_upper_curtain.closed = True + mock_dual_roller_shutter.position_lower_curtain.closed = True + await update_callback_entity(hass, mock_dual_roller_shutter) + state = hass.states.get(entity_id_dual) + assert state is not None + assert state.state == STATE_CLOSED + + state = hass.states.get(entity_id_upper) + assert state is not None + assert state.state == STATE_CLOSED + + state = hass.states.get(entity_id_lower) + assert state is not None + assert state.state == STATE_CLOSED + + # Blind command tests From e49767d37aaf2db09031e8f5fb7ed239f1a7a903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Mon, 16 Feb 2026 18:59:45 +0100 Subject: [PATCH 25/39] GIOS quality scale fixes to platinum (#162510) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- homeassistant/components/gios/__init__.py | 16 +--- homeassistant/components/gios/config_flow.py | 16 ++-- homeassistant/components/gios/coordinator.py | 10 +-- homeassistant/components/gios/diagnostics.py | 2 +- homeassistant/components/gios/manifest.json | 1 + .../components/gios/quality_scale.yaml | 31 ++------ homeassistant/components/gios/sensor.py | 4 +- homeassistant/components/gios/strings.json | 75 ------------------- script/hassfest/quality_scale.py | 1 - .../gios/snapshots/test_sensor.ambr | 5 +- tests/components/gios/test_config_flow.py | 41 ++++++---- tests/components/gios/test_init.py | 33 +------- tests/components/gios/test_sensor.py | 16 ---- 13 files changed, 55 insertions(+), 196 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 31f704fcaccad..e19b1d280d2b9 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -8,15 +8,14 @@ from gios import Gios from gios.exceptions import GiosError -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID, DOMAIN -from .coordinator import GiosConfigEntry, GiosData, GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -56,19 +55,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = GiosData(coordinator) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Remove air_quality entities from registry if they exist - ent_reg = er.async_get(hass) - unique_id = str(coordinator.gios.station_id) - if entity_id := ent_reg.async_get_entity_id( - AIR_QUALITY_PLATFORM, DOMAIN, unique_id - ): - _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) - ent_reg.async_remove(entity_id) - return True diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 5745d15e72e81..eb83e92bc0335 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -38,14 +38,18 @@ async def async_step_user( if user_input is not None: station_id = user_input[CONF_STATION_ID] - try: - await self.async_set_unique_id(station_id, raise_on_progress=False) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(station_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + try: async with asyncio.timeout(API_TIMEOUT): gios = await Gios.create(websession, int(station_id)) await gios.async_update() - + except ApiError, ClientConnectorError, TimeoutError: + errors["base"] = "cannot_connect" + except InvalidSensorsDataError: + errors[CONF_STATION_ID] = "invalid_sensors_data" + else: # GIOS treats station ID as int user_input[CONF_STATION_ID] = int(station_id) @@ -60,10 +64,6 @@ async def async_step_user( # raising errors. data={**user_input, CONF_NAME: gios.station_name}, ) - except ApiError, ClientConnectorError, TimeoutError: - errors["base"] = "cannot_connect" - except InvalidSensorsDataError: - errors[CONF_STATION_ID] = "invalid_sensors_data" try: gios = await Gios.create(websession) diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index c80557da55f24..60525b33edf29 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass import logging from typing import TYPE_CHECKING @@ -22,14 +21,7 @@ _LOGGER = logging.getLogger(__name__) -type GiosConfigEntry = ConfigEntry[GiosData] - - -@dataclass -class GiosData: - """Data for GIOS integration.""" - - coordinator: GiosDataUpdateCoordinator +type GiosConfigEntry = ConfigEntry[GiosDataUpdateCoordinator] class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index 7e938d5ac6b58..e25f56dcbc70a 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -14,7 +14,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: GiosConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = config_entry.runtime_data.coordinator + coordinator = config_entry.runtime_data return { "config_entry": config_entry.as_dict(), diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 5cdd0d513a336..e92e14ae55539 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], + "quality_scale": "platinum", "requirements": ["gios==7.0.0"] } diff --git a/homeassistant/components/gios/quality_scale.yaml b/homeassistant/components/gios/quality_scale.yaml index cab565d35cf83..f1b25b15b55cb 100644 --- a/homeassistant/components/gios/quality_scale.yaml +++ b/homeassistant/components/gios/quality_scale.yaml @@ -1,7 +1,4 @@ rules: - # Other comments: - # - we could consider removing the air quality entity removal - # Bronze action-setup: status: exempt @@ -9,14 +6,8 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: - We should have the happy flow as the first test, which can be merged with test_show_form. - The config flow tests are missing adding a duplicate entry test. - config-flow: - status: todo - comment: Limit the scope of the try block in the user step + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: status: exempt @@ -27,9 +18,7 @@ rules: entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: - status: todo - comment: No direct need to wrap the coordinator in a dataclass to store in the config entry + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -50,11 +39,7 @@ rules: reauthentication-flow: status: exempt comment: This integration does not require authentication. - test-coverage: - status: todo - comment: - The `test_async_setup_entry` should test the state of the mock config entry, instead of an entity state - The `test_availability` doesn't really do what it says it does, and this is now already tested via the snapshot tests. + test-coverage: done # Gold devices: done @@ -78,13 +63,9 @@ rules: status: exempt comment: This integration does not have devices. entity-category: done - entity-device-class: - status: todo - comment: We can use the CO device class for the carbon monoxide sensor + entity-device-class: done entity-disabled-by-default: done - entity-translations: - status: todo - comment: We can remove the options state_attributes. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7fb6fcf431ce4..b51526ebcaf66 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -72,9 +72,9 @@ class GiosSensorEntityDescription(SensorEntityDescription): key=ATTR_CO, value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, + device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO, @@ -181,7 +181,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a GIOS entities from a config_entry.""" - coordinator = entry.runtime_data.coordinator + coordinator = entry.runtime_data # Due to the change of the attribute name of one sensor, it is necessary to migrate # the unique_id to the new name. entity_registry = er.async_get(hass) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index da9c246600a99..09d9a1dfc7b7d 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -31,26 +31,11 @@ "sufficient": "Sufficient", "very_bad": "Very bad", "very_good": "Very good" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "c6h6": { "name": "Benzene" }, - "co": { - "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" - }, "no2_index": { "name": "Nitrogen dioxide index", "state": { @@ -60,18 +45,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "nox": { @@ -86,18 +59,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "pm10_index": { @@ -109,18 +70,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "pm25_index": { @@ -132,18 +81,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } }, "so2_index": { @@ -155,18 +92,6 @@ "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - }, - "state_attributes": { - "options": { - "state": { - "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", - "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", - "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", - "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", - "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", - "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" - } - } } } } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e7b0cafd0914f..4a17f8babfb11 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1403,7 +1403,6 @@ class Rule: "geofency", "geonetnz_quakes", "geonetnz_volcano", - "gios", "github", "gitlab_ci", "gitter", diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index e5125b140d703..8ef0f86216a14 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -153,14 +153,14 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Carbon monoxide', 'platform': 'gios', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'co', + 'translation_key': None, 'unique_id': '123-co', 'unit_of_measurement': 'μg/m³', }) @@ -169,6 +169,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by GIOŚ', + 'device_class': 'carbon_monoxide', 'friendly_name': 'Home Carbon monoxide', 'state_class': , 'unit_of_measurement': 'μg/m³', diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index b7229c621be12..b0b676fdfc32e 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + CONFIG = { CONF_STATION_ID: "123", } @@ -18,8 +20,8 @@ pytestmark = pytest.mark.usefixtures("mock_gios") -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" +async def test_happy_flow(hass: HomeAssistant) -> None: + """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -28,6 +30,19 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert len(result["data_schema"].schema[CONF_STATION_ID].config["options"]) == 2 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Home" + assert result["data"] == { + CONF_STATION_ID: 123, + CONF_NAME: "Home", + } + + assert result["result"].unique_id == "123" + async def test_form_with_api_error(hass: HomeAssistant, mock_gios: MagicMock) -> None: """Test the form is aborted because of API error.""" @@ -76,21 +91,19 @@ async def test_form_submission_errors( assert result["title"] == "Home" -async def test_create_entry(hass: HomeAssistant) -> None: - """Test that the user step works.""" +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate station IDs are rejected.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Home" - assert result["data"] == { - CONF_STATION_ID: 123, - CONF_NAME: "Home", - } - - assert result["result"].unique_id == "123" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index 20944ea44276f..97e1f2f6462c3 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -4,12 +4,10 @@ import pytest -from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.gios.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from . import setup_integration @@ -19,12 +17,10 @@ @pytest.mark.usefixtures("init_integration") async def test_async_setup_entry( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test a successful setup entry.""" - state = hass.states.get("sensor.home_pm2_5") - assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == "4" + assert mock_config_entry.state is ConfigEntryState.LOADED async def test_config_not_ready( @@ -93,26 +89,3 @@ async def test_migrate_unique_id_to_str( await setup_integration(hass, mock_config_entry) assert mock_config_entry.unique_id == "123" - - -async def test_remove_air_quality_entities( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_gios: MagicMock, -) -> None: - """Test remove air_quality entities from registry.""" - mock_config_entry.add_to_hass(hass) - entity_registry.async_get_or_create( - AIR_QUALITY_PLATFORM, - DOMAIN, - "123", - suggested_object_id="home", - disabled_by=None, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - entry = entity_registry.async_get("air_quality.home") - assert entry is None diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index b668de99a4e7f..37cd27b78b61c 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -37,22 +37,6 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.usefixtures("init_integration") -async def test_availability(hass: HomeAssistant) -> None: - """Ensure that we mark the entities unavailable correctly when service causes an error.""" - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" - - state = hass.states.get("sensor.home_pm2_5_index") - assert state - assert state.state == "good" - - state = hass.states.get("sensor.home_air_quality_index") - assert state - assert state.state == "good" - - @pytest.mark.usefixtures("init_integration") async def test_availability_api_error( hass: HomeAssistant, From 9dc38eda9f155a2e83e0cf607156011031b2b807 Mon Sep 17 00:00:00 2001 From: theobld-ww <60600399+theobld-ww@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:00:49 +0100 Subject: [PATCH 26/39] Reauthentication flow for Watts Vision + integration (#163141) --- homeassistant/components/watts/config_flow.py | 31 +++++- homeassistant/components/watts/manifest.json | 2 +- .../components/watts/quality_scale.yaml | 2 +- homeassistant/components/watts/strings.json | 6 + tests/components/watts/test_config_flow.py | 103 ++++++++++++++++++ 5 files changed, 141 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/watts/config_flow.py b/homeassistant/components/watts/config_flow.py index c71e67528aa2a..620d376cfec41 100644 --- a/homeassistant/components/watts/config_flow.py +++ b/homeassistant/components/watts/config_flow.py @@ -1,11 +1,12 @@ """Config flow for Watts Vision integration.""" +from collections.abc import Mapping import logging from typing import Any from visionpluspython.auth import WattsVisionAuth -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -32,6 +33,25 @@ def extra_authorize_data(self) -> dict[str, Any]: "prompt": "consent", } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + + return await self.async_step_pick_implementation( + user_input={ + "implementation": self._get_reauth_entry().data["auth_implementation"] + } + ) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the OAuth2 flow.""" @@ -42,6 +62,15 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu return self.async_abort(reason="invalid_token") await self.async_set_unique_id(user_id) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data=data, + ) + self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/watts/manifest.json b/homeassistant/components/watts/manifest.json index 71fac5e6a6935..25135798cb2b8 100644 --- a/homeassistant/components/watts/manifest.json +++ b/homeassistant/components/watts/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials", "cloud"], "documentation": "https://www.home-assistant.io/integrations/watts", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["visionpluspython==1.0.2"] } diff --git a/homeassistant/components/watts/quality_scale.yaml b/homeassistant/components/watts/quality_scale.yaml index 152dcbbd3f5c5..812a904bc0769 100644 --- a/homeassistant/components/watts/quality_scale.yaml +++ b/homeassistant/components/watts/quality_scale.yaml @@ -30,7 +30,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/watts/strings.json b/homeassistant/components/watts/strings.json index d7a38341abe14..4524f670e731f 100644 --- a/homeassistant/components/watts/strings.json +++ b/homeassistant/components/watts/strings.json @@ -12,6 +12,8 @@ "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_account_mismatch": "The authenticated account does not match the account that needed re-authentication", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -20,6 +22,10 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Watts Vision + integration needs to re-authenticate your account", + "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/tests/components/watts/test_config_flow.py b/tests/components/watts/test_config_flow.py index 8b56bda1ae1e3..67c9fbf64a63f 100644 --- a/tests/components/watts/test_config_flow.py +++ b/tests/components/watts/test_config_flow.py @@ -197,6 +197,109 @@ async def test_oauth_invalid_response( assert result.get("reason") == "oauth_failed" +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="test-user-id", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + mock_config_entry.data["token"].pop("expires_at") + assert mock_config_entry.data["token"] == { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + } + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication with a different account aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + + with patch( + "homeassistant.components.watts.config_flow.WattsVisionAuth.extract_user_id_from_token", + return_value="different-user-id", + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + + @pytest.mark.usefixtures("current_request_with_host") async def test_unique_config_entry( hass: HomeAssistant, From c833cfa395af4afecff6e9ad29bc3d8574357409 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 16 Feb 2026 19:11:36 +0100 Subject: [PATCH 27/39] Don't mock out filesystem operations in backup_restore tests (#163172) --- tests/common.py | 6 +- .../backup_restore/backup_from_future.tar | Bin 0 -> 10240 bytes .../empty_backup_database_included.tar | Bin 0 -> 10240 bytes .../core/backup_restore/restore1.json | 0 .../core/backup_restore/restore2.json | 1 + .../core/backup_restore/restore3.json | 7 + .../core/backup_restore/restore4.json | 7 + tests/test_backup_restore.py | 522 ++++++++++-------- 8 files changed, 305 insertions(+), 238 deletions(-) create mode 100644 tests/fixtures/core/backup_restore/backup_from_future.tar create mode 100644 tests/fixtures/core/backup_restore/empty_backup_database_included.tar create mode 100644 tests/fixtures/core/backup_restore/restore1.json create mode 100644 tests/fixtures/core/backup_restore/restore2.json create mode 100644 tests/fixtures/core/backup_restore/restore3.json create mode 100644 tests/fixtures/core/backup_restore/restore4.json diff --git a/tests/common.py b/tests/common.py index efda5a6a1c3d6..2e1a9f3fe14ee 100644 --- a/tests/common.py +++ b/tests/common.py @@ -560,7 +560,11 @@ def _async_fire_time_changed( def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.Path: """Get path of fixture.""" - if integration is None and "/" in filename and not filename.startswith("helpers/"): + if ( + integration is None + and "/" in filename + and not filename.startswith(("core/", "helpers/")) + ): integration, filename = filename.split("/", 1) if integration is None: diff --git a/tests/fixtures/core/backup_restore/backup_from_future.tar b/tests/fixtures/core/backup_restore/backup_from_future.tar new file mode 100644 index 0000000000000000000000000000000000000000..4e4b4545ff590e0998067724b72e4bc40b96d540 GIT binary patch literal 10240 zcmeIw-D<)x6bEo0rQ~|+(o%K$9A0gV65FVmZBmkx=@8$2C*2qBUKscffs)gcoReRh zKde==k3;?%oSj@}NFKAQnxrR>zfW1s=ijl<_k}E{o3C__+@`{sA!dMoy zjjl~PLPWc_?qKT}dCiFjL%UiE3m*A6MLN`~e!Rm<2g=IS$lx96hQ^C-R2R}sLl?Jc zbE`Mibe6K{eHix*%9BxD3jG!-&o}d*;Gz(K00bZa0SG_<0uX=z1Rwwb2tWV=5P$## cAOHafKmY;|fB*y_009U<00Izz!2c8Y0B6p0pa1{> literal 0 HcmV?d00001 diff --git a/tests/fixtures/core/backup_restore/empty_backup_database_included.tar b/tests/fixtures/core/backup_restore/empty_backup_database_included.tar new file mode 100644 index 0000000000000000000000000000000000000000..0090e2237db1cddbbf9265807ac100c6ccc25d9e GIT binary patch literal 10240 zcmeH|YitZb9Ka93DM6%YBH>)Lh}xUoZf~z#(~3kIB_S#jT4#DUYTLWrvpah|J$3br zR;p1Ep&wMLJrb$wClQZ|ND=Q4AKD;DiVx=%tyfsHCnbmYAo>9_v&m%s`+v=E=0C&O zilMtq^E0KYESdVJ0n+R|Z!$*Gz8?WE2z|0I|5?CgawZGtpL7zvYAS{b37;taZ)PDY z^mt@Rwa&7vC`z|j-LlWGAXP;kBEgD=h|B{qA~P$S6{v4t}d@hdV$ZX91|pSkMQN(@_A>}I(PLkq9-$L;)3!6Nh;qHi&{%D?`cFgC`P zy9fsJ5G2t1XE{bulh#+`-;V%fe7zgK=#lzoN&N@=C!IvE|9Sm$4vPZCH@4ubg4QwXM^LCCSrEdZ(M-1ru z|EKriXZ|MbFq`L0*)5v%A5MZPw)bY~W{|QqbUN~6IG+|CbF1MTQ+`4)uWjKA%Br7s zUN{hl29RXlA9x!UhAnpFoL5KIZ<%uaSoiDVh0;fF@{_4WGnxwvdt+HmXII&CYBR2t zkJP}KU3CY0s?I7ahb-$HyQF60q4`QZ(_A>vc{gPDtk2`l%quHRMJKt`2NfS*Id?DE zp4`~BL2XUuKka=~7kJs;BYo%!g|qd@=qq8;^-J{)t2$b%?!CKyc+vFk@Ccx@q(?4i z>B3TRd`oN5qpd|Zp69;u=GNZ1ePs0AccUJr>4V3Y+Up)(s(##lTswT~YES2~=4)5F zdZX=c7UVgjdK>AH{1V-^vn3_3DXiyrOz#bki0YAr_4^7Nj<(j!jaI~xW}oWVqlcgA zUDPPy&PPy& Generator[None]: - """Remove the restore result file.""" - yield - Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) - - -def restore_result_file_content() -> dict[str, Any] | None: +def restore_result_file_content(config_dir: Path) -> dict[str, Any] | None: """Return the content of the restore result file.""" try: - return json.loads( - Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") - ) + return json.loads((config_dir / ".HA_RESTORE_RESULT").read_text("utf-8")) except FileNotFoundError: return None @pytest.mark.parametrize( - ("side_effect", "content", "expected"), + ("restore_config", "expected", "restore_result"), [ - (FileNotFoundError, "", None), - (None, "", None), ( + "restore1.json", # Empty file, so JSONDecodeError is expected None, - '{"path": "test"}', - None, + { + "success": False, + "error": "Expecting value: line 1 column 1 (char 0)", + "error_type": "JSONDecodeError", + }, ), ( + "restore2.json", # File missing the 'password' key, so KeyError is expected None, - '{"path": "test", "password": "psw", "remove_after_restore": false, "restore_database": false, "restore_homeassistant": true}', + {"success": False, "error": "'password'", "error_type": "KeyError"}, + ), + ( + "restore3.json", # Valid file backup_restore.RestoreBackupFileContent( backup_file_path=Path("test"), password="psw", @@ -51,10 +47,10 @@ def restore_result_file_content() -> dict[str, Any] | None: restore_database=False, restore_homeassistant=True, ), + None, ), ( - None, - '{"path": "test", "password": null, "remove_after_restore": true, "restore_database": true, "restore_homeassistant": false}', + "restore4.json", # Valid file backup_restore.RestoreBackupFileContent( backup_file_path=Path("test"), password=None, @@ -62,128 +58,180 @@ def restore_result_file_content() -> dict[str, Any] | None: restore_database=True, restore_homeassistant=False, ), + None, ), ], ) def test_reading_the_instruction_contents( - side_effect: Exception | None, - content: str, + restore_config: str, expected: backup_restore.RestoreBackupFileContent | None, + restore_result: dict[str, Any] | None, + tmp_path: Path, ) -> None: """Test reading the content of the .HA_RESTORE file.""" - with ( - mock.patch( - "pathlib.Path.read_text", - return_value=content, - side_effect=side_effect, - ), - mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, - ): - config_path = Path(get_test_config_dir()) - read_content = backup_restore.restore_backup_file_content(config_path) - assert read_content == expected - unlink_mock.assert_called_once_with( - config_path / ".HA_RESTORE", missing_ok=True - ) + get_fixture_path(f"core/backup_restore/{restore_config}", None).copy( + tmp_path / ".HA_RESTORE" + ) + restore_file_path = tmp_path / ".HA_RESTORE" + assert restore_file_path.exists() + + read_content = backup_restore.restore_backup_file_content(tmp_path) + assert read_content == expected + assert not restore_file_path.exists() + assert restore_result_file_content(tmp_path) == restore_result -def test_restoring_backup_that_does_not_exist() -> None: +def test_reading_the_instruction_contents_missing(tmp_path: Path) -> None: + """Test reading the content of the .HA_RESTORE file when it is missing.""" + assert not (tmp_path / ".HA_RESTORE").exists() + + read_content = backup_restore.restore_backup_file_content(tmp_path) + assert read_content is None + assert not (tmp_path / ".HA_RESTORE").exists() + assert restore_result_file_content(tmp_path) is None + + +@pytest.mark.parametrize( + ("restore_config"), + [ + "restore3.json", + "restore4.json", + ], +) +def test_restoring_backup_that_does_not_exist( + restore_config: str, tmp_path: Path +) -> None: """Test restoring a backup that does not exist.""" - backup_file_path = Path(get_test_config_dir("backups", "test")) + get_fixture_path(f"core/backup_restore/{restore_config}", None).copy( + tmp_path / ".HA_RESTORE" + ) + restore_file_path = tmp_path / ".HA_RESTORE" + assert restore_file_path.exists() with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path, - password=None, - remove_after_restore=False, - restore_database=True, - restore_homeassistant=True, - ), - ), - mock.patch("pathlib.Path.read_text", side_effect=FileNotFoundError), - pytest.raises( - ValueError, match=f"Backup file {backup_file_path} does not exist" - ), + pytest.raises(ValueError, match="Backup file test does not exist"), ): - assert backup_restore.restore_backup(Path(get_test_config_dir())) is False - assert restore_result_file_content() == { - "error": f"Backup file {backup_file_path} does not exist", + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + assert restore_result_file_content(tmp_path) == { + "error": "Backup file test does not exist", "error_type": "ValueError", "success": False, } -def test_restoring_backup_when_instructions_can_not_be_read() -> None: - """Test restoring a backup when instructions can not be read.""" - with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=None, +@pytest.mark.parametrize( + ("restore_config", "restore_result"), + [ + ( + "restore1.json", # Empty file, so JSONDecodeError is expected + { + "success": False, + "error": "Expecting value: line 1 column 1 (char 0)", + "error_type": "JSONDecodeError", + }, ), - ): - assert backup_restore.restore_backup(Path(get_test_config_dir())) is False - assert restore_result_file_content() is None + ( + "restore2.json", # File missing the 'password' key, so KeyError is expected + {"success": False, "error": "'password'", "error_type": "KeyError"}, + ), + ], +) +def test_restoring_backup_when_instructions_can_not_be_read( + restore_config: str, restore_result: dict[str, Any], tmp_path: Path +) -> None: + """Test restoring a backup when instructions can not be read.""" + get_fixture_path(f"core/backup_restore/{restore_config}", None).copy( + tmp_path / ".HA_RESTORE" + ) + restore_file_path = tmp_path / ".HA_RESTORE" + assert restore_file_path.exists() + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + assert not restore_file_path.exists() + assert restore_result_file_content(tmp_path) == restore_result + + +def test_restoring_backup_when_instructions_missing(tmp_path: Path) -> None: + """Test restoring a backup when instructions are missing.""" + restore_file_path = tmp_path / ".HA_RESTORE" + assert not restore_file_path.exists() + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + assert not restore_file_path.exists() + assert restore_result_file_content(tmp_path) is None -def test_restoring_backup_that_is_not_a_file() -> None: +@pytest.mark.parametrize( + ("restore_config"), + [ + "restore3.json", + "restore4.json", + ], +) +def test_restoring_backup_that_is_not_a_file( + restore_config: str, tmp_path: Path +) -> None: """Test restoring a backup that is not a file.""" - backup_file_path = Path(get_test_config_dir("backups", "test")) + backup_file_path = tmp_path / "test" + restore_file_path = tmp_path / ".HA_RESTORE" + + # Set up restore file to point to a file within the temporary directory + restore_config = json.load( + get_fixture_path(f"core/backup_restore/{restore_config}", None).open( + "r", encoding="utf-8" + ) + ) + restore_config["path"] = backup_file_path.as_posix() + json.dump(restore_config, restore_file_path.open("w", encoding="utf-8")) + assert restore_file_path.exists() + + # Create a directory at the backup file path to simulate the backup file not being a file + backup_file_path.mkdir(exist_ok=True) + with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path, - password=None, - remove_after_restore=False, - restore_database=True, - restore_homeassistant=True, - ), - ), - mock.patch("pathlib.Path.exists", return_value=True), - mock.patch("pathlib.Path.is_file", return_value=False), - pytest.raises( - ValueError, match=f"Backup file {backup_file_path} does not exist" - ), + pytest.raises(IsADirectoryError, match="\\[Errno 21\\] Is a directory"), ): - assert backup_restore.restore_backup(Path(get_test_config_dir())) is False - assert restore_result_file_content() == { - "error": f"Backup file {backup_file_path} does not exist", - "error_type": "ValueError", + assert backup_restore.restore_backup(tmp_path.as_posix()) is False + restore_result = restore_result_file_content(tmp_path) + assert restore_result == { + "error": mock.ANY, + "error_type": "IsADirectoryError", "success": False, } + assert restore_result["error"].startswith("[Errno 21] Is a directory:") -def test_aborting_for_older_versions() -> None: +@pytest.mark.parametrize( + ("restore_config"), + [ + "restore3.json", + "restore4.json", + ], +) +def test_aborting_for_older_versions(restore_config: str, tmp_path: Path) -> None: """Test that we abort for older versions.""" - config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") + backup_file_path = tmp_path / "backup_from_future.tar" + restore_file_path = tmp_path / ".HA_RESTORE" - def _patched_path_read_text(path: Path, **kwargs): - return '{"homeassistant": {"version": "9999.99.99"}, "compressed": false}' + # Set up restore file to point to a file within the temporary directory + restore_config = json.load( + get_fixture_path(f"core/backup_restore/{restore_config}", None).open( + "r", encoding="utf-8" + ) + ) + restore_config["path"] = backup_file_path.as_posix() + json.dump(restore_config, restore_file_path.open("w", encoding="utf-8")) + assert restore_file_path.exists() + + get_fixture_path("core/backup_restore/backup_from_future.tar", None).copy_into( + tmp_path + ) with ( - mock.patch( - "homeassistant.backup_restore.restore_backup_file_content", - return_value=backup_restore.RestoreBackupFileContent( - backup_file_path=backup_file_path, - password=None, - remove_after_restore=False, - restore_database=True, - restore_homeassistant=True, - ), - ), - mock.patch("securetar.SecureTarFile"), - mock.patch("homeassistant.backup_restore.TemporaryDirectory"), - mock.patch("pathlib.Path.read_text", _patched_path_read_text), - mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), pytest.raises( ValueError, match="You need at least Home Assistant version 9999.99.99 to restore this backup", ), ): - assert backup_restore.restore_backup(config_dir) is True - assert restore_result_file_content() == { + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + assert restore_result_file_content(tmp_path) == { "error": ( "You need at least Home Assistant version 9999.99.99 to restore this backup" ), @@ -195,10 +243,9 @@ def _patched_path_read_text(path: Path, **kwargs): @pytest.mark.parametrize( ( "restore_backup_content", - "expected_removed_files", - "expected_removed_directories", - "expected_copied_files", - "expected_copied_trees", + "expected_kept_files", + "expected_restored_files", + "expected_directories_after_restore", ), [ ( @@ -209,15 +256,9 @@ def _patched_path_read_text(path: Path, **kwargs): restore_database=True, restore_homeassistant=True, ), - ( - ".HA_RESTORE", - ".HA_VERSION", - "home-assistant_v2.db", - "home-assistant_v2.db-wal", - ), - ("tmp_backups", "www"), - (), - ("data",), + {"backups/test.tar"}, + {"home-assistant_v2.db", "home-assistant_v2.db-wal"}, + {"backups"}, ), ( backup_restore.RestoreBackupFileContent( @@ -227,10 +268,9 @@ def _patched_path_read_text(path: Path, **kwargs): remove_after_restore=False, restore_homeassistant=True, ), - (".HA_RESTORE", ".HA_VERSION"), - ("tmp_backups", "www"), - (), - ("data",), + {"backups/test.tar", "home-assistant_v2.db", "home-assistant_v2.db-wal"}, + set(), + {"backups"}, ), ( backup_restore.RestoreBackupFileContent( @@ -240,109 +280,124 @@ def _patched_path_read_text(path: Path, **kwargs): remove_after_restore=False, restore_homeassistant=False, ), - ("home-assistant_v2.db", "home-assistant_v2.db-wal"), - (), - ("home-assistant_v2.db", "home-assistant_v2.db-wal"), - (), + {".HA_RESTORE", ".HA_VERSION", "backups/test.tar"}, + {"home-assistant_v2.db", "home-assistant_v2.db-wal"}, + {"backups", "tmp_backups", "www"}, ), ], ) -def test_removal_of_current_configuration_when_restoring( +def test_restore_backup( restore_backup_content: backup_restore.RestoreBackupFileContent, - expected_removed_files: tuple[str, ...], - expected_removed_directories: tuple[str, ...], - expected_copied_files: tuple[str, ...], - expected_copied_trees: tuple[str, ...], + expected_kept_files: set[str], + expected_restored_files: set[str], + expected_directories_after_restore: set[str], + tmp_path: Path, ) -> None: - """Test that we are removing the current configuration directory.""" - config_dir = Path(get_test_config_dir()) - restore_backup_content.backup_file_path = Path(config_dir, "backups", "test.tar") - mock_config_dir = [ - {"path": Path(config_dir, ".HA_RESTORE"), "is_file": True}, - {"path": Path(config_dir, ".HA_VERSION"), "is_file": True}, - {"path": Path(config_dir, "home-assistant_v2.db"), "is_file": True}, - {"path": Path(config_dir, "home-assistant_v2.db-wal"), "is_file": True}, - {"path": Path(config_dir, "backups"), "is_file": False}, - {"path": Path(config_dir, "tmp_backups"), "is_file": False}, - {"path": Path(config_dir, "www"), "is_file": False}, - ] - - def _patched_path_read_text(path: Path, **kwargs): - return '{"homeassistant": {"version": "2013.09.17"}, "compressed": false}' - - def _patched_path_is_file(path: Path, **kwargs): - return [x for x in mock_config_dir if x["path"] == path][0]["is_file"] - - def _patched_path_is_dir(path: Path, **kwargs): - return not [x for x in mock_config_dir if x["path"] == path][0]["is_file"] + """Test restoring a backup. + + This includes checking that expected files are kept, restored, and + that we are cleaning up the current configuration directory. + """ + backup_file_path = tmp_path / "backups" / "test.tar" + + def get_files(path: Path) -> set[str]: + """Get all files under path.""" + return {str(f.relative_to(path)) for f in path.rglob("*")} + + existing_dirs = { + "backups", + "tmp_backups", + "www", + } + existing_files = { + ".HA_RESTORE", + ".HA_VERSION", + "home-assistant_v2.db", + "home-assistant_v2.db-wal", + } + + for d in existing_dirs: + (tmp_path / d).mkdir(exist_ok=True) + for f in existing_files: + (tmp_path / f).write_text("before_restore") + + get_fixture_path( + "core/backup_restore/empty_backup_database_included.tar", None + ).copy(backup_file_path) + + files_before_restore = get_files(tmp_path) + assert files_before_restore == { + ".HA_RESTORE", + ".HA_VERSION", + "backups", + "backups/test.tar", + "home-assistant_v2.db", + "home-assistant_v2.db-wal", + "tmp_backups", + "www", + } + kept_files_data = {} + for file in expected_kept_files: + kept_files_data[file] = (tmp_path / file).read_bytes() + + restore_backup_content.backup_file_path = backup_file_path with ( mock.patch( "homeassistant.backup_restore.restore_backup_file_content", return_value=restore_backup_content, ), - mock.patch("securetar.SecureTarFile"), - mock.patch("homeassistant.backup_restore.TemporaryDirectory") as temp_dir_mock, - mock.patch("homeassistant.backup_restore.HA_VERSION", "2013.09.17"), - mock.patch("pathlib.Path.read_text", _patched_path_read_text), - mock.patch("pathlib.Path.is_file", _patched_path_is_file), - mock.patch("pathlib.Path.is_dir", _patched_path_is_dir), - mock.patch( - "pathlib.Path.iterdir", - return_value=[x["path"] for x in mock_config_dir], - ), - mock.patch("pathlib.Path.unlink", autospec=True) as unlink_mock, - mock.patch("shutil.copy") as copy_mock, - mock.patch("shutil.copytree") as copytree_mock, - mock.patch("shutil.rmtree") as rmtree_mock, ): - temp_dir_mock.return_value.__enter__.return_value = "tmp" - - assert backup_restore.restore_backup(config_dir) is True - - tmp_ha = Path("tmp", "homeassistant") - assert copy_mock.call_count == len(expected_copied_files) - copied_files = {Path(call.args[0]) for call in copy_mock.mock_calls} - assert copied_files == {Path(tmp_ha, "data", f) for f in expected_copied_files} - - assert copytree_mock.call_count == len(expected_copied_trees) - copied_trees = {Path(call.args[0]) for call in copytree_mock.mock_calls} - assert copied_trees == {Path(tmp_ha, t) for t in expected_copied_trees} + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + + files_after_restore = get_files(tmp_path) + assert ( + files_after_restore + == {".HA_RESTORE_RESULT"} + | expected_kept_files + | expected_restored_files + | expected_directories_after_restore + ) - assert unlink_mock.call_count == len(expected_removed_files) - removed_files = {Path(call.args[0]) for call in unlink_mock.mock_calls} - assert removed_files == {Path(config_dir, f) for f in expected_removed_files} + for d in expected_directories_after_restore: + assert (tmp_path / d).is_dir() + for file in expected_kept_files: + assert (tmp_path / file).read_bytes() == kept_files_data[file] + for file in expected_restored_files: + assert (tmp_path / file).read_bytes() == b"restored_from_backup" - assert rmtree_mock.call_count == len(expected_removed_directories) - removed_directories = {Path(call.args[0]) for call in rmtree_mock.mock_calls} - assert removed_directories == { - Path(config_dir, d) for d in expected_removed_directories - } - assert restore_result_file_content() == { + assert restore_result_file_content(tmp_path) == { "error": None, "error_type": None, "success": True, } -def test_extracting_the_contents_of_a_backup_file() -> None: - """Test extracting the contents of a backup file.""" - config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") +def test_restore_backup_filter_files(tmp_path: Path) -> None: + """Test filtering dangerous files when restoring a backup.""" + backup_file_path = tmp_path / "backups" / "test.tar" + backup_file_path.parent.mkdir() + get_fixture_path( + "core/backup_restore/empty_backup_database_included.tar", None + ).copy(backup_file_path) - def _patched_path_read_text(path: Path, **kwargs): - return '{"homeassistant": {"version": "2013.09.17"}, "compressed": false}' + with ( + tarfile.open(backup_file_path, "r") as outer_tar, + tarfile.open( + fileobj=outer_tar.extractfile("homeassistant.tar.gz"), mode="r|gz" + ) as inner_tar, + ): + member_names = {member.name for member in inner_tar.getmembers()} + assert member_names == { + ".", + "../bad_file_with_parent_link", + "/bad_absolute_file", + "data", + "data/home-assistant_v2.db", + "data/home-assistant_v2.db-wal", + } - getmembers_mock = mock.MagicMock( - return_value=[ - tarfile.TarInfo(name="../data/test"), - tarfile.TarInfo(name="data"), - tarfile.TarInfo(name="data/.HA_VERSION"), - tarfile.TarInfo(name="data/.storage"), - tarfile.TarInfo(name="data/www"), - ] - ) - extractall_mock = mock.MagicMock() + real_extractone = tarfile.TarFile._extract_one with ( mock.patch( @@ -356,41 +411,38 @@ def _patched_path_read_text(path: Path, **kwargs): ), ), mock.patch( - "tarfile.open", - return_value=mock.MagicMock( - getmembers=getmembers_mock, - extractall=extractall_mock, - __iter__=lambda x: iter(getmembers_mock.return_value), - ), - ), - mock.patch("homeassistant.backup_restore.TemporaryDirectory"), - mock.patch("pathlib.Path.read_text", _patched_path_read_text), - mock.patch("pathlib.Path.is_file", return_value=False), - mock.patch("pathlib.Path.iterdir", return_value=[]), - mock.patch("shutil.copytree"), + "tarfile.TarFile._extract_one", autospec=True, wraps=real_extractone + ) as extractone_mock, ): - assert backup_restore.restore_backup(config_dir) is True - assert extractall_mock.call_count == 2 - - assert { - member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] - } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} - assert restore_result_file_content() == { + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + + # Check the unsafe files are not extracted, and that the safe files are extracted + extracted_files = {call.args[1].name for call in extractone_mock.mock_calls} + assert extracted_files == { + "./backup.json", # From the outer tar + "homeassistant.tar.gz", # From the outer tar + ".", + "data", + "data/home-assistant_v2.db", + "data/home-assistant_v2.db-wal", + } + assert restore_result_file_content(tmp_path) == { "error": None, "error_type": None, "success": True, } -@pytest.mark.parametrize( - ("remove_after_restore", "unlink_calls"), [(True, 1), (False, 0)] -) +@pytest.mark.parametrize(("remove_after_restore"), [True, False]) def test_remove_backup_file_after_restore( - remove_after_restore: bool, unlink_calls: int + remove_after_restore: bool, tmp_path: Path ) -> None: """Test removing a backup file after restore.""" - config_dir = Path(get_test_config_dir()) - backup_file_path = Path(config_dir, "backups", "test.tar") + backup_file_path = tmp_path / "backups" / "test.tar" + backup_file_path.parent.mkdir() + get_fixture_path( + "core/backup_restore/empty_backup_database_included.tar", None + ).copy(backup_file_path) with ( mock.patch( @@ -403,14 +455,10 @@ def test_remove_backup_file_after_restore( restore_homeassistant=True, ), ), - mock.patch("homeassistant.backup_restore._extract_backup"), - mock.patch("pathlib.Path.unlink", autospec=True) as mock_unlink, ): - assert backup_restore.restore_backup(config_dir) is True - assert mock_unlink.call_count == unlink_calls - for call in mock_unlink.mock_calls: - assert call.args[0] == backup_file_path - assert restore_result_file_content() == { + assert backup_restore.restore_backup(tmp_path.as_posix()) is True + assert backup_file_path.exists() == (not remove_after_restore) + assert restore_result_file_content(tmp_path) == { "error": None, "error_type": None, "success": True, From 957c6039e9c65dd9bb5d395efe652707017d8ea9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 16 Feb 2026 19:43:28 +0100 Subject: [PATCH 28/39] Fix `reboot_gateway` action deprecation message in `velux` (#163201) --- homeassistant/components/velux/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 98745106b3dbc..a52fb0a245c9d 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -68,8 +68,8 @@ }, "issues": { "deprecated_reboot_service": { - "description": "The `velux.reboot_gateway` service is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", - "title": "Velux reboot service is deprecated" + "description": "The `velux.reboot_gateway` action is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", + "title": "Velux 'Reboot gateway' action deprecated" } }, "services": { From 47d6e3e93804fa4ddd3a50ee4bed2d4084e0b01e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:11:04 +0100 Subject: [PATCH 29/39] Refactor HTML5 integration to use aiohttp instead of requests (#163202) --- homeassistant/components/html5/notify.py | 50 +++--- tests/components/html5/conftest.py | 58 ++++++- tests/components/html5/test_notify.py | 195 ++++++++++++----------- 3 files changed, 186 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index ff3354e5c7776..49f7522c03c64 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -4,7 +4,6 @@ from contextlib import suppress from datetime import datetime, timedelta -from functools import partial from http import HTTPStatus import json import logging @@ -13,7 +12,7 @@ from urllib.parse import urlparse import uuid -from aiohttp import web +from aiohttp import ClientSession, web from aiohttp.hdrs import AUTHORIZATION import jwt from py_vapid import Vapid @@ -35,6 +34,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string @@ -203,8 +203,9 @@ def websocket_appkey( hass.http.register_view(HTML5PushRegistrationView(registrations, json_path)) hass.http.register_view(HTML5PushCallbackView(registrations)) + session = async_get_clientsession(hass) return HTML5NotificationService( - hass, vapid_prv_key, vapid_email, registrations, json_path + hass, session, vapid_prv_key, vapid_email, registrations, json_path ) @@ -420,12 +421,14 @@ class HTML5NotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, + session: ClientSession, vapid_prv: str, vapid_email: str, registrations: dict[str, Registration], json_path: str, ) -> None: """Initialize the service.""" + self.session = session self._vapid_prv = vapid_prv self._vapid_email = vapid_email self.registrations = registrations @@ -456,22 +459,18 @@ def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" return {registration: registration for registration in self.registrations} - def dismiss(self, **kwargs: Any) -> None: - """Dismisses a notification.""" - data: dict[str, Any] | None = kwargs.get(ATTR_DATA) - tag: str = data.get(ATTR_TAG, "") if data else "" - payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} - - self._push_message(payload, **kwargs) - - async def async_dismiss(self, **kwargs) -> None: + async def async_dismiss(self, **kwargs: Any) -> None: """Dismisses a notification. This method must be run in the event loop. """ - await self.hass.async_add_executor_job(partial(self.dismiss, **kwargs)) + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) + tag: str = data.get(ATTR_TAG, "") if data else "" + payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} + + await self._push_message(payload, **kwargs) - def send_message(self, message: str = "", **kwargs: Any) -> None: + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" tag = str(uuid.uuid4()) payload: dict[str, Any] = { @@ -503,9 +502,9 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: ): payload[ATTR_DATA][ATTR_URL] = URL_ROOT - self._push_message(payload, **kwargs) + await self._push_message(payload, **kwargs) - def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: + async def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: """Send the message.""" timestamp = int(time.time()) @@ -535,7 +534,9 @@ def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: subscription["keys"]["auth"], ) - webpusher = WebPusher(cast(dict[str, Any], info["subscription"])) + webpusher = WebPusher( + cast(dict[str, Any], info["subscription"]), aiohttp_session=self.session + ) endpoint = urlparse(subscription["endpoint"]) vapid_claims = { @@ -545,28 +546,31 @@ def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: } vapid_headers = Vapid.from_string(self._vapid_prv).sign(vapid_claims) vapid_headers.update({"urgency": priority, "priority": priority}) - response = webpusher.send( + + response = await webpusher.send_async( data=json.dumps(payload), headers=vapid_headers, ttl=ttl ) if TYPE_CHECKING: assert not isinstance(response, str) - if response.status_code == HTTPStatus.GONE: + if response.status == HTTPStatus.GONE: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) try: - save_json(self.registrations_json_path, self.registrations) + await self.hass.async_add_executor_job( + save_json, self.registrations_json_path, self.registrations + ) except HomeAssistantError: self.registrations[target] = reg _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") - elif response.status_code >= HTTPStatus.BAD_REQUEST: + elif response.status >= HTTPStatus.BAD_REQUEST: _LOGGER.error( "There was an issue sending the notification %s: %s", - response.status_code, - response.text, + response.status, + await response.text(), ) diff --git a/tests/components/html5/conftest.py b/tests/components/html5/conftest.py index 9c5322b94a67a..d24e3102142ee 100644 --- a/tests/components/html5/conftest.py +++ b/tests/components/html5/conftest.py @@ -1,8 +1,9 @@ """Common fixtures for html5 integration.""" from collections.abc import Generator -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +from aiohttp import ClientResponse import pytest from homeassistant.components.html5.const import ( @@ -45,3 +46,58 @@ def mock_load_config() -> Generator[MagicMock]: "homeassistant.components.html5.notify._load_config", return_value={} ) as mock_load_config: yield mock_load_config + + +@pytest.fixture +def mock_wp() -> Generator[AsyncMock]: + """Mock WebPusher.""" + + with ( + patch( + "homeassistant.components.html5.notify.WebPusher", autospec=True + ) as mock_client, + ): + client = mock_client.return_value + client.cls = mock_client + client.send_async.return_value = AsyncMock(spec=ClientResponse, status=201) + yield client + + +@pytest.fixture +def mock_jwt() -> Generator[MagicMock]: + """Mock JWT.""" + + with ( + patch("homeassistant.components.html5.notify.jwt") as mock_client, + ): + mock_client.encode.return_value = "JWT" + mock_client.decode.return_value = {"target": "device"} + yield mock_client + + +@pytest.fixture +def mock_uuid() -> Generator[MagicMock]: + """Mock UUID.""" + + with ( + patch("homeassistant.components.html5.notify.uuid") as mock_client, + ): + mock_client.uuid4.return_value = "12345678-1234-5678-1234-567812345678" + yield mock_client + + +@pytest.fixture +def mock_vapid() -> Generator[MagicMock]: + """Mock VAPID headers.""" + + with ( + patch( + "homeassistant.components.html5.notify.Vapid", autospec=True + ) as mock_client, + ): + mock_client.from_string.return_value.sign.return_value = { + "Authorization": "vapid t=signed!!!", + "urgency": "normal", + "priority": "normal", + } + yield mock_client diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index d1d37cc0e164d..3861cca25cd67 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -2,7 +2,7 @@ from http import HTTPStatus import json -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch from aiohttp.hdrs import AUTHORIZATION import pytest @@ -71,6 +71,12 @@ REGISTER_URL = "/api/notify.html5" PUBLISH_URL = "/api/notify.html5/callback" +VAPID_HEADERS = { + "Authorization": "vapid t=signed!!!", + "urgency": "normal", + "priority": "normal", +} + async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" @@ -82,11 +88,11 @@ async def test_get_service_with_no_json(hass: HomeAssistant) -> None: assert service is not None -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_dismissing_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test dismissing message.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"device": SUBSCRIPTION_1} @@ -99,23 +105,18 @@ async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: await service.async_dismiss(target=["device", "non_existing"], data={"tag": "test"}) - assert len(mock_wp.mock_calls) == 4 - - # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert payload["dismiss"] is True - assert payload["tag"] == "test" + mock_wp.send_async.assert_awaited_once_with( + data='{"tag": "test", "dismiss": true, "data": {"jwt": "JWT"}, "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_sending_message(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test sending message.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"device": SUBSCRIPTION_1} @@ -130,23 +131,21 @@ async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: "Hello", target=["device", "non_existing"], data={"icon": "beer.png"} ) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - payload = json.loads(mock_wp.mock_calls[3][2]["data"]) + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] - assert payload["body"] == "Hello" - assert payload["icon"] == "beer.png" - -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_key_include(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the FCM header is included.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -159,19 +158,23 @@ async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello", target=["chrome"]) - assert len(mock_wp.mock_calls) == 4 - # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["Authorization"] is not None + # WebPusher constructor + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_send_with_unknown_priority( + mock_wp: AsyncMock, hass: HomeAssistant +) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -184,19 +187,20 @@ async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> N await service.async_send_message("Hello", target=["chrome"], priority="undefined") - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_no_targets(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -209,19 +213,20 @@ async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello") - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" - -@patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_fcm_additional_data(mock_wp: AsyncMock, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) - mock_wp().send().status_code = 201 data = {"chrome": SUBSCRIPTION_5} @@ -234,12 +239,13 @@ async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: await service.async_send_message("Hello", data={"mykey": "myvalue"}) - assert len(mock_wp.mock_calls) == 4 + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"mykey": "myvalue", "url": "/", "jwt": "JWT"}, "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] - - # Get the keys passed to the WebPusher's send method - assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] @pytest.mark.usefixtures("load_config") @@ -581,11 +587,14 @@ async def test_callback_view_no_jwt( assert resp.status == HTTPStatus.UNAUTHORIZED +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_callback_view_with_jwt( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the notification callback view works with JWT.""" load_config.return_value = {"device": SUBSCRIPTION_1} @@ -599,27 +608,22 @@ async def test_callback_view_with_jwt( client = await hass_client() - with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: - mock_wp().send().status_code = 201 - await hass.services.async_call( - "notify", - "html5", - {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, - blocking=True, - ) - - assert len(mock_wp.mock_calls) == 4 + await hass.services.async_call( + "notify", + "html5", + {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, + blocking=True, + ) + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_1["subscription"] - - # Call to send - push_payload = json.loads(mock_wp.mock_calls[3][2]["data"]) - - assert push_payload["body"] == "Hello" - assert push_payload["icon"] == "beer.png" + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_1["subscription"] - bearer_token = f"Bearer {push_payload['data']['jwt']}" + bearer_token = "Bearer JWT" resp = await client.post( PUBLISH_URL, json={"type": "push"}, headers={AUTHORIZATION: bearer_token} @@ -630,10 +634,13 @@ async def test_callback_view_with_jwt( assert body == {"event": "push", "status": "ok"} +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_without_targets( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the notification is send with FCM without targets.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -645,25 +652,29 @@ async def test_send_fcm_without_targets( assert config_entry.state is ConfigEntryState.LOADED - with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: - mock_wp().send().status_code = 201 - await hass.services.async_call( - "notify", - "html5", - {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, - blocking=True, - ) - - assert len(mock_wp.mock_calls) == 4 + await hass.services.async_call( + "notify", + "html5", + {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, + blocking=True, + ) + mock_wp.send_async.assert_awaited_once_with( + data='{"badge": "/static/images/notification-badge.png", "body": "Hello", "data": {"url": "/", "jwt": "JWT"}, "icon": "beer.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Home Assistant", "timestamp": 1234567890000}', + headers=VAPID_HEADERS, + ttl=86400, + ) # WebPusher constructor - assert mock_wp.mock_calls[2][1][0] == SUBSCRIPTION_5["subscription"] + assert mock_wp.cls.call_args[0][0] == SUBSCRIPTION_5["subscription"] +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_expired( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, + mock_wp: AsyncMock, ) -> None: """Test that the FCM target is removed when expired.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -674,12 +685,10 @@ async def test_send_fcm_expired( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - + mock_wp.send_async.return_value.status = 410 with ( - patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, patch("homeassistant.components.html5.notify.save_json") as mock_save, ): - mock_wp().send().status_code = 410 await hass.services.async_call( "notify", "html5", @@ -690,11 +699,14 @@ async def test_send_fcm_expired( mock_save.assert_called_once_with(hass.config.path(html5.REGISTRATIONS_FILE), {}) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_fcm_expired_save_fails( hass: HomeAssistant, config_entry: MockConfigEntry, load_config: MagicMock, caplog: pytest.LogCaptureFixture, + mock_wp: AsyncMock, ) -> None: """Test that the FCM target remains after expiry if save_json fails.""" load_config.return_value = {"device": SUBSCRIPTION_5} @@ -705,16 +717,13 @@ async def test_send_fcm_expired_save_fails( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - + mock_wp.send_async.return_value.status = 410 with ( - patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError(), ), ): - mock_wp().send().status_code = 410 - await hass.services.async_call( "notify", "html5", From eec854386a8d25a7badc9bb989edeb574b3f1a8e Mon Sep 17 00:00:00 2001 From: wollew Date: Mon, 16 Feb 2026 21:06:07 +0100 Subject: [PATCH 30/39] bump pyvlx to 0.2.30 (#163203) --- homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index fbd0d94e6fa56..6e9247aeed8fe 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pyvlx"], "quality_scale": "silver", - "requirements": ["pyvlx==0.2.29"] + "requirements": ["pyvlx==0.2.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc9fde4c04797..03c4c4b395b5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2696,7 +2696,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.29 +pyvlx==0.2.30 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dff39369c2b4..2fd188a6df547 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2277,7 +2277,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.29 +pyvlx==0.2.30 # homeassistant.components.volumio pyvolumio==0.1.5 From 459996b7601b83f9a1c00c8cef6a3f0d2b965ae1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 16 Feb 2026 21:30:52 +0100 Subject: [PATCH 31/39] Add 100% coverage of sensors for Fritz (#163005) --- tests/components/fritz/test_sensor.py | 46 +++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index d8820eb9b0eb7..4731b9845163f 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -10,13 +10,13 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import MOCK_USER_DATA +from .const import MOCK_FB_SERVICES, MOCK_USER_DATA from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -69,3 +69,45 @@ async def test_sensor_update_fail( sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: assert sensor.state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2026-02-14T09:30:00+00:00") +async def test_sensor_uptime_spike( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + fc_class_mock, + fh_class_mock, + fs_class_mock, +) -> None: + """Test handling of uptime spikes in Fritz!Tools sensors.""" + + entity_id = "sensor.mock_title_last_restart" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == "2026-01-16T06:00:21+00:00" + + # Simulate uptime spike by setting uptime to a value between + # the previous one and a delta smaller than UPTIME_DEVIATION + base_uptime = MOCK_FB_SERVICES["DeviceInfo1"]["GetInfo"]["NewUpTime"] + update_uptime = { + "DeviceInfo1": { + "GetInfo": { + "NewUpTime": base_uptime + SCAN_INTERVAL - UPTIME_DEVIATION + 1, + }, + }, + } + fc_class_mock().override_services({**MOCK_FB_SERVICES, **update_uptime}) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (new_state := hass.states.get(entity_id)) + assert new_state.state == "2026-01-16T06:00:21+00:00" From 24180367982d7e3bce46d2d10655f4a9f0816fbf Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:33:10 +0200 Subject: [PATCH 32/39] Saunum integration fix: close client on unload (#163183) --- homeassistant/components/saunum/__init__.py | 7 +++---- homeassistant/components/saunum/quality_scale.yaml | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index f0a1a9161f476..208f0ab986114 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -43,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> except SaunumConnectionError as exc: raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc + entry.async_on_unload(client.async_close) + coordinator = LeilSaunaCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -55,7 +57,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await entry.runtime_data.client.async_close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml index eb0a70d673268..fa3f1a67bf07c 100644 --- a/homeassistant/components/saunum/quality_scale.yaml +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -21,7 +21,7 @@ rules: test-before-setup: done unique-config-entry: done - # Silver tier + # Silver action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done @@ -35,7 +35,7 @@ rules: comment: Modbus TCP does not require authentication. test-coverage: done - # Gold tier + # Gold devices: done diagnostics: done discovery: From 8c146624f928c87c45597ef6163326dffaca34fc Mon Sep 17 00:00:00 2001 From: David Recordon Date: Mon, 16 Feb 2026 12:37:59 -0800 Subject: [PATCH 33/39] Add Celsius Temperature Support for Control4 Integration (#163196) --- homeassistant/components/control4/climate.py | 97 +++++++++++++++----- tests/components/control4/conftest.py | 3 + tests/components/control4/test_climate.py | 58 +++++++++++- 3 files changed, 132 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index d28fceb8bbe8c..8669d09122dec 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -34,20 +34,33 @@ # Control4 variable names CONTROL4_HVAC_STATE = "HVAC_STATE" CONTROL4_HVAC_MODE = "HVAC_MODE" -CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F" CONTROL4_HUMIDITY = "HUMIDITY" -CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F" -CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F" +CONTROL4_SCALE = "SCALE" # "FAHRENHEIT" or "CELSIUS" + +# Temperature variables - Fahrenheit +CONTROL4_CURRENT_TEMPERATURE_F = "TEMPERATURE_F" +CONTROL4_COOL_SETPOINT_F = "COOL_SETPOINT_F" +CONTROL4_HEAT_SETPOINT_F = "HEAT_SETPOINT_F" + +# Temperature variables - Celsius +CONTROL4_CURRENT_TEMPERATURE_C = "TEMPERATURE_C" +CONTROL4_COOL_SETPOINT_C = "COOL_SETPOINT_C" +CONTROL4_HEAT_SETPOINT_C = "HEAT_SETPOINT_C" + CONTROL4_FAN_MODE = "FAN_MODE" CONTROL4_FAN_MODES_LIST = "FAN_MODES_LIST" VARIABLES_OF_INTEREST = { CONTROL4_HVAC_STATE, CONTROL4_HVAC_MODE, - CONTROL4_CURRENT_TEMPERATURE, CONTROL4_HUMIDITY, - CONTROL4_COOL_SETPOINT, - CONTROL4_HEAT_SETPOINT, + CONTROL4_CURRENT_TEMPERATURE_F, + CONTROL4_CURRENT_TEMPERATURE_C, + CONTROL4_COOL_SETPOINT_F, + CONTROL4_HEAT_SETPOINT_F, + CONTROL4_COOL_SETPOINT_C, + CONTROL4_HEAT_SETPOINT_C, + CONTROL4_SCALE, CONTROL4_FAN_MODE, CONTROL4_FAN_MODES_LIST, } @@ -156,7 +169,6 @@ class Control4Climate(Control4Entity, ClimateEntity): """Control4 climate entity.""" _attr_has_entity_name = True - _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_translation_key = "thermostat" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL] @@ -213,13 +225,45 @@ def supported_features(self) -> ClimateEntityFeature: features |= ClimateEntityFeature.FAN_MODE return features + @property + def temperature_unit(self) -> str: + """Return the temperature unit based on the thermostat's SCALE setting.""" + data = self._thermostat_data + if data is None: + return UnitOfTemperature.CELSIUS # Default per HA conventions + if data.get(CONTROL4_SCALE) == "FAHRENHEIT": + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS + + @property + def _cool_setpoint(self) -> float | None: + """Return the cooling setpoint from the appropriate variable.""" + data = self._thermostat_data + if data is None: + return None + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_COOL_SETPOINT_C) + return data.get(CONTROL4_COOL_SETPOINT_F) + + @property + def _heat_setpoint(self) -> float | None: + """Return the heating setpoint from the appropriate variable.""" + data = self._thermostat_data + if data is None: + return None + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_HEAT_SETPOINT_C) + return data.get(CONTROL4_HEAT_SETPOINT_F) + @property def current_temperature(self) -> float | None: """Return the current temperature.""" data = self._thermostat_data if data is None: return None - return data.get(CONTROL4_CURRENT_TEMPERATURE) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + return data.get(CONTROL4_CURRENT_TEMPERATURE_C) + return data.get(CONTROL4_CURRENT_TEMPERATURE_F) @property def current_humidity(self) -> int | None: @@ -257,34 +301,25 @@ def hvac_action(self) -> HVACAction | None: @property def target_temperature(self) -> float | None: """Return the target temperature.""" - data = self._thermostat_data - if data is None: - return None hvac_mode = self.hvac_mode if hvac_mode == HVACMode.COOL: - return data.get(CONTROL4_COOL_SETPOINT) + return self._cool_setpoint if hvac_mode == HVACMode.HEAT: - return data.get(CONTROL4_HEAT_SETPOINT) + return self._heat_setpoint return None @property def target_temperature_high(self) -> float | None: """Return the high target temperature for auto mode.""" - data = self._thermostat_data - if data is None: - return None if self.hvac_mode == HVACMode.HEAT_COOL: - return data.get(CONTROL4_COOL_SETPOINT) + return self._cool_setpoint return None @property def target_temperature_low(self) -> float | None: """Return the low target temperature for auto mode.""" - data = self._thermostat_data - if data is None: - return None if self.hvac_mode == HVACMode.HEAT_COOL: - return data.get(CONTROL4_HEAT_SETPOINT) + return self._heat_setpoint return None @property @@ -326,15 +361,27 @@ async def async_set_temperature(self, **kwargs: Any) -> None: # Handle temperature range for auto mode if self.hvac_mode == HVACMode.HEAT_COOL: if low_temp is not None: - await c4_climate.setHeatSetpointF(low_temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setHeatSetpointC(low_temp) + else: + await c4_climate.setHeatSetpointF(low_temp) if high_temp is not None: - await c4_climate.setCoolSetpointF(high_temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setCoolSetpointC(high_temp) + else: + await c4_climate.setCoolSetpointF(high_temp) # Handle single temperature setpoint elif temp is not None: if self.hvac_mode == HVACMode.COOL: - await c4_climate.setCoolSetpointF(temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setCoolSetpointC(temp) + else: + await c4_climate.setCoolSetpointF(temp) elif self.hvac_mode == HVACMode.HEAT: - await c4_climate.setHeatSetpointF(temp) + if self.temperature_unit == UnitOfTemperature.CELSIUS: + await c4_climate.setHeatSetpointC(temp) + else: + await c4_climate.setHeatSetpointF(temp) await self.coordinator.async_request_refresh() diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index 671588c0d943b..38300b88f0aa6 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -129,6 +129,7 @@ def mock_climate_variables() -> dict: "HEAT_SETPOINT_F": 68.0, "FAN_MODE": "Auto", "FAN_MODES_LIST": "Auto,On,Circulate", + "SCALE": "FAHRENHEIT", } } @@ -160,6 +161,8 @@ def mock_c4_climate() -> Generator[MagicMock]: mock_instance.setHeatSetpointF = AsyncMock() mock_instance.setCoolSetpointF = AsyncMock() mock_instance.setFanMode = AsyncMock() + mock_instance.setHeatSetpointC = AsyncMock() + mock_instance.setCoolSetpointC = AsyncMock() yield mock_instance diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index 97d432d55b331..50015672e65e8 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -39,8 +39,9 @@ def _make_climate_data( humidity: int = 50, cool_setpoint: float = 75.0, heat_setpoint: float = 68.0, + scale: str = "FAHRENHEIT", ) -> dict[int, dict[str, Any]]: - """Build mock climate variable data for item ID 123.""" + """Build mock climate variable data for item ID 123 (Fahrenheit).""" return { 123: { "HVAC_STATE": hvac_state, @@ -49,6 +50,7 @@ def _make_climate_data( "HUMIDITY": humidity, "COOL_SETPOINT_F": cool_setpoint, "HEAT_SETPOINT_F": heat_setpoint, + "SCALE": scale, } } @@ -344,6 +346,7 @@ async def test_climate_not_created_when_no_initial_data( # Missing TEMPERATURE_F and HUMIDITY "COOL_SETPOINT_F": 75.0, "HEAT_SETPOINT_F": 68.0, + "SCALE": "FAHRENHEIT", } } ], @@ -444,6 +447,7 @@ async def test_set_fan_mode( "HUMIDITY": 50, "COOL_SETPOINT_F": 75.0, "HEAT_SETPOINT_F": 68.0, + "SCALE": "FAHRENHEIT", # No FAN_MODE or FAN_MODES_LIST } } @@ -467,3 +471,55 @@ async def test_fan_mode_not_supported( assert not ( state.attributes.get("supported_features") & ClimateEntityFeature.FAN_MODE ) + + +# Temperature unit tests - verify correct API methods are called based on SCALE + + +@pytest.mark.parametrize( + ("mock_climate_variables", "expected_method", "unexpected_method"), + [ + pytest.param( + _make_climate_data(hvac_state="Off", hvac_mode="Heat"), + "setHeatSetpointF", + "setHeatSetpointC", + id="fahrenheit_heat_calls_F_not_C", + ), + pytest.param( + _make_climate_data(hvac_state="Cool", hvac_mode="Cool"), + "setCoolSetpointF", + "setCoolSetpointC", + id="fahrenheit_cool_calls_F_not_C", + ), + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_set_temperature_calls_correct_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_c4_climate: MagicMock, + expected_method: str, + unexpected_method: str, +) -> None: + """Test setting temperature calls correct API method based on SCALE. + + Verifies that when setting temperature: + - The correct method for the scale is called + - The wrong scale's method is NOT called + """ + # Reset mock to clear any calls from previous parametrized test runs + mock_c4_climate.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 70.0}, + blocking=True, + ) + getattr(mock_c4_climate, expected_method).assert_called_once_with(70.0) + getattr(mock_c4_climate, unexpected_method).assert_not_called() From 667a77502d0d436925f2cd8ee58b47a7b57239a1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 16 Feb 2026 12:43:27 -0800 Subject: [PATCH 34/39] Store nest media in a .cache subdirectory (#163200) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/nest/media_source.py | 40 +++++++--- tests/components/nest/conftest.py | 16 +++- tests/components/nest/test_media_source.py | 73 +++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index a3d2901e91153..4c7eb87636cf2 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -24,6 +24,7 @@ import logging import os import pathlib +import shutil from typing import Any from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait @@ -70,7 +71,8 @@ # Buffer writes every few minutes (plus guaranteed to be written at shutdown) STORAGE_SAVE_DELAY_SECONDS = 120 # Path under config directory -MEDIA_PATH = f"{DOMAIN}/event_media" +LEGACY_MEDIA_PATH = f"{DOMAIN}/event_media" +MEDIA_CACHE_PATH = "event_media" # Size of small in-memory disk cache to avoid excessive disk reads DISK_READ_LRU_MAX_SIZE = 32 @@ -83,19 +85,39 @@ async def async_get_media_event_store( hass: HomeAssistant, subscriber: GoogleNestSubscriber ) -> EventMediaStore: """Create the disk backed EventMediaStore.""" - media_path = hass.config.path(MEDIA_PATH) - - def mkdir() -> None: - os.makedirs(media_path, exist_ok=True) - - await hass.async_add_executor_job(mkdir) + media_path = pathlib.Path(hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH)) + legacy_media_path = pathlib.Path(hass.config.path(LEGACY_MEDIA_PATH)) + await hass.async_add_executor_job( + _prepare_media_cache_dir, media_path, legacy_media_path + ) store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY, private=True) - return NestEventMediaStore(hass, subscriber, store, media_path) + return NestEventMediaStore(hass, subscriber, store, str(media_path)) + + +def _prepare_media_cache_dir( + media_path: pathlib.Path, legacy_media_path: pathlib.Path +) -> None: + """Prepare the media cache directory.""" + # Migrate media from legacy path to new path. + if legacy_media_path.exists() and not media_path.exists(): + _LOGGER.info( + "Migrating media cache directory from %s to %s", + legacy_media_path, + media_path, + ) + media_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.move(legacy_media_path, media_path) + except OSError as error: + _LOGGER.info( + "Failed to migrate media cache directory, abandoning: %s", error + ) + media_path.mkdir(parents=True, exist_ok=True) async def async_get_transcoder(hass: HomeAssistant) -> Transcoder: """Get a nest clip transcoder.""" - media_path = hass.config.path(MEDIA_PATH) + media_path = hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH) ffmpeg_manager = get_ffmpeg_manager(hass) return Transcoder(ffmpeg_manager.binary, media_path) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 394d1f22c56a2..2f417aba9131e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -149,7 +149,21 @@ async def auth( def cleanup_media_storage(hass: HomeAssistant) -> Generator[str]: """Test cleanup, remove any media storage persisted during the test.""" tmp_path = str(uuid.uuid4()) - with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path): + with patch( + "homeassistant.components.nest.media_source.MEDIA_CACHE_PATH", new=tmp_path + ): + full_path = hass.config.cache_path(DOMAIN, tmp_path) + yield full_path + shutil.rmtree(full_path, ignore_errors=True) + + +@pytest.fixture(name="legacy_media_path") +def cleanup_legacy_media_storage(hass: HomeAssistant) -> Generator[str]: + """Test cleanup, remove any media storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch( + "homeassistant.components.nest.media_source.LEGACY_MEDIA_PATH", new=tmp_path + ): full_path = hass.config.path(tmp_path) yield full_path shutil.rmtree(full_path, ignore_errors=True) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 0b0654fc69c2d..50275050b4d80 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -1653,3 +1653,76 @@ async def test_remove_stale_media( assert not extra_media1.exists() assert not extra_media2.exists() assert extra_media3.exists() + + +async def test_media_migration( + hass: HomeAssistant, + setup_platform, + legacy_media_path: str, + media_path: str, +) -> None: + """Test migration of media files from legacy path to new path.""" + legacy_path = pathlib.Path(legacy_media_path) + cache_path = pathlib.Path(media_path) + + # Create some dummy files in the legacy path + device_id = "device-1" + legacy_device_path = legacy_path / device_id + legacy_device_path.mkdir(parents=True) + + file1 = legacy_device_path / "event1.jpg" + file1.write_text("content1") + + file2 = legacy_device_path / "event2.mp4" + file2.write_text("content2") + + # Run setup (which triggers migration) + await setup_platform() + + # Check if files are moved to cache path + cache_device_path = cache_path / device_id + assert (cache_device_path / "event1.jpg").exists() + assert (cache_device_path / "event1.jpg").read_text() == "content1" + assert (cache_device_path / "event2.mp4").exists() + assert (cache_device_path / "event2.mp4").read_text() == "content2" + + # Check if files are removed from legacy path + assert not file1.exists() + assert not file2.exists() + assert not legacy_device_path.exists() + assert not legacy_path.exists() + + +async def test_media_migration_failure( + hass: HomeAssistant, + setup_platform, + legacy_media_path: str, + media_path: str, +) -> None: + """Test migration failure handles the error gracefully.""" + legacy_path = pathlib.Path(legacy_media_path) + cache_path = pathlib.Path(media_path) + + # Create some dummy files in the legacy path + device_id = "device-1" + legacy_device_path = legacy_path / device_id + legacy_device_path.mkdir(parents=True) + file1 = legacy_device_path / "event1.jpg" + file1.write_text("content1") + + # Mock shutil.move to fail + with patch( + "homeassistant.components.nest.media_source.shutil.move", + side_effect=OSError("Storage full"), + ): + # Run setup (which triggers migration) + # Note: setup_platform handles the integration setup which calls async_get_media_event_store + await setup_platform() + + # Verify that the legacy path still exists (migration was abandoned) + assert file1.exists() + assert legacy_path.exists() + + # Verify that the cache path was still created (it should be empty) + assert cache_path.exists() + assert not (cache_path / device_id).exists() From 76ebc134f34d7fa44c4c9cc22ef63d5297817bd4 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 16 Feb 2026 20:43:36 +0000 Subject: [PATCH 35/39] Mealie add get shopping list items action (#163090) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mealie/icons.json | 3 + homeassistant/components/mealie/services.py | 12 ++ homeassistant/components/mealie/services.yaml | 6 + homeassistant/components/mealie/strings.json | 7 + homeassistant/components/mealie/todo.py | 27 +++- .../mealie/snapshots/test_services.ambr | 153 ++++++++++++++++++ tests/components/mealie/test_services.py | 42 +++++ 7 files changed, 248 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index 6a2afcdba3b72..c7bc5e0772e88 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -33,6 +33,9 @@ "get_recipes": { "service": "mdi:book-open-page-variant" }, + "get_shopping_list_items": { + "service": "mdi:basket" + }, "import_recipe": { "service": "mdi:map-search" }, diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f6ba4fea1b775..d1e4745bf5988 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -12,6 +12,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, @@ -64,6 +65,8 @@ } ) +SERVICE_GET_SHOPPING_LIST_ITEMS = "get_shopping_list_items" + SERVICE_IMPORT_RECIPE = "import_recipe" SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( { @@ -321,3 +324,12 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + entity_domain=TODO_DOMAIN, + schema=None, + func="async_get_shopping_list_items", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 31181c0d0917e..6eef192dfabfe 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -45,6 +45,12 @@ get_recipes: mode: box unit_of_measurement: recipes +get_shopping_list_items: + target: + entity: + integration: mealie + domain: todo + import_recipe: fields: config_entry_id: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index c3b6dfd6992fe..2c337dee445d5 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -147,6 +147,9 @@ "setup_failed": { "message": "Could not connect to the Mealie instance." }, + "shopping_list_not_found": { + "message": "Shopping list with name or ID `{shopping_list}` not found." + }, "update_failed_mealplan": { "message": "Could not fetch mealplan data." }, @@ -227,6 +230,10 @@ }, "name": "Get recipes" }, + "get_shopping_list_items": { + "description": "Gets items from a shopping list in Mealie", + "name": "Get shopping list items" + }, "import_recipe": { "description": "Imports a recipe from an URL", "fields": { diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index c701af2865cdf..c504ba1e7f059 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -2,7 +2,15 @@ from __future__ import annotations -from aiomealie import MealieError, MutateShoppingItem, ShoppingItem, ShoppingList +from dataclasses import asdict + +from aiomealie import ( + MealieConnectionError, + MealieError, + MutateShoppingItem, + ShoppingItem, + ShoppingList, +) from homeassistant.components.todo import ( DOMAIN as TODO_DOMAIN, @@ -11,7 +19,7 @@ TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -265,3 +273,18 @@ async def async_move_todo_item( def available(self) -> bool: """Return False if shopping list no longer available.""" return super().available and self._shopping_list_id in self.coordinator.data + + async def async_get_shopping_list_items(self) -> ServiceResponse: + """Get structured shopping list items.""" + client = self.coordinator.client + try: + shopping_items = await client.get_shopping_items(self._shopping_list_id) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + return { + "name": self.shopping_list.name, + "items": [asdict(item) for item in shopping_items.items], + } diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 6cc2e60882bb6..1b3506e942271 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1637,6 +1637,159 @@ }), }) # --- +# name: test_service_get_shopping_list_items + dict({ + 'todo.mealie_supermarket': dict({ + 'items': list([ + dict({ + 'checked': False, + 'disable_amount': None, + 'display': '2 Apples', + 'food': None, + 'food_id': None, + 'is_food': None, + 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': 'Apples', + 'position': 0, + 'quantity': 2.0, + 'unit': None, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': '1 can acorn squash', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 4, 454134), + 'description': '', + 'extras': dict({ + }), + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'households_with_ingredient_food': None, + 'label': None, + 'label_id': None, + 'name': 'acorn squash', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 4, 454141), + }), + 'food_id': '09322430-d24c-4b1a-abb6-22b6ed3a88f5', + 'is_food': True, + 'item_id': '84d8fd74-8eb0-402e-84b6-71f251bfb7cc', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 1, + 'quantity': 1.0, + 'unit': dict({ + 'abbreviation': '', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464122), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'can', + 'plural_abbreviation': '', + 'plural_name': None, + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 2, 464124), + 'use_abbreviation': False, + }), + 'unit_id': '7bf539d4-fc78-48bc-b48e-c35ccccec34a', + }), + dict({ + 'checked': False, + 'disable_amount': False, + 'display': 'aubergine', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 5, 14, 14, 45, 3, 868792), + 'description': '', + 'extras': dict({ + }), + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'households_with_ingredient_food': None, + 'label': None, + 'label_id': None, + 'name': 'aubergine', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 5, 14, 14, 45, 3, 868794), + }), + 'food_id': '96801494-4e26-4148-849a-8155deb76327', + 'is_food': True, + 'item_id': '69913b9a-7c75-4935-abec-297cf7483f88', + 'label': None, + 'label_id': None, + 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', + 'note': '', + 'position': 2, + 'quantity': 0.0, + 'unit': None, + 'unit_id': None, + }), + dict({ + 'checked': False, + 'disable_amount': None, + 'display': '1 US cup flour', + 'food': dict({ + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 29, 40354, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'food_id': '8d2ef4d7-bfc2-4420-9cba-152016c1ee7c', + 'households_with_ingredient_food': list([ + ]), + 'label': None, + 'label_id': None, + 'name': 'flour', + 'plural_name': None, + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 29, 40371, tzinfo=datetime.timezone.utc), + }), + 'food_id': '8d2ef4d7-bfc2-4420-9cba-152016c1ee7c', + 'is_food': None, + 'item_id': '22b389bb-e079-481c-915d-394e5edb20a5', + 'label': dict({ + 'label_id': '0e55cae5-6037-4cbb-8d4f-1042cbb83fd0', + 'name': 'Household', + }), + 'label_id': None, + 'list_id': 'a33af640-4704-453c-ab03-a95a393bf1c4', + 'note': '', + 'position': 0, + 'quantity': 1.0, + 'unit': dict({ + 'abbreviation': 'US cup', + 'aliases': list([ + ]), + 'created_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477518, tzinfo=datetime.timezone.utc), + 'description': '', + 'extras': dict({ + }), + 'fraction': True, + 'name': 'US cup', + 'plural_abbreviation': '', + 'plural_name': None, + 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', + 'updated_at': datetime.datetime(2024, 8, 25, 13, 29, 25, 477535, tzinfo=datetime.timezone.utc), + 'use_abbreviation': False, + }), + 'unit_id': '89765d44-8412-4ab5-a6de-594aa8eac44c', + }), + ]), + 'name': 'Supermarket', + }), + }) +# --- # name: test_service_import_recipe dict({ 'recipe': dict({ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 0c31d783ceee3..957a219f90140 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -31,6 +31,7 @@ SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, SERVICE_GET_RECIPES, + SERVICE_GET_SHOPPING_LIST_ITEMS, SERVICE_IMPORT_RECIPE, SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, @@ -395,6 +396,47 @@ async def test_service_set_mealplan_invalid_entry_type( mock_mealie_client.set_mealplan.assert_not_called() +async def test_service_get_shopping_list_items( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_shopping_list_items service.""" + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + target={"entity_id": "todo.mealie_supermarket"}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_service_get_shopping_list_items_connection_error( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_shopping_list_items service with connection error.""" + + await setup_integration(hass, mock_config_entry) + + mock_mealie_client.get_shopping_items.side_effect = MealieConnectionError + + with pytest.raises(HomeAssistantError, match="Error connecting to Mealie instance"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_SHOPPING_LIST_ITEMS, + target={"entity_id": "todo.mealie_supermarket"}, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ From 5cf37afbf6be0e54f88c05d285b9b3a0cced6c22 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 16 Feb 2026 21:47:48 +0100 Subject: [PATCH 36/39] Add `quality_scale` with `strict-typing` done for SpaceAPI (#163003) --- .strict-typing | 1 + .../components/spaceapi/quality_scale.yaml | 120 ++++++++++++++++++ mypy.ini | 10 ++ script/hassfest/quality_scale.py | 1 - 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/spaceapi/quality_scale.yaml diff --git a/.strict-typing b/.strict-typing index 34961f012c0cc..fa8588e3dc55d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -496,6 +496,7 @@ homeassistant.components.smtp.* homeassistant.components.snooz.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* +homeassistant.components.spaceapi.* homeassistant.components.speedtestdotnet.* homeassistant.components.spotify.* homeassistant.components.sql.* diff --git a/homeassistant/components/spaceapi/quality_scale.yaml b/homeassistant/components/spaceapi/quality_scale.yaml new file mode 100644 index 0000000000000..8791627d97d47 --- /dev/null +++ b/homeassistant/components/spaceapi/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration has no custom service actions. + appropriate-polling: + status: exempt + comment: This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has no entities and no coordinator. + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: exempt + comment: This integration has no dependencies. + docs-actions: + status: exempt + comment: This integration has no custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: + status: exempt + comment: This integration has no entities. + entity-unique-id: + status: exempt + comment: This integration has no entities. + has-entity-name: + status: exempt + comment: This integration has no entities. + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: This integration has no custom service actions. + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration has no entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: This integration has no entities. + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: This integration has no entities. + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration is a service and has no devices. + discovery: + status: exempt + comment: This integration is a service and has no devices. + docs-data-update: + status: exempt + comment: This integration does not poll. + docs-examples: + status: exempt + comment: This integration does not provide any automation + docs-known-limitations: + status: done + comment: Only SpaceAPI v13 is supported. + docs-supported-devices: + status: exempt + comment: This integration is a service and has no devices. + docs-supported-functions: + status: exempt + comment: This integration has no entities. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration is a service and has no devices. + entity-category: + status: exempt + comment: This integration has no entities. + entity-device-class: + status: exempt + comment: This integration has no entities. + entity-disabled-by-default: + status: exempt + comment: This integration has no entities. + entity-translations: + status: exempt + comment: This integration has no entities. + exception-translations: + status: exempt + comment: This integration has no custom exceptions. + icon-translations: + status: exempt + comment: This integration does not use icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration is a service and has no devices. + + # Platinum + async-dependency: + status: exempt + comment: This integration has no dependencies. + inject-websession: + status: exempt + comment: This integration does not use web sessions. + strict-typing: done diff --git a/mypy.ini b/mypy.ini index a98121fc0d8f1..5a9058326c6dc 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4716,6 +4716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.spaceapi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.speedtestdotnet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4a17f8babfb11..d2bf1422e5ab7 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -891,7 +891,6 @@ class Rule: "songpal", "sony_projector", "soundtouch", - "spaceapi", "spc", "speedtestdotnet", "spider", From 49744398502b279d2b49a5cd88fa5c55af89f997 Mon Sep 17 00:00:00 2001 From: johanzander Date: Mon, 16 Feb 2026 22:00:55 +0100 Subject: [PATCH 37/39] Add on-grid discharge stop SOC control for Growatt MIN devices (#160634) Co-authored-by: Claude Opus 4.6 --- .../components/growatt_server/number.py | 16 +++- .../components/growatt_server/strings.json | 7 +- tests/components/growatt_server/conftest.py | 3 +- .../growatt_server/snapshots/test_number.ambr | 75 +++++++++++++++++-- .../components/growatt_server/test_number.py | 15 +++- 5 files changed, 100 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/growatt_server/number.py b/homeassistant/components/growatt_server/number.py index 7016c25cadb22..a7006d13f1f71 100644 --- a/homeassistant/components/growatt_server/number.py +++ b/homeassistant/components/growatt_server/number.py @@ -68,15 +68,25 @@ class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKey native_unit_of_measurement=PERCENTAGE, ), GrowattNumberEntityDescription( - key="battery_discharge_soc_limit", - translation_key="battery_discharge_soc_limit", - api_key="wdisChargeSOCLowLimit", # Key returned by V1 API + key="battery_discharge_soc_limit", # Keep original key to preserve unique_id + translation_key="battery_discharge_soc_limit_off_grid", + api_key="wdisChargeSOCLowLimit", # Key returned by V1 API (off-grid) write_key="discharge_stop_soc", # Key used to write parameter native_step=1, native_min_value=0, native_max_value=100, native_unit_of_measurement=PERCENTAGE, ), + GrowattNumberEntityDescription( + key="battery_discharge_soc_limit_on_grid", + translation_key="battery_discharge_soc_limit_on_grid", + api_key="onGridDischargeStopSOC", # Key returned by V1 API (on-grid) + write_key="on_grid_discharge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), ) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index ffb4654407934..22443c586052d 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -53,8 +53,11 @@ "battery_discharge_power_limit": { "name": "Battery discharge power limit" }, - "battery_discharge_soc_limit": { - "name": "Battery discharge SOC limit" + "battery_discharge_soc_limit_off_grid": { + "name": "Battery discharge SOC limit (off-grid)" + }, + "battery_discharge_soc_limit_on_grid": { + "name": "Battery discharge SOC limit (on-grid)" } }, "sensor": { diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 08399f4034d98..10e5884825bf4 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -64,7 +64,8 @@ def mock_growatt_v1_api(): "chargePowerCommand": 50, # 50% charge power - read by number entity "wchargeSOCLowLimit": 10, # 10% charge stop SOC - read by number entity "disChargePowerCommand": 80, # 80% discharge power - read by number entity - "wdisChargeSOCLowLimit": 20, # 20% discharge stop SOC - read by number entity + "wdisChargeSOCLowLimit": 20, # 20% discharge stop SOC (off-grid) - read by number entity + "onGridDischargeStopSOC": 15, # 15% on-grid discharge stop SOC - read by number entity } # Called by MIN device coordinator during refresh diff --git a/tests/components/growatt_server/snapshots/test_number.ambr b/tests/components/growatt_server/snapshots/test_number.ambr index e43cf4fea40ac..278ce3b0ad8c5 100644 --- a/tests/components/growatt_server/snapshots/test_number.ambr +++ b/tests/components/growatt_server/snapshots/test_number.ambr @@ -176,7 +176,7 @@ 'state': '80', }) # --- -# name: test_number_entities[number.min123456_battery_discharge_soc_limit-entry] +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_off_grid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -194,7 +194,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.min123456_battery_discharge_soc_limit', + 'entity_id': 'number.min123456_battery_discharge_soc_limit_off_grid', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -202,25 +202,25 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Battery discharge SOC limit', + 'object_id_base': 'Battery discharge SOC limit (off-grid)', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Battery discharge SOC limit', + 'original_name': 'Battery discharge SOC limit (off-grid)', 'platform': 'growatt_server', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'battery_discharge_soc_limit', + 'translation_key': 'battery_discharge_soc_limit_off_grid', 'unique_id': 'MIN123456_battery_discharge_soc_limit', 'unit_of_measurement': '%', }) # --- -# name: test_number_entities[number.min123456_battery_discharge_soc_limit-state] +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_off_grid-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'MIN123456 Battery discharge SOC limit', + 'friendly_name': 'MIN123456 Battery discharge SOC limit (off-grid)', 'max': 100, 'min': 0, 'mode': , @@ -228,10 +228,69 @@ 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'number.min123456_battery_discharge_soc_limit', + 'entity_id': 'number.min123456_battery_discharge_soc_limit_off_grid', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '20', }) # --- +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_on_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.min123456_battery_discharge_soc_limit_on_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery discharge SOC limit (on-grid)', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery discharge SOC limit (on-grid)', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_discharge_soc_limit_on_grid', + 'unique_id': 'MIN123456_battery_discharge_soc_limit_on_grid', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[number.min123456_battery_discharge_soc_limit_on_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Battery discharge SOC limit (on-grid)', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.min123456_battery_discharge_soc_limit_on_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- diff --git a/tests/components/growatt_server/test_number.py b/tests/components/growatt_server/test_number.py index 0d06b0d8d5010..f78af273a3784 100644 --- a/tests/components/growatt_server/test_number.py +++ b/tests/components/growatt_server/test_number.py @@ -72,12 +72,21 @@ async def test_all_number_entities_service_calls( mock_growatt_v1_api, ) -> None: """Test service calls work for all number entities.""" - # Test all four number entities + # Test all five number entities test_cases = [ ("number.min123456_battery_charge_power_limit", "charge_power", 75), ("number.min123456_battery_charge_soc_limit", "charge_stop_soc", 85), ("number.min123456_battery_discharge_power_limit", "discharge_power", 90), - ("number.min123456_battery_discharge_soc_limit", "discharge_stop_soc", 25), + ( + "number.min123456_battery_discharge_soc_limit_off_grid", + "discharge_stop_soc", + 25, + ), + ( + "number.min123456_battery_discharge_soc_limit_on_grid", + "on_grid_discharge_stop_soc", + 30, + ), ] for entity_id, expected_write_key, test_value in test_cases: @@ -110,6 +119,7 @@ async def test_number_missing_data( "wchargeSOCLowLimit": 10, "disChargePowerCommand": 80, "wdisChargeSOCLowLimit": 20, + "onGridDischargeStopSOC": 15, } mock_config_entry.add_to_hass(hass) @@ -228,6 +238,7 @@ async def test_number_coordinator_data_update( "wchargeSOCLowLimit": 10, "disChargePowerCommand": 80, "wdisChargeSOCLowLimit": 20, + "onGridDischargeStopSOC": 15, } # Advance time to trigger coordinator refresh From 9ec456d28e15d41c0794fa7ae99b8c852e492451 Mon Sep 17 00:00:00 2001 From: Jordan Rodgers Date: Mon, 16 Feb 2026 13:15:50 -0800 Subject: [PATCH 38/39] Add port link speed sensor to UniFi integration (#162847) --- homeassistant/components/unifi/icons.json | 3 + homeassistant/components/unifi/sensor.py | 17 +++ homeassistant/components/unifi/strings.json | 3 + tests/components/unifi/test_sensor.py | 121 ++++++++++++++++++++ 4 files changed, 144 insertions(+) diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 97ff2f734a3dc..ba94eabcc93ca 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -38,6 +38,9 @@ "port_bandwidth_tx": { "default": "mdi:upload" }, + "port_link_speed": { + "default": "mdi:speedometer" + }, "wlan_clients": { "default": "mdi:account-multiple" } diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 898a59d951b8c..7a161a9d7c2ce 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -485,6 +485,23 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( unique_id_fn=lambda hub, obj_id: f"port_tx-{obj_id}", value_fn=lambda hub, port: port.tx_bytes_r, ), + UnifiSensorEntityDescription[Ports, Port]( + key="Port speed", + translation_key="port_link_speed", + device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + suggested_display_precision=0, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.ports, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + name_fn=lambda port: f"{port.name} link speed", + object_fn=lambda api, obj_id: api.ports[obj_id], + supported_fn=lambda hub, obj_id: hub.api.ports[obj_id].raw.get("speed", 0) > 0, + unique_id_fn=lambda hub, obj_id: f"port_link_speed-{obj_id}", + value_fn=lambda hub, port: port.raw.get("speed", 0), + ), UnifiSensorEntityDescription[Clients, Client]( key="Client uptime", device_class=SensorDeviceClass.TIMESTAMP, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 084aa3e4fd764..ef6a7c1d42ce8 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -56,6 +56,9 @@ "upgrading": "Upgrading" } }, + "port_link_speed": { + "name": "Link speed" + }, "wired_client_link_speed": { "name": "Link speed" } diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index e0b716919af2b..4aef840982f2a 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1114,6 +1114,127 @@ async def test_bandwidth_port_sensors( assert hass.states.get("sensor.mock_name_port_2_tx") is None +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: False, + CONF_ALLOW_UPTIME_SENSORS: False, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 2, + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "10:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_class": "Class 4", + "poe_enable": False, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": False, + "up": True, + "speed": 1000, + }, + { + "media": "GE", + "name": "Port 2", + "port_idx": 2, + "poe_class": "Class 4", + "poe_enable": False, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a2", + "port_poe": False, + "up": True, + "speed": 100, + }, + { + "media": "GE", + "name": "Port 3", + "port_idx": 3, + "poe_class": "Unknown", + "poe_enable": False, + "poe_mode": "off", + "poe_power": "0.00", + "poe_voltage": "0.00", + "portconf_id": "1a3", + "port_poe": False, + "up": False, + "speed": 0, + }, + ], + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_port_link_speed_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_websocket_message: WebsocketMessageMock, + device_payload: list[dict[str, Any]], +) -> None: + """Verify that port link speed sensors are working as expected.""" + p1_reg_entry = entity_registry.async_get("sensor.mock_name_port_1_link_speed") + assert p1_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + + p2_reg_entry = entity_registry.async_get("sensor.mock_name_port_2_link_speed") + assert p2_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + + # Port with speed 0 should not create an entity + assert not entity_registry.async_get("sensor.mock_name_port_3_link_speed") + + # Enable entity + entity_registry.async_update_entity( + entity_id="sensor.mock_name_port_1_link_speed", disabled_by=None + ) + entity_registry.async_update_entity( + entity_id="sensor.mock_name_port_2_link_speed", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Verify sensor state + assert hass.states.get("sensor.mock_name_port_1_link_speed").state == "1000" + assert hass.states.get("sensor.mock_name_port_2_link_speed").state == "100" + + # Verify state update + device_1 = device_payload[0] + device_1["port_table"][0]["speed"] = 100 + + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mock_name_port_1_link_speed").state == "100" + + @pytest.mark.parametrize( "device_payload", [ From 73fa9925c4dd54b1311ccb0678815d35312c06bd Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:17:56 +0000 Subject: [PATCH 39/39] Add test coverage for tplink_omada update entities (#162549) --- .../tplink_omada/quality_scale.yaml | 2 +- tests/components/tplink_omada/conftest.py | 49 +++-- .../tplink_omada/fixtures/devices.json | 2 +- .../firmware-update-54-AF-97-00-00-01.json | 5 + .../tplink_omada/snapshots/test_update.ambr | 125 +++++++++++ .../tplink_omada/test_binary_sensor.py | 20 +- tests/components/tplink_omada/test_sensor.py | 16 +- tests/components/tplink_omada/test_update.py | 204 ++++++++++++++++++ 8 files changed, 391 insertions(+), 32 deletions(-) create mode 100644 tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json create mode 100644 tests/components/tplink_omada/snapshots/test_update.ambr create mode 100644 tests/components/tplink_omada/test_update.py diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index 0feda35f46e33..ace158c44ea87 100644 --- a/homeassistant/components/tplink_omada/quality_scale.yaml +++ b/homeassistant/components/tplink_omada/quality_scale.yaml @@ -39,7 +39,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index 07eb636ccad2b..f6d840e565039 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -2,7 +2,6 @@ from collections.abc import AsyncGenerator, Generator from functools import partial -import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -14,6 +13,7 @@ OmadaWirelessClient, ) from tplink_omada_client.devices import ( + OmadaFirmwareUpdate, OmadaGateway, OmadaListDevice, OmadaSwitch, @@ -25,7 +25,11 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) @pytest.fixture @@ -59,29 +63,44 @@ async def mock_omada_site_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMoc """Mock Omada site client.""" site_client = MagicMock() - gateway_data = json.loads( - await async_load_fixture(hass, "gateway-TL-ER7212PC.json", DOMAIN) + gateway_data = await async_load_json_object_fixture( + hass, "gateway-TL-ER7212PC.json", DOMAIN ) gateway = OmadaGateway(gateway_data) site_client.get_gateway = AsyncMock(return_value=gateway) - switch1_data = json.loads( - await async_load_fixture(hass, "switch-TL-SG3210XHP-M2.json", DOMAIN) + switch1_data = await async_load_json_object_fixture( + hass, "switch-TL-SG3210XHP-M2.json", DOMAIN ) switch1 = OmadaSwitch(switch1_data) site_client.get_switches = AsyncMock(return_value=[switch1]) site_client.get_switch = AsyncMock(return_value=switch1) - devices_data = json.loads(await async_load_fixture(hass, "devices.json", DOMAIN)) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) devices = [OmadaListDevice(d) for d in devices_data] site_client.get_devices = AsyncMock(return_value=devices) - switch1_ports_data = json.loads( - await async_load_fixture(hass, "switch-ports-TL-SG3210XHP-M2.json", DOMAIN) + switch1_ports_data = await async_load_json_array_fixture( + hass, "switch-ports-TL-SG3210XHP-M2.json", DOMAIN ) switch1_ports = [OmadaSwitchPortDetails(p) for p in switch1_ports_data] site_client.get_switch_ports = AsyncMock(return_value=switch1_ports) + # Mock firmware update API + async def get_firmware_details( + device: OmadaListDevice, + ) -> OmadaFirmwareUpdate | None: + """Mock getting firmware details for a device.""" + if device.need_upgrade: + firmware_data = await async_load_json_object_fixture( + hass, f"firmware-update-{device.mac}.json", DOMAIN + ) + return OmadaFirmwareUpdate(firmware_data) + return None + + site_client.get_firmware_details = AsyncMock(side_effect=get_firmware_details) + site_client.start_firmware_upgrade = AsyncMock() + async def async_empty() -> AsyncGenerator: for c in (): yield c @@ -114,8 +133,8 @@ async def _get_mock_known_clients( hass: HomeAssistant, ) -> AsyncGenerator[OmadaNetworkClient]: """Mock known clients of the Omada network.""" - known_clients_data = json.loads( - await async_load_fixture(hass, "known-clients.json", DOMAIN) + known_clients_data = await async_load_json_array_fixture( + hass, "known-clients.json", DOMAIN ) for c in known_clients_data: if c["wireless"]: @@ -128,8 +147,8 @@ async def _get_mock_connected_clients( hass: HomeAssistant, ) -> AsyncGenerator[OmadaConnectedClient]: """Mock connected clients of the Omada network.""" - connected_clients_data = json.loads( - await async_load_fixture(hass, "connected-clients.json", DOMAIN) + connected_clients_data = await async_load_json_array_fixture( + hass, "connected-clients.json", DOMAIN ) for c in connected_clients_data: if c["wireless"]: @@ -140,8 +159,8 @@ async def _get_mock_connected_clients( async def _get_mock_client(hass: HomeAssistant, mac: str) -> OmadaNetworkClient: """Mock an Omada client.""" - connected_clients_data = json.loads( - await async_load_fixture(hass, "connected-clients.json", DOMAIN) + connected_clients_data = await async_load_json_array_fixture( + hass, "connected-clients.json", DOMAIN ) for c in connected_clients_data: diff --git a/tests/components/tplink_omada/fixtures/devices.json b/tests/components/tplink_omada/fixtures/devices.json index d92fd5f7d663d..16cda0612d278 100644 --- a/tests/components/tplink_omada/fixtures/devices.json +++ b/tests/components/tplink_omada/fixtures/devices.json @@ -35,7 +35,7 @@ "memUtil": 20, "status": 14, "statusCategory": 1, - "needUpgrade": false, + "needUpgrade": true, "fwDownload": false } ] diff --git a/tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json b/tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json new file mode 100644 index 0000000000000..1d3147139768e --- /dev/null +++ b/tests/components/tplink_omada/fixtures/firmware-update-54-AF-97-00-00-01.json @@ -0,0 +1,5 @@ +{ + "curFwVer": "1.0.12 Build 20230203 Rel.36545", + "lastFwVer": "1.0.15 Build 20231101 Rel.40123", + "fwReleaseLog": "Bug fixes and performance improvements" +} diff --git a/tests/components/tplink_omada/snapshots/test_update.ambr b/tests/components/tplink_omada/snapshots/test_update.ambr new file mode 100644 index 0000000000000..ce856b4adf5ee --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_update.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_entities[update.test_poe_switch_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_poe_switch_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'tplink_omada', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update.test_poe_switch_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/tplink_omada/icon.png', + 'friendly_name': 'Test PoE Switch Firmware', + 'in_progress': False, + 'installed_version': '1.0.12 Build 20230203 Rel.36545', + 'latest_version': '1.0.15 Build 20231101 Rel.40123', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_poe_switch_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[update.test_router_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_router_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'tplink_omada', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AA-BB-CC-DD-EE-FF_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update.test_router_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/tplink_omada/icon.png', + 'friendly_name': 'Test Router Firmware', + 'in_progress': False, + 'installed_version': '1.1.1 Build 20230901 Rel.55651', + 'latest_version': '1.1.1 Build 20230901 Rel.55651', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_router_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tplink_omada/test_binary_sensor.py b/tests/components/tplink_omada/test_binary_sensor.py index 3b258f172b926..9f4929fb123a2 100644 --- a/tests/components/tplink_omada/test_binary_sensor.py +++ b/tests/components/tplink_omada/test_binary_sensor.py @@ -18,7 +18,7 @@ Generator, MockConfigEntry, async_fire_time_changed, - load_json_array_fixture, + async_load_json_array_fixture, snapshot_platform, ) @@ -51,7 +51,7 @@ async def test_no_gateway_creates_no_port_sensors( ) -> None: """Test that if there is no gateway, no gateway port sensors are created.""" - _remove_test_device(mock_omada_site_client, 0) + await _remove_test_device(hass, mock_omada_site_client, 0) mock_config_entry.add_to_hass(hass) @@ -70,7 +70,8 @@ async def test_disconnected_device_sensor_not_registered( ) -> None: """Test that if the gateway is not connected to the controller, gateway entities are not created.""" - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, 0, DeviceStatus.DISCONNECTED.value, @@ -87,7 +88,8 @@ async def test_disconnected_device_sensor_not_registered( assert entity is None # "Connect" the gateway - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, 0, DeviceStatus.CONNECTED.value, @@ -105,13 +107,14 @@ async def test_disconnected_device_sensor_not_registered( mock_omada_site_client.get_gateway.assert_called_once_with("AA-BB-CC-DD-EE-FF") -def _set_test_device_status( +async def _set_test_device_status( + hass: HomeAssistant, mock_omada_site_client: MagicMock, dev_index: int, status: int, status_category: int, ) -> None: - devices_data = load_json_array_fixture("devices.json", DOMAIN) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) devices_data[dev_index]["status"] = status devices_data[dev_index]["statusCategory"] = status_category devices = [OmadaListDevice(d) for d in devices_data] @@ -120,11 +123,12 @@ def _set_test_device_status( mock_omada_site_client.get_devices.return_value = devices -def _remove_test_device( +async def _remove_test_device( + hass: HomeAssistant, mock_omada_site_client: MagicMock, dev_index: int, ) -> None: - devices_data = load_json_array_fixture("devices.json", DOMAIN) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) del devices_data[dev_index] devices = [OmadaListDevice(d) for d in devices_data] diff --git a/tests/components/tplink_omada/test_sensor.py b/tests/components/tplink_omada/test_sensor.py index 9fcea14129c08..fcae399fbdfd0 100644 --- a/tests/components/tplink_omada/test_sensor.py +++ b/tests/components/tplink_omada/test_sensor.py @@ -1,7 +1,6 @@ """Tests for TP-Link Omada sensor entities.""" from datetime import timedelta -import json from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -18,7 +17,7 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_fixture, + async_load_json_array_fixture, snapshot_platform, ) @@ -63,7 +62,8 @@ async def test_device_specific_status( assert entity is not None assert entity.state == "connected" - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, DeviceStatus.ADOPT_FAILED.value, DeviceStatusCategory.CONNECTED.value, @@ -89,9 +89,10 @@ async def test_device_category_status( assert entity is not None assert entity.state == "connected" - _set_test_device_status( + await _set_test_device_status( + hass, mock_omada_site_client, - DeviceStatus.PENDING_WIRELESS, + DeviceStatus.PENDING_WIRELESS.value, DeviceStatusCategory.PENDING.value, ) @@ -103,12 +104,13 @@ async def test_device_category_status( assert entity and entity.state == "pending" -def _set_test_device_status( +async def _set_test_device_status( + hass: HomeAssistant, mock_omada_site_client: MagicMock, status: int, status_category: int, ) -> None: - devices_data = json.loads(load_fixture("devices.json", DOMAIN)) + devices_data = await async_load_json_array_fixture(hass, "devices.json", DOMAIN) devices_data[1]["status"] = status devices_data[1]["statusCategory"] = status_category devices = [OmadaListDevice(d) for d in devices_data] diff --git a/tests/components/tplink_omada/test_update.py b/tests/components/tplink_omada/test_update.py new file mode 100644 index 0000000000000..af6280edfae89 --- /dev/null +++ b/tests/components/tplink_omada/test_update.py @@ -0,0 +1,204 @@ +"""Tests for TP-Link Omada update entities.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from tplink_omada_client.devices import OmadaListDevice +from tplink_omada_client.exceptions import OmadaClientException, RequestFailed + +from homeassistant.components.tplink_omada.coordinator import POLL_DEVICES +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) +from tests.typing import WebSocketGenerator + +POLL_INTERVAL = timedelta(seconds=POLL_DEVICES) + + +async def _rebuild_device_list_with_update( + hass: HomeAssistant, mac: str, **overrides +) -> list[OmadaListDevice]: + """Rebuild device list from fixture with specified overrides for a device.""" + devices_data = await async_load_json_array_fixture( + hass, "devices.json", "tplink_omada" + ) + + for device_data in devices_data: + if device_data["mac"] == mac: + device_data.update(overrides) + + return [OmadaListDevice(d) for d in devices_data] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, +) -> MockConfigEntry: + """Set up the TP-Link Omada integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the TP-Link Omada update entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +async def test_firmware_download_in_progress( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_omada_site_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update entity when firmware download is in progress.""" + entity_id = "update.test_poe_switch_firmware" + + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Rebuild device list with fwDownload set to True for the switch + updated_devices = await _rebuild_device_list_with_update( + hass, "54-AF-97-00-00-01", fwDownload=True + ) + mock_omada_site_client.get_devices.return_value = updated_devices + + # Trigger coordinator update + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify update entity shows in progress + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.attributes.get(ATTR_IN_PROGRESS) is True + + +async def test_install_firmware_success( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_omada_site_client: MagicMock, +) -> None: + """Test successful firmware installation.""" + entity_id = "update.test_poe_switch_firmware" + + # Verify update is available + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_ON + + # Call install service + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify start_firmware_upgrade was called with the correct device + mock_omada_site_client.start_firmware_upgrade.assert_awaited_once() + await_args = mock_omada_site_client.start_firmware_upgrade.await_args[0] + assert await_args[0].mac == "54-AF-97-00-00-01" + + +@pytest.mark.parametrize( + ("exception_type", "error_message"), + [ + ( + RequestFailed(500, "Update rejected"), + "Firmware update request rejected", + ), + ( + OmadaClientException("Connection error"), + "Unable to send Firmware update request. Check the controller is online.", + ), + ], +) +async def test_install_firmware_exceptions( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_omada_site_client: MagicMock, + exception_type: Exception, + error_message: str, +) -> None: + """Test firmware installation exception handling.""" + entity_id = "update.test_poe_switch_firmware" + + # Mock exception + mock_omada_site_client.start_firmware_upgrade = AsyncMock( + side_effect=exception_type + ) + + # Call install service and expect error + with pytest.raises( + HomeAssistantError, + match=error_message, + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("entity_name", "expected_notes"), + [ + ("test_router", None), + ("test_poe_switch", "Bug fixes and performance improvements"), + ], +) +async def test_release_notes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + entity_name: str, + expected_notes: str | None, +) -> None: + """Test that release notes are available via websocket.""" + entity_id = f"update.{entity_name}_firmware" + + # Get release notes via websocket + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await client.receive_json() + + assert expected_notes == result["result"]