From babefe930667e189c697509e133ef564846bfd58 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 5 Dec 2024 13:00:43 +0000 Subject: [PATCH] fix: respects min_cycle_duration while switching to fan Fixes #270 --- .../hvac_action_reason_internal.py | 2 + .../hvac_controller/generic_controller.py | 22 ++-- .../hvac_device/cooler_fan_device.py | 100 ++++++++++-------- .../hvac_device/generic_hvac_device.py | 4 + tests/__init__.py | 10 +- tests/common.py | 26 +++-- tests/test_fan_mode.py | 91 +++++++++++++++- 7 files changed, 186 insertions(+), 69 deletions(-) diff --git a/custom_components/dual_smart_thermostat/hvac_action_reason/hvac_action_reason_internal.py b/custom_components/dual_smart_thermostat/hvac_action_reason/hvac_action_reason_internal.py index 6e44319..be1d5ef 100644 --- a/custom_components/dual_smart_thermostat/hvac_action_reason/hvac_action_reason_internal.py +++ b/custom_components/dual_smart_thermostat/hvac_action_reason/hvac_action_reason_internal.py @@ -4,6 +4,8 @@ class HVACActionReasonInternal(enum.StrEnum): """Internal HVAC Action Reason for climate devices.""" + MIN_CYCLE_DURATION_NOT_REACHED = "min_cycle_duration_not_reached" + TARGET_TEMP_NOT_REACHED = "target_temp_not_reached" TARGET_TEMP_REACHED = "target_temp_reached" diff --git a/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py index 5714293..bcb7111 100644 --- a/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py +++ b/custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py @@ -6,6 +6,7 @@ from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.const import STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition import homeassistant.util.dt as dt_util @@ -82,7 +83,7 @@ def is_active(self) -> bool: return True return False - def _ran_long_enough(self) -> bool: + def ran_long_enough(self) -> bool: if self.is_active: current_state = STATE_ON else: @@ -93,12 +94,15 @@ def _ran_long_enough(self) -> bool: _LOGGER.debug("min_cycle_duration: %s", self.min_cycle_duration) _LOGGER.debug("time: %s", dt_util.utcnow()) - long_enough = condition.state( - self.hass, - self.entity_id, - current_state, - self.min_cycle_duration, - ) + try: + long_enough = condition.state( + self.hass, + self.entity_id, + current_state, + self.min_cycle_duration, + ) + except ConditionError: + long_enough = False return long_enough @@ -121,9 +125,9 @@ def needs_control( # keep-alive purposes, and `min_cycle_duration` is irrelevant. if self.min_cycle_duration: _LOGGER.debug( - "Checking if device ran long enough: %s", self._ran_long_enough() + "Checking if device ran long enough: %s", self.ran_long_enough() ) - return self._ran_long_enough() + return self.ran_long_enough() return True async def async_control_device_when_on( diff --git a/custom_components/dual_smart_thermostat/hvac_device/cooler_fan_device.py b/custom_components/dual_smart_thermostat/hvac_device/cooler_fan_device.py index d7bfef7..9bd4408 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/cooler_fan_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/cooler_fan_device.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import logging from typing import Callable @@ -126,53 +127,9 @@ async def async_control_hvac(self, time=None, force=False): match self._hvac_mode: case HVACMode.COOL: if self._fan_on_with_cooler: - await self.fan_device.async_control_hvac(time, force) - await self.cooler_device.async_control_hvac(time, force) - self.HVACActionReason = self.cooler_device.HVACActionReason + await self._async_control_when_fan_on_with_cooler(time, force) else: - - is_within_fan_tolerance = self.environment.is_within_fan_tolerance( - self.fan_device.target_env_attr - ) - is_warmer_outside = self.environment.is_warmer_outside - is_fan_air_outside = self.fan_device.fan_air_surce_outside - - # If the fan_hot_tolerance is set, enforce the action for the fan or cooler device - # to ignore cycles as we switch between the fan and cooler device - # and we want to avoid idle time gaps between the devices - force_override = ( - True - if self.environment.fan_hot_tolerance is not None - else force - ) - - if ( - self._fan_hot_tolerance_on - and is_within_fan_tolerance - and not (is_fan_air_outside and is_warmer_outside) - ): - _LOGGER.debug("within fan tolerance") - _LOGGER.debug( - "fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on - ) - _LOGGER.debug("force_override: %s", force_override) - - self.fan_device.hvac_mode = HVACMode.FAN_ONLY - await self.fan_device.async_control_hvac(time, force_override) - await self.cooler_device.async_turn_off() - self.HVACActionReason = ( - HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN - ) - else: - _LOGGER.debug("outside fan tolerance") - _LOGGER.debug( - "fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on - ) - await self.cooler_device.async_control_hvac( - time, force_override - ) - await self.fan_device.async_turn_off() - self.HVACActionReason = self.cooler_device.HVACActionReason + await self._async_control_cooler(time, force) case HVACMode.FAN_ONLY: await self.cooler_device.async_turn_off() @@ -184,3 +141,54 @@ async def async_control_hvac(self, time=None, force=False): case _: if self._hvac_mode is not None: _LOGGER.warning("Invalid HVAC mode: %s", self._hvac_mode) + + async def _async_control_when_fan_on_with_cooler(self, time=None, force=False): + await self.fan_device.async_control_hvac(time, force) + await self.cooler_device.async_control_hvac(time, force) + self.HVACActionReason = self.cooler_device.HVACActionReason + + async def _async_control_cooler(self, time=None, force=False): + is_within_fan_tolerance = self.environment.is_within_fan_tolerance( + self.fan_device.target_env_attr + ) + is_warmer_outside = self.environment.is_warmer_outside + is_fan_air_outside = self.fan_device.fan_air_surce_outside + + # If the fan_hot_tolerance is set, enforce the action for the fan or cooler device + # to ignore cycles as we switch between the fan and cooler device + # and we want to avoid idle time gaps between the devices + force_override = ( + True if self.environment.fan_hot_tolerance is not None else force + ) + + has_cooler_run_long_enough = ( + self.cooler_device.hvac_controller.ran_long_enough() + ) + + if self.cooler_device.is_on and not has_cooler_run_long_enough: + _LOGGER.debug( + "Cooler has not run long enough at: %s", + datetime.now(timezone.utc), + ) + self.HVACActionReason = HVACActionReason.MIN_CYCLE_DURATION_NOT_REACHED + return + + if ( + self._fan_hot_tolerance_on + and is_within_fan_tolerance + and not (is_fan_air_outside and is_warmer_outside) + ): + _LOGGER.debug("within fan tolerance") + _LOGGER.debug("fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on) + _LOGGER.debug("force_override: %s", force_override) + + self.fan_device.hvac_mode = HVACMode.FAN_ONLY + await self.fan_device.async_control_hvac(time, force_override) + await self.cooler_device.async_turn_off() + self.HVACActionReason = HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN + else: + _LOGGER.debug("outside fan tolerance") + _LOGGER.debug("fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on) + await self.cooler_device.async_control_hvac(time, force_override) + await self.fan_device.async_turn_off() + self.HVACActionReason = self.cooler_device.HVACActionReason diff --git a/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py index c85731a..51cf3d3 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py @@ -150,6 +150,10 @@ def is_active(self) -> bool: """If the toggleable hvac device is currently active.""" return self.hvac_controller.is_active + @property + def is_on(self) -> bool: + return self._entity_state is not None and self._entity_state.state == STATE_ON + def is_below_target_env_attr(self) -> bool: """is too cold?""" return self.environment.is_too_cold(self.target_env_attr) diff --git a/tests/__init__.py b/tests/__init__.py index d867bc7..a51190e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -390,7 +390,7 @@ async def setup_comp_heat_ac_cool_fan_config_tolerance(hass: HomeAssistant) -> N await hass.async_block_till_done() -@pytest.fixture +# @pytest.fixture async def setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle( hass: HomeAssistant, ) -> None: @@ -403,14 +403,14 @@ async def setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle( "climate": { "platform": DOMAIN, "name": "test", - "cold_tolerance": 2, - "hot_tolerance": 4, + "cold_tolerance": 0.2, + "hot_tolerance": 0.2, "ac_mode": True, "heater": common.ENT_SWITCH, "target_sensor": common.ENT_SENSOR, "fan": common.ENT_FAN, - "fan_hot_tolerance": 1, - "min_cycle_duration": datetime.timedelta(minutes=10), + "fan_hot_tolerance": 0.5, + "min_cycle_duration": datetime.timedelta(minutes=2), "initial_hvac_mode": HVACMode.OFF, PRESET_AWAY: {"temperature": 30}, } diff --git a/tests/common.py b/tests/common.py index 16dfc55..23eada8 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,4 +1,5 @@ import asyncio +from asyncio import TimerHandle from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta import functools as ft @@ -315,8 +316,8 @@ def async_fire_time_changed( def _async_fire_time_changed( hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool ) -> None: - timestamp = dt_util.utc_to_timestamp(utc_datetime) - for task in list(hass.loop._scheduled): + timestamp = utc_datetime.timestamp() + for task in list(get_scheduled_timer_handles(hass.loop)): if not isinstance(task, asyncio.TimerHandle): continue if task.cancelled(): @@ -326,12 +327,15 @@ def _async_fire_time_changed( future_seconds = task.when() - (hass.loop.time() + _MONOTONIC_RESOLUTION) if fire_all or mock_seconds_into_future >= future_seconds: - with patch( - "homeassistant.helpers.event.time_tracker_utcnow", - return_value=utc_datetime, - ), patch( - "homeassistant.helpers.event.time_tracker_timestamp", - return_value=timestamp, + with ( + patch( + "homeassistant.helpers.event.time_tracker_utcnow", + return_value=utc_datetime, + ), + patch( + "homeassistant.helpers.event.time_tracker_timestamp", + return_value=timestamp, + ), ): task._run() task.cancel() @@ -340,6 +344,12 @@ def _async_fire_time_changed( fire_time_changed = threadsafe_callback_factory(async_fire_time_changed) +def get_scheduled_timer_handles(loop: asyncio.AbstractEventLoop) -> list[TimerHandle]: + """Return a list of scheduled TimerHandles.""" + handles: list[TimerHandle] = loop._scheduled # type: ignore[attr-defined] # noqa: SLF001 + return handles + + def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: """Mock the DATA_RESTORE_CACHE.""" key = restore_state.DATA_RESTORE_STATE diff --git a/tests/test_fan_mode.py b/tests/test_fan_mode.py index 6e5e42d..819a7cf 100644 --- a/tests/test_fan_mode.py +++ b/tests/test_fan_mode.py @@ -58,6 +58,7 @@ setup_comp_heat_ac_cool_fan_config_cycle, setup_comp_heat_ac_cool_fan_config_presets, setup_comp_heat_ac_cool_fan_config_tolerance, + setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle, setup_comp_heat_ac_cool_presets, setup_fan, setup_fan_heat_tolerance_toggle, @@ -2440,7 +2441,7 @@ async def test_set_target_temp_ac_on_tolerance_and_cycle( async def test_set_target_temp_ac_on_after_fan_tolerance( hass: HomeAssistant, setup_comp_heat_ac_cool_fan_config_tolerance # noqa: F811 ) -> None: - """Test if target temperature turn ac on.""" + """Test if target temperature turn fan on.""" calls = setup_switch_dual(hass, common.ENT_FAN, False, False) await common.async_set_hvac_mode(hass, HVACMode.COOL) setup_sensor(hass, 26) @@ -2462,6 +2463,94 @@ async def test_set_target_temp_ac_on_after_fan_tolerance( assert call.data["entity_id"] == common.ENT_FAN +async def test_set_target_temp_ac_on_dont_switch_to_fan_during_cycle1( + hass: HomeAssistant, +) -> None: + """Test if cooler stay on because min_cycle_duration not reached.""" + # Given + await setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(hass) + calls = setup_switch_dual(hass, common.ENT_FAN, False, False) + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await common.async_set_temperature(hass, 20) + # outside fan_hot_tolerance, within hot_tolerance + setup_sensor(hass, 20.8) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == common.ENT_SWITCH + + # When + calls = setup_switch_dual(hass, common.ENT_FAN, True, False) + setup_sensor(hass, 20.6) + await hass.async_block_till_done() + + # Then + state = hass.states.get(common.ENTITY) + assert len(calls) == 0 + assert ( + state.attributes["hvac_action_reason"] + == HVACActionReason.MIN_CYCLE_DURATION_NOT_REACHED + ) + + +async def test_set_target_temp_ac_on_dont_switch_to_fan_during_cycle2( + hass: HomeAssistant, +) -> None: + """Test if cooler stay on because min_cycle_duration not reached.""" + # Given + await setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(hass) + + calls = setup_switch_dual(hass, common.ENT_FAN, True, False) + + # When + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await common.async_set_temperature(hass, 20) + setup_sensor(hass, 20.6) + await hass.async_block_till_done() + + # Then + assert len(calls) == 0 + + +async def test_set_target_temp_ac_on_dont_switch_to_fan_during_cycle3( + hass: HomeAssistant, +) -> None: + """Test if switched to fan because min_cycle_duration reached.""" + # Given + await setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(hass) + + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + with freeze_time(fake_changed): + calls = setup_switch_dual(hass, common.ENT_FAN, True, False) + + # When + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await common.async_set_temperature(hass, 20) + setup_sensor(hass, 20.6) + await hass.async_block_till_done() + + # Then + state = hass.states.get(common.ENTITY) + assert len(calls) == 2 + + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == common.ENT_FAN + + call = calls[1] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == common.ENT_SWITCH + assert ( + state.attributes["hvac_action_reason"] + == HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN + ) + + async def test_set_target_temp_ac_on_after_fan_tolerance_2( hass: HomeAssistant, setup_comp_1 # noqa: F811 ) -> None: