diff --git a/.coveragerc b/.coveragerc index 625057e9900..0ff06a1184c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -596,7 +596,13 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/incomfort/* + homeassistant/components/incomfort/__init__.py + homeassistant/components/incomfort/binary_sensor.py + homeassistant/components/incomfort/climate.py + homeassistant/components/incomfort/errors.py + homeassistant/components/incomfort/models.py + homeassistant/components/incomfort/sensor.py + homeassistant/components/incomfort/water_heater.py homeassistant/components/insteon/binary_sensor.py homeassistant/components/insteon/climate.py homeassistant/components/insteon/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 3f1247de891..a72683c1737 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -659,6 +659,7 @@ build.json @home-assistant/supervisor /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh +/tests/components/incomfort/ @jbouwh /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 /homeassistant/components/inkbird/ @bdraco diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 72453bb5290..3f6b36aa27c 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -2,24 +2,23 @@ from __future__ import annotations -import logging - from aiohttp import ClientResponseError -from incomfortclient import Gateway as InComfortGateway +from incomfortclient import IncomfortError, InvalidHeaterList import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "incomfort" +from .const import DOMAIN +from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound +from .models import DATA_INCOMFORT, async_connect_gateway CONFIG_SCHEMA = vol.Schema( { @@ -41,35 +40,87 @@ PLATFORMS = ( Platform.CLIMATE, ) +INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" + + +async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: + """Import config entry from configuration.yaml.""" + if not hass.config_entries.async_entries(DOMAIN): + # Start import flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.1.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": INTEGRATION_TITLE, + }, + ) + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: """Create an Intergas InComfort/Intouch system.""" - incomfort_data = hass.data[DOMAIN] = {} - - credentials = dict(hass_config[DOMAIN]) - hostname = credentials.pop(CONF_HOST) - - client = incomfort_data["client"] = InComfortGateway( - hostname, **credentials, session=async_get_clientsession(hass) - ) - - try: - heaters = incomfort_data["heaters"] = list(await client.heaters()) - except ClientResponseError as err: - _LOGGER.warning("Setup failed, check your configuration, message is: %s", err) - return False - - for heater in heaters: - await heater.update() - - for platform in PLATFORMS: - hass.async_create_task( - async_load_platform(hass, platform, DOMAIN, {}, hass_config) - ) - + if config := hass_config.get(DOMAIN): + hass.async_create_task(_async_import(hass, config)) return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + try: + data = await async_connect_gateway(hass, dict(entry.data)) + for heater in data.heaters: + await heater.update() + except InvalidHeaterList as exc: + raise NoHeaters from exc + except IncomfortError as exc: + if isinstance(exc.message, ClientResponseError): + if exc.message.status == 401: + raise ConfigEntryAuthFailed("Incorrect credentials") from exc + if exc.message.status == 404: + raise NotFound from exc + raise InConfortUnknownError from exc + except TimeoutError as exc: + raise InConfortTimeout from exc + + hass.data.setdefault(DATA_INCOMFORT, {entry.entry_id: data}) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + del hass.data[DOMAIN][entry.entry_id] + return unload_ok + + class IncomfortEntity(Entity): """Base class for all InComfort entities.""" @@ -77,7 +128,11 @@ class IncomfortEntity(Entity): async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" - self.async_on_remove(async_dispatcher_connect(self.hass, DOMAIN, self._refresh)) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"{DOMAIN}_{self.unique_id}", self._refresh + ) + ) @callback def _refresh(self) -> None: diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 04c0c17ba2a..9bfe637e09a 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -7,27 +7,23 @@ from typing import Any from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, IncomfortEntity +from . import DATA_INCOMFORT, IncomfortEntity -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/InTouch binary_sensor device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - - async_add_entities([IncomfortFailed(client, h) for h in heaters]) + """Set up an InComfort/InTouch binary_sensor entity.""" + incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id] + async_add_entities( + IncomfortFailed(incomfort_data.client, h) for h in incomfort_data.heaters + ) class IncomfortFailed(IncomfortEntity, BinarySensorEntity): diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 32816900034..21871a66487 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -15,29 +15,25 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) +from homeassistant.config_entries import ConfigEntry 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 . import DOMAIN, IncomfortEntity +from . import DATA_INCOMFORT, IncomfortEntity -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/InTouch climate device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - + """Set up InComfort/InTouch climate devices.""" + incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id] async_add_entities( - [InComfortClimate(client, h, r) for h in heaters for r in h.rooms] + InComfortClimate(incomfort_data.client, h, r) + for h in incomfort_data.heaters + for r in h.rooms ) diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py new file mode 100644 index 00000000000..bc928997b32 --- /dev/null +++ b/homeassistant/components/incomfort/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow support for Intergas InComfort integration.""" + +from typing import Any + +from aiohttp import ClientResponseError +from incomfortclient import IncomfortError, InvalidHeaterList +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN +from .models import async_connect_gateway + +TITLE = "Intergas InComfort/Intouch Lan2RF gateway" + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin") + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + +ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { + 401: (CONF_PASSWORD, "auth_error"), + 404: ("base", "not_found"), +} + + +async def async_try_connect_gateway( + hass: HomeAssistant, config: dict[str, Any] +) -> dict[str, str] | None: + """Try to connect to the Lan2RF gateway.""" + try: + await async_connect_gateway(hass, config) + except InvalidHeaterList: + return {"base": "no_heaters"} + except IncomfortError as exc: + if isinstance(exc.message, ClientResponseError): + scope, error = ERROR_STATUS_MAPPING.get( + exc.message.status, ("base", "unknown") + ) + return {scope: error} + return {"base": "unknown"} + except TimeoutError: + return {"base": "timeout_error"} + except Exception: # noqa: BLE001 + return {"base": "unknown"} + + return None + + +class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow to set up an Intergas InComfort boyler and thermostats.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] | None = None + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + if ( + errors := await async_try_connect_gateway(self.hass, user_input) + ) is None: + return self.async_create_entry(title=TITLE, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import `incomfort` config entry from configuration.yaml.""" + errors: dict[str, str] | None = None + if (errors := await async_try_connect_gateway(self.hass, import_data)) is None: + return self.async_create_entry(title=TITLE, data=import_data) + reason = next(iter(errors.items()))[1] + return self.async_abort(reason=reason) diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py new file mode 100644 index 00000000000..721dd8591b0 --- /dev/null +++ b/homeassistant/components/incomfort/const.py @@ -0,0 +1,3 @@ +"""Constants for Intergas InComfort integration.""" + +DOMAIN = "incomfort" diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py new file mode 100644 index 00000000000..1023ce70eec --- /dev/null +++ b/homeassistant/components/incomfort/errors.py @@ -0,0 +1,32 @@ +"""Exceptions raised by Intergas InComfort integration.""" + +from homeassistant.core import DOMAIN +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError + + +class NotFound(HomeAssistantError): + """Raise exception if no Lan2RF Gateway was found.""" + + translation_domain = DOMAIN + translation_key = "not_found" + + +class NoHeaters(ConfigEntryNotReady): + """Raise exception if no heaters are found.""" + + translation_domain = DOMAIN + translation_key = "no_heaters" + + +class InConfortTimeout(ConfigEntryNotReady): + """Raise exception if no heaters are found.""" + + translation_domain = DOMAIN + translation_key = "timeout_error" + + +class InConfortUnknownError(ConfigEntryNotReady): + """Raise exception if no heaters are found.""" + + translation_domain = DOMAIN + translation_key = "unknown" diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index 3b5a1b76e7d..8ef57047cce 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -2,6 +2,7 @@ "domain": "incomfort", "name": "Intergas InComfort/Intouch Lan2RF gateway", "codeowners": ["@jbouwh"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], diff --git a/homeassistant/components/incomfort/models.py b/homeassistant/components/incomfort/models.py new file mode 100644 index 00000000000..19e4269e0b4 --- /dev/null +++ b/homeassistant/components/incomfort/models.py @@ -0,0 +1,40 @@ +"""Models for Intergas InComfort integration.""" + +from dataclasses import dataclass, field +from typing import Any + +from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + + +@dataclass +class InComfortData: + """Keep the Intergas InComfort entry data.""" + + client: InComfortGateway + heaters: list[InComfortHeater] = field(default_factory=list) + + +DATA_INCOMFORT: HassKey[dict[str, InComfortData]] = HassKey(DOMAIN) + + +async def async_connect_gateway( + hass: HomeAssistant, + entry_data: dict[str, Any], +) -> InComfortData: + """Validate the configuration.""" + credentials = dict(entry_data) + hostname = credentials.pop(CONF_HOST) + + client = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) + ) + heaters = await client.heaters() + + return InComfortData(client=client, heaters=heaters) diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index e75fbee2676..d74c6a18e59 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -12,13 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from . import DOMAIN, IncomfortEntity +from . import DATA_INCOMFORT, IncomfortEntity INCOMFORT_HEATER_TEMP = "CV Temp" INCOMFORT_PRESSURE = "CV Pressure" @@ -59,26 +59,18 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/InTouch sensor device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - - entities = [ - IncomfortSensor(client, heater, description) - for heater in heaters + """Set up InComfort/InTouch sensor entities.""" + incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id] + async_add_entities( + IncomfortSensor(incomfort_data.client, heater, description) + for heater in incomfort_data.heaters for description in SENSOR_TYPES - ] - - async_add_entities(entities) + ) class IncomfortSensor(IncomfortEntity, SensorEntity): diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json new file mode 100644 index 00000000000..e94c2e508ad --- /dev/null +++ b/homeassistant/components/incomfort/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.", + "username": "The username to log into the gateway. This is `admin` in most cases.", + "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "auth_error": "Invalid credentials.", + "no_heaters": "No heaters found.", + "not_found": "No Lan2RF gateway found.", + "timeout_error": "Time out when connection to Lan2RF gateway.", + "unknown": "Unknown error when connection to Lan2RF gateway." + }, + "error": { + "auth_error": "[%key:component::incomfort::config::abort::auth_error%]", + "no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]", + "not_found": "[%key:component::incomfort::config::abort::not_found%]", + "timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]", + "unknown": "[%key:component::incomfort::config::abort::unknown%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_unknown": { + "title": "YAML import failed with 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." + }, + "deprecated_yaml_import_issue_auth_error": { + "title": "YAML import failed due to an authentication error", + "description": "Configuring {integration_title} using YAML is being removed but there was an authentication 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_no_heaters": { + "title": "YAML import failed because no heaters were found", + "description": "Configuring {integration_title} using YAML is being removed but no heaters were found 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_not_found": { + "title": "YAML import failed because no gateway was found", + "description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found 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_timeout_error": { + "title": "YAML import failed because of timeout issues", + "description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} 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/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 883d8555832..6b982b7f71e 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -9,33 +9,29 @@ from aiohttp import ClientResponseError from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater from homeassistant.components.water_heater import WaterHeaterEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, IncomfortEntity +from . import DATA_INCOMFORT, DOMAIN, IncomfortEntity _LOGGER = logging.getLogger(__name__) HEATER_ATTRS = ["display_code", "display_text", "is_burning"] -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/Intouch water_heater device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - - async_add_entities([IncomfortWaterHeater(client, h) for h in heaters]) + """Set up an InComfort/InTouch water_heater device.""" + incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id] + async_add_entities( + IncomfortWaterHeater(incomfort_data.client, h) for h in incomfort_data.heaters + ) class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): @@ -92,4 +88,4 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): _LOGGER.warning("Update failed, message is: %s", err) else: - async_dispatcher_send(self.hass, DOMAIN) + async_dispatcher_send(self.hass, f"{DOMAIN}_{self.unique_id}") diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 567c00d63e7..e38513046f1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -256,6 +256,7 @@ FLOWS = { "imap", "imgw_pib", "improv_ble", + "incomfort", "inkbird", "insteon", "intellifire", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 70995bb3d63..194ca540b3f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2809,7 +2809,7 @@ "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "indianamichiganpower": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce0764a2c80..3b96777fe38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -935,6 +935,9 @@ ifaddr==0.2.0 # homeassistant.components.imgw_pib imgw_pib==1.0.1 +# homeassistant.components.incomfort +incomfort-client==0.5.0 + # homeassistant.components.influxdb influxdb-client==1.24.0 diff --git a/tests/components/incomfort/__init__.py b/tests/components/incomfort/__init__.py new file mode 100644 index 00000000000..dd398f37a68 --- /dev/null +++ b/tests/components/incomfort/__init__.py @@ -0,0 +1 @@ +"""Tests for the Intergas InComfort integration.""" diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py new file mode 100644 index 00000000000..5f5a2c9be16 --- /dev/null +++ b/tests/components/incomfort/conftest.py @@ -0,0 +1,94 @@ +"""Fixtures for Intergas InComfort integration.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.incomfort.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_heater_status() -> dict[str, Any]: + """Mock heater status.""" + return { + "display_code": 126, + "display_text": "standby", + "fault_code": None, + "is_burning": False, + "is_failed": False, + "is_pumping": False, + "is_tapping": False, + "heater_temp": 35.34, + "tap_temp": 30.21, + "pressure": 1.86, + "serial_no": "2404c08648", + "nodenr": 249, + "rf_message_rssi": 30, + "rfstatus_cntr": 0, + } + + +@pytest.fixture +def mock_room_status() -> dict[str, Any]: + """Mock room status.""" + return {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0} + + +@pytest.fixture +def mock_incomfort( + hass: HomeAssistant, + mock_heater_status: dict[str, Any], + mock_room_status: dict[str, Any], +) -> Generator[MagicMock, None]: + """Mock the InComfort gateway client.""" + + class MockRoom: + """Mocked InComfort room class.""" + + override: float + room_no: int + room_temp: float + setpoint: float + status: dict[str, Any] + + def __init__(self) -> None: + """Initialize mocked room.""" + self.override = mock_room_status["override"] + self.room_no = 1 + self.room_temp = mock_room_status["room_temp"] + self.setpoint = mock_room_status["setpoint"] + self.status = mock_room_status + + class MockHeater: + """Mocked InComfort heater class.""" + + serial_no: str + status: dict[str, Any] + rooms: list[MockRoom] + + def __init__(self) -> None: + """Initialize mocked heater.""" + self.serial_no = "c0ffeec0ffee" + + async def update(self) -> None: + self.status = mock_heater_status + self.rooms = [MockRoom] + + with patch( + "homeassistant.components.incomfort.models.InComfortGateway", MagicMock() + ) as patch_gateway: + patch_gateway().heaters = AsyncMock() + patch_gateway().heaters.return_value = [MockHeater()] + yield patch_gateway diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py new file mode 100644 index 00000000000..08f03d96bdb --- /dev/null +++ b/tests/components/incomfort/test_config_flow.py @@ -0,0 +1,163 @@ +"""Tests for the Intergas InComfort config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import ClientResponseError +from incomfortclient import IncomfortError, InvalidHeaterList +import pytest + +from homeassistant.components.incomfort import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + "host": "192.168.1.12", + "username": "admin", + "password": "verysecret", +} + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test we get the full form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test we van import from YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exc", "abort_reason"), + [ + (IncomfortError(ClientResponseError(None, None, status=401)), "auth_error"), + (IncomfortError(ClientResponseError(None, None, status=404)), "not_found"), + (IncomfortError(ClientResponseError(None, None, status=500)), "unknown"), + (IncomfortError, "unknown"), + (InvalidHeaterList, "no_heaters"), + (ValueError, "unknown"), + (TimeoutError, "timeout_error"), + ], +) +async def test_import_fails( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_incomfort: MagicMock, + exc: Exception, + abort_reason: str, +) -> None: + """Test YAML import fails.""" + mock_incomfort().heaters.side_effect = exc + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_entry_already_configured(hass: HomeAssistant) -> None: + """Test aborting if the entry is already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_CONFIG[CONF_HOST], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exc", "error", "base"), + [ + ( + IncomfortError(ClientResponseError(None, None, status=401)), + "auth_error", + CONF_PASSWORD, + ), + ( + IncomfortError(ClientResponseError(None, None, status=404)), + "not_found", + "base", + ), + ( + IncomfortError(ClientResponseError(None, None, status=500)), + "unknown", + "base", + ), + (IncomfortError, "unknown", "base"), + (ValueError, "unknown", "base"), + (TimeoutError, "timeout_error", "base"), + (InvalidHeaterList, "no_heaters", "base"), + ], +) +async def test_form_validation( + hass: HomeAssistant, + mock_incomfort: MagicMock, + exc: Exception, + error: str, + base: str, +) -> None: + """Test form validation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Simulate issue and retry + mock_incomfort().heaters.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + base: error, + } + + # Fix the issue and retry + mock_incomfort().heaters.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert "errors" not in result