diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 14cb6fa..3d88607 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -219,3 +219,4 @@ frontend: state-binary_sensor-window-on-color: "rgb(156, 39, 176)" state-binary_sensor-motion-on-color: "rgb(156, 39, 176)" state-binary_sensor-presence-on-color: "lightgreen" + state-binary_sensor-running-on-color: "orange" diff --git a/README-fr.md b/README-fr.md index cbd9a21..344a6c5 100644 --- a/README-fr.md +++ b/README-fr.md @@ -518,6 +518,7 @@ frontend: state-binary_sensor-window-on-color: "rgb(156, 39, 176)" state-binary_sensor-motion-on-color: "rgb(156, 39, 176)" state-binary_sensor-presence-on-color: "lightgreen" + state-binary_sensor-running-on-color: "orange" ``` et choisissez le thème ```versatile_thermostat_theme``` dans la configuration du panel. Vous obtiendrez quelque-chose qui va ressembler à ça : diff --git a/README.md b/README.md index 23d752b..007092d 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,7 @@ frontend: state-binary_sensor-window-on-color: "rgb(156, 39, 176)" state-binary_sensor-motion-on-color: "rgb(156, 39, 176)" state-binary_sensor-presence-on-color: "lightgreen" + state-binary_sensor-running-on-color: "orange" ``` and choose the ```versatile_thermostat_theme``` theme in the panel configuration. You will get something that will look like this: diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index d3076fb..a2f9f96 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1324,8 +1324,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): #PR - Adding Window ByPass _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) if self._window_bypass_state: - _LOGGER.info("Window ByPass is activated. Ignore window event") - self.update_custom_attributes() + _LOGGER.info("%s - Window ByPass is activated. Ignore window event", self) return if self._window_state == STATE_OFF: @@ -1706,6 +1705,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self, slope if slope is not None else 0.0, ) + + if self.window_bypass_state: + _LOGGER.info("%s - Window auto event is ignored because bypass is ON", self) + return + if ( self._window_auto_algo.is_window_open_detected() and self._window_auto_state is False diff --git a/custom_components/versatile_thermostat/translations/sk.json b/custom_components/versatile_thermostat/translations/sk.json index b79be67..ed07bc2 100644 --- a/custom_components/versatile_thermostat/translations/sk.json +++ b/custom_components/versatile_thermostat/translations/sk.json @@ -326,7 +326,7 @@ "thermostat_type": { "options": { "thermostat_over_switch": "Termostat nad spínačom", - "thermostat_over_climate": "Termostat nad iným termostatom" + "thermostat_over_climate": "Termostat nad iným termostatom", "thermostat_over_valve": "Thermostat over a valve" } } @@ -346,4 +346,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/test_open_window_algo.py b/tests/test_open_window_algo.py index 3834eda..fea12eb 100644 --- a/tests/test_open_window_algo.py +++ b/tests/test_open_window_algo.py @@ -1,9 +1,11 @@ +# pylint: disable=unused-argument, line-too-long """ Test the OpenWindow algorithm """ from datetime import datetime, timedelta -from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from custom_components.versatile_thermostat.open_window_algorithm import WindowOpenDetectionAlgorithm +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + async def test_open_window_algo( hass: HomeAssistant, diff --git a/tests/test_window.py b/tests/test_window.py index 64de8a6..0a5f826 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1,10 +1,12 @@ +# pylint: disable=unused-argument, line-too-long, protected-access """ Test the Window management """ import asyncio +import logging from unittest.mock import patch, call, PropertyMock -from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from datetime import datetime, timedelta -import logging +from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import logging.getLogger().setLevel(logging.DEBUG) @@ -47,7 +49,7 @@ async def test_window_management_time_not_enough( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -135,7 +137,7 @@ async def test_window_management_time_enough( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -274,7 +276,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -424,7 +426,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_state): - """Test the Power management""" + """Test the Window auto management""" entry = MockConfigEntry( domain=DOMAIN, @@ -458,7 +460,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -603,7 +605,7 @@ async def test_window_auto_no_on_percent( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -711,7 +713,7 @@ async def test_window_bypass( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: BaseThermostat = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -746,8 +748,11 @@ async def test_window_bypass( assert mock_heater_off.call_count == 0 assert mock_send_event.call_count == 0 - #Set Window ByPass to true - entity._window_bypass_state = True + # Set Window ByPass to true + await entity.service_set_window_bypass_state(True) + assert entity.window_bypass_state is True + + # entity._window_bypass_state = True # Open the window, condition of time is satisfied, check the thermostat and heater turns off with patch( @@ -802,5 +807,246 @@ async def test_window_bypass( assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST + # Clean the entity + entity.remove_thermostat() + +#PR - Adding Window bypass for window auto algorithm +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Window auto management""" + + 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": 21, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + 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_WINDOW_AUTO_OPEN_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test + }, + ) + + entity: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + tpi_algo = entity._prop_algorithm + assert tpi_algo + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.overpowering_state is None + assert entity.target_temperature == 21 + + assert entity.window_state is None + + # Make the temperature down + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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, + ): + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_heater_on.call_count == 1 + assert entity.last_temperature_slope is None + assert entity._window_auto_algo.is_window_open_detected() is False + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.hvac_mode is HVACMode.HEAT + + # send one degre down in one minute with window bypass on + await entity.service_set_window_bypass_state(True) + assert entity.window_bypass_state is True + # entity._window_bypass_state = True + + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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, + ): + event_timestamp = now - timedelta(minutes=3) + await send_temperature_change_event(entity, 18, event_timestamp, sleep=False) + + # No change should have been done + assert mock_send_event.call_count == 0 + + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert entity.last_temperature_slope == -1 + assert entity._window_auto_algo.is_window_open_detected() is True + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.window_auto_state == STATE_OFF + assert entity.hvac_mode is HVACMode.HEAT + + # Clean the entity + entity.remove_thermostat() + +#PR - Adding Window bypass AFTER detection have been done should reactivate the heater +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Window management when window is open and then bypass is set to on""" + + 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, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + 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_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + }, + ) + + entity: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tpi_algo = entity._prop_algorithm + assert tpi_algo + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.overpowering_state is None + assert entity.target_temperature == 19 + + assert entity.window_state is None + + # change temperature to force turning on the heater + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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=False, + ): + await send_temperature_change_event(entity, 15, datetime.now()) + + # Heater shoud turn-on + assert mock_heater_on.call_count >= 1 + assert mock_heater_off.call_count == 0 + assert mock_send_event.call_count == 0 + + # Open the window, condition of time is satisfied, check the thermostat and heater turns off + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + await send_window_change_event(entity, True, False, datetime.now()) + + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})] + ) + + # Heater should not be on + assert mock_heater_on.call_count == 0 + # One call in set_hvac_mode turn_off and one call in the control_heating for security + assert mock_heater_off.call_count == 2 + assert mock_condition.call_count == 1 + assert entity.hvac_mode is HVACMode.OFF + assert entity.window_state == STATE_ON + + # Call the set bypass service to set bypass ON + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.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=False, + ): + await entity.service_set_window_bypass_state(True) + + assert entity.window_state == STATE_ON + assert entity.preset_mode is PRESET_BOOST + assert entity.hvac_mode is HVACMode.HEAT + # assert mock_heater_on.call_count == 1 + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT} + ), + ], + any_order=False, + ) + # Clean the entity entity.remove_thermostat() \ No newline at end of file