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) 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..643f222 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,15 @@ 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_HEATING_THRESHOLD, + HEAT_MODE_IDLE_THRESHOLD, OPERATION_MODE_AUTO, OPERATION_MODE_FROST, OPERATION_MODE_HOLIDAY, @@ -59,8 +66,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 +88,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 +104,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 +152,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.2°C below target -> immediately heating + # - When temp rises 0.3°C above target -> after 5 min delay, idle + + 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_IDLE_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.3°C and +0.2°C) + # 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..27df318 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,32 @@ 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 + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return TouchlineOptionsFlow() + + +class TouchlineOptionsFlow(OptionsFlow): + """Handle options flow for Roth Touchline.""" + + 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..d6b5c39 100644 --- a/custom_components/touchline/const.py +++ b/custom_components/touchline/const.py @@ -3,9 +3,15 @@ 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_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/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." + } + } + } } } diff --git a/tests/test_climate.py b/tests/test_climate.py index e875c31..bc6a85c 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,9 @@ TouchlineClimate, ) from custom_components.touchline.const import ( + HEAT_MODE_DELAY, + HEAT_MODE_HEATING_THRESHOLD, + HEAT_MODE_IDLE_THRESHOLD, OPERATION_MODE_AUTO, OPERATION_MODE_FROST, OPERATION_MODE_HOLIDAY, @@ -43,8 +48,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 +350,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.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_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 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.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): + """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_IDLE_THRESHOLD (-0.3) + 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.9, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = True + # 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.2, target_temp=22.0) + entity = _make_entity(_make_coordinator([dev]), virtual_heat_mode=True) + entity._is_heating = False + # 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): + """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..8797572 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,70 @@ 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() + flow._config_entry = 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() + 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}) + + 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() + flow._config_entry = 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 +170,4 @@ async def _sync_exec(func, *args): async def _async_noop(*args, **kwargs): """No-op coroutine used to stub async_set_unique_id.""" +