Skip to content

Commit 37ecf6a

Browse files
authored
Merge branch 'master' into swingerman/issue312
2 parents 6a7344c + babefe9 commit 37ecf6a

File tree

7 files changed

+186
-69
lines changed

7 files changed

+186
-69
lines changed

custom_components/dual_smart_thermostat/hvac_action_reason/hvac_action_reason_internal.py

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
class HVACActionReasonInternal(enum.StrEnum):
55
"""Internal HVAC Action Reason for climate devices."""
66

7+
MIN_CYCLE_DURATION_NOT_REACHED = "min_cycle_duration_not_reached"
8+
79
TARGET_TEMP_NOT_REACHED = "target_temp_not_reached"
810

911
TARGET_TEMP_REACHED = "target_temp_reached"

custom_components/dual_smart_thermostat/hvac_controller/generic_controller.py

+13-9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN
77
from homeassistant.const import STATE_ON, STATE_OPEN
88
from homeassistant.core import HomeAssistant
9+
from homeassistant.exceptions import ConditionError
910
from homeassistant.helpers import condition
1011
import homeassistant.util.dt as dt_util
1112

@@ -82,7 +83,7 @@ def is_active(self) -> bool:
8283
return True
8384
return False
8485

85-
def _ran_long_enough(self) -> bool:
86+
def ran_long_enough(self) -> bool:
8687
if self.is_active:
8788
current_state = STATE_ON
8889
else:
@@ -93,12 +94,15 @@ def _ran_long_enough(self) -> bool:
9394
_LOGGER.debug("min_cycle_duration: %s", self.min_cycle_duration)
9495
_LOGGER.debug("time: %s", dt_util.utcnow())
9596

96-
long_enough = condition.state(
97-
self.hass,
98-
self.entity_id,
99-
current_state,
100-
self.min_cycle_duration,
101-
)
97+
try:
98+
long_enough = condition.state(
99+
self.hass,
100+
self.entity_id,
101+
current_state,
102+
self.min_cycle_duration,
103+
)
104+
except ConditionError:
105+
long_enough = False
102106

103107
return long_enough
104108

