Compare commits

..

12 Commits

Author SHA1 Message Date
Gernot Messow
60bd522a97 Filter out-of-range target temperature sent from underlying climate devices (#581)
* Filter out-of-range temperature from underlying climate

* Fixed broken test case, added new test case for range filtering
2024-10-27 09:21:08 +01:00
Jean-Marc Collin
fc39cf5f40 Maia suggestion to README 2024-10-26 11:27:16 +02:00
Jean-Marc Collin
f6fb7487d5 Issue #467 - Always apply offset compensation (#567)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-10-16 19:33:55 +02:00
Jean-Marc Collin
0f585be0c9 issue #556 - enhance motion detection feature (2) 2024-10-16 05:08:57 +00:00
Jean-Marc Collin
492c95aff5 FIX issue #556 - enhance motion detection feature (#560)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-10-14 20:14:51 +02:00
Jean-Marc Collin
a530051bbd FIX #518 TypeError: unsupported operand type(s) for -: 'int' and 'NoneType' (#559)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-10-14 19:29:34 +02:00
Jean-Marc Collin
4ef82af8ce Merge branch 'issue_554-simulate-hvac-action' 2024-10-14 17:01:01 +00:00
Jean-Marc Collin
2ea5cf471b Cleaning 2024-10-14 16:58:18 +00:00
Jean-Marc Collin
f6afaf2715 with local tests ok. (#555)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-10-14 09:02:50 +02:00
Jean-Marc Collin
f29b2f9b81 with local tests ok. 2024-10-14 07:01:27 +00:00
Jean-Marc Collin
de9b95903e Add testu 2024-10-14 04:56:12 +00:00
Jean-Marc Collin
d112273c58 Fix preset temp is sommetimes lost on over_climate 2024-10-14 04:43:19 +00:00
6 changed files with 412 additions and 75 deletions

View File

@@ -468,21 +468,16 @@ and of course, configure the VTherm's self-regulation mode in **Expert** mode. A
For the changes to be taken into account, you must either **completely restart Home Assistant** or just the **Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat). For the changes to be taken into account, you must either **completely restart Home Assistant** or just the **Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat).
#### Internal temperature compensation #### Internal temperature compensation
Sometimes, it happens that the internal thermometer of the underlying (TRV, air conditioning, etc.) is so wrong that self-regulation is not enough to regulate. Sometimes, a devices internal temperature sensor (like in a TRV or AC) can give inaccurate readings, especially if its too close to a heat source. This can cause the device to stop heating too soon.
This happens when the internal thermometer is too close to the heat source. The internal temperature then rises much faster than the room temperature, which generates faults in the regulation. For example:
Example : 1. target temperature: 20 °C, room temperature: 18 °C,
1. the room temperature is 18°, the setpoint is 20°, 2. devices internal sensor: 22 °C
2. the internal temperature of the equipment is 22°, 3. If the target temperature is increased to 21 °C, the device wont heat because it thinks its already warm (internal temperature is 22°C).
3. if VTherm sends 21° as setpoint (= 20° + 1° auto-regulation), then the equipment will not heat because its internal temperature (22°) is above the setpoint (21°)
To overcome this, a new optional option was added in version 5.4: ![Use of internal temperature](images/config-use-internal-temp.png) The Adjust Setpoint for Room vs. TRV Temperature feature fixes this by adding the temperature difference between the room and the devices internal reading to the target. In this case, VTherm would adjust the target to 25°C (21°C + 4°C difference), forcing the device to continue heating.
When enabled, this function will add the difference between the internal temperature and the room temperature to the setpoint to force heating. This adjustment is specific to each device, making the heating system more accurate and avoiding issues from faulty sensor readings.
In the example above, the difference is +4° (22° - 18°), so VTherm will send 25° (21°+4°) to the equipment forcing it to heat up. See ![Use of internal temperature](images/config-use-internal-temp.png)
This difference is calculated for each underlying because each has its own internal temperature. Think of a VTherm which would be connected to 3 TRVs each with its internal temperature for example.
We then obtain much more effective self-regulation which avoids the pitfall of large variations in faulty internal temperature.
#### synthesis of the self-regulation algorithm #### synthesis of the self-regulation algorithm
The self-regulation algorithm can be summarized as follows: The self-regulation algorithm can be summarized as follows:

View File

@@ -1654,9 +1654,28 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
if not long_enough: if not long_enough:
_LOGGER.debug( _LOGGER.debug(
"Motion delay condition is not satisfied. Ignore motion event" "Motion delay condition is not satisfied (the sensor have change its state during the delay). Check motion sensor state"
) )
else: # Get sensor current state
motion_state = self.hass.states.get(self._motion_sensor_entity_id)
_LOGGER.debug(
"%s - motion_state=%s, new_state.state=%s",
self,
motion_state.state,
new_state.state,
)
if (
motion_state.state == new_state.state
and new_state.state == STATE_ON
):
_LOGGER.debug(
"%s - the motion sensor is finally 'on' after the delay", self
)
long_enough = True
else:
long_enough = False
if long_enough:
_LOGGER.debug("%s - Motion delay condition is satisfied", self) _LOGGER.debug("%s - Motion delay condition is satisfied", self)
self._motion_state = new_state.state self._motion_state = new_state.state
if self._attr_preset_mode == PRESET_ACTIVITY: if self._attr_preset_mode == PRESET_ACTIVITY:
@@ -1679,6 +1698,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
) )
self.recalculate() self.recalculate()
await self.async_control_heating(force=True) await self.async_control_heating(force=True)
else:
self._motion_state = (
STATE_ON if new_state.state == STATE_OFF else STATE_OFF
)
self._motion_call_cancel = None self._motion_call_cancel = None
im_on = self._motion_state == STATE_ON im_on = self._motion_state == STATE_ON

View File

@@ -228,17 +228,18 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
and self.auto_regulation_use_device_temp and self.auto_regulation_use_device_temp
# and we have access to the device temp # and we have access to the device temp
and (device_temp := under.underlying_current_temperature) is not None and (device_temp := under.underlying_current_temperature) is not None
# issue 467 - always apply offset. TODO removes this if ok
# and target is not reach (ie we need regulation) # and target is not reach (ie we need regulation)
and ( # and (
( # (
self.hvac_mode == HVACMode.COOL # self.hvac_mode == HVACMode.COOL
and self.target_temperature < self.current_temperature # and self.target_temperature < self.current_temperature
) # )
or ( # or (
self.hvac_mode == HVACMode.HEAT # self.hvac_mode == HVACMode.HEAT
and self.target_temperature > self.current_temperature # and self.target_temperature > self.current_temperature
) # )
) # )
): ):
offset_temp = device_temp - self.current_temperature offset_temp = device_temp - self.current_temperature
@@ -692,6 +693,13 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
else None else None
) )
last_sent_temperature = under.last_sent_temperature or 0
under_temp_diff = (
(new_target_temp - last_sent_temperature) if new_target_temp else 0
)
if -1 < under_temp_diff < 1:
under_temp_diff = 0
# Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command # Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command
# Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is # Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is
# if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE: # if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE:
@@ -702,7 +710,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
if ( if (
new_hvac_mode == self._hvac_mode new_hvac_mode == self._hvac_mode
and new_hvac_action == old_hvac_action and new_hvac_action == old_hvac_action
and new_target_temp is None and under_temp_diff == 0
and (new_fan_mode is None or new_fan_mode == self._attr_fan_mode) and (new_fan_mode is None or new_fan_mode == self._attr_fan_mode)
): ):
_LOGGER.debug( _LOGGER.debug(
@@ -711,6 +719,22 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
) )
return return
# Forget event when the new target temperature is out of range
if (
not new_target_temp is None
and not self._attr_min_temp is None
and not self._attr_max_temp is None
and not (self._attr_min_temp <= new_target_temp <= self._attr_max_temp)
):
_LOGGER.debug(
"%s - underlying sent a target temperature (%s) which is out of configured min/max range (%s / %s). The value will be ignored",
self,
new_target_temp,
self._attr_min_temp,
self._attr_max_temp,
)
return
# A real changes have to be managed # A real changes have to be managed
_LOGGER.info( _LOGGER.info(
"%s - Underlying climate %s have changed. new_hvac_mode is %s (vs %s), new_hvac_action=%s (vs %s), new_target_temp=%s (vs %s), new_fan_mode=%s (vs %s)", "%s - Underlying climate %s have changed. new_hvac_mode is %s (vs %s), new_hvac_action=%s (vs %s), new_target_temp=%s (vs %s), new_fan_mode=%s (vs %s)",
@@ -834,11 +858,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
under.last_sent_temperature, under.last_sent_temperature,
new_target_temp, new_target_temp,
) )
if ( # if the underlying have change its target temperature
# if the underlying have change its target temperature if under_temp_diff != 0:
new_target_temp is not None
and new_target_temp != under.last_sent_temperature
):
_LOGGER.info( _LOGGER.info(
"%s - Target temp in underlying have change to %s (vs %s)", "%s - Target temp in underlying have change to %s (vs %s)",
self, self,
@@ -849,7 +870,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
changes = True changes = True
else: else:
_LOGGER.debug( _LOGGER.debug(
"%s - Forget the eventual underlying temperature change because VTherm is regulated", "%s - Forget the eventual underlying temperature change there is no real change",
self, self,
) )

View File

@@ -550,14 +550,11 @@ class UnderlyingClimate(UnderlyingEntity):
def is_device_active(self): def is_device_active(self):
"""If the toggleable device is currently active.""" """If the toggleable device is currently active."""
if self.is_initialized: if self.is_initialized:
return ( return self.hvac_mode != HVACMode.OFF and self.hvac_action not in [
self._underlying_climate.hvac_mode != HVACMode.OFF HVACAction.IDLE,
and self._underlying_climate.hvac_action HVACAction.OFF,
not in [ None,
HVACAction.IDLE, ]
HVACAction.OFF,
]
)
else: else:
return None return None
@@ -650,7 +647,36 @@ class UnderlyingClimate(UnderlyingEntity):
"""Get the hvac action of the underlying""" """Get the hvac action of the underlying"""
if not self.is_initialized: if not self.is_initialized:
return None return None
return self._underlying_climate.hvac_action
hvac_action = self._underlying_climate.hvac_action
if hvac_action is None:
target = (
self.underlying_target_temperature
or self._thermostat.target_temperature
)
current = (
self.underlying_current_temperature
or self._thermostat.current_temperature
)
hvac_mode = self.hvac_mode
_LOGGER.debug(
"%s - hvac_action simulation target=%s, current=%s, hvac_mode=%s",
self,
target,
current,
hvac_mode,
)
hvac_action = HVACAction.IDLE
if target is not None and current is not None:
dtemp = target - current
if hvac_mode == HVACMode.COOL and dtemp < 0:
hvac_action = HVACAction.COOLING
elif hvac_mode in [HVACMode.HEAT, HVACMode.HEAT_COOL] and dtemp > 0:
hvac_action = HVACAction.HEATING
return hvac_action
@property @property
def hvac_mode(self) -> HVACMode | None: def hvac_mode(self) -> HVACMode | None:
@@ -730,18 +756,19 @@ class UnderlyingClimate(UnderlyingEntity):
return self._underlying_climate.target_temperature_low return self._underlying_climate.target_temperature_low
@property @property
def target_temperature(self) -> float: def underlying_target_temperature(self) -> float:
"""Get the target_temperature""" """Get the target_temperature"""
if not self.is_initialized: if not self.is_initialized:
return None return None
return self._underlying_climate.target_temperature
@property if not hasattr(self._underlying_climate, "target_temperature"):
def is_aux_heat(self) -> bool: return None
"""Get the is_aux_heat""" else:
if not self.is_initialized: return self._underlying_climate.target_temperature
return False
return self._underlying_climate.is_aux_heat # return self._hass.states.get(self._entity_id).attributes.get(
# "target_temperature"
# )
@property @property
def underlying_current_temperature(self) -> float | None: def underlying_current_temperature(self) -> float | None:
@@ -752,8 +779,17 @@ class UnderlyingClimate(UnderlyingEntity):
if not hasattr(self._underlying_climate, "current_temperature"): if not hasattr(self._underlying_climate, "current_temperature"):
return None return None
else:
return self._underlying_climate.current_temperature
return self._hass.states.get(self._entity_id).attributes.get("current_temperature") # return self._hass.states.get(self._entity_id).attributes.get("current_temperature")
@property
def is_aux_heat(self) -> bool:
"""Get the is_aux_heat"""
if not self.is_initialized:
return False
return self._underlying_climate.is_aux_heat
def turn_aux_heat_on(self) -> None: def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on.""" """Turn auxiliary heater on."""

View File

@@ -97,7 +97,12 @@ async def test_movement_management_time_not_enough(
return_value=False, return_value=False,
), patch( ), patch(
"homeassistant.helpers.condition.state", return_value=False "homeassistant.helpers.condition.state", return_value=False
) as mock_condition: ) as mock_condition, patch(
"homeassistant.core.StateMachine.get",
return_value=State(
entity_id="binary_sensor.mock_motion_sensor", state=STATE_OFF
),
):
event_timestamp = now - timedelta(minutes=4) event_timestamp = now - timedelta(minutes=4)
try_condition = await send_motion_change_event(entity, True, False, event_timestamp) try_condition = await send_motion_change_event(entity, True, False, event_timestamp)
@@ -109,8 +114,8 @@ async def test_movement_management_time_not_enough(
# because no motion is detected yet # because no motion is detected yet
assert entity.target_temperature == 18 assert entity.target_temperature == 18
# state is not changed if time is not enough # state is not changed if time is not enough
assert entity.motion_state is None assert entity.motion_state is STATE_OFF
assert entity.presence_state == "on" assert entity.presence_state == STATE_ON
assert mock_send_event.call_count == 0 assert mock_send_event.call_count == 0
# Change is not confirmed # Change is not confirmed
@@ -141,8 +146,8 @@ async def test_movement_management_time_not_enough(
assert entity.preset_mode is PRESET_ACTIVITY assert entity.preset_mode is PRESET_ACTIVITY
# because motion is detected yet # because motion is detected yet
assert entity.target_temperature == 19 assert entity.target_temperature == 19
assert entity.motion_state == "on" assert entity.motion_state == STATE_ON
assert entity.presence_state == "on" assert entity.presence_state == STATE_ON
# stop detecting motion with off delay too low # stop detecting motion with off delay too low
with patch( with patch(
@@ -156,19 +161,24 @@ async def test_movement_management_time_not_enough(
return_value=True, return_value=True,
) as mock_device_active, patch( ) as mock_device_active, patch(
"homeassistant.helpers.condition.state", return_value=False "homeassistant.helpers.condition.state", return_value=False
) as mock_condition: ) as mock_condition, patch(
"homeassistant.core.StateMachine.get",
return_value=State(
entity_id="binary_sensor.mock_motion_sensor", state=STATE_OFF
),
):
event_timestamp = now - timedelta(minutes=2) event_timestamp = now - timedelta(minutes=2)
try_condition = await send_motion_change_event(entity, False, True, event_timestamp) try_condition = await send_motion_change_event(entity, False, True, event_timestamp)
# Will return False -> we will stay to movement On # Will return False -> we will stay to movement On
await try_condition(None) await try_condition(None)
assert entity.hvac_mode is HVACMode.HEAT assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet # because no motion is detected yet
assert entity.target_temperature == 19 assert entity.target_temperature == 19
assert entity.motion_state == "on" assert entity.motion_state == STATE_ON
assert entity.presence_state == "on" assert entity.presence_state == STATE_ON
assert mock_send_event.call_count == 0 assert mock_send_event.call_count == 0
# The heater must heat now # The heater must heat now
@@ -192,15 +202,15 @@ async def test_movement_management_time_not_enough(
event_timestamp = now - timedelta(minutes=1) event_timestamp = now - timedelta(minutes=1)
try_condition = await send_motion_change_event(entity, False, True, event_timestamp) try_condition = await send_motion_change_event(entity, False, True, event_timestamp)
# Will return True -> we will switch to movement Off # Will return True -> we will switch to movement Off
await try_condition(None) await try_condition(None)
assert entity.hvac_mode is HVACMode.HEAT assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet # because no motion is detected yet
assert entity.target_temperature == 18 assert entity.target_temperature == 18
assert entity.motion_state == "off" assert entity.motion_state == STATE_OFF
assert entity.presence_state == "on" assert entity.presence_state == STATE_ON
assert mock_send_event.call_count == 0 assert mock_send_event.call_count == 0
# The heater must stop heating now # The heater must stop heating now
@@ -214,7 +224,7 @@ async def test_movement_management_time_not_enough(
async def test_movement_management_time_enough_and_presence( async def test_movement_management_time_enough_and_presence(
hass: HomeAssistant, skip_hass_states_is_state hass: HomeAssistant, skip_hass_states_is_state
): ):
"""Test the Presence management when time is not enough""" """Test the Motion management when time is not enough"""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@@ -479,7 +489,7 @@ async def test_movement_management_time_enoughand_not_presence(
async def test_movement_management_with_stop_during_condition( async def test_movement_management_with_stop_during_condition(
hass: HomeAssistant, skip_hass_states_is_state hass: HomeAssistant, skip_hass_states_is_state
): ):
"""Test the Presence management when the movement sensor switch to off and then to on during the test condition""" """Test the Motion management when the movement sensor switch to off and then to on during the test condition"""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@@ -558,9 +568,13 @@ async def test_movement_management_with_stop_during_condition(
) as mock_heater_off, patch( ) as mock_heater_off, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True, return_value=True,
), patch("homeassistant.helpers.condition.state", return_value=True): # Not needed for this test ), patch(
"homeassistant.helpers.condition.state", return_value=True
): # Not needed for this test
event_timestamp = now - timedelta(minutes=5) event_timestamp = now - timedelta(minutes=5)
try_condition1 = await send_motion_change_event(entity, True, False, event_timestamp) try_condition1 = await send_motion_change_event(
entity, True, False, event_timestamp
)
assert try_condition1 is not None assert try_condition1 is not None
@@ -573,8 +587,10 @@ async def test_movement_management_with_stop_during_condition(
# Send a stop detection # Send a stop detection
event_timestamp = now - timedelta(minutes=4) event_timestamp = now - timedelta(minutes=4)
try_condition = await send_motion_change_event(entity, False, True, event_timestamp) try_condition = await send_motion_change_event(
assert try_condition is None # The timer should not have been stopped entity, False, True, event_timestamp
)
assert try_condition is None # The timer should not have been stopped
assert entity.hvac_mode is HVACMode.HEAT assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY assert entity.preset_mode is PRESET_ACTIVITY
@@ -584,8 +600,12 @@ async def test_movement_management_with_stop_during_condition(
# Resend a start detection # Resend a start detection
event_timestamp = now - timedelta(minutes=3) event_timestamp = now - timedelta(minutes=3)
try_condition = await send_motion_change_event(entity, True, False, event_timestamp) try_condition = await send_motion_change_event(
assert try_condition is None # The timer should not have been restarted (we keep the first one) entity, True, False, event_timestamp
)
assert (
try_condition is None
) # The timer should not have been restarted (we keep the first one)
assert entity.hvac_mode is HVACMode.HEAT assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY assert entity.preset_mode is PRESET_ACTIVITY
@@ -596,6 +616,122 @@ async def test_movement_management_with_stop_during_condition(
await try_condition1(None) await try_condition1(None)
# We should have switch this time # We should have switch this time
assert entity.target_temperature == 19 # Boost assert entity.target_temperature == 19 # Boost
assert entity.motion_state == "on" # switch to movement on assert entity.motion_state == "on" # switch to movement on
assert entity.presence_state == "off" # Non change assert entity.presence_state == "off" # Non change
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_movement_management_with_stop_during_condition_last_state_on(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test the Motion management when the movement sensor switch to off and then to on during the test condition"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
"comfort_temp": 18,
"boost_temp": 19,
"eco_away_temp": 17,
"comfort_away_temp": 18,
"boost_away_temp": 19,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: True,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False,
CONF_HEATER: "switch.mock_switch",
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_MOTION_SENSOR: "binary_sensor.mock_motion_sensor",
CONF_MOTION_DELAY: 10,
CONF_MOTION_OFF_DELAY: 30,
CONF_MOTION_PRESET: "boost",
CONF_NO_MOTION_PRESET: "comfort",
},
)
entity: BaseThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# 0. start heating, in boost mode. We block the control_heating to avoid running a cycle
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.async_control_heating"
):
await entity.async_set_hvac_mode(HVACMode.HEAT)
await entity.async_set_preset_mode(PRESET_ACTIVITY)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.preset_mode is PRESET_ACTIVITY
# because no motion is detected yet
assert entity.target_temperature == 18
assert entity.motion_state is None
event_timestamp = now - timedelta(minutes=6)
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# 1. starts detecting motion but the sensor is off
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
), patch("homeassistant.helpers.condition.state", return_value=False), patch(
"homeassistant.core.StateMachine.get",
return_value=State(
entity_id="binary_sensor.mock_motion_sensor", state=STATE_OFF
),
):
event_timestamp = now - timedelta(minutes=5)
try_condition1 = await send_motion_change_event(
entity, True, False, event_timestamp
)
assert try_condition1 is not None
await try_condition1(None)
# because no motion is detected yet -> condition.state is False and sensor is not active
assert entity.target_temperature == 18
assert entity.motion_state is STATE_OFF
# 2. starts detecting motion but the sensor is on
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active",
return_value=True,
), patch("homeassistant.helpers.condition.state", return_value=False), patch(
"homeassistant.core.StateMachine.get",
return_value=State(
entity_id="binary_sensor.mock_motion_sensor", state=STATE_ON
),
):
event_timestamp = now - timedelta(minutes=5)
try_condition1 = await send_motion_change_event(
entity, True, False, event_timestamp
)
assert try_condition1 is not None
await try_condition1(None)
# because no motion is detected yet -> condition.state is False and sensor is not active
assert entity.target_temperature == 19
assert entity.motion_state is STATE_ON

View File

@@ -276,7 +276,7 @@ async def test_bug_101(
HVACAction.OFF, HVACAction.OFF,
HVACAction.OFF, HVACAction.OFF,
now, now,
12.75, entity.min_temp + 1,
True, True,
"climate.mock_climate", # the underlying climate entity id "climate.mock_climate", # the underlying climate entity id
) )
@@ -295,11 +295,28 @@ async def test_bug_101(
HVACAction.OFF, HVACAction.OFF,
HVACAction.OFF, HVACAction.OFF,
event_timestamp, event_timestamp,
12.75, entity.min_temp + 1,
True, True,
"climate.mock_climate", # the underlying climate entity id "climate.mock_climate", # the underlying climate entity id
) )
assert entity.target_temperature == 12.75 assert entity.target_temperature == entity.min_temp + 1
assert entity.preset_mode is PRESET_NONE
# 4. Change the target temp with < 1 value. The value should not be taken
# Wait 11 sec
event_timestamp = now + timedelta(seconds=11)
await send_climate_change_event_with_temperature(
entity,
HVACMode.HEAT,
HVACMode.HEAT,
HVACAction.OFF,
HVACAction.OFF,
event_timestamp,
entity.min_temp + 1.5,
True,
"climate.mock_climate", # the underlying climate entity id
)
assert entity.target_temperature == entity.min_temp + 1
assert entity.preset_mode is PRESET_NONE assert entity.preset_mode is PRESET_NONE
@@ -506,3 +523,111 @@ async def test_bug_524(hass: HomeAssistant, skip_hass_states_is_state):
await send_presence_change_event(vtherm, True, False, datetime.now()) await send_presence_change_event(vtherm, True, False, datetime.now())
await hass.async_block_till_done() await hass.async_block_till_done()
assert vtherm.target_temperature == 25 assert vtherm.target_temperature == 25
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_ignore_temp_outside_minmax_range(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that when a underlying climate target temp is changed, the VTherm ignores the target temp if it is outside the min/max range"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data=PARTIAL_CLIMATE_NOT_REGULATED_CONFIG, # 5 minutes security delay
)
# Underlying is in HEAT mode but should be shutdown at startup
fake_underlying_climate = MockClimate(
hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING
)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
) as mock_find_climate, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode"
) as mock_underlying_set_hvac_mode:
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
assert entity
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.hvac_mode is HVACMode.OFF
# because in MockClimate HVACAction is HEATING if hvac_mode is not set
assert entity.hvac_action is HVACAction.HEATING
# Underlying should have been shutdown
assert mock_underlying_set_hvac_mode.call_count == 1
mock_underlying_set_hvac_mode.assert_has_calls(
[
call.set_hvac_mode(HVACMode.OFF),
]
)
assert entity.target_temperature == entity.min_temp
assert entity.preset_mode is PRESET_NONE
# should have been called with EventType.PRESET_EVENT and EventType.HVAC_MODE_EVENT
assert mock_send_event.call_count == 2
mock_send_event.assert_has_calls(
[
call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_NONE}),
call.send_event(
EventType.HVAC_MODE_EVENT,
{"hvac_mode": HVACMode.OFF},
),
]
)
assert mock_find_climate.call_count == 1
assert mock_find_climate.mock_calls[0] == call()
mock_find_climate.assert_has_calls([call.find_underlying_entity()])
# 1. Force preset mode
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode == HVACMode.HEAT
await entity.async_set_preset_mode(PRESET_COMFORT)
assert entity.preset_mode == PRESET_COMFORT
# 1. Try to set the target temperature to a below min_temp -> should be ignored
# Wait 11 sec
event_timestamp = now + timedelta(seconds=11)
assert entity.is_regulated is False
await send_climate_change_event_with_temperature(
entity,
HVACMode.HEAT,
HVACMode.HEAT,
HVACAction.OFF,
HVACAction.OFF,
event_timestamp,
entity.min_temp - 1,
True,
"climate.mock_climate", # the underlying climate entity id
)
assert entity.target_temperature == 17
# 2. Try to set the target temperature to a above max_temp -> should be ignored
event_timestamp = event_timestamp + timedelta(seconds=11)
assert entity.is_regulated is False
await send_climate_change_event_with_temperature(
entity,
HVACMode.HEAT,
HVACMode.HEAT,
HVACAction.OFF,
HVACAction.OFF,
event_timestamp,
entity.max_temp + 1,
True,
"climate.mock_climate", # the underlying climate entity id
)
assert entity.target_temperature == 17