From 492c95aff545770bd0c2a5480c1071aba8a9ec3c Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 14 Oct 2024 20:14:51 +0200 Subject: [PATCH] FIX issue #556 - enhance motion detection feature (#560) Co-authored-by: Jean-Marc Collin --- .../versatile_thermostat/base_thermostat.py | 19 ++- tests/test_movement.py | 160 +++++++++++++++--- 2 files changed, 157 insertions(+), 22 deletions(-) diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 44a203a..cadb558 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1654,9 +1654,19 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if not long_enough: _LOGGER.debug( - "Motion delay condition is not satisfied. Ignore motion event" + "Motion delay condition is not satisfied. Check motion sensor state" ) - else: + # Get sensor current state + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + if motion_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) self._motion_state = new_state.state if self._attr_preset_mode == PRESET_ACTIVITY: @@ -1679,6 +1689,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) self.recalculate() 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 im_on = self._motion_state == STATE_ON diff --git a/tests/test_movement.py b/tests/test_movement.py index d4eac56..f5c9558 100644 --- a/tests/test_movement.py +++ b/tests/test_movement.py @@ -109,8 +109,8 @@ async def test_movement_management_time_not_enough( # because no motion is detected yet assert entity.target_temperature == 18 # state is not changed if time is not enough - assert entity.motion_state is None - assert entity.presence_state == "on" + assert entity.motion_state is STATE_OFF + assert entity.presence_state == STATE_ON assert mock_send_event.call_count == 0 # Change is not confirmed @@ -141,8 +141,8 @@ async def test_movement_management_time_not_enough( assert entity.preset_mode is PRESET_ACTIVITY # because motion is detected yet assert entity.target_temperature == 19 - assert entity.motion_state == "on" - assert entity.presence_state == "on" + assert entity.motion_state == STATE_ON + assert entity.presence_state == STATE_ON # stop detecting motion with off delay too low with patch( @@ -160,15 +160,15 @@ async def test_movement_management_time_not_enough( event_timestamp = now - timedelta(minutes=2) 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) assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_ACTIVITY # because no motion is detected yet assert entity.target_temperature == 19 - assert entity.motion_state == "on" - assert entity.presence_state == "on" + assert entity.motion_state == STATE_ON + assert entity.presence_state == STATE_ON assert mock_send_event.call_count == 0 # The heater must heat now @@ -192,15 +192,15 @@ async def test_movement_management_time_not_enough( event_timestamp = now - timedelta(minutes=1) 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) 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 == "off" - assert entity.presence_state == "on" + assert entity.motion_state == STATE_OFF + assert entity.presence_state == STATE_ON assert mock_send_event.call_count == 0 # The heater must stop heating now @@ -214,7 +214,7 @@ async def test_movement_management_time_not_enough( async def test_movement_management_time_enough_and_presence( 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( domain=DOMAIN, @@ -479,7 +479,7 @@ async def test_movement_management_time_enoughand_not_presence( 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""" + """Test the Motion management when the movement sensor switch to off and then to on during the test condition""" entry = MockConfigEntry( domain=DOMAIN, @@ -558,9 +558,13 @@ async def test_movement_management_with_stop_during_condition( ) 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 + ), 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) + try_condition1 = await send_motion_change_event( + entity, True, False, event_timestamp + ) assert try_condition1 is not None @@ -573,8 +577,10 @@ async def test_movement_management_with_stop_during_condition( # 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 + 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 @@ -584,8 +590,12 @@ async def test_movement_management_with_stop_during_condition( # 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) + 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 @@ -596,6 +606,116 @@ async def test_movement_management_with_stop_during_condition( await try_condition1(None) # We should have switch this time - assert entity.target_temperature == 19 # Boost - assert entity.motion_state == "on" # switch to movement on + assert entity.target_temperature == 19 # Boost + assert entity.motion_state == "on" # switch to movement on 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_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_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