diff --git a/CODEOWNERS b/CODEOWNERS index f4c7815a972..31057488869 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1474,7 +1474,8 @@ build.json @home-assistant/supervisor /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco -/homeassistant/components/stiebel_eltron/ @fucm +/homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS +/tests/components/stiebel_eltron/ @fucm @ThyMYthOS /homeassistant/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 94a3bd1058b..d2824ab10e5 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -1,22 +1,29 @@ """The component for STIEBEL ELTRON heat pumps with ISGWeb Modbus module.""" -from datetime import timedelta import logging +from typing import Any from pymodbus.client import ModbusTcpClient -from pystiebeleltron import pystiebeleltron +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI import voluptuous as vol -from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + DEVICE_DEFAULT_NAME, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -CONF_HUB = "hub" -DEFAULT_HUB = "modbus_hub" +from .const import CONF_HUB, DEFAULT_HUB, DOMAIN + MODBUS_DOMAIN = "modbus" -DOMAIN = "stiebel_eltron" CONFIG_SCHEMA = vol.Schema( { @@ -31,39 +38,109 @@ CONFIG_SCHEMA = vol.Schema( ) _LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +_PLATFORMS: list[Platform] = [Platform.CLIMATE] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the STIEBEL ELTRON unit. +async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: + """Set up the STIEBEL ELTRON component.""" + hub_config: dict[str, Any] | None = None + if MODBUS_DOMAIN in config: + for hub in config[MODBUS_DOMAIN]: + if hub[CONF_NAME] == config[DOMAIN][CONF_HUB]: + hub_config = hub + break + if hub_config is None: + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_missing_hub", + breaks_in_ha_version="2025.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_missing_hub", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) + return + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: hub_config[CONF_HOST], + CONF_PORT: hub_config[CONF_PORT], + CONF_NAME: config[DOMAIN][CONF_NAME], + }, + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) + return - Will automatically load climate platform. - """ - name = config[DOMAIN][CONF_NAME] - modbus_client = hass.data[MODBUS_DOMAIN][config[DOMAIN][CONF_HUB]] + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) - hass.data[DOMAIN] = { - "name": name, - "ste_data": StiebelEltronData(name, modbus_client), - } - discovery.load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the STIEBEL ELTRON component.""" + if DOMAIN in config: + hass.async_create_task(_async_import(hass, config)) return True -class StiebelEltronData: - """Get the latest data and update the states.""" +type StiebelEltronConfigEntry = ConfigEntry[StiebelEltronAPI] - def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: - """Init the STIEBEL ELTRON data object.""" - self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) +async def async_setup_entry( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> bool: + """Set up STIEBEL ELTRON from a config entry.""" + client = StiebelEltronAPI( + ModbusTcpClient(entry.data[CONF_HOST], port=entry.data[CONF_PORT]), 1 + ) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Update unit data.""" - if not self.api.update(): - _LOGGER.warning("Modbus read failed") - else: - _LOGGER.debug("Data updated successfully") + success = await hass.async_add_executor_job(client.update) + if not success: + raise ConfigEntryNotReady("Could not connect to device") + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 4d302a0f70d..f10ef0df667 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging from typing import Any +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI + from homeassistant.components.climate import ( PRESET_ECO, ClimateEntity, @@ -13,10 +15,9 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as STE_DOMAIN, StiebelEltronData +from . import StiebelEltronConfigEntry DEPENDENCIES = ["stiebel_eltron"] @@ -56,17 +57,14 @@ HA_TO_STE_HVAC = { HA_TO_STE_PRESET = {k: i for i, k in STE_TO_HA_PRESET.items()} -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: StiebelEltronConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the StiebelEltron platform.""" - name = hass.data[STE_DOMAIN]["name"] - ste_data = hass.data[STE_DOMAIN]["ste_data"] + """Set up STIEBEL ELTRON climate platform.""" - add_entities([StiebelEltron(name, ste_data)], True) + async_add_entities([StiebelEltron(entry.title, entry.runtime_data)], True) class StiebelEltron(ClimateEntity): @@ -81,7 +79,7 @@ class StiebelEltron(ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, name: str, ste_data: StiebelEltronData) -> None: + def __init__(self, name: str, client: StiebelEltronAPI) -> None: """Initialize the unit.""" self._name = name self._target_temperature: float | int | None = None @@ -89,19 +87,17 @@ class StiebelEltron(ClimateEntity): self._current_humidity: float | int | None = None self._operation: str | None = None self._filter_alarm: bool | None = None - self._force_update: bool = False - self._ste_data = ste_data + self._client = client def update(self) -> None: """Update unit attributes.""" - self._ste_data.update(no_throttle=self._force_update) - self._force_update = False + self._client.update() - self._target_temperature = self._ste_data.api.get_target_temp() - self._current_temperature = self._ste_data.api.get_current_temp() - self._current_humidity = self._ste_data.api.get_current_humidity() - self._filter_alarm = self._ste_data.api.get_filter_alarm_status() - self._operation = self._ste_data.api.get_operation() + self._target_temperature = self._client.get_target_temp() + self._current_temperature = self._client.get_current_temp() + self._current_humidity = self._client.get_current_humidity() + self._filter_alarm = self._client.get_filter_alarm_status() + self._operation = self._client.get_operation() _LOGGER.debug( "Update %s, current temp: %s", self._name, self._current_temperature @@ -170,20 +166,17 @@ class StiebelEltron(ClimateEntity): return new_mode = HA_TO_STE_HVAC.get(hvac_mode) _LOGGER.debug("set_hvac_mode: %s -> %s", self._operation, new_mode) - self._ste_data.api.set_operation(new_mode) - self._force_update = True + self._client.set_operation(new_mode) def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = kwargs.get(ATTR_TEMPERATURE) if target_temperature is not None: _LOGGER.debug("set_temperature: %s", target_temperature) - self._ste_data.api.set_target_temp(target_temperature) - self._force_update = True + self._client.set_target_temp(target_temperature) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" new_mode = HA_TO_STE_PRESET.get(preset_mode) _LOGGER.debug("set_hvac_mode: %s -> %s", self._operation, new_mode) - self._ste_data.api.set_operation(new_mode) - self._force_update = True + self._client.set_operation(new_mode) diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py new file mode 100644 index 00000000000..022fa50805a --- /dev/null +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for the STIEBEL ELTRON integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymodbus.client import ModbusTcpClient +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for STIEBEL ELTRON.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + client = StiebelEltronAPI( + ModbusTcpClient(user_input[CONF_HOST], port=user_input[CONF_PORT]), 1 + ) + try: + success = await self.hass.async_add_executor_job(client.update) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not success: + errors["base"] = "cannot_connect" + if not errors: + return self.async_create_entry(title="Stiebel Eltron", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import.""" + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + client = StiebelEltronAPI( + ModbusTcpClient(user_input[CONF_HOST], port=user_input[CONF_PORT]), 1 + ) + try: + success = await self.hass.async_add_executor_job(client.update) + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + if not success: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) diff --git a/homeassistant/components/stiebel_eltron/const.py b/homeassistant/components/stiebel_eltron/const.py new file mode 100644 index 00000000000..e6241caa77e --- /dev/null +++ b/homeassistant/components/stiebel_eltron/const.py @@ -0,0 +1,8 @@ +"""Constants for the STIEBEL ELTRON integration.""" + +DOMAIN = "stiebel_eltron" + +CONF_HUB = "hub" + +DEFAULT_HUB = "modbus_hub" +DEFAULT_PORT = 502 diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 9580cd4d4ca..f8140ed36d7 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -1,11 +1,10 @@ { "domain": "stiebel_eltron", "name": "STIEBEL ELTRON", - "codeowners": ["@fucm"], - "dependencies": ["modbus"], + "codeowners": ["@fucm", "@ThyMYthOS"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "quality_scale": "legacy", - "requirements": ["pystiebeleltron==0.0.1.dev2"] + "requirements": ["pystiebeleltron==0.1.0"] } diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json new file mode 100644 index 00000000000..8ff2b4025a9 --- /dev/null +++ b/homeassistant/components/stiebel_eltron/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Stiebel Eltron device.", + "port": "The port of your Stiebel Eltron device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove both the `{domain}` and the relevant Modbus configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_missing_hub": { + "title": "YAML import failed due to incomplete config", + "description": "Configuring {integration_title} using YAML is being removed but the configuration was not complete, thus we could not import your configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "YAML import failed due to an unknown error", + "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bff9c0e5159..ab1b2510d45 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -603,6 +603,7 @@ FLOWS = { "starlink", "steam_online", "steamist", + "stiebel_eltron", "stookwijzer", "streamlabswater", "subaru", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8fd8514324c..5955bcc6582 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6269,7 +6269,7 @@ "stiebel_eltron": { "name": "STIEBEL ELTRON", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "stookwijzer": { diff --git a/requirements_all.txt b/requirements_all.txt index f2c3ad896b5..f87d9327435 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2359,7 +2359,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.0.1.dev2 +pystiebeleltron==0.1.0 # homeassistant.components.suez_water pysuezV2==2.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 038cb4e0c1a..731ba830199 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1931,6 +1931,9 @@ pyspeex-noise==1.0.2 # homeassistant.components.squeezebox pysqueezebox==0.12.0 +# homeassistant.components.stiebel_eltron +pystiebeleltron==0.1.0 + # homeassistant.components.suez_water pysuezV2==2.0.4 diff --git a/tests/components/stiebel_eltron/__init__.py b/tests/components/stiebel_eltron/__init__.py new file mode 100644 index 00000000000..eaddd4c578b --- /dev/null +++ b/tests/components/stiebel_eltron/__init__.py @@ -0,0 +1 @@ +"""Tests for the STIEBEL ELTRON integration.""" diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py new file mode 100644 index 00000000000..7ee2612efa7 --- /dev/null +++ b/tests/components/stiebel_eltron/conftest.py @@ -0,0 +1,55 @@ +"""Common fixtures for the STIEBEL ELTRON tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.stiebel_eltron import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_stiebel_eltron_client() -> Generator[MagicMock]: + """Mock a stiebel eltron client.""" + with ( + patch( + "homeassistant.components.stiebel_eltron.StiebelEltronAPI", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.stiebel_eltron.config_flow.StiebelEltronAPI", + new=mock_client, + ), + ): + client = mock_client.return_value + client.update.return_value = True + yield client + + +@pytest.fixture(autouse=True) +def mock_modbus() -> Generator[MagicMock]: + """Mock a modbus client.""" + with ( + patch( + "homeassistant.components.stiebel_eltron.ModbusTcpClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.stiebel_eltron.config_flow.ModbusTcpClient", + new=mock_client, + ), + ): + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data={CONF_HOST: "1.1.1.1", CONF_PORT: 502}, + ) diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py new file mode 100644 index 00000000000..278ab6eea6f --- /dev/null +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -0,0 +1,209 @@ +"""Test the STIEBEL ELTRON config flow.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.stiebel_eltron.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_stiebel_eltron_client.update.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_stiebel_eltron_client.update.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_unknown_exception( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_stiebel_eltron_client.update.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_stiebel_eltron_client.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, 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_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + + +async def test_import_cannot_connect( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + mock_stiebel_eltron_client.update.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown_exception( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + mock_stiebel_eltron_client.update.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_import_already_configured( + hass: HomeAssistant, 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_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py new file mode 100644 index 00000000000..f8413c41461 --- /dev/null +++ b/tests/components/stiebel_eltron/test_init.py @@ -0,0 +1,177 @@ +"""Tests for the STIEBEL ELTRON integration.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.stiebel_eltron.const import CONF_HUB, DEFAULT_HUB, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_async_setup_success( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test successful async_setup.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + ], + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue + assert issue.active is True + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_async_setup_already_configured( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry, +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + ], + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue + assert issue.active is True + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_async_setup_with_non_existing_hub( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test async_setup with non-existing modbus hub.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: "non_existing_hub", + }, + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_missing_hub" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_missing_hub" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_async_setup_import_failure( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_stiebel_eltron_client: AsyncMock, +) -> None: + """Test async_setup with import failure.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "invalid_host", + CONF_PORT: 502, + } + ], + } + + # Simulate an import failure + mock_stiebel_eltron_client.update.side_effect = Exception("Import failure") + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_unknown" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_unknown" + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_modbus") +async def test_async_setup_cannot_connect( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_stiebel_eltron_client: AsyncMock, +) -> None: + """Test async_setup with import failure.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "invalid_host", + CONF_PORT: 502, + } + ], + } + + # Simulate a cannot connect error + mock_stiebel_eltron_client.update.return_value = False + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + assert issue.severity == ir.IssueSeverity.WARNING