From 68b08a614713095dfc35f084d832ecfb42610486 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:26:10 +0100 Subject: [PATCH 1/2] Remove deprecated yaml import from HTML5 integration (#163094) --- homeassistant/components/html5/__init__.py | 10 +- homeassistant/components/html5/config_flow.py | 12 - homeassistant/components/html5/issues.py | 50 --- homeassistant/components/html5/notify.py | 23 -- tests/components/html5/conftest.py | 47 +++ tests/components/html5/test_config_flow.py | 87 +---- tests/components/html5/test_init.py | 38 +-- tests/components/html5/test_notify.py | 309 +++++++++++++----- 8 files changed, 291 insertions(+), 285 deletions(-) delete mode 100644 homeassistant/components/html5/issues.py create mode 100644 tests/components/html5/conftest.py diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 4b85bf8ab8cd3..26d7b50992145 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -3,14 +3,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from .const import DOMAIN +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HTML5 from a config entry.""" - await discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, dict(entry.data), {} + hass.async_create_task( + discovery.async_load_platform( + hass, Platform.NOTIFY, DOMAIN, dict(entry.data), {} + ) ) return True diff --git a/homeassistant/components/html5/config_flow.py b/homeassistant/components/html5/config_flow.py index def9d74b5d4b8..ae409d1366edf 100644 --- a/homeassistant/components/html5/config_flow.py +++ b/homeassistant/components/html5/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.core import callback from .const import ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, DOMAIN -from .issues import async_create_html5_issue def vapid_generate_private_key() -> str: @@ -92,14 +91,3 @@ async def async_step_user( ), errors=errors, ) - - async def async_step_import( - self: HTML5ConfigFlow, import_config: dict - ) -> ConfigFlowResult: - """Handle config import from yaml.""" - _, flow_result = self._async_create_html5_entry(import_config) - if not flow_result: - async_create_html5_issue(self.hass, False) - return self.async_abort(reason="invalid_config") - async_create_html5_issue(self.hass, True) - return flow_result diff --git a/homeassistant/components/html5/issues.py b/homeassistant/components/html5/issues.py deleted file mode 100644 index 8892562d347e5..0000000000000 --- a/homeassistant/components/html5/issues.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Issues utility for HTML5.""" - -import logging - -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SUCCESSFUL_IMPORT_TRANSLATION_KEY = "deprecated_yaml" -FAILED_IMPORT_TRANSLATION_KEY = "deprecated_yaml_import_issue" - -INTEGRATION_TITLE = "HTML5 Push Notifications" - - -@callback -def async_create_html5_issue(hass: HomeAssistant, import_success: bool) -> None: - """Create issues for HTML5.""" - if import_success: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": INTEGRATION_TITLE, - }, - ) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 859a7b7e5678a..f38ff0dce1d77 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -27,10 +27,8 @@ ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ATTR_NAME, URL_ROOT from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError @@ -47,23 +45,12 @@ DOMAIN, SERVICE_DISMISS, ) -from .issues import async_create_html5_issue _LOGGER = logging.getLogger(__name__) REGISTRATIONS_FILE = "html5_push_registrations.conf" -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Optional("gcm_sender_id"): cv.string, - vol.Optional("gcm_api_key"): cv.string, - vol.Required(ATTR_VAPID_PUB_KEY): cv.string, - vol.Required(ATTR_VAPID_PRV_KEY): cv.string, - vol.Required(ATTR_VAPID_EMAIL): cv.string, - } -) - ATTR_SUBSCRIPTION = "subscription" ATTR_BROWSER = "browser" @@ -166,17 +153,7 @@ async def async_get_service( ) -> HTML5NotificationService | None: """Get the HTML5 push notification service.""" if config: - existing_config_entry = hass.config_entries.async_entries(DOMAIN) - if existing_config_entry: - async_create_html5_issue(hass, True) - return None - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) return None - if discovery_info is None: return None diff --git a/tests/components/html5/conftest.py b/tests/components/html5/conftest.py new file mode 100644 index 0000000000000..9c5322b94a67a --- /dev/null +++ b/tests/components/html5/conftest.py @@ -0,0 +1,47 @@ +"""Common fixtures for html5 integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.html5.const import ( + ATTR_VAPID_EMAIL, + ATTR_VAPID_PRV_KEY, + ATTR_VAPID_PUB_KEY, + DOMAIN, +) +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry, patch + +MOCK_CONF = { + ATTR_VAPID_EMAIL: "test@example.com", + ATTR_VAPID_PRV_KEY: "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", +} +MOCK_CONF_PUB_KEY = "BIUtPN7Rq_8U7RBEqClZrfZ5dR9zPCfvxYPtLpWtRVZTJEc7lzv2dhzDU6Aw1m29Ao0-UA1Uq6XO9Df8KALBKqA" + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock ntfy configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="HTML5", + data={ + ATTR_VAPID_PRV_KEY: MOCK_CONF[ATTR_VAPID_PRV_KEY], + ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, + ATTR_VAPID_EMAIL: MOCK_CONF[ATTR_VAPID_EMAIL], + CONF_NAME: DOMAIN, + }, + ) + + +@pytest.fixture(name="load_config") +def mock_load_config() -> Generator[MagicMock]: + """Mock load config.""" + + with patch( + "homeassistant.components.html5.notify._load_config", return_value={} + ) as mock_load_config: + yield mock_load_config diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py index 3cde435771ef2..9019d321eecf5 100644 --- a/tests/components/html5/test_config_flow.py +++ b/tests/components/html5/test_config_flow.py @@ -11,19 +11,10 @@ ATTR_VAPID_PUB_KEY, DOMAIN, ) -from homeassistant.components.html5.issues import ( - FAILED_IMPORT_TRANSLATION_KEY, - SUCCESSFUL_IMPORT_TRANSLATION_KEY, -) from homeassistant.const import CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant -MOCK_CONF = { - ATTR_VAPID_EMAIL: "test@example.com", - ATTR_VAPID_PRV_KEY: "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", -} -MOCK_CONF_PUB_KEY = "BIUtPN7Rq_8U7RBEqClZrfZ5dR9zPCfvxYPtLpWtRVZTJEc7lzv2dhzDU6Aw1m29Ao0-UA1Uq6XO9Df8KALBKqA" +from .conftest import MOCK_CONF, MOCK_CONF_PUB_KEY async def test_step_user_success(hass: HomeAssistant) -> None: @@ -127,77 +118,3 @@ async def test_step_user_form_invalid_key( ) assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY assert mock_setup_entry.call_count == 1 - - -async def test_step_import_good( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test valid import input.""" - - with ( - patch( - "homeassistant.components.html5.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - conf = MOCK_CONF.copy() - conf[ATTR_VAPID_PUB_KEY] = MOCK_CONF_PUB_KEY - conf["random_key"] = "random_value" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == { - ATTR_VAPID_PRV_KEY: conf[ATTR_VAPID_PRV_KEY], - ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, - ATTR_VAPID_EMAIL: conf[ATTR_VAPID_EMAIL], - CONF_NAME: DOMAIN, - } - - assert mock_setup_entry.call_count == 1 - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue - assert issue.translation_key == SUCCESSFUL_IMPORT_TRANSLATION_KEY - - -@pytest.mark.parametrize( - ("key", "value"), - [ - (ATTR_VAPID_PRV_KEY, "invalid"), - ], -) -async def test_step_import_bad( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, key: str, value: str -) -> None: - """Test invalid import input.""" - - with ( - patch( - "homeassistant.components.html5.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - bad_conf = MOCK_CONF.copy() - bad_conf[key] = value - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=bad_conf - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert mock_setup_entry.call_count == 0 - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, f"deprecated_yaml_{DOMAIN}") - assert issue - assert issue.translation_key == FAILED_IMPORT_TRANSLATION_KEY diff --git a/tests/components/html5/test_init.py b/tests/components/html5/test_init.py index 840890f18d198..51f34b50f4c3f 100644 --- a/tests/components/html5/test_init.py +++ b/tests/components/html5/test_init.py @@ -1,44 +1,16 @@ """Test the HTML5 setup.""" +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -NOTIFY_CONF = { - "notify": [ - { - "platform": "html5", - "name": "html5", - "vapid_pub_key": "BIUtPN7Rq_8U7RBEqClZrfZ5dR9zPCfvxYPtLpWtRVZTJEc7lzv2dhzDU6Aw1m29Ao0-UA1Uq6XO9Df8KALBKqA", - "vapid_prv_key": "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", - "vapid_email": "test@example.com", - } - ] -} - -async def test_setup_entry( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: +async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test setup of a good config entry.""" - config_entry = MockConfigEntry(domain="html5", data={}) - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, "html5", {}) - - assert len(issue_registry.issues) == 0 - -async def test_setup_entry_issue( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test setup of an imported config entry with deprecated YAML.""" - config_entry = MockConfigEntry(domain="html5", data={}) config_entry.add_to_hass(hass) - assert await async_setup_component(hass, "notify", NOTIFY_CONF) - assert await async_setup_component(hass, "html5", NOTIFY_CONF) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert len(issue_registry.issues) == 1 + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index f602a8f380765..d1d37cc0e164d 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -2,17 +2,18 @@ from http import HTTPStatus import json -from typing import Any -from unittest.mock import mock_open, patch +from unittest.mock import MagicMock, mock_open, patch from aiohttp.hdrs import AUTHORIZATION -from aiohttp.test_utils import TestClient +import pytest from homeassistant.components.html5 import notify as html5 +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator CONFIG_FILE = "file.conf" @@ -71,24 +72,6 @@ PUBLISH_URL = "/api/notify.html5/callback" -async def mock_client( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - registrations: dict[str, Any] | None = None, -) -> TestClient: - """Create a test client for HTML5 views.""" - if registrations is None: - registrations = {} - - with patch( - "homeassistant.components.html5.notify._load_config", return_value=registrations - ): - await async_setup_component(hass, "notify", {"notify": VAPID_CONF}) - await hass.async_block_till_done() - - return await hass_client() - - async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" await async_setup_component(hass, "http", {}) @@ -259,11 +242,22 @@ async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: assert mock_wp.mock_calls[3][2]["headers"]["priority"] == "normal" +@pytest.mark.usefixtures("load_config") async def test_registering_new_device_view( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test that the HTML view works.""" - client = await mock_client(hass, hass_client) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -273,11 +267,22 @@ async def test_registering_new_device_view( assert mock_save.mock_calls[0][1][1] == {"unnamed device": SUBSCRIPTION_1} +@pytest.mark.usefixtures("load_config") async def test_registering_new_device_view_with_name( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test that the HTML view works with name attribute.""" - client = await mock_client(hass, hass_client) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() SUB_WITH_NAME = SUBSCRIPTION_1.copy() SUB_WITH_NAME["name"] = "test device" @@ -290,11 +295,22 @@ async def test_registering_new_device_view_with_name( assert mock_save.mock_calls[0][1][1] == {"test device": SUBSCRIPTION_1} +@pytest.mark.usefixtures("load_config") async def test_registering_new_device_expiration_view( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test that the HTML view works.""" - client = await mock_client(hass, hass_client) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) @@ -303,13 +319,22 @@ async def test_registering_new_device_expiration_view( assert mock_save.mock_calls[0][1][1] == {"unnamed device": SUBSCRIPTION_4} +@pytest.mark.usefixtures("load_config") async def test_registering_new_device_fails_view( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test subs. are not altered when registering a new device fails.""" - registrations = {} - client = await mock_client(hass, hass_client, registrations) + await async_setup_component(hass, "http", {}) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch( "homeassistant.components.html5.notify.save_json", side_effect=HomeAssistantError(), @@ -317,31 +342,51 @@ async def test_registering_new_device_fails_view( resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert registrations == {} +@pytest.mark.usefixtures("load_config") async def test_registering_existing_device_view( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test subscription is updated when registering existing device.""" - registrations = {} - client = await mock_client(hass, hass_client, registrations) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.OK - assert mock_save.mock_calls[0][1][1] == {"unnamed device": SUBSCRIPTION_4} - assert registrations == {"unnamed device": SUBSCRIPTION_4} + mock_save.assert_called_with( + hass.config.path(html5.REGISTRATIONS_FILE), {"unnamed device": SUBSCRIPTION_4} + ) +@pytest.mark.usefixtures("load_config") async def test_registering_existing_device_view_with_name( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test subscription is updated when reg'ing existing device with name.""" - registrations = {} - client = await mock_client(hass, hass_client, registrations) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() SUB_WITH_NAME = SUBSCRIPTION_1.copy() SUB_WITH_NAME["name"] = "test device" @@ -351,16 +396,28 @@ async def test_registering_existing_device_view_with_name( resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.OK - assert mock_save.mock_calls[0][1][1] == {"test device": SUBSCRIPTION_4} - assert registrations == {"test device": SUBSCRIPTION_4} + + mock_save.assert_called_with( + hass.config.path(html5.REGISTRATIONS_FILE), {"test device": SUBSCRIPTION_4} + ) +@pytest.mark.usefixtures("load_config") async def test_registering_existing_device_fails_view( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test sub. is not updated when registering existing device fails.""" - registrations = {} - client = await mock_client(hass, hass_client, registrations) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -368,14 +425,24 @@ async def test_registering_existing_device_fails_view( resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert registrations == {"unnamed device": SUBSCRIPTION_1} +@pytest.mark.usefixtures("load_config") async def test_registering_new_device_validation( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test various errors when registering a new device.""" - client = await mock_client(hass, hass_client) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() resp = await client.post( REGISTER_URL, @@ -395,11 +462,25 @@ async def test_registering_new_device_validation( async def test_unregistering_device_view( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + load_config: MagicMock, ) -> None: """Test that the HTML unregister view works.""" - registrations = {"some device": SUBSCRIPTION_1, "other device": SUBSCRIPTION_2} - client = await mock_client(hass, hass_client, registrations) + load_config.return_value = { + "some device": SUBSCRIPTION_1, + "other device": SUBSCRIPTION_2, + } + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.delete( @@ -409,15 +490,27 @@ async def test_unregistering_device_view( assert resp.status == HTTPStatus.OK assert len(mock_save.mock_calls) == 1 - assert registrations == {"other device": SUBSCRIPTION_2} + mock_save.assert_called_once_with( + hass.config.path(html5.REGISTRATIONS_FILE), {"other device": SUBSCRIPTION_2} + ) +@pytest.mark.usefixtures("load_config") async def test_unregister_device_view_handle_unknown_subscription( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test that the HTML unregister view handles unknown subscriptions.""" - registrations = {} - client = await mock_client(hass, hass_client, registrations) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.save_json") as mock_save: resp = await client.delete( @@ -426,16 +519,29 @@ async def test_unregister_device_view_handle_unknown_subscription( ) assert resp.status == HTTPStatus.OK, resp.response - assert registrations == {} assert len(mock_save.mock_calls) == 0 async def test_unregistering_device_view_handles_save_error( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + load_config: MagicMock, ) -> None: """Test that the HTML unregister view handles save errors.""" - registrations = {"some device": SUBSCRIPTION_1, "other device": SUBSCRIPTION_2} - client = await mock_client(hass, hass_client, registrations) + load_config.return_value = { + "some device": SUBSCRIPTION_1, + "other device": SUBSCRIPTION_2, + } + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch( "homeassistant.components.html5.notify.save_json", @@ -447,17 +553,24 @@ async def test_unregistering_device_view_handles_save_error( ) assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR, resp.response - assert registrations == { - "some device": SUBSCRIPTION_1, - "other device": SUBSCRIPTION_2, - } +@pytest.mark.usefixtures("load_config") async def test_callback_view_no_jwt( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, ) -> None: """Test that the notification callback view works without JWT.""" - client = await mock_client(hass, hass_client) + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() resp = await client.post( PUBLISH_URL, data=json.dumps( @@ -469,11 +582,22 @@ async def test_callback_view_no_jwt( async def test_callback_view_with_jwt( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + load_config: MagicMock, ) -> None: """Test that the notification callback view works with JWT.""" - registrations = {"device": SUBSCRIPTION_1} - client = await mock_client(hass, hass_client, registrations) + load_config.return_value = {"device": SUBSCRIPTION_1} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: mock_wp().send().status_code = 201 @@ -507,11 +631,20 @@ async def test_callback_view_with_jwt( async def test_send_fcm_without_targets( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, ) -> None: """Test that the notification is send with FCM without targets.""" - registrations = {"device": SUBSCRIPTION_5} - await mock_client(hass, hass_client, registrations) + load_config.return_value = {"device": SUBSCRIPTION_5} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + with patch("homeassistant.components.html5.notify.WebPusher") as mock_wp: mock_wp().send().status_code = 201 await hass.services.async_call( @@ -528,15 +661,23 @@ async def test_send_fcm_without_targets( async def test_send_fcm_expired( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, ) -> None: """Test that the FCM target is removed when expired.""" - registrations = {"device": SUBSCRIPTION_5} - await mock_client(hass, hass_client, registrations) + load_config.return_value = {"device": SUBSCRIPTION_5} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED with ( patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, - patch("homeassistant.components.html5.notify.save_json"), + patch("homeassistant.components.html5.notify.save_json") as mock_save, ): mock_wp().send().status_code = 410 await hass.services.async_call( @@ -546,15 +687,24 @@ async def test_send_fcm_expired( blocking=True, ) # "device" should be removed when expired. - assert "device" not in registrations + mock_save.assert_called_once_with(hass.config.path(html5.REGISTRATIONS_FILE), {}) async def test_send_fcm_expired_save_fails( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the FCM target remains after expiry if save_json fails.""" - registrations = {"device": SUBSCRIPTION_5} - await mock_client(hass, hass_client, registrations) + load_config.return_value = {"device": SUBSCRIPTION_5} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED with ( patch("homeassistant.components.html5.notify.WebPusher") as mock_wp, @@ -564,6 +714,7 @@ async def test_send_fcm_expired_save_fails( ), ): mock_wp().send().status_code = 410 + await hass.services.async_call( "notify", "html5", @@ -571,4 +722,4 @@ async def test_send_fcm_expired_save_fails( blocking=True, ) # "device" should still exist if save fails. - assert "device" in registrations + assert "Error saving registration" in caplog.text From ddb13b4ee7232b3b15ec71e9046209f3bf446810 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:55:06 +0100 Subject: [PATCH 2/2] Fix Z-Wave fan speed (#163093) --- homeassistant/components/zwave_js/fan.py | 30 +++++------------------- tests/components/zwave_js/test_fan.py | 20 ++++++++-------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 8e47cbbeb1ddb..710c052327131 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -267,22 +267,10 @@ def percentage_to_zwave_speed(self, percentage: int) -> int: if percentage == 0: return 0 - # Since the percentage steps are computed with rounding, we have to - # search to find the appropriate speed. - for speed_range in self.fan_value_mapping.speeds: - (_, max_speed) = speed_range - step_percentage = self.zwave_speed_to_percentage(max_speed) - - # zwave_speed_to_percentage will only return None if - # `self.fan_value_mapping.speeds` doesn't contain the - # specified speed. This can't happen here, because - # the input is coming from the same data structure. - assert step_percentage - - if percentage <= step_percentage: - break - - return max_speed + speed_level = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + return self.fan_value_mapping.speeds[speed_level - 1][1] def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: """Convert a Zwave speed to a percentage. @@ -293,15 +281,9 @@ def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: if zwave_speed == 0: return 0 - percentage = 0.0 - for speed_range in self.fan_value_mapping.speeds: - (min_speed, max_speed) = speed_range - percentage += self.percentage_step + for index, (min_speed, max_speed) in enumerate(self.fan_value_mapping.speeds): if min_speed <= zwave_speed <= max_speed: - # This choice of rounding function is to provide consistency with how - # the UI handles steps e.g., for a 3-speed fan, you get steps at 33, - # 67, and 100. - return round(percentage) + return ranged_value_to_percentage((1, self.speed_count), index + 1) # The specified Z-Wave device value doesn't map to a defined speed. return None diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index f57f412f2ad0f..15d41ba4b8d51 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -281,8 +281,8 @@ async def get_percentage_from_zwave_speed(zwave_speed): percentages_to_zwave_speeds = [ [[0], [0]], [range(1, 34), range(1, 34)], - [range(34, 68), range(34, 67)], - [range(68, 101), range(67, 100)], + [range(34, 67), range(34, 67)], + [range(67, 101), range(67, 100)], ] for percentages, zwave_speeds in percentages_to_zwave_speeds: @@ -407,8 +407,8 @@ async def get_percentage_from_zwave_speed(zwave_speed): percentages_to_zwave_speeds = [ [[0], [0]], [range(1, 34), range(1, 34)], - [range(34, 68), range(34, 68)], - [range(68, 101), range(68, 100)], + [range(34, 67), range(34, 68)], + [range(67, 101), range(68, 100)], ] for percentages, zwave_speeds in percentages_to_zwave_speeds: @@ -500,8 +500,8 @@ async def get_percentage_from_zwave_speed(zwave_speed): percentages_to_zwave_speeds = [ [[0], [0]], [range(1, 34), range(1, 33)], # percentages 1-33 → zwave 1-32 - [range(34, 68), range(33, 67)], # percentages 34-67 → zwave 33-66 - [range(68, 101), range(67, 100)], # percentages 68-100 → zwave 67-99 + [range(34, 67), range(33, 67)], # percentages 34-66 → zwave 33-66 + [range(67, 101), range(67, 100)], # percentages 67-100 → zwave 67-99 ] for percentages, zwave_speeds in percentages_to_zwave_speeds: @@ -597,8 +597,8 @@ async def get_percentage_from_zwave_speed(zwave_speed): percentages_to_zwave_speeds = [ [[0], [0]], [range(1, 34), range(2, 34)], - [range(34, 68), range(34, 67)], - [range(68, 101), range(67, 100)], + [range(34, 67), range(34, 67)], + [range(67, 101), range(67, 100)], ] for percentages, zwave_speeds in percentages_to_zwave_speeds: @@ -1106,8 +1106,8 @@ async def get_percentage_from_zwave_speed(zwave_speed): percentages_to_zwave_speeds = [ [[0], [0]], [range(1, 34), range(1, 33)], - [range(34, 68), range(33, 67)], - [range(68, 101), range(67, 100)], + [range(34, 67), range(33, 67)], + [range(67, 101), range(67, 100)], ] for percentages, zwave_speeds in percentages_to_zwave_speeds: