Migrate Intergas InComfort/Intouch Lan2RF gateway YAML to config flow (#118642)

* Add config flow

* Make sure the device is polled - refactor

* Fix

* Add tests config flow

* Update test requirements

* Ensure dispatcher has a unique signal per heater

* Followup on review

* Follow up comments

* One more docstr

* Make specific try blocks and refactoring

* Handle import exceptions

* Restore removed lines

* Move initial heater update in try block

* Raise issue failed import

* Update test codeowners

* Remove entity device info

* Remove entity device info

* Appy suggestions from code review

* Remove broad exception handling from entry setup

* Test coverage
This commit is contained in:
Jan Bouwhuis 2024-06-03 20:37:48 +02:00 committed by GitHub
parent aac31059b0
commit dd1dd4c6a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 621 additions and 94 deletions

View file

@ -596,7 +596,13 @@ omit =
homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/ifttt/alarm_control_panel.py
homeassistant/components/iglo/light.py homeassistant/components/iglo/light.py
homeassistant/components/ihc/* 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/binary_sensor.py
homeassistant/components/insteon/climate.py homeassistant/components/insteon/climate.py
homeassistant/components/insteon/cover.py homeassistant/components/insteon/cover.py

View file

@ -659,6 +659,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/improv_ble/ @emontnemery /homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh /homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/influxdb/ @mdegat01 /homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco /homeassistant/components/inkbird/ @bdraco

View file

@ -2,24 +2,23 @@
from __future__ import annotations from __future__ import annotations
import logging
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from incomfortclient import Gateway as InComfortGateway from incomfortclient import IncomfortError, InvalidHeaterList
import voluptuous as vol 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.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__) from .const import DOMAIN
from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound
DOMAIN = "incomfort" from .models import DATA_INCOMFORT, async_connect_gateway
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -41,35 +40,87 @@ PLATFORMS = (
Platform.CLIMATE, 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: async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Create an Intergas InComfort/Intouch system.""" """Create an Intergas InComfort/Intouch system."""
incomfort_data = hass.data[DOMAIN] = {} if config := hass_config.get(DOMAIN):
hass.async_create_task(_async_import(hass, config))
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)
)
return True 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): class IncomfortEntity(Entity):
"""Base class for all InComfort entities.""" """Base class for all InComfort entities."""
@ -77,7 +128,11 @@ class IncomfortEntity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Set up a listener when this entity is added to HA.""" """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 @callback
def _refresh(self) -> None: def _refresh(self) -> None:

View file

@ -7,27 +7,23 @@ from typing import Any
from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater
from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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, hass: HomeAssistant,
config: ConfigType, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up an InComfort/InTouch binary_sensor device.""" """Set up an InComfort/InTouch binary_sensor entity."""
if discovery_info is None: incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
return async_add_entities(
IncomfortFailed(incomfort_data.client, h) for h in incomfort_data.heaters
client = hass.data[DOMAIN]["client"] )
heaters = hass.data[DOMAIN]["heaters"]
async_add_entities([IncomfortFailed(client, h) for h in heaters])
class IncomfortFailed(IncomfortEntity, BinarySensorEntity): class IncomfortFailed(IncomfortEntity, BinarySensorEntity):

View file

@ -15,29 +15,25 @@ from homeassistant.components.climate import (
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, HVACMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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, hass: HomeAssistant,
config: ConfigType, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up an InComfort/InTouch climate device.""" """Set up InComfort/InTouch climate devices."""
if discovery_info is None: incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
return
client = hass.data[DOMAIN]["client"]
heaters = hass.data[DOMAIN]["heaters"]
async_add_entities( 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
) )

View file

@ -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)

View file

@ -0,0 +1,3 @@
"""Constants for Intergas InComfort integration."""
DOMAIN = "incomfort"

View file

@ -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"

View file

@ -2,6 +2,7 @@
"domain": "incomfort", "domain": "incomfort",
"name": "Intergas InComfort/Intouch Lan2RF gateway", "name": "Intergas InComfort/Intouch Lan2RF gateway",
"codeowners": ["@jbouwh"], "codeowners": ["@jbouwh"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/incomfort", "documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["incomfortclient"], "loggers": ["incomfortclient"],

View file

@ -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)

View file

@ -12,13 +12,13 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfTemperature from homeassistant.const import UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify from homeassistant.util import slugify
from . import DOMAIN, IncomfortEntity from . import DATA_INCOMFORT, IncomfortEntity
INCOMFORT_HEATER_TEMP = "CV Temp" INCOMFORT_HEATER_TEMP = "CV Temp"
INCOMFORT_PRESSURE = "CV Pressure" INCOMFORT_PRESSURE = "CV Pressure"
@ -59,26 +59,18 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
) )
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up an InComfort/InTouch sensor device.""" """Set up InComfort/InTouch sensor entities."""
if discovery_info is None: incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
return async_add_entities(
IncomfortSensor(incomfort_data.client, heater, description)
client = hass.data[DOMAIN]["client"] for heater in incomfort_data.heaters
heaters = hass.data[DOMAIN]["heaters"]
entities = [
IncomfortSensor(client, heater, description)
for heater in heaters
for description in SENSOR_TYPES for description in SENSOR_TYPES
] )
async_add_entities(entities)
class IncomfortSensor(IncomfortEntity, SensorEntity): class IncomfortSensor(IncomfortEntity, SensorEntity):

View file

@ -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."
}
}
}

View file

@ -9,33 +9,29 @@ from aiohttp import ClientResponseError
from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater
from homeassistant.components.water_heater import WaterHeaterEntity from homeassistant.components.water_heater import WaterHeaterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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__) _LOGGER = logging.getLogger(__name__)
HEATER_ATTRS = ["display_code", "display_text", "is_burning"] HEATER_ATTRS = ["display_code", "display_text", "is_burning"]
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up an InComfort/Intouch water_heater device.""" """Set up an InComfort/InTouch water_heater device."""
if discovery_info is None: incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
return async_add_entities(
IncomfortWaterHeater(incomfort_data.client, h) for h in incomfort_data.heaters
client = hass.data[DOMAIN]["client"] )
heaters = hass.data[DOMAIN]["heaters"]
async_add_entities([IncomfortWaterHeater(client, h) for h in heaters])
class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity):
@ -92,4 +88,4 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity):
_LOGGER.warning("Update failed, message is: %s", err) _LOGGER.warning("Update failed, message is: %s", err)
else: else:
async_dispatcher_send(self.hass, DOMAIN) async_dispatcher_send(self.hass, f"{DOMAIN}_{self.unique_id}")

View file

@ -256,6 +256,7 @@ FLOWS = {
"imap", "imap",
"imgw_pib", "imgw_pib",
"improv_ble", "improv_ble",
"incomfort",
"inkbird", "inkbird",
"insteon", "insteon",
"intellifire", "intellifire",

View file

@ -2809,7 +2809,7 @@
"incomfort": { "incomfort": {
"name": "Intergas InComfort/Intouch Lan2RF gateway", "name": "Intergas InComfort/Intouch Lan2RF gateway",
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": true,
"iot_class": "local_polling" "iot_class": "local_polling"
}, },
"indianamichiganpower": { "indianamichiganpower": {

View file

@ -935,6 +935,9 @@ ifaddr==0.2.0
# homeassistant.components.imgw_pib # homeassistant.components.imgw_pib
imgw_pib==1.0.1 imgw_pib==1.0.1
# homeassistant.components.incomfort
incomfort-client==0.5.0
# homeassistant.components.influxdb # homeassistant.components.influxdb
influxdb-client==1.24.0 influxdb-client==1.24.0

View file

@ -0,0 +1 @@
"""Tests for the Intergas InComfort integration."""

View file

@ -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

View file

@ -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