@@ -121,9 +125,9 @@ def needs_control(
121125
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
122126
if self.min_cycle_duration:
123127
_LOGGER.debug(
124-
"Checking if device ran long enough: %s", self._ran_long_enough()
128+
"Checking if device ran long enough: %s", self.ran_long_enough()
125129
)
126-
return self._ran_long_enough()
130+
return self.ran_long_enough()
127131
return True
128132

129133
async def async_control_device_when_on(

custom_components/dual_smart_thermostat/hvac_device/cooler_fan_device.py

+54-46
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timezone
12
import logging
23
from typing import Callable
34

@@ -126,53 +127,9 @@ async def async_control_hvac(self, time=None, force=False):
126127
match self._hvac_mode:
127128
case HVACMode.COOL:
128129
if self._fan_on_with_cooler:
129-
await self.fan_device.async_control_hvac(time, force)
130-
await self.cooler_device.async_control_hvac(time, force)
131-
self.HVACActionReason = self.cooler_device.HVACActionReason
130+
await self._async_control_when_fan_on_with_cooler(time, force)
132131
else:
133-
134-
is_within_fan_tolerance = self.environment.is_within_fan_tolerance(
135-
self.fan_device.target_env_attr
136-
)
137-
is_warmer_outside = self.environment.is_warmer_outside
138-
is_fan_air_outside = self.fan_device.fan_air_surce_outside
139-
140-
# If the fan_hot_tolerance is set, enforce the action for the fan or cooler device
141-
# to ignore cycles as we switch between the fan and cooler device
142-
# and we want to avoid idle time gaps between the devices
143-
force_override = (
144-
True
145-
if self.environment.fan_hot_tolerance is not None
146-
else force
147-
)
148-
149-
if (
150-
self._fan_hot_tolerance_on
151-
and is_within_fan_tolerance
152-
and not (is_fan_air_outside and is_warmer_outside)
153-
):
154-
_LOGGER.debug("within fan tolerance")
155-
_LOGGER.debug(
156-
"fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on
157-
)
158-
_LOGGER.debug("force_override: %s", force_override)
159-
160-
self.fan_device.hvac_mode = HVACMode.FAN_ONLY
161-
await self.fan_device.async_control_hvac(time, force_override)
162-
await self.cooler_device.async_turn_off()
163-
self.HVACActionReason = (
164-
HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN
165-
)
166-
else:
167-
_LOGGER.debug("outside fan tolerance")
168-
_LOGGER.debug(
169-
"fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on
170-
)
171-
await self.cooler_device.async_control_hvac(
172-
time, force_override
173-
)
174-
await self.fan_device.async_turn_off()
175-
self.HVACActionReason = self.cooler_device.HVACActionReason
132+
await self._async_control_cooler(time, force)
176133

177134
case HVACMode.FAN_ONLY:
178135
await self.cooler_device.async_turn_off()
@@ -184,3 +141,54 @@ async def async_control_hvac(self, time=None, force=False):
184141
case _:
185142
if self._hvac_mode is not None:
186143
_LOGGER.warning("Invalid HVAC mode: %s", self._hvac_mode)
144+
145+
async def _async_control_when_fan_on_with_cooler(self, time=None, force=False):
146+
await self.fan_device.async_control_hvac(time, force)
147+
await self.cooler_device.async_control_hvac(time, force)
148+
self.HVACActionReason = self.cooler_device.HVACActionReason
149+
150+
async def _async_control_cooler(self, time=None, force=False):
151+
is_within_fan_tolerance = self.environment.is_within_fan_tolerance(
152+
self.fan_device.target_env_attr
153+
)
154+
is_warmer_outside = self.environment.is_warmer_outside
155+
is_fan_air_outside = self.fan_device.fan_air_surce_outside
156+
157+
# If the fan_hot_tolerance is set, enforce the action for the fan or cooler device
158+
# to ignore cycles as we switch between the fan and cooler device
159+
# and we want to avoid idle time gaps between the devices
160+
force_override = (
161+
True if self.environment.fan_hot_tolerance is not None else force
162+
)
163+
164+
has_cooler_run_long_enough = (
165+
self.cooler_device.hvac_controller.ran_long_enough()
166+
)
167+
168+
if self.cooler_device.is_on and not has_cooler_run_long_enough:
169+
_LOGGER.debug(
170+
"Cooler has not run long enough at: %s",
171+
datetime.now(timezone.utc),
172+
)
173+
self.HVACActionReason = HVACActionReason.MIN_CYCLE_DURATION_NOT_REACHED
174+
return
175+
176+
if (
177+
self._fan_hot_tolerance_on
178+
and is_within_fan_tolerance
179+
and not (is_fan_air_outside and is_warmer_outside)
180+
):
181+
_LOGGER.debug("within fan tolerance")
182+
_LOGGER.debug("fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on)
183+
_LOGGER.debug("force_override: %s", force_override)
184+
185+
self.fan_device.hvac_mode = HVACMode.FAN_ONLY
186+
await self.fan_device.async_control_hvac(time, force_override)
187+
await self.cooler_device.async_turn_off()
188+
self.HVACActionReason = HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN
189+
else:
190+
_LOGGER.debug("outside fan tolerance")
191+
_LOGGER.debug("fan_hot_tolerance_on: %s", self._fan_hot_tolerance_on)
192+
await self.cooler_device.async_control_hvac(time, force_override)
193+
await self.fan_device.async_turn_off()
194+
self.HVACActionReason = self.cooler_device.HVACActionReason

custom_components/dual_smart_thermostat/hvac_device/generic_hvac_device.py

+4
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ def is_active(self) -> bool:
150150
"""If the toggleable hvac device is currently active."""
151151
return self.hvac_controller.is_active
152152

153+
@property
154+
def is_on(self) -> bool:
155+
return self._entity_state is not None and self._entity_state.state == STATE_ON
156+
153157
def is_below_target_env_attr(self) -> bool:
154158
"""is too cold?"""
155159
return self.environment.is_too_cold(self.target_env_attr)

tests/__init__.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ async def setup_comp_heat_ac_cool_fan_config_tolerance(hass: HomeAssistant) -> N
390390
await hass.async_block_till_done()
391391

392392

393-
@pytest.fixture
393+
# @pytest.fixture
394394
async def setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(
395395
hass: HomeAssistant,
396396
) -> None:
@@ -403,14 +403,14 @@ async def setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(
403403
"climate": {
404404
"platform": DOMAIN,
405405
"name": "test",
406-
"cold_tolerance": 2,
407-
"hot_tolerance": 4,
406+
"cold_tolerance": 0.2,
407+
"hot_tolerance": 0.2,
408408
"ac_mode": True,
409409
"heater": common.ENT_SWITCH,
410410
"target_sensor": common.ENT_SENSOR,
411411
"fan": common.ENT_FAN,
412-
"fan_hot_tolerance": 1,
413-
"min_cycle_duration": datetime.timedelta(minutes=10),
412+
"fan_hot_tolerance": 0.5,
413+
"min_cycle_duration": datetime.timedelta(minutes=2),
414414
"initial_hvac_mode": HVACMode.OFF,
415415
PRESET_AWAY: {"temperature": 30},
416416
}

tests/common.py

+18-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from asyncio import TimerHandle
23
from collections.abc import Mapping, Sequence
34
from datetime import UTC, datetime, timedelta
45
import functools as ft
@@ -315,8 +316,8 @@ def async_fire_time_changed(
315316
def _async_fire_time_changed(
316317
hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool
317318
) -> None:
318-
timestamp = dt_util.utc_to_timestamp(utc_datetime)
319-
for task in list(hass.loop._scheduled):
319+
timestamp = utc_datetime.timestamp()
320+
for task in list(get_scheduled_timer_handles(hass.loop)):
320321
if not isinstance(task, asyncio.TimerHandle):
321322
continue
322323
if task.cancelled():
@@ -326,12 +327,15 @@ def _async_fire_time_changed(
326327
future_seconds = task.when() - (hass.loop.time() + _MONOTONIC_RESOLUTION)
327328

328329
if fire_all or mock_seconds_into_future >= future_seconds:
329-
with patch(
330-
"homeassistant.helpers.event.time_tracker_utcnow",
331-
return_value=utc_datetime,
332-
), patch(
333-
"homeassistant.helpers.event.time_tracker_timestamp",
334-
return_value=timestamp,
330+
with (
331+
patch(
332+
"homeassistant.helpers.event.time_tracker_utcnow",
333+
return_value=utc_datetime,
334+
),
335+
patch(
336+
"homeassistant.helpers.event.time_tracker_timestamp",
337+
return_value=timestamp,
338+
),
335339
):
336340
task._run()
337341
task.cancel()
@@ -340,6 +344,12 @@ def _async_fire_time_changed(
340344
fire_time_changed = threadsafe_callback_factory(async_fire_time_changed)
341345

342346

347+
def get_scheduled_timer_handles(loop: asyncio.AbstractEventLoop) -> list[TimerHandle]:
348+
"""Return a list of scheduled TimerHandles."""
349+
handles: list[TimerHandle] = loop._scheduled # type: ignore[attr-defined] # noqa: SLF001
350+
return handles
351+
352+
343353
def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None:
344354
"""Mock the DATA_RESTORE_CACHE."""
345355
key = restore_state.DATA_RESTORE_STATE

tests/test_fan_mode.py

+90-1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
setup_comp_heat_ac_cool_fan_config_cycle,
5959
setup_comp_heat_ac_cool_fan_config_presets,
6060
setup_comp_heat_ac_cool_fan_config_tolerance,
61+
setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle,
6162
setup_comp_heat_ac_cool_presets,
6263
setup_fan,
6364
setup_fan_heat_tolerance_toggle,
@@ -2440,7 +2441,7 @@ async def test_set_target_temp_ac_on_tolerance_and_cycle(
24402441
async def test_set_target_temp_ac_on_after_fan_tolerance(
24412442
hass: HomeAssistant, setup_comp_heat_ac_cool_fan_config_tolerance # noqa: F811
24422443
) -> None:
2443-
"""Test if target temperature turn ac on."""
2444+
"""Test if target temperature turn fan on."""
24442445
calls = setup_switch_dual(hass, common.ENT_FAN, False, False)
24452446
await common.async_set_hvac_mode(hass, HVACMode.COOL)
24462447
setup_sensor(hass, 26)
@@ -2462,6 +2463,94 @@ async def test_set_target_temp_ac_on_after_fan_tolerance(
24622463
assert call.data["entity_id"] == common.ENT_FAN
24632464

24642465

2466+
async def test_set_target_temp_ac_on_dont_switch_to_fan_during_cycle1(
2467+
hass: HomeAssistant,
2468+
) -> None:
2469+
"""Test if cooler stay on because min_cycle_duration not reached."""
2470+
# Given
2471+
await setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(hass)
2472+
calls = setup_switch_dual(hass, common.ENT_FAN, False, False)
2473+
await common.async_set_hvac_mode(hass, HVACMode.COOL)
2474+
await common.async_set_temperature(hass, 20)
2475+
# outside fan_hot_tolerance, within hot_tolerance
2476+
setup_sensor(hass, 20.8)
2477+
await hass.async_block_till_done()
2478+
2479+
assert len(calls) == 1
2480+
call = calls[0]
2481+
assert call.domain == HASS_DOMAIN
2482+
assert call.service == SERVICE_TURN_ON
2483+
assert call.data["entity_id"] == common.ENT_SWITCH
2484+
2485+
# When
2486+
calls = setup_switch_dual(hass, common.ENT_FAN, True, False)
2487+
setup_sensor(hass, 20.6)
2488+
await hass.async_block_till_done()
2489+
2490+
# Then
2491+
state = hass.states.get(common.ENTITY)
2492+
assert len(calls) == 0
2493+
assert (
2494+
state.attributes["hvac_action_reason"]
2495+
== HVACActionReason.MIN_CYCLE_DURATION_NOT_REACHED
2496+
)
2497+
2498+
2499+
async def test_set_target_temp_ac_on_dont_switch_to_fan_during_cycle2(
2500+
hass: HomeAssistant,
2501+
) -> None:
2502+
"""Test if cooler stay on because min_cycle_duration not reached."""
2503+
# Given
2504+
await setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(hass)
2505+
2506+
calls = setup_switch_dual(hass, common.ENT_FAN, True, False)
2507+
2508+
# When
2509+
await common.async_set_hvac_mode(hass, HVACMode.COOL)
2510+
await common.async_set_temperature(hass, 20)
2511+
setup_sensor(hass, 20.6)
2512+
await hass.async_block_till_done()
2513+
2514+
# Then
2515+
assert len(calls) == 0
2516+
2517+
2518+
async def test_set_target_temp_ac_on_dont_switch_to_fan_during_cycle3(
2519+
hass: HomeAssistant,
2520+
) -> None:
2521+
"""Test if switched to fan because min_cycle_duration reached."""
2522+
# Given
2523+
await setup_comp_heat_ac_cool_fan_config_tolerance_min_cycle(hass)
2524+
2525+
fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC)
2526+
with freeze_time(fake_changed):
2527+
calls = setup_switch_dual(hass, common.ENT_FAN, True, False)
2528+
2529+
# When
2530+
await common.async_set_hvac_mode(hass, HVACMode.COOL)
2531+
await common.async_set_temperature(hass, 20)
2532+
setup_sensor(hass, 20.6)
2533+
await hass.async_block_till_done()
2534+
2535+
# Then
2536+
state = hass.states.get(common.ENTITY)
2537+
assert len(calls) == 2
2538+
2539+
call = calls[0]
2540+
assert call.domain == HASS_DOMAIN
2541+
assert call.service == SERVICE_TURN_ON
2542+
assert call.data["entity_id"] == common.ENT_FAN
2543+
2544+
call = calls[1]
2545+
assert call.domain == HASS_DOMAIN
2546+
assert call.service == SERVICE_TURN_OFF
2547+
assert call.data["entity_id"] == common.ENT_SWITCH
2548+
assert (
2549+
state.attributes["hvac_action_reason"]
2550+
== HVACActionReason.TARGET_TEMP_NOT_REACHED_WITH_FAN
2551+
)
2552+
2553+
24652554
async def test_set_target_temp_ac_on_after_fan_tolerance_2(
24662555
hass: HomeAssistant, setup_comp_1 # noqa: F811
24672556
) -> None:

0 commit comments

Comments
 (0)