Skip to content

Commit

Permalink
Merge branch 'master' into swingerman/issue312
Browse files Browse the repository at this point in the history
  • Loading branch information
swingerman authored Dec 6, 2024
2 parents 0508ff4 + babefe9 commit 72d2513
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
import logging
from typing import Callable

Expand Down Expand Up @@ -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()
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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},
}
Expand Down
26 changes: 18 additions & 8 deletions tests/common.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -339,8 +340,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():
Expand All @@ -350,12 +351,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()
Expand All @@ -364,6 +368,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
Expand Down
91 changes: 90 additions & 1 deletion tests/test_fan_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down

0 comments on commit 72d2513

Please sign in to comment.