Skip to content

Commit

Permalink
feat: expose fan_hot_tolerance as a switch to enable/disable on frontend
Browse files Browse the repository at this point in the history
Fixes #255
  • Loading branch information
= authored and swingerman committed Aug 6, 2024
1 parent 7e62418 commit 720d3d6
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 8 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions custom_components/dual_smart_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}

Expand Down
1 change: 1 addition & 0 deletions custom_components/dual_smart_thermostat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
18 changes: 18 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions tests/test_fan_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 720d3d6

Please sign in to comment.