Skip to content

Commit

Permalink
feat: enables/disables fan_hot_tolerance based on outside air tempera…
Browse files Browse the repository at this point in the history
…ture

Fixes #195
  • Loading branch information
= authored and swingerman committed May 17, 2024
1 parent 3555db8 commit fd6dfc4
Show file tree
Hide file tree
Showing 11 changed files with 541 additions and 5 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ heater: switch.study_heater
ac_mode: true
fan: switch.study_fan
```
#### Fan Hot Toelerance

If you also set the [`fan_hot_tolerance`](#fan_hot_tolerance) the fan will turn on when the temperature is above the target temperature and the fan_hot_tolerance is not reached. If the temperature is above the target temperature and the fan_hot_tolerance is reached the AC will turn on.

### Cooler With Auto Fan Mode Example
##### Cooler With Auto Fan Mode Example

```yaml
heater: switch.study_heater
Expand All @@ -97,6 +98,10 @@ fan: switch.study_fan
fan_hot_tolerance: 0.5
```

#### Outside Temperature And Fan Hot Tolerance

If you set the [`fan_hot_tolerance`](#fan_hot_tolerance), [`outside_sensor`](#outside_sensor) and the [`fan_air_outside`](#fan_air_outside) the fan will turn on only if the outside temperature is colder than the inside temperature and the fan_hot_tolerance is not reached. If the outside temperature is colder than the inside temperature and the fan_hot_tolerance is reached the AC will turn on.

## AC With Fan Switch Support

Some AC systems have independent fan controls to cycle the house air for filtering or humidity control; without using the heating or cooling elements. Central AC systems require the thermostat to turn on both the AC wire ("Y" wire) and the air-handler/fan wire ("G" wire) in order to activate the AC
Expand All @@ -105,6 +110,8 @@ This feature let's you do just that.

In order to use this feature you need to set the [`heater`](#heater) entity, the [`ac_mode`](#ac_mode), the [`fan)`](#fan) entity and the [`fan_on_with_ac`](#fan_on_with_ac) to `true`.



### example
```yaml
heater: switch.study_heater
Expand Down Expand Up @@ -324,6 +331,11 @@ The internal values can be set by the component only and the external values can

_requires: `fan`_

### fan_air_outside

_(optional) (boolean)_ "If set to `true` the fan will be turned on only if the outside temperature is colder than the inside temperature and the `fan_hot_tolerance` is not reached. If the outside temperature is colder than the inside temperature and the `fan_hot_tolerance` is reached the AC will turn on."

_requires: `fan` , `sensor_outside`_

### target_sensor

Expand All @@ -337,6 +349,10 @@ The internal values can be set by the component only and the external values can

_(optional) (string)_ "`entity_id` for the floor temperature sensor, floor_sensor.state must be temperature."

### outside_sensor

_(optional) (string)_ "`entity_id` for the outside temperature sensor, oustide_sensor.state must be temperature."

### openings

_(optional) (list)_ "list of opening `entity_id`'s and/or objects for detecting open widows or doors that will idle the thermostat until any of them are open. Note: if min_floor_temp is set and the floor temperature is below the minimum temperature, the thermostat will not idle even if any of the openings are open."
Expand Down
33 changes: 33 additions & 0 deletions custom_components/dual_smart_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
CONF_COLD_TOLERANCE,
CONF_COOLER,
CONF_FAN,
CONF_FAN_AIR_OUTSIDE,
CONF_FAN_HOT_TOLERANCE,
CONF_FAN_MODE,
CONF_FAN_ON_WITH_AC,
Expand All @@ -102,6 +103,7 @@
CONF_MIN_TEMP,
CONF_OPENINGS,
CONF_OPENINGS_SCOPE,
CONF_OUTSIDE_SENSOR,
CONF_PRECISION,
CONF_PRESETS,
CONF_PRESETS_OLD,
Expand Down Expand Up @@ -149,6 +151,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_AIR_OUTSIDE): cv.boolean,
}

OPENINGS_SCHEMA = {
Expand All @@ -166,6 +169,7 @@
vol.Optional(CONF_SENSOR_SAFETY_DELAY): vol.All(
cv.time_period, cv.positive_timedelta
),
vol.Optional(CONF_OUTSIDE_SENSOR): cv.entity_id,
vol.Optional(CONF_AC_MODE): cv.boolean,
vol.Optional(CONF_HEAT_COOL_MODE): cv.boolean,
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
Expand Down Expand Up @@ -224,6 +228,7 @@ async def async_setup_platform(
name = config[CONF_NAME]
sensor_entity_id = config[CONF_SENSOR]
sensor_floor_entity_id = config.get(CONF_FLOOR_SENSOR)
sensor_outside_entity_id = config.get(CONF_OUTSIDE_SENSOR)
keep_alive = config.get(CONF_KEEP_ALIVE)
presets_dict = {
key: config[value] for key, value in CONF_PRESETS.items() if value in config
Expand Down Expand Up @@ -273,6 +278,7 @@ async def async_setup_platform(
name,
sensor_entity_id,
sensor_floor_entity_id,
sensor_outside_entity_id,
keep_alive,
precision,
unit,
Expand Down Expand Up @@ -323,6 +329,7 @@ def __init__(
name,
sensor_entity_id,
sensor_floor_entity_id,
sensor_outside_entity_id,
keep_alive,
precision,
unit,
Expand Down Expand Up @@ -356,6 +363,7 @@ def __init__(
# sensors
self.sensor_entity_id = sensor_entity_id
self.sensor_floor_entity_id = sensor_floor_entity_id
self.sensor_outside_entity_id = sensor_outside_entity_id

self._keep_alive = keep_alive

Expand Down Expand Up @@ -423,6 +431,18 @@ async def async_added_to_hass(self) -> None:
)
)

if self.sensor_outside_entity_id is not None:
_LOGGER.debug(
"Adding outside sensor listener: %s", self.sensor_outside_entity_id
)
self.async_on_remove(
async_track_state_change_event(
self.hass,
[self.sensor_outside_entity_id],
self._async_sensor_outside_changed,
)
)

if self._keep_alive:
self.async_on_remove(
async_track_time_interval(
Expand Down Expand Up @@ -773,6 +793,19 @@ async def _async_sensor_floor_changed(
await self._async_control_climate()
self.async_write_ha_state()

async def _async_sensor_outside_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle outside temperature changes."""
new_state = event.data.get("new_state")
_LOGGER.info("Sensor outside change: %s", new_state)
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return

self.temperatures.update_floor_temp_from_state(new_state)
await self._async_control_climate()
self.async_write_ha_state()

async def _check_device_initial_state(self) -> None:
"""Prevent the device from keep running if HVACMode.OFF."""
_LOGGER.debug("Checking device initial state")
Expand Down
2 changes: 2 additions & 0 deletions custom_components/dual_smart_thermostat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
CONF_FAN_MODE = "fan_mode"
CONF_FAN_ON_WITH_AC = "fan_on_with_ac"
CONF_FAN_HOT_TOLERANCE = "fan_hot_tolerance"
CONF_FAN_AIR_OUTSIDE = "fan_air_outside"
CONF_SENSOR = "target_sensor"
CONF_SENSOR_SAFETY_DELAY = "target_sensor_safety_delay"
CONF_FLOOR_SENSOR = "floor_sensor"
CONF_OUTSIDE_SENSOR = "outside_sensor"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
CONF_MAX_FLOOR_TEMP = "max_floor_temp"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,23 @@ async def async_control_hvac(self, time=None, force=False):
self.HVACActionReason = self.cooler_device.HVACActionReason
else:

if self.temperatures.is_within_fan_tolerance(
is_within_fan_tolerance = self.temperatures.is_within_fan_tolerance(
self.fan_device.target_temp_attr
)
is_warmer_outside = self.temperatures.is_warmer_outside
is_fan_air_outside = self.fan_device.fan_air_surce_outside

if is_within_fan_tolerance and not (
is_fan_air_outside and is_warmer_outside
):
_LOGGER.info("within fan tolerance")
_LOGGER.debug("within fan tolerance")
await self.fan_device.async_control_hvac(time, force)
await self.cooler_device.async_turn_off()
self.HVACActionReason = (
HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN
)

else:
_LOGGER.info("outside fan tolerance")
_LOGGER.debug("outside fan tolerance")
await self.cooler_device.async_control_hvac(time, force)
await self.fan_device.async_turn_off()
self.HVACActionReason = self.cooler_device.HVACActionReason
Expand Down
24 changes: 24 additions & 0 deletions custom_components/dual_smart_thermostat/hvac_device/fan_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,27 @@
class FanDevice(CoolerDevice):

hvac_modes = [HVACMode.FAN_ONLY, HVACMode.OFF]
fan_air_surce_outside = False

def __init__(
self,
hass,
entity_id,
min_cycle_duration,
initial_hvac_mode,
temperatures,
openings,
features,
) -> None:
super().__init__(
hass,
entity_id,
min_cycle_duration,
initial_hvac_mode,
temperatures,
openings,
features,
)

if self.features.is_fan_uses_outside_air:
self.fan_air_surce_outside = True
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CONF_AUX_HEATING_TIMEOUT,
CONF_COOLER,
CONF_FAN,
CONF_FAN_AIR_OUTSIDE,
CONF_FAN_HOT_TOLERANCE,
CONF_FAN_MODE,
CONF_FAN_ON_WITH_AC,
Expand Down Expand Up @@ -48,6 +49,7 @@ def __init__(
self._fan_entity_id = config.get(CONF_FAN)
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._aux_heater_entity_id = config.get(CONF_AUX_HEATER)
self._aux_heater_timeout = config.get(CONF_AUX_HEATING_TIMEOUT)
Expand Down Expand Up @@ -138,6 +140,10 @@ def is_configured_for_fan_on_with_cooler(self) -> bool:
"""Determines if the fan mode with cooler is configured."""
return self._fan_on_with_cooler

@property
def is_fan_uses_outside_air(self) -> bool:
return self._fan_air_outside

def set_support_flags(
self,
presets: dict[str, Any],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
CONF_MAX_TEMP,
CONF_MIN_FLOOR_TEMP,
CONF_MIN_TEMP,
CONF_OUTSIDE_SENSOR,
CONF_PRECISION,
CONF_SENSOR,
CONF_SENSOR_SAFETY_DELAY,
Expand All @@ -60,6 +61,7 @@ def __init__(self, temperature: float, temp_high: float, temp_low: float) -> Non


class TemperatureManager(StateManager):
"""Class to manage the temperatures of the thermostat."""

def __init__(
self,
Expand All @@ -70,6 +72,7 @@ def __init__(
self.hass = hass
self._sensor_floor = config.get(CONF_FLOOR_SENSOR)
self._sensor = config.get(CONF_SENSOR)
self._outside_sensor = config.get(CONF_OUTSIDE_SENSOR)
self._sensor_safety_delay = config.get(CONF_SENSOR_SAFETY_DELAY)

self._min_temp = config.get(CONF_MIN_TEMP)
Expand All @@ -96,6 +99,7 @@ def __init__(

self._cur_temp = None
self._cur_floor_temp = None
self._cur_outside_temp = None

@property
def cur_temp(self) -> float:
Expand All @@ -114,6 +118,10 @@ def cur_floor_temp(self) -> float:
def cur_floor_temp(self, temperature) -> None:
self._cur_floor_temp = temperature

@property
def cur_outside_temp(self) -> float:
return self._cur_outside_temp

@property
def target_temp(self) -> float:
return self._target_temp
Expand Down Expand Up @@ -265,6 +273,19 @@ def is_within_fan_tolerance(self, target_attr="_target_temp") -> bool:
and self._cur_temp <= too_hot_for_fan_temp
)

@property
def is_warmer_outside(self) -> bool:
"""Checks if the outside temperature is warmer or equal than the inside temperature."""
if self._cur_temp is None or self._outside_sensor is None:
return False

outside_state = self.hass.states.get(self._outside_sensor)
if outside_state is None:
return False

outside_temp = float(outside_state.state)
return outside_temp >= self._cur_temp

def is_too_cold(self, target_attr="_target_temp") -> bool:
"""Checks if the current temperature is below target."""
if self._cur_temp is None:
Expand Down Expand Up @@ -376,6 +397,17 @@ def update_floor_temp_from_state(self, state: State):
except ValueError as ex:
_LOGGER.error("Unable to update from floor temp sensor: %s", ex)

@callback
def update_outside_temp_from_state(self, state: State):
"""Update ermostat with latest outside temp state from outside temp sensor."""
try:
cur_outside_temp = float(state.state)
if not math.isfinite(cur_outside_temp):
raise ValueError(f"Sensor has illegal state {state.state}")
self._cur_outside_temp = cur_outside_temp
except ValueError as ex:
_LOGGER.error("Unable to update from outside temp sensor: %s", ex)

def set_default_target_temps(
self, is_target_mode: bool, is_range_mode: bool, hvac_modes: list[HVACMode]
) -> None:
Expand Down
5 changes: 5 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,11 @@ def setup_floor_sensor(hass: HomeAssistant, temp: float) -> None:
hass.states.async_set(common.ENT_FLOOR_SENSOR, temp)


def setup_outside_sensor(hass: HomeAssistant, temp: float) -> None:
"""Set up the test sensor."""
hass.states.async_set(common.ENT_OUTSIDE_SENSOR, temp)


def setup_boolean(hass: HomeAssistant, entity, state) -> None:
"""Set up the test sensor."""
hass.states.async_set(entity, state)
Expand Down
1 change: 1 addition & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
ENTITY = "climate.test"
ENT_SENSOR = "sensor.test"
ENT_FLOOR_SENSOR = "input_number.floor_temp"
ENT_OUTSIDE_SENSOR = "input_number.outside_temp"
ENT_OPENING_SENSOR = "input_number.opneing1"
ENT_SWITCH = "switch.test"
ENT_HEATER = "input_boolean.test"
Expand Down
Loading

0 comments on commit fd6dfc4

Please sign in to comment.