Add option to allow to use setpoint instead of override for legacy incomfort RF gateway (#135143)

* Add option to allow to use setpoint in stead of override for legacy incomfort RF gateway

* Add test to assert state with legacy_setpoint_status option

* Use selector

* Update homeassistant/components/incomfort/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Follow up on code review

* Rephrase data_description

* Rephrase

* Use async_schedule_reload helper

* Move option flow after config flow

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jan Bouwhuis 2025-01-13 19:50:06 +01:00 committed by GitHub
parent 1c053485a9
commit 984c380e13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 278 additions and 20 deletions

View File

@ -25,7 +25,7 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
try: try:
data = await async_connect_gateway(hass, dict(entry.data)) data = await async_connect_gateway(hass, dict(entry.data))

View File

@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import InComfortConfigEntry from . import InComfortConfigEntry
from .const import DOMAIN from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import InComfortDataCoordinator from .coordinator import InComfortDataCoordinator
from .entity import IncomfortEntity from .entity import IncomfortEntity
@ -32,9 +32,12 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up InComfort/InTouch climate devices.""" """Set up InComfort/InTouch climate devices."""
incomfort_coordinator = entry.runtime_data incomfort_coordinator = entry.runtime_data
legacy_setpoint_status = entry.options.get(CONF_LEGACY_SETPOINT_STATUS, False)
heaters = incomfort_coordinator.data.heaters heaters = incomfort_coordinator.data.heaters
async_add_entities( async_add_entities(
InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms InComfortClimate(incomfort_coordinator, h, r, legacy_setpoint_status)
for h in heaters
for r in h.rooms
) )
@ -54,12 +57,14 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
coordinator: InComfortDataCoordinator, coordinator: InComfortDataCoordinator,
heater: InComfortHeater, heater: InComfortHeater,
room: InComfortRoom, room: InComfortRoom,
legacy_setpoint_status: bool,
) -> None: ) -> None:
"""Initialize the climate device.""" """Initialize the climate device."""
super().__init__(coordinator) super().__init__(coordinator)
self._heater = heater self._heater = heater
self._room = room self._room = room
self._legacy_setpoint_status = legacy_setpoint_status
self._attr_unique_id = f"{heater.serial_no}_{room.room_no}" self._attr_unique_id = f"{heater.serial_no}_{room.room_no}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@ -91,9 +96,11 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
As we set the override, we report back the override. The actual set point is As we set the override, we report back the override. The actual set point is
is returned at a later time. is returned at a later time.
Some older thermostats return 0.0 as override, in that case we fallback to Some older thermostats do not clear the override setting in that case, in that case
the actual setpoint. we fallback to the returning actual setpoint.
""" """
if self._legacy_setpoint_status:
return self._room.setpoint
return self._room.override or self._room.setpoint return self._room.override or self._room.setpoint
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:

View File

@ -1,21 +1,30 @@
"""Config flow support for Intergas InComfort integration.""" """Config flow support for Intergas InComfort integration."""
from __future__ import annotations
from typing import Any from typing import Any
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from incomfortclient import IncomfortError, InvalidHeaterList from incomfortclient import IncomfortError, InvalidHeaterList
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
BooleanSelector,
BooleanSelectorConfig,
TextSelector, TextSelector,
TextSelectorConfig, TextSelectorConfig,
TextSelectorType, TextSelectorType,
) )
from .const import DOMAIN from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import async_connect_gateway from .coordinator import async_connect_gateway
TITLE = "Intergas InComfort/Intouch Lan2RF gateway" TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
@ -34,6 +43,14 @@ CONFIG_SCHEMA = vol.Schema(
} }
) )
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector(
BooleanSelectorConfig()
)
}
)
ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = {
401: (CONF_PASSWORD, "auth_error"), 401: (CONF_PASSWORD, "auth_error"),
404: ("base", "not_found"), 404: ("base", "not_found"),
@ -66,6 +83,14 @@ async def async_try_connect_gateway(
class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to set up an Intergas InComfort boyler and thermostats.""" """Config flow to set up an Intergas InComfort boyler and thermostats."""
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> InComfortOptionsFlowHandler:
"""Get the options flow for this handler."""
return InComfortOptionsFlowHandler()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -81,3 +106,29 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
) )
class InComfortOptionsFlowHandler(OptionsFlow):
"""Handle InComfort Lan2RF gateway options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors: dict[str, str] | None = None
if user_input is not None:
new_options: dict[str, Any] = self.config_entry.options | user_input
self.hass.config_entries.async_update_entry(
self.config_entry, options=new_options
)
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return self.async_create_entry(data=new_options)
data_schema = self.add_suggested_values_to_schema(
OPTIONS_SCHEMA, self.config_entry.options
)
return self.async_show_form(
step_id="init",
data_schema=data_schema,
errors=errors,
)

View File

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

View File

@ -31,6 +31,19 @@
"unknown": "[%key:component::incomfort::config::abort::unknown%]" "unknown": "[%key:component::incomfort::config::abort::unknown%]"
} }
}, },
"options": {
"step": {
"init": {
"title": "Intergas InComfort Lan2RF Gateway options",
"data": {
"legacy_setpoint_status": "Legacy setpoint handling"
},
"data_description": {
"legacy_setpoint_status": "Some older gateway models with an older firmware versions might not update the thermostat setpoint and override settings correctly. Enable this option if you experience issues in updating the setpoint for your thermostat. It will use the actual setpoint of the thermostat instead of the override. As side effect is that it might take a few minutes before the setpoint is updated."
}
}
}
},
"issues": { "issues": {
"deprecated_yaml_import_issue_unknown": { "deprecated_yaml_import_issue_unknown": {
"title": "YAML import failed with unknown error", "title": "YAML import failed with unknown error",

View File

@ -53,12 +53,22 @@ def mock_entry_data() -> dict[str, Any]:
return MOCK_CONFIG return MOCK_CONFIG
@pytest.fixture
def mock_entry_options() -> dict[str, Any] | None:
"""Mock config entry options for fixture."""
return None
@pytest.fixture @pytest.fixture
def mock_config_entry( def mock_config_entry(
hass: HomeAssistant, mock_entry_data: dict[str, Any] hass: HomeAssistant,
mock_entry_data: dict[str, Any],
mock_entry_options: dict[str, Any],
) -> ConfigEntry: ) -> ConfigEntry:
"""Mock a config entry setup for incomfort integration.""" """Mock a config entry setup for incomfort integration."""
entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data) entry = MockConfigEntry(
domain=DOMAIN, data=mock_entry_data, options=mock_entry_options
)
entry.add_to_hass(hass) entry.add_to_hass(hass)
return entry return entry

View File

@ -1,5 +1,5 @@
# serializer version: 1 # serializer version: 1
# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry] # name: test_setup_platform[legacy-override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({
}), }),
@ -38,7 +38,73 @@
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state] # name: test_setup_platform[legacy-override][climate.thermostat_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.4,
'friendly_name': 'Thermostat 1',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
'status': dict({
'override': 19.0,
'room_temp': 21.42,
'setpoint': 18.0,
}),
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 18.0,
}),
'context': <ANY>,
'entity_id': 'climate.thermostat_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'incomfort',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'unique_id': 'c0ffeec0ffee_1',
'unit_of_measurement': None,
})
# ---
# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'current_temperature': 21.4, 'current_temperature': 21.4,
@ -65,7 +131,7 @@
'state': 'heat', 'state': 'heat',
}) })
# --- # ---
# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry] # name: test_setup_platform[modern-override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({
}), }),
@ -104,7 +170,7 @@
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
# name: test_setup_platform[new_thermostat][climate.thermostat_1-state] # name: test_setup_platform[modern-override][climate.thermostat_1-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'current_temperature': 21.4, 'current_temperature': 21.4,
@ -116,7 +182,73 @@
'max_temp': 30.0, 'max_temp': 30.0,
'min_temp': 5.0, 'min_temp': 5.0,
'status': dict({ 'status': dict({
'override': 18.0, 'override': 19.0,
'room_temp': 21.42,
'setpoint': 18.0,
}),
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 19.0,
}),
'context': <ANY>,
'entity_id': 'climate.thermostat_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'incomfort',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'unique_id': 'c0ffeec0ffee_1',
'unit_of_measurement': None,
})
# ---
# name: test_setup_platform[modern-zero_override][climate.thermostat_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.4,
'friendly_name': 'Thermostat 1',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
'status': dict({
'override': 0.0,
'room_temp': 21.42, 'room_temp': 21.42,
'setpoint': 18.0, 'setpoint': 18.0,
}), }),

View File

@ -17,10 +17,15 @@ from tests.common import snapshot_platform
@pytest.mark.parametrize( @pytest.mark.parametrize(
"mock_room_status", "mock_room_status",
[ [
{"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}, {"room_temp": 21.42, "setpoint": 18.0, "override": 19.0},
{"room_temp": 21.42, "setpoint": 18.0, "override": 0.0}, {"room_temp": 21.42, "setpoint": 18.0, "override": 0.0},
], ],
ids=["new_thermostat", "legacy_thermostat"], ids=["override", "zero_override"],
)
@pytest.mark.parametrize(
"mock_entry_options",
[None, {"legacy_setpoint_status": True}],
ids=["modern", "legacy"],
) )
async def test_setup_platform( async def test_setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
@ -31,8 +36,9 @@ async def test_setup_platform(
) -> None: ) -> None:
"""Test the incomfort entities are set up correctly. """Test the incomfort entities are set up correctly.
Legacy thermostats report 0.0 as override if no override is set, Thermostats report 0.0 as override if no override is set
but new thermostat sync the override with the actual setpoint instead. or when the setpoint has been changed manually,
Some older thermostats do not reset the override setpoint has been changed manually.
""" """
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@ -1,6 +1,7 @@
"""Tests for the Intergas InComfort config flow.""" """Tests for the Intergas InComfort config flow."""
from unittest.mock import AsyncMock, MagicMock from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from incomfortclient import IncomfortError, InvalidHeaterList from incomfortclient import IncomfortError, InvalidHeaterList
@ -113,3 +114,39 @@ async def test_form_validation(
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert "errors" not in result assert "errors" not in result
@pytest.mark.parametrize(
("user_input", "legacy_setpoint_status"),
[
({}, False),
({"legacy_setpoint_status": False}, False),
({"legacy_setpoint_status": True}, True),
],
)
async def test_options_flow(
hass: HomeAssistant,
mock_incomfort: MagicMock,
user_input: dict[str, Any],
legacy_setpoint_status: bool,
) -> None:
"""Test options flow."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
with patch("homeassistant.components.incomfort.async_setup_entry") as restart_mock:
result2 = await hass.config_entries.options.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done(wait_background_tasks=True)
assert restart_mock.call_count == 1
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {"legacy_setpoint_status": legacy_setpoint_status}
assert entry.options.get("legacy_setpoint_status", False) is legacy_setpoint_status