From 5df141490e4f6eeda711fad40b37d866dee31d5e Mon Sep 17 00:00:00 2001 From: = <=> Date: Sun, 11 Aug 2024 20:53:37 +0000 Subject: [PATCH] fix: toggling fan_hot_tolerance malfunction Fixes #264 --- .../dual_smart_thermostat/climate.py | 3 +- .../hvac_device/cooler_fan_device.py | 26 +++- .../hvac_device/generic_hvac_device.py | 6 +- .../hvac_device/multi_hvac_device.py | 6 +- tests/__init__.py | 6 +- tests/test_fan_mode.py | 125 ++++++++++++++---- 6 files changed, 137 insertions(+), 35 deletions(-) diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index 8ab0083..5da002e 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -581,7 +581,7 @@ async def _async_startup(*_) -> None: self.environment.update_floor_temp_from_state(floor_sensor_state) self.async_write_ha_state() - await self.hvac_device.async_on_startup() + await self.hvac_device.async_on_startup(self.async_write_ha_state) if self.hass.state == CoreState.running: await _async_startup() @@ -1162,6 +1162,7 @@ async def _async_control_climate(self, time=None, force=False) -> None: async with self._temp_lock: await self.hvac_device.async_control_hvac(time, force) + _LOGGER.info( "updating HVACActionReason: %s", self.hvac_device.HVACActionReason ) 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 6e0e240..6f2d929 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,4 +1,5 @@ import logging +from typing import Callable from homeassistant.components.climate import HVACMode from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN @@ -52,9 +53,16 @@ def __init__( if self.fan_device is None or self.cooler_device is None: _LOGGER.error("Fan or cooler device is not found") + self._set_fan_hot_tolerance_on_state() + + def _set_fan_hot_tolerance_on_state(self): if self._features.fan_hot_tolerance_on_entity is not None: + _LOGGER.debug( + "Setting fan_hot_tolerance_on state: %s", + self.hass.states.get(self._features.fan_hot_tolerance_on_entity).state, + ) self._fan_hot_tolerance_on = ( - self.hass.states.get(self._features.fan_hot_tolerance_on_entity) + self.hass.states.get(self._features.fan_hot_tolerance_on_entity).state == STATE_ON ) else: @@ -71,8 +79,8 @@ def hvac_mode(self, hvac_mode: HVACMode): # noqa: F811 self._hvac_mode = hvac_mode self.set_sub_devices_hvac_mode(hvac_mode) - async def async_on_startup(self): - await super().async_on_startup() + async def async_on_startup(self, async_write_ha_state_cb: Callable): + await super().async_on_startup(async_write_ha_state_cb) if self._features.fan_hot_tolerance_on_entity is not None: self.async_on_remove( @@ -90,7 +98,7 @@ async def _async_fan_hot_tolerance_on_changed( new_state = data["new_state"] - _LOGGER.debug("Fan hot tolerance state changed: %s", new_state) + _LOGGER.debug("Fan hot tolerance on changed: %s", new_state) if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._fan_hot_tolerance_on = True @@ -100,7 +108,8 @@ async def _async_fan_hot_tolerance_on_changed( _LOGGER.debug("fan_hot_tolerance_on is %s", self._fan_hot_tolerance_on) - await self.async_control_hvac(force=True) + await self.async_control_hvac() + self._async_write_ha_state_cb() async def _async_check_device_initial_state(self) -> None: """Prevent the device from keep running if HVACMode.OFF.""" @@ -109,6 +118,11 @@ async def _async_check_device_initial_state(self) -> None: async def async_control_hvac(self, time=None, force=False): _LOGGER.info({self.__class__.__name__}) _LOGGER.debug("hvac_mode: %s", self._hvac_mode) + self._set_fan_hot_tolerance_on_state() + _LOGGER.debug( + "async_control_hvac fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on + ) + match self._hvac_mode: case HVACMode.COOL: if self._fan_on_with_cooler: @@ -141,6 +155,8 @@ async def async_control_hvac(self, time=None, force=False): _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() 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 7615d20..3f01cab 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 @@ -1,5 +1,6 @@ from datetime import timedelta import logging +from typing import Callable from homeassistant.components.climate import HVACMode from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveEntityFeature @@ -237,8 +238,11 @@ async def async_control_hvac(self, time=None, force=False): self._hvac_action_reason = self.hvac_controller.hvac_action_reason self.hvac_power.update_hvac_power(self.strategy, self.target_env_attr) - async def async_on_startup(self): + async def async_on_startup(self, async_write_ha_state_cb: Callable = None): + + self._async_write_ha_state_cb = async_write_ha_state_cb entity_state = self.hass.states.get(self.entity_id) + if entity_state and entity_state.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, diff --git a/custom_components/dual_smart_thermostat/hvac_device/multi_hvac_device.py b/custom_components/dual_smart_thermostat/hvac_device/multi_hvac_device.py index 7dfb96d..39965bb 100644 --- a/custom_components/dual_smart_thermostat/hvac_device/multi_hvac_device.py +++ b/custom_components/dual_smart_thermostat/hvac_device/multi_hvac_device.py @@ -1,4 +1,5 @@ import logging +from typing import Callable from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.core import Context, HomeAssistant, callback @@ -151,9 +152,10 @@ async def async_control_hvac(self, time=None, force: bool = False): self._hvac_action_reason = device.HVACActionReason - async def async_on_startup(self): + async def async_on_startup(self, async_write_ha_state_cb: Callable = None): + self._async_write_ha_state_cb = async_write_ha_state_cb for device in self.hvac_devices: - await device.async_on_startup() + await device.async_on_startup(async_write_ha_state_cb) async def async_turn_on(self): await self.async_control_hvac(force=True) diff --git a/tests/__init__.py b/tests/__init__.py index 6b620f2..5f57964 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1065,9 +1065,11 @@ def setup_boolean(hass: HomeAssistant, entity, state) -> None: hass.states.async_set(entity, state) -def setup_switch(hass: HomeAssistant, is_on: bool) -> None: +def setup_switch( + hass: HomeAssistant, is_on: bool, entity_id: str = common.ENT_SWITCH +) -> None: """Set up the test switch.""" - hass.states.async_set(common.ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + hass.states.async_set(entity_id, STATE_ON if is_on else STATE_OFF) calls = [] @callback diff --git a/tests/test_fan_mode.py b/tests/test_fan_mode.py index cc333de..6cc9234 100644 --- a/tests/test_fan_mode.py +++ b/tests/test_fan_mode.py @@ -2619,37 +2619,114 @@ async def test_set_target_temp_ac_on_after_fan_tolerance_toggle_off( hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.COOLING ) - # setup_fan_heat_tolerance_toggle(hass, True) - # await hass.async_block_till_done() + calls = setup_switch(hass, True, cooler_switch) + setup_fan_heat_tolerance_toggle(hass, True) - # assert hass.states.get(cooler_switch).state == STATE_OFF - # assert hass.states.get(fan_switch).state == STATE_ON - # assert hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.FAN + await hass.async_block_till_done() + + _LOGGER.debug("after fan_hot_tolerance_toggle on") + _LOGGER.debug("call 1: %s ", calls[0]) + _LOGGER.debug("call 2: %s ", calls[1]) - # # within hot_tolerance and fan_hot_tolerance - # setup_sensor(hass, 20.5) - # await hass.async_block_till_done() + assert len(calls) == 2 + call1 = calls[0] + assert call1.domain == HASS_DOMAIN + assert call1.service == SERVICE_TURN_ON + assert call1.data["entity_id"] == fan_switch - # assert hass.states.get(cooler_switch).state == STATE_OFF - # assert hass.states.get(fan_switch).state == STATE_ON - # assert hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.FAN + call2 = calls[1] + assert call2.domain == HASS_DOMAIN + assert call2.service == SERVICE_TURN_OFF + assert call2.data["entity_id"] == cooler_switch - # # within hot_tolerance and fan_hot_tolerance - # setup_sensor(hass, 20.7) - # await hass.async_block_till_done() + # if toggling in idle state not turningon anything + setup_sensor(hass, 20) + calls = setup_switch(hass, False, cooler_switch) + setup_fan_heat_tolerance_toggle(hass, False) + await hass.async_block_till_done() - # assert hass.states.get(cooler_switch).state == STATE_OFF - # assert hass.states.get(fan_switch).state == STATE_ON + assert len(calls) == 0 + + setup_fan_heat_tolerance_toggle(hass, True) + await hass.async_block_till_done() + + assert len(calls) == 0 - # # outside fan_hot_tolerance, within hot_tolerance - # setup_sensor(hass, 20.8) - # await hass.async_block_till_done() - # assert hass.states.get(cooler_switch).state == STATE_ON - # assert hass.states.get(fan_switch).state == STATE_OFF - # assert ( - # hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.COOLING - # ) +async def test_set_target_temp_ac_on_after_fan_tolerance_toggle_when_idle( + hass: HomeAssistant, setup_comp_1 # noqa: F811 +) -> None: + cooler_switch = "input_boolean.test" + fan_switch = "input_boolean.fan" + fan_hot_tolerance_toggle = common.ENT_FAN_HOT_TOLERNACE_TOGGLE + + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + { + "input_boolean": { + "test": None, + "fan": None, + "test_fan_hot_tolerance_toggle": None, + } + }, + ) + + assert await async_setup_component( + hass, + input_number.DOMAIN, + { + "input_number": { + "temp": {"name": "test", "initial": 10, "min": 0, "max": 40, "step": 1} + } + }, + ) + + assert await async_setup_component( + hass, + CLIMATE, + { + "climate": { + "platform": DOMAIN, + "name": "test", + "cold_tolerance": 0.2, + "hot_tolerance": 0.2, + "fan_hot_tolerance_toggle": fan_hot_tolerance_toggle, + "ac_mode": True, + "heater": cooler_switch, + "target_sensor": common.ENT_SENSOR, + "fan": fan_switch, + "fan_hot_tolerance": 0.5, + "initial_hvac_mode": HVACMode.OFF, + } + }, + ) + await hass.async_block_till_done() + + await common.async_set_hvac_mode(hass, HVACMode.COOL) + await common.async_set_temperature(hass, 20) + setup_fan_heat_tolerance_toggle(hass, False) + calls = setup_switch(hass, False, cooler_switch) + + # below hot_tolerance + setup_sensor(hass, 20) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(fan_switch).state == STATE_OFF + assert hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.IDLE + + # within hot_tolerance and fan_hot_tolerance + # calls = setup_switch(hass, False, cooler_switch) + setup_fan_heat_tolerance_toggle(hass, True) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(fan_switch).state == STATE_OFF + + assert hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.IDLE async def test_set_target_temp_ac_on_ignore_fan_tolerance(