From f956b8575232ae4be9aa0e6b47c375815400925a Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sat, 21 Oct 2023 00:59:16 +0200 Subject: [PATCH] Fix movement detection --- .../versatile_thermostat/climate.py | 86 ++++++++---- .../tests/test_movement.py | 128 ++++++++++++++++++ 2 files changed, 184 insertions(+), 30 deletions(-) diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index a2872d8f..602e7bb0 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -1533,6 +1533,7 @@ async def _async_motion_changed(self, event): # Check delay condition async def try_motion_condition(_): try: + delay = self._motion_delay_sec if new_state.state == STATE_ON else self._motion_off_delay_sec long_enough = condition.state( self.hass, self._motion_sensor_entity_id, @@ -1546,41 +1547,66 @@ async def try_motion_condition(_): _LOGGER.debug( "Motion delay condition is not satisfied. Ignore motion event" ) - return + else: + _LOGGER.debug("%s - Motion delay condition is satisfied", self) + self._motion_state = new_state.state + if self._attr_preset_mode == PRESET_ACTIVITY: + new_preset = ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ) + _LOGGER.info( + "%s - Motion condition have changes. New preset temp will be %s", + self, + new_preset, + ) + # We do not change the preset which is kept to ACTIVITY but only the target_temperature + # We take the presence into account + await self._async_internal_set_temperature( + self.find_preset_temp(new_preset) + ) + self.recalculate() + await self._async_control_heating(force=True) + self._motion_call_cancel = None - _LOGGER.debug("%s - Motion delay condition is satisfied", self) - self._motion_state = new_state.state - if self._attr_preset_mode == PRESET_ACTIVITY: - new_preset = ( - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset - ) - _LOGGER.info( - "%s - Motion condition have changes. New preset temp will be %s", - self, - new_preset, - ) - # We do not change the preset which is kept to ACTIVITY but only the target_temperature - # We take the presence into account - await self._async_internal_set_temperature( - self.find_preset_temp(new_preset) - ) - self.recalculate() - await self._async_control_heating(force=True) + im_on = (self._motion_state == STATE_ON) + delay_running = (self._motion_call_cancel is not None) + event_on = (new_state.state == STATE_ON) + + def arm(): + """ Arm the timer""" + delay = self._motion_delay_sec if new_state.state == STATE_ON else self._motion_off_delay_sec + self._motion_call_cancel = async_call_later( + self.hass, timedelta(seconds=delay), try_motion_condition + ) - if self._motion_call_cancel: + def desarm(): + # restart the timer self._motion_call_cancel() self._motion_call_cancel = None - # Delay - delay = self._motion_delay_sec if new_state.state == STATE_ON else self._motion_off_delay_sec - self._motion_call_cancel = async_call_later( - self.hass, timedelta(seconds=delay), try_motion_condition - ) - - # For testing purpose we need to access the inner function - return try_motion_condition + # if I'm off + if not im_on: + if event_on and not delay_running: + _LOGGER.debug("%s - Arm delay cause i'm off and event is on and no delay is running", self) + arm() + return try_motion_condition + # Ignore the event + _LOGGER.debug("%s - Event ignored cause i'm already off", self) + return None + else: # I'm On + if not event_on and not delay_running: + _LOGGER.info("%s - Arm delay cause i'm on and event is off", self) + arm() + return try_motion_condition + if event_on and delay_running: + _LOGGER.debug("%s - Desarm off delay cause i'm on and event is on and a delay is running", self) + desarm() + return None + # Ignore the event + _LOGGER.debug("%s - Event ignored cause i'm already on", self) + return None @callback async def _check_switch_initial_state(self): diff --git a/custom_components/versatile_thermostat/tests/test_movement.py b/custom_components/versatile_thermostat/tests/test_movement.py index 6da2e45d..ab1d31c3 100644 --- a/custom_components/versatile_thermostat/tests/test_movement.py +++ b/custom_components/versatile_thermostat/tests/test_movement.py @@ -470,3 +470,131 @@ async def test_movement_management_time_enoughand_not_presence( assert entity.proportional_algorithm.on_percent == 0.11 assert mock_heater_off.call_count == 0 assert mock_send_event.call_count == 0 + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_movement_management_with_stop_during_condition( + 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""" + + 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: True, + 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", + CONF_PRESENCE_SENSOR: "binary_sensor.mock_presence_sensor", + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # start heating, in boost mode. We block the control_heating to avoid running a cycle + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._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 + assert entity.presence_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) + + await send_presence_change_event(entity, False, True, event_timestamp) + assert entity.presence_state is "off" + + # starts detecting motion + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ), patch("homeassistant.helpers.condition.state", return_value=True): # Not needed for this test + event_timestamp = now - timedelta(minutes=5) + try_condition1 = await send_motion_change_event(entity, True, False, event_timestamp) + + assert try_condition1 is not None + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_ACTIVITY + # because motion is detected yet -> switch to Boost mode + assert entity.target_temperature == 18 + assert entity.motion_state is None + assert entity.presence_state is "off" + + # Send a stop detection + event_timestamp = now - timedelta(minutes=4) + try_condition = await send_motion_change_event(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.preset_mode is PRESET_ACTIVITY + assert entity.target_temperature == 18 + assert entity.motion_state is None + assert entity.presence_state is "off" + + # Resend a start detection + event_timestamp = now - timedelta(minutes=3) + try_condition = await send_motion_change_event(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.preset_mode is PRESET_ACTIVITY + # still no motion detected + assert entity.target_temperature == 18 + assert entity.motion_state is None + assert entity.presence_state is "off" + + await try_condition1(None) + # We should have switch this time + assert entity.target_temperature == 19 # Boost + assert entity.motion_state is "on" # switch to movement on + assert entity.presence_state is "off" # Non change +