From 7f62ffb591c586b127726421ca24e3c90e3c88ff Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:05:43 +0000 Subject: [PATCH 1/9] Initial plan From a703a29b563331c220da8237c8ce18d911ac0365 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:09:43 +0000 Subject: [PATCH 2/9] Add virtual heat mode feature with options flow Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- custom_components/touchline/__init__.py | 7 ++ custom_components/touchline/climate.py | 73 ++++++++++++++++++- custom_components/touchline/config_flow.py | 39 +++++++++- custom_components/touchline/const.py | 5 ++ custom_components/touchline/strings.json | 13 ++++ .../touchline/translations/en.json | 13 ++++ 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/custom_components/touchline/__init__.py b/custom_components/touchline/__init__.py index 878e2cb..b815916 100644 --- a/custom_components/touchline/__init__.py +++ b/custom_components/touchline/__init__.py @@ -143,9 +143,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/touchline/climate.py b/custom_components/touchline/climate.py index a43315f..8e49d13 100644 --- a/custom_components/touchline/climate.py +++ b/custom_components/touchline/climate.py @@ -2,11 +2,13 @@ from __future__ import annotations import logging +from datetime import datetime, timedelta from typing import Any, NamedTuple from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -15,10 +17,14 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from . import TouchlineDataUpdateCoordinator, ExtendedPyTouchline from .const import ( + CONF_VIRTUAL_HEAT_MODE, DOMAIN, + HEAT_MODE_DELAY, + HEAT_MODE_THRESHOLD, OPERATION_MODE_AUTO, OPERATION_MODE_FROST, OPERATION_MODE_HOLIDAY, @@ -59,8 +65,10 @@ async def async_setup_entry( await coordinator.async_refresh() + virtual_heat_mode = entry.options.get(CONF_VIRTUAL_HEAT_MODE, False) + async_add_entities( - TouchlineClimate(coordinator, idx) + TouchlineClimate(coordinator, idx, virtual_heat_mode) for idx in range(len(coordinator.data)) ) @@ -79,10 +87,12 @@ def __init__( self, coordinator: TouchlineDataUpdateCoordinator, idx: int, + virtual_heat_mode: bool, ) -> None: """Initialize the climate entity.""" super().__init__(coordinator) self._idx = idx + self._virtual_heat_mode = virtual_heat_mode self._attr_unique_id = f"{coordinator.host}_{idx}" device_id = self._device.get_device_id() self._attr_device_info = DeviceInfo( @@ -93,6 +103,9 @@ def __init__( serial_number=str(device_id) if device_id is not None else None, via_device=(DOMAIN, f"{coordinator.host}_controller"), ) + # Track state for virtual heat mode + self._last_heating_time: datetime | None = None + self._is_heating = False @property def _device(self) -> ExtendedPyTouchline: @@ -138,6 +151,64 @@ def hvac_mode(self) -> HVACMode: return HVACMode.OFF return HVACMode.HEAT + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + # If not in HEAT mode, return OFF + if self.hvac_mode == HVACMode.OFF: + return HVACAction.OFF + + # If virtual heat mode is not enabled, return None (no action reported) + if not self._virtual_heat_mode: + return None + + # Get current and target temperatures + current_temp = self.current_temperature + target_temp = self.target_temperature + + # If temperatures are unavailable, return None + if current_temp is None or target_temp is None: + return None + + # Calculate temperature difference + temp_diff = target_temp - current_temp + + # Logic as per requirements: + # - When temp drops 0.1 below target -> immediately heating + # - When temp rises 0.1 above target -> after 5 min delay, idle + + if temp_diff >= HEAT_MODE_THRESHOLD: + # Temperature is below target (needs heating) + self._is_heating = True + self._last_heating_time = dt_util.utcnow() + return HVACAction.HEATING + elif temp_diff <= -HEAT_MODE_THRESHOLD: + # Temperature is above target + # Check if we should transition to idle after delay + if self._is_heating: + # We were heating, check if delay has passed + if self._last_heating_time is None: + self._last_heating_time = dt_util.utcnow() + + time_since_heating = dt_util.utcnow() - self._last_heating_time + if time_since_heating.total_seconds() >= HEAT_MODE_DELAY: + # Delay passed, transition to idle + self._is_heating = False + return HVACAction.IDLE + else: + # Still within delay period, remain heating + return HVACAction.HEATING + else: + # Already idle + return HVACAction.IDLE + else: + # Within hysteresis band (between -0.1 and +0.1) + # Maintain current state + if self._is_heating: + return HVACAction.HEATING + else: + return HVACAction.IDLE + @property def preset_mode(self) -> str | None: """Return the current preset mode.""" diff --git a/custom_components/touchline/config_flow.py b/custom_components/touchline/config_flow.py index 0a20d6b..920e692 100644 --- a/custom_components/touchline/config_flow.py +++ b/custom_components/touchline/config_flow.py @@ -7,10 +7,11 @@ import voluptuous as vol from pytouchline import PyTouchline -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST +from homeassistant.core import callback -from .const import DOMAIN +from .const import DOMAIN, CONF_VIRTUAL_HEAT_MODE _LOGGER = logging.getLogger(__name__) @@ -62,3 +63,37 @@ def _get_number_of_devices(host: str) -> str: """Return number of devices from the controller.""" touchline = PyTouchline() return touchline.get_number_of_devices(f"http://{host}") + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> TouchlineOptionsFlow: + """Get the options flow for this handler.""" + return TouchlineOptionsFlow(config_entry) + + +class TouchlineOptionsFlow(OptionsFlow): + """Handle options flow for Roth Touchline.""" + + def __init__(self, config_entry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_VIRTUAL_HEAT_MODE, + default=self.config_entry.options.get(CONF_VIRTUAL_HEAT_MODE, False), + ): bool, + } + ), + ) + diff --git a/custom_components/touchline/const.py b/custom_components/touchline/const.py index 52e5d99..969704b 100644 --- a/custom_components/touchline/const.py +++ b/custom_components/touchline/const.py @@ -3,9 +3,14 @@ DOMAIN = "touchline" CONF_HOST = "host" +CONF_VIRTUAL_HEAT_MODE = "virtual_heat_mode" # Operation modes from pyTouchline OPERATION_MODE_AUTO = 0 OPERATION_MODE_MANUAL = 1 OPERATION_MODE_HOLIDAY = 2 OPERATION_MODE_FROST = 3 + +# Virtual heat mode thresholds +HEAT_MODE_THRESHOLD = 0.1 # Temperature difference threshold in °C +HEAT_MODE_DELAY = 300 # Delay in seconds (5 minutes) before switching to idle diff --git a/custom_components/touchline/strings.json b/custom_components/touchline/strings.json index ed77176..443f1c0 100644 --- a/custom_components/touchline/strings.json +++ b/custom_components/touchline/strings.json @@ -15,5 +15,18 @@ "abort": { "already_configured": "This Touchline controller is already configured." } + }, + "options": { + "step": { + "init": { + "title": "Touchline Options", + "data": { + "virtual_heat_mode": "Enable virtual heat mode (heating/idle based on temperature)" + }, + "data_description": { + "virtual_heat_mode": "When enabled, climate entities will show 'heating' when temperature is below target and 'idle' when temperature is above target, with hysteresis and delay for stability." + } + } + } } } diff --git a/custom_components/touchline/translations/en.json b/custom_components/touchline/translations/en.json index ed77176..443f1c0 100644 --- a/custom_components/touchline/translations/en.json +++ b/custom_components/touchline/translations/en.json @@ -15,5 +15,18 @@ "abort": { "already_configured": "This Touchline controller is already configured." } + }, + "options": { + "step": { + "init": { + "title": "Touchline Options", + "data": { + "virtual_heat_mode": "Enable virtual heat mode (heating/idle based on temperature)" + }, + "data_description": { + "virtual_heat_mode": "When enabled, climate entities will show 'heating' when temperature is below target and 'idle' when temperature is above target, with hysteresis and delay for stability." + } + } + } } } From b2d93ac6053d58d3efbda781969283d69da65b4a Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:12:56 +0000 Subject: [PATCH 3/9] Add comprehensive tests for virtual heat mode feature Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- tests/test_climate.py | 115 +++++++++++++++++++++++++++++++++++++- tests/test_config_flow.py | 69 ++++++++++++++++++++++- 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index e875c31..9337ab6 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1,9 +1,11 @@ """Tests for the Roth Touchline climate platform.""" +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.util import dt as dt_util from custom_components.touchline.climate import ( PRESET_MODES, @@ -11,6 +13,8 @@ TouchlineClimate, ) from custom_components.touchline.const import ( + HEAT_MODE_DELAY, + HEAT_MODE_THRESHOLD, OPERATION_MODE_AUTO, OPERATION_MODE_FROST, OPERATION_MODE_HOLIDAY, @@ -43,8 +47,8 @@ def _make_coordinator(devices): return coordinator -def _make_entity(coordinator, idx=0): - entity = TouchlineClimate(coordinator, idx) +def _make_entity(coordinator, idx=0, virtual_heat_mode=False): + entity = TouchlineClimate(coordinator, idx, virtual_heat_mode) entity.hass = MagicMock() entity.hass.async_add_executor_job = AsyncMock(return_value=None) return entity @@ -345,3 +349,108 @@ async def test_set_preset_mode_invalid(self): with pytest.raises(ValueError): await entity.async_set_preset_mode("InvalidPreset") + + +class TestVirtualHeatMode: + """Test virtual heat mode functionality.""" + + def test_hvac_action_disabled_when_off(self): + """Test that HVAC action is OFF when in OFF mode.""" + dev = _make_device(op_mode=OPERATION_MODE_HOLIDAY) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + assert entity.hvac_action == HVACAction.OFF + + def test_hvac_action_none_when_disabled(self): + """Test that HVAC action is None when virtual heat mode is disabled.""" + dev = _make_device(current_temp=21.5, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=False) + assert entity.hvac_action is None + + def test_hvac_action_heating_when_temp_below_threshold(self): + """Test HVAC action is HEATING when temp is 0.1+ below target.""" + dev = _make_device(current_temp=21.0, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + # Temperature difference is 1.0, which is >= HEAT_MODE_THRESHOLD (0.1) + assert entity.hvac_action == HVACAction.HEATING + + def test_hvac_action_heating_exactly_at_threshold(self): + """Test HVAC action is HEATING when temp is exactly at threshold below target.""" + dev = _make_device(current_temp=21.9, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + # Temperature difference is 0.1, which is exactly HEAT_MODE_THRESHOLD + assert entity.hvac_action == HVACAction.HEATING + + def test_hvac_action_idle_when_temp_above_threshold_and_not_heating(self): + """Test HVAC action is IDLE when temp is above target and not heating.""" + dev = _make_device(current_temp=22.5, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = False + # Temperature difference is -0.5, which is <= -HEAT_MODE_THRESHOLD (-0.1) + assert entity.hvac_action == HVACAction.IDLE + + def test_hvac_action_remains_heating_during_delay(self): + """Test HVAC action remains HEATING during the 5-minute delay.""" + dev = _make_device(current_temp=22.5, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = True + entity._last_heating_time = dt_util.utcnow() - timedelta(seconds=60) # 1 minute ago + + # Temperature is above target but delay hasn't passed + assert entity.hvac_action == HVACAction.HEATING + + def test_hvac_action_idle_after_delay(self): + """Test HVAC action becomes IDLE after 5-minute delay.""" + dev = _make_device(current_temp=22.5, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = True + entity._last_heating_time = dt_util.utcnow() - timedelta(seconds=HEAT_MODE_DELAY + 1) + + # Temperature is above target and delay has passed + assert entity.hvac_action == HVACAction.IDLE + + def test_hvac_action_within_hysteresis_maintains_heating(self): + """Test HVAC action maintains HEATING when within hysteresis band.""" + dev = _make_device(current_temp=21.95, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = True + # Temperature difference is 0.05, within hysteresis band + assert entity.hvac_action == HVACAction.HEATING + + def test_hvac_action_within_hysteresis_maintains_idle(self): + """Test HVAC action maintains IDLE when within hysteresis band.""" + dev = _make_device(current_temp=22.05, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = False + # Temperature difference is -0.05, within hysteresis band + assert entity.hvac_action == HVACAction.IDLE + + def test_hvac_action_none_when_temperatures_unavailable(self): + """Test HVAC action is None when temperatures are unavailable.""" + dev = _make_device() + dev.get_current_temperature.return_value = None + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + assert entity.hvac_action is None + + def test_hvac_action_updates_heating_state(self): + """Test that accessing hvac_action updates internal heating state.""" + dev = _make_device(current_temp=21.0, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + + # Initial check - should be heating + assert entity.hvac_action == HVACAction.HEATING + assert entity._is_heating is True + assert entity._last_heating_time is not None + + # Update temperature to above target + dev.get_current_temperature.return_value = 22.5 + + # Check again - should still be heating during delay + assert entity.hvac_action == HVACAction.HEATING + + # Simulate delay passing + entity._last_heating_time = dt_util.utcnow() - timedelta(seconds=HEAT_MODE_DELAY + 1) + + # Now should be idle + assert entity.hvac_action == HVACAction.IDLE + assert entity._is_heating is False + diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 5578b75..0e765a8 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -3,8 +3,11 @@ import pytest -from custom_components.touchline.config_flow import TouchlineConfigFlow -from custom_components.touchline.const import DOMAIN +from custom_components.touchline.config_flow import ( + TouchlineConfigFlow, + TouchlineOptionsFlow, +) +from custom_components.touchline.const import DOMAIN, CONF_VIRTUAL_HEAT_MODE @pytest.mark.asyncio @@ -92,6 +95,67 @@ async def test_config_flow_user_step_shows_form_without_input(): assert result["errors"] == {} +@pytest.mark.asyncio +async def test_options_flow_init_default(): + """Test options flow with default values.""" + config_entry = MagicMock() + config_entry.options = {} + + flow = TouchlineOptionsFlow(config_entry) + flow.async_show_form = lambda step_id, data_schema: { + "step_id": step_id, + "data_schema": data_schema, + } + + result = await flow.async_step_init(None) + + assert result["step_id"] == "init" + # Check that the schema has the virtual_heat_mode option + assert CONF_VIRTUAL_HEAT_MODE in str(result["data_schema"]) + + +@pytest.mark.asyncio +async def test_options_flow_init_with_user_input(): + """Test options flow with user input.""" + config_entry = MagicMock() + config_entry.options = {} + + flow = TouchlineOptionsFlow(config_entry) + flow.async_create_entry = lambda title, data: {"title": title, "data": data} + + result = await flow.async_step_init({CONF_VIRTUAL_HEAT_MODE: True}) + + assert result["data"][CONF_VIRTUAL_HEAT_MODE] is True + + +@pytest.mark.asyncio +async def test_options_flow_init_preserves_existing_options(): + """Test options flow preserves existing option values.""" + config_entry = MagicMock() + config_entry.options = {CONF_VIRTUAL_HEAT_MODE: True} + + flow = TouchlineOptionsFlow(config_entry) + + # Mock the show form method and capture the schema + captured_schema = None + def mock_show_form(step_id, data_schema): + nonlocal captured_schema + captured_schema = data_schema + return {"step_id": step_id, "data_schema": data_schema} + + flow.async_show_form = mock_show_form + + result = await flow.async_step_init(None) + + # The form should be shown + assert result["step_id"] == "init" + + # Check that the schema contains the virtual_heat_mode option + # The default value should be True (from existing options) + assert captured_schema is not None + # We can't easily check the default in the schema, but we verified it's created properly + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -103,3 +167,4 @@ async def _sync_exec(func, *args): async def _async_noop(*args, **kwargs): """No-op coroutine used to stub async_set_unique_id.""" + From 69febb2daaceee2afc0130b1ca71b3d0472cbd83 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:28:16 +0000 Subject: [PATCH 4/9] Fix options flow 500 error by removing @callback decorator Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- custom_components/touchline/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/touchline/config_flow.py b/custom_components/touchline/config_flow.py index 920e692..03565b4 100644 --- a/custom_components/touchline/config_flow.py +++ b/custom_components/touchline/config_flow.py @@ -65,8 +65,7 @@ def _get_number_of_devices(host: str) -> str: return touchline.get_number_of_devices(f"http://{host}") @staticmethod - @callback - def async_get_options_flow(config_entry) -> TouchlineOptionsFlow: + def async_get_options_flow(config_entry): """Get the options flow for this handler.""" return TouchlineOptionsFlow(config_entry) From 4a5bb7e0e2cb605b0cdfc3989723e1b0a8deb64e Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:38:38 +0000 Subject: [PATCH 5/9] Fix OptionsFlow AttributeError by removing __init__ override Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- custom_components/touchline/config_flow.py | 4 ---- tests/test_config_flow.py | 9 ++++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/custom_components/touchline/config_flow.py b/custom_components/touchline/config_flow.py index 03565b4..e20582b 100644 --- a/custom_components/touchline/config_flow.py +++ b/custom_components/touchline/config_flow.py @@ -73,10 +73,6 @@ def async_get_options_flow(config_entry): class TouchlineOptionsFlow(OptionsFlow): """Handle options flow for Roth Touchline.""" - def __init__(self, config_entry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0e765a8..8797572 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -101,7 +101,8 @@ async def test_options_flow_init_default(): config_entry = MagicMock() config_entry.options = {} - flow = TouchlineOptionsFlow(config_entry) + flow = TouchlineOptionsFlow() + flow._config_entry = config_entry flow.async_show_form = lambda step_id, data_schema: { "step_id": step_id, "data_schema": data_schema, @@ -120,7 +121,8 @@ async def test_options_flow_init_with_user_input(): config_entry = MagicMock() config_entry.options = {} - flow = TouchlineOptionsFlow(config_entry) + flow = TouchlineOptionsFlow() + flow._config_entry = config_entry flow.async_create_entry = lambda title, data: {"title": title, "data": data} result = await flow.async_step_init({CONF_VIRTUAL_HEAT_MODE: True}) @@ -134,7 +136,8 @@ async def test_options_flow_init_preserves_existing_options(): config_entry = MagicMock() config_entry.options = {CONF_VIRTUAL_HEAT_MODE: True} - flow = TouchlineOptionsFlow(config_entry) + flow = TouchlineOptionsFlow() + flow._config_entry = config_entry # Mock the show form method and capture the schema captured_schema = None From 0496be0917fe4c454465d7709ef33179d26661cf Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:47:10 +0000 Subject: [PATCH 6/9] Fix options flow instantiation by removing config_entry parameter Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- custom_components/touchline/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/touchline/config_flow.py b/custom_components/touchline/config_flow.py index e20582b..27df318 100644 --- a/custom_components/touchline/config_flow.py +++ b/custom_components/touchline/config_flow.py @@ -67,7 +67,7 @@ def _get_number_of_devices(host: str) -> str: @staticmethod def async_get_options_flow(config_entry): """Get the options flow for this handler.""" - return TouchlineOptionsFlow(config_entry) + return TouchlineOptionsFlow() class TouchlineOptionsFlow(OptionsFlow): From 3f60d99faabb5e5169f04343cd404e88c5efeddd Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:50:04 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Update=20virtual=20heat=20mode=20threshold?= =?UTF-8?q?=20from=200.1=C2=B0C=20to=200.3=C2=B0C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- custom_components/touchline/climate.py | 6 +++--- custom_components/touchline/const.py | 2 +- tests/test_climate.py | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/custom_components/touchline/climate.py b/custom_components/touchline/climate.py index 8e49d13..4f5092d 100644 --- a/custom_components/touchline/climate.py +++ b/custom_components/touchline/climate.py @@ -174,8 +174,8 @@ def hvac_action(self) -> HVACAction | None: temp_diff = target_temp - current_temp # Logic as per requirements: - # - When temp drops 0.1 below target -> immediately heating - # - When temp rises 0.1 above target -> after 5 min delay, idle + # - When temp drops 0.3 below target -> immediately heating + # - When temp rises 0.3 above target -> after 5 min delay, idle if temp_diff >= HEAT_MODE_THRESHOLD: # Temperature is below target (needs heating) @@ -202,7 +202,7 @@ def hvac_action(self) -> HVACAction | None: # Already idle return HVACAction.IDLE else: - # Within hysteresis band (between -0.1 and +0.1) + # Within hysteresis band (between -0.3 and +0.3) # Maintain current state if self._is_heating: return HVACAction.HEATING diff --git a/custom_components/touchline/const.py b/custom_components/touchline/const.py index 969704b..79f6719 100644 --- a/custom_components/touchline/const.py +++ b/custom_components/touchline/const.py @@ -12,5 +12,5 @@ OPERATION_MODE_FROST = 3 # Virtual heat mode thresholds -HEAT_MODE_THRESHOLD = 0.1 # Temperature difference threshold in °C +HEAT_MODE_THRESHOLD = 0.3 # Temperature difference threshold in °C HEAT_MODE_DELAY = 300 # Delay in seconds (5 minutes) before switching to idle diff --git a/tests/test_climate.py b/tests/test_climate.py index 9337ab6..1584ec6 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -367,17 +367,17 @@ def test_hvac_action_none_when_disabled(self): assert entity.hvac_action is None def test_hvac_action_heating_when_temp_below_threshold(self): - """Test HVAC action is HEATING when temp is 0.1+ below target.""" + """Test HVAC action is HEATING when temp is 0.3+ below target.""" dev = _make_device(current_temp=21.0, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) - # Temperature difference is 1.0, which is >= HEAT_MODE_THRESHOLD (0.1) + # Temperature difference is 1.0, which is >= HEAT_MODE_THRESHOLD (0.3) assert entity.hvac_action == HVACAction.HEATING def test_hvac_action_heating_exactly_at_threshold(self): """Test HVAC action is HEATING when temp is exactly at threshold below target.""" - dev = _make_device(current_temp=21.9, target_temp=22.0) + dev = _make_device(current_temp=21.7, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) - # Temperature difference is 0.1, which is exactly HEAT_MODE_THRESHOLD + # Temperature difference is 0.3, which is exactly HEAT_MODE_THRESHOLD assert entity.hvac_action == HVACAction.HEATING def test_hvac_action_idle_when_temp_above_threshold_and_not_heating(self): @@ -385,7 +385,7 @@ def test_hvac_action_idle_when_temp_above_threshold_and_not_heating(self): dev = _make_device(current_temp=22.5, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) entity._is_heating = False - # Temperature difference is -0.5, which is <= -HEAT_MODE_THRESHOLD (-0.1) + # Temperature difference is -0.5, which is <= -HEAT_MODE_THRESHOLD (-0.3) assert entity.hvac_action == HVACAction.IDLE def test_hvac_action_remains_heating_during_delay(self): @@ -410,18 +410,18 @@ def test_hvac_action_idle_after_delay(self): def test_hvac_action_within_hysteresis_maintains_heating(self): """Test HVAC action maintains HEATING when within hysteresis band.""" - dev = _make_device(current_temp=21.95, target_temp=22.0) + dev = _make_device(current_temp=21.85, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) entity._is_heating = True - # Temperature difference is 0.05, within hysteresis band + # Temperature difference is 0.15, within hysteresis band assert entity.hvac_action == HVACAction.HEATING def test_hvac_action_within_hysteresis_maintains_idle(self): """Test HVAC action maintains IDLE when within hysteresis band.""" - dev = _make_device(current_temp=22.05, target_temp=22.0) + dev = _make_device(current_temp=22.15, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) entity._is_heating = False - # Temperature difference is -0.05, within hysteresis band + # Temperature difference is -0.15, within hysteresis band assert entity.hvac_action == HVACAction.IDLE def test_hvac_action_none_when_temperatures_unavailable(self): From 29309ee7137a589575d971f45fe997e66507a56f Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:35:40 +0000 Subject: [PATCH 8/9] =?UTF-8?q?Change=20threshold=20band=20to=20asymmetric?= =?UTF-8?q?=20-0.2=C2=B0C=20/=20+0.3=C2=B0C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- custom_components/touchline/climate.py | 13 +++++++------ custom_components/touchline/const.py | 3 ++- tests/test_climate.py | 23 ++++++++++++----------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/custom_components/touchline/climate.py b/custom_components/touchline/climate.py index 4f5092d..643f222 100644 --- a/custom_components/touchline/climate.py +++ b/custom_components/touchline/climate.py @@ -24,7 +24,8 @@ CONF_VIRTUAL_HEAT_MODE, DOMAIN, HEAT_MODE_DELAY, - HEAT_MODE_THRESHOLD, + HEAT_MODE_HEATING_THRESHOLD, + HEAT_MODE_IDLE_THRESHOLD, OPERATION_MODE_AUTO, OPERATION_MODE_FROST, OPERATION_MODE_HOLIDAY, @@ -174,15 +175,15 @@ def hvac_action(self) -> HVACAction | None: temp_diff = target_temp - current_temp # Logic as per requirements: - # - When temp drops 0.3 below target -> immediately heating - # - When temp rises 0.3 above target -> after 5 min delay, idle + # - When temp drops 0.2°C below target -> immediately heating + # - When temp rises 0.3°C above target -> after 5 min delay, idle - if temp_diff >= HEAT_MODE_THRESHOLD: + if temp_diff >= HEAT_MODE_HEATING_THRESHOLD: # Temperature is below target (needs heating) self._is_heating = True self._last_heating_time = dt_util.utcnow() return HVACAction.HEATING - elif temp_diff <= -HEAT_MODE_THRESHOLD: + elif temp_diff <= -HEAT_MODE_IDLE_THRESHOLD: # Temperature is above target # Check if we should transition to idle after delay if self._is_heating: @@ -202,7 +203,7 @@ def hvac_action(self) -> HVACAction | None: # Already idle return HVACAction.IDLE else: - # Within hysteresis band (between -0.3 and +0.3) + # Within hysteresis band (between -0.3°C and +0.2°C) # Maintain current state if self._is_heating: return HVACAction.HEATING diff --git a/custom_components/touchline/const.py b/custom_components/touchline/const.py index 79f6719..d6b5c39 100644 --- a/custom_components/touchline/const.py +++ b/custom_components/touchline/const.py @@ -12,5 +12,6 @@ OPERATION_MODE_FROST = 3 # Virtual heat mode thresholds -HEAT_MODE_THRESHOLD = 0.3 # Temperature difference threshold in °C +HEAT_MODE_HEATING_THRESHOLD = 0.2 # Temperature difference to trigger heating (°C below target) +HEAT_MODE_IDLE_THRESHOLD = 0.3 # Temperature difference to trigger idle (°C above target) HEAT_MODE_DELAY = 300 # Delay in seconds (5 minutes) before switching to idle diff --git a/tests/test_climate.py b/tests/test_climate.py index 1584ec6..bc6a85c 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -14,7 +14,8 @@ ) from custom_components.touchline.const import ( HEAT_MODE_DELAY, - HEAT_MODE_THRESHOLD, + HEAT_MODE_HEATING_THRESHOLD, + HEAT_MODE_IDLE_THRESHOLD, OPERATION_MODE_AUTO, OPERATION_MODE_FROST, OPERATION_MODE_HOLIDAY, @@ -367,17 +368,17 @@ def test_hvac_action_none_when_disabled(self): assert entity.hvac_action is None def test_hvac_action_heating_when_temp_below_threshold(self): - """Test HVAC action is HEATING when temp is 0.3+ below target.""" + """Test HVAC action is HEATING when temp is 0.2+ below target.""" dev = _make_device(current_temp=21.0, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) - # Temperature difference is 1.0, which is >= HEAT_MODE_THRESHOLD (0.3) + # Temperature difference is 1.0, which is >= HEAT_MODE_HEATING_THRESHOLD (0.2) assert entity.hvac_action == HVACAction.HEATING def test_hvac_action_heating_exactly_at_threshold(self): - """Test HVAC action is HEATING when temp is exactly at threshold below target.""" - dev = _make_device(current_temp=21.7, target_temp=22.0) + """Test HVAC action is HEATING when temp is at or below heating threshold.""" + dev = _make_device(current_temp=21.75, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) - # Temperature difference is 0.3, which is exactly HEAT_MODE_THRESHOLD + # Temperature difference is 0.25, which is >= HEAT_MODE_HEATING_THRESHOLD (0.2) assert entity.hvac_action == HVACAction.HEATING def test_hvac_action_idle_when_temp_above_threshold_and_not_heating(self): @@ -385,7 +386,7 @@ def test_hvac_action_idle_when_temp_above_threshold_and_not_heating(self): dev = _make_device(current_temp=22.5, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) entity._is_heating = False - # Temperature difference is -0.5, which is <= -HEAT_MODE_THRESHOLD (-0.3) + # Temperature difference is -0.5, which is <= -HEAT_MODE_IDLE_THRESHOLD (-0.3) assert entity.hvac_action == HVACAction.IDLE def test_hvac_action_remains_heating_during_delay(self): @@ -410,18 +411,18 @@ def test_hvac_action_idle_after_delay(self): def test_hvac_action_within_hysteresis_maintains_heating(self): """Test HVAC action maintains HEATING when within hysteresis band.""" - dev = _make_device(current_temp=21.85, target_temp=22.0) + dev = _make_device(current_temp=21.9, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) entity._is_heating = True - # Temperature difference is 0.15, within hysteresis band + # Temperature difference is 0.1, within hysteresis band (below 0.2 heating threshold) assert entity.hvac_action == HVACAction.HEATING def test_hvac_action_within_hysteresis_maintains_idle(self): """Test HVAC action maintains IDLE when within hysteresis band.""" - dev = _make_device(current_temp=22.15, target_temp=22.0) + dev = _make_device(current_temp=22.2, target_temp=22.0) entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) entity._is_heating = False - # Temperature difference is -0.15, within hysteresis band + # Temperature difference is -0.2, within hysteresis band (above -0.3 idle threshold) assert entity.hvac_action == HVACAction.IDLE def test_hvac_action_none_when_temperatures_unavailable(self): From 72b4852d52208263ea7c210ee877cad2367d48a4 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:58:31 +0000 Subject: [PATCH 9/9] Add virtual heat mode documentation to README Co-authored-by: gllmlbrt <58825251+gllmlbrt@users.noreply.github.com> --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 4d52b34..b73acb0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This integration has been developed for and tested to be **fully functional** wi This custom integration provides enhanced functionality compared to the core Home Assistant Touchline integration: ### Enhanced Features +- **Virtual Heat Mode**: Optional HVAC action monitoring with intelligent hysteresis control - **Extended System Monitoring**: Access to controller datetime, error codes, and system status sensors - **Time Synchronization**: Built-in button entity to sync controller time with Home Assistant - **Controller Metadata**: Retrieves ownerKurzID and additional R0 parameters from the controller @@ -76,6 +77,41 @@ The integration will automatically discover all heating zones configured on your All configuration is done through the Home Assistant UI. No YAML configuration is required. +### Optional Features + +#### Virtual Heat Mode + +The integration includes an optional **Virtual Heat Mode** feature that provides HVAC action monitoring for your climate entities. This feature displays whether your heating system is actively heating or idle, giving you better visibility into your system's operation. + +**Enabling Virtual Heat Mode:** +1. Go to **Settings** → **Devices & Services** +2. Find the "Roth Touchline" integration +3. Click **Configure** +4. Check the **"Virtual heat mode"** option +5. Click **Submit** + +**How It Works:** + +Virtual heat mode uses intelligent temperature-based logic with hysteresis control to determine the heating state: + +- **Heating State**: Activated when the current temperature drops **0.2°C or more** below the target temperature +- **Idle State**: Activated when the current temperature rises **0.3°C or more** above the target temperature (with a 5-minute delay) +- **Hysteresis Band**: When temperature is within the -0.3°C to +0.2°C range relative to target, the system maintains its current state to prevent frequent switching + +**Benefits:** +- **Better Visibility**: See at a glance whether your heating is actively working or idle +- **Energy Monitoring**: Track heating activity for better energy management +- **Automation Triggers**: Use HVAC action states in Home Assistant automations +- **Reduced Oscillation**: Asymmetric hysteresis prevents rapid on/off cycling + +**Example Use Cases:** +- Create automations that notify you when heating starts or stops +- Track daily heating activity patterns +- Optimize heating schedules based on actual heating demand +- Monitor system efficiency + +**Note**: This is a virtual/simulated feature based on temperature differences. The Roth Touchline controller itself does not provide direct heating state information. + ## Usage ### Climate Entities @@ -83,6 +119,7 @@ All configuration is done through the Home Assistant UI. No YAML configuration i Each heating zone in your Roth Touchline system will be available as a climate entity. You can: - **View current temperature**: Check the current temperature reading from each zone - **Set target temperature**: Adjust the desired temperature for each zone +- **Monitor HVAC action** (when virtual heat mode is enabled): See whether the zone is actively heating, idle, or off - **Change HVAC mode**: - `Heat`: Normal heating operation - `Off`: Holiday mode (disables heating)