From 720d3d6c89ba01938dfb4619b801bae243051c22 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 6 Aug 2024 15:08:33 +0000 Subject: [PATCH] feat: expose fan_hot_tolerance as a switch to enable/disable on frontend Fixes #255 --- README.md | 9 ++ .../dual_smart_thermostat/climate.py | 2 + .../dual_smart_thermostat/const.py | 1 + .../hvac_device/cooler_fan_device.py | 55 ++++++++- .../hvac_device/generic_hvac_device.py | 5 - .../managers/feature_manager.py | 6 + tests/__init__.py | 18 +++ tests/common.py | 1 + tests/test_fan_mode.py | 105 ++++++++++++++++++ 9 files changed, 194 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b8cde46..1ec693f 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,15 @@ The internal values can be set by the component only and the external values can _requires: `fan`_ +### fan_hot_tolerance_toggle + + _(optional) (string)_ `entity_id` for a switch that will toggle the `fan_hot_tolerance` feature on and off. + This is enabled by default. + + _default: True_ + + _requires: `fan` , `fan_hot_tolerance`_ + ### fan_on_with_ac _(optional) (boolean)_ If set to `true` the fan will be turned on together with the AC. This is useful for central AC systems that require the fan to be turned on together with the AC. diff --git a/custom_components/dual_smart_thermostat/climate.py b/custom_components/dual_smart_thermostat/climate.py index 5560048..6f0df7a 100644 --- a/custom_components/dual_smart_thermostat/climate.py +++ b/custom_components/dual_smart_thermostat/climate.py @@ -103,6 +103,7 @@ CONF_FAN, CONF_FAN_AIR_OUTSIDE, CONF_FAN_HOT_TOLERANCE, + CONF_FAN_HOT_TOLERANCE_TOGGLE, CONF_FAN_MODE, CONF_FAN_ON_WITH_AC, CONF_FLOOR_SENSOR, @@ -177,6 +178,7 @@ vol.Optional(CONF_FAN_MODE): cv.boolean, vol.Optional(CONF_FAN_ON_WITH_AC): cv.boolean, vol.Optional(CONF_FAN_HOT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_FAN_HOT_TOLERANCE_TOGGLE): cv.entity_id, vol.Optional(CONF_FAN_AIR_OUTSIDE): cv.boolean, } diff --git a/custom_components/dual_smart_thermostat/const.py b/custom_components/dual_smart_thermostat/const.py index 523bd4f..457efb2 100644 --- a/custom_components/dual_smart_thermostat/const.py +++ b/custom_components/dual_smart_thermostat/const.py @@ -39,6 +39,7 @@ CONF_FAN_MODE = "fan_mode" CONF_FAN_ON_WITH_AC = "fan_on_with_ac" CONF_FAN_HOT_TOLERANCE = "fan_hot_tolerance" +CONF_FAN_HOT_TOLERANCE_TOGGLE = "fan_hot_tolerance_toggle" CONF_FAN_AIR_OUTSIDE = "fan_air_outside" CONF_SENSOR = "target_sensor" CONF_STALE_DURATION = "sensor_stale_duration" 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 3b9ad03..6e0e240 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,7 +1,9 @@ import logging from homeassistant.components.climate import HVACMode -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Event, EventStateChangedData, HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event from custom_components.dual_smart_thermostat.hvac_action_reason.hvac_action_reason import ( HVACActionReason, @@ -50,6 +52,14 @@ def __init__( if self.fan_device is None or self.cooler_device is None: _LOGGER.error("Fan or cooler device is not found") + if self._features.fan_hot_tolerance_on_entity is not None: + self._fan_hot_tolerance_on = ( + self.hass.states.get(self._features.fan_hot_tolerance_on_entity) + == STATE_ON + ) + else: + self._fan_hot_tolerance_on = True + @property def hvac_mode(self) -> HVACMode: return self._hvac_mode @@ -61,6 +71,37 @@ 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() + + if self._features.fan_hot_tolerance_on_entity is not None: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._features.fan_hot_tolerance_on_entity], + self._async_fan_hot_tolerance_on_changed, + ) + ) + + async def _async_fan_hot_tolerance_on_changed( + self, event: Event[EventStateChangedData] + ): + data = event.data + + new_state = data["new_state"] + + _LOGGER.debug("Fan hot tolerance state changed: %s", new_state) + + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self._fan_hot_tolerance_on = True + return + + self._fan_hot_tolerance_on = new_state.state == STATE_ON + + _LOGGER.debug("fan_hot_tolerance_on is %s", self._fan_hot_tolerance_on) + + await self.async_control_hvac(force=True) + async def _async_check_device_initial_state(self) -> None: """Prevent the device from keep running if HVACMode.OFF.""" pass @@ -91,10 +132,15 @@ async def async_control_hvac(self, time=None, force=False): else force ) - if is_within_fan_tolerance and not ( - is_fan_air_outside and is_warmer_outside + 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 + ) 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() @@ -103,6 +149,9 @@ async def async_control_hvac(self, time=None, force=False): ) 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 ) 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 a82c10d..7615d20 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 @@ -289,11 +289,6 @@ async def _async_turn_on_entity(self) -> None: "%s. Turning on entity %s", self.__class__.__name__, self.entity_id ) - _LOGGER.debug("entity_id: %s", self.entity_id) - _LOGGER.debug( - "is_state: %s", self.hass.states.is_state(self.entity_id, STATE_OFF) - ) - if self.entity_id is not None and self.hass.states.is_state( self.entity_id, STATE_OFF ): diff --git a/custom_components/dual_smart_thermostat/managers/feature_manager.py b/custom_components/dual_smart_thermostat/managers/feature_manager.py index 0b9a345..8194ed3 100644 --- a/custom_components/dual_smart_thermostat/managers/feature_manager.py +++ b/custom_components/dual_smart_thermostat/managers/feature_manager.py @@ -20,6 +20,7 @@ CONF_FAN, CONF_FAN_AIR_OUTSIDE, CONF_FAN_HOT_TOLERANCE, + CONF_FAN_HOT_TOLERANCE_TOGGLE, CONF_FAN_MODE, CONF_FAN_ON_WITH_AC, CONF_HEAT_COOL_MODE, @@ -56,6 +57,7 @@ def __init__( self._fan_on_with_cooler = config.get(CONF_FAN_ON_WITH_AC) self._fan_tolerance = config.get(CONF_FAN_HOT_TOLERANCE) self._fan_air_outside = config.get(CONF_FAN_AIR_OUTSIDE) + self._fan_tolerance_on_entity_id = config.get(CONF_FAN_HOT_TOLERANCE_TOGGLE) self._dryer_entity_id = config.get(CONF_DRYER) self._humidity_sensor_entity_id = config.get(CONF_HUMIDITY_SENSOR) @@ -172,6 +174,10 @@ def is_configured_for_fan_on_with_cooler(self) -> bool: def is_fan_uses_outside_air(self) -> bool: return self._fan_air_outside + @property + def fan_hot_tolerance_on_entity(self) -> bool: + return self._fan_tolerance_on_entity_id + @property def is_configured_for_dryer_mode(self) -> bool: """Determines if the dryer mode is configured.""" diff --git a/tests/__init__.py b/tests/__init__.py index 9b90652..6b620f2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1101,6 +1101,24 @@ def log_call(call) -> None: return calls +def setup_fan_heat_tolerance_toggle(hass: HomeAssistant, is_on: bool) -> None: + """Set up the test switch.""" + hass.states.async_set( + common.ENT_FAN_HOT_TOLERNACE_TOGGLE, STATE_ON if is_on else STATE_OFF + ) + calls = [] + + @callback + def log_call(call) -> None: + """Log service calls.""" + calls.append(call) + + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_ON, log_call) + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, log_call) + + return calls + + def setup_heat_pump_cooling_status(hass: HomeAssistant, is_on: bool) -> None: """Set up the test switch.""" hass.states.async_set( diff --git a/tests/common.py b/tests/common.py index 063c310..16dfc55 100644 --- a/tests/common.py +++ b/tests/common.py @@ -67,6 +67,7 @@ ENT_HEATER = "input_boolean.test" ENT_COOLER = "input_boolean.test_cooler" ENT_FAN = "switch.test_fan" +ENT_FAN_HOT_TOLERNACE_TOGGLE = "input_boolean.test_fan_hot_tolerance_toggle" ENT_DRYER = "switch.test_dryer" ENT_HEAT_PUMP_COOLING = "switch.test_heat_pump_cooling" MIN_TEMP = 3.0 diff --git a/tests/test_fan_mode.py b/tests/test_fan_mode.py index 109523d..cc333de 100644 --- a/tests/test_fan_mode.py +++ b/tests/test_fan_mode.py @@ -60,6 +60,7 @@ setup_comp_heat_ac_cool_fan_config_tolerance, setup_comp_heat_ac_cool_presets, setup_fan, + setup_fan_heat_tolerance_toggle, setup_outside_sensor, setup_sensor, setup_switch, @@ -2547,6 +2548,110 @@ async def test_set_target_temp_ac_on_after_fan_tolerance_2( ) +async def test_set_target_temp_ac_on_after_fan_tolerance_toggle_off( + 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) + + # below hot_tolerance + setup_sensor(hass, 20) + await hass.async_block_till_done() + + assert hass.states.get(cooler_switch).state == STATE_OFF + assert hass.states.get(fan_switch).state == STATE_OFF + + # within hot_tolerance and fan_hot_tolerance + setup_sensor(hass, 20.2) + setup_fan_heat_tolerance_toggle(hass, False) + 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 + ) + + # setup_fan_heat_tolerance_toggle(hass, True) + # 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 hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.FAN + + # # within hot_tolerance and fan_hot_tolerance + # setup_sensor(hass, 20.5) + # 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 hass.states.get(common.ENTITY).attributes["hvac_action"] == HVACAction.FAN + + # # within hot_tolerance and fan_hot_tolerance + # setup_sensor(hass, 20.7) + # await hass.async_block_till_done() + + # assert hass.states.get(cooler_switch).state == STATE_OFF + # assert hass.states.get(fan_switch).state == STATE_ON + + # # 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_ignore_fan_tolerance( hass: HomeAssistant, setup_comp_1 # noqa: F811 ) -> None: