From 0b81a94d0f85e75c50f6460bd11d15dfb69e6be0 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 19 Feb 2023 19:10:08 +0100 Subject: [PATCH] Add testus and change date timestamp. --- .../versatile_thermostat/climate.py | 67 ++++-- .../versatile_thermostat/prop_algorithm.py | 3 +- .../versatile_thermostat/tests/commons.py | 42 +++- .../tests/test_security.py | 26 ++- .../versatile_thermostat/tests/test_window.py | 200 ++++++++++++++++++ 5 files changed, 311 insertions(+), 27 deletions(-) create mode 100644 custom_components/versatile_thermostat/tests/test_window.py diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index f0c38cc..6c25e45 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -8,6 +8,7 @@ from datetime import timedelta, datetime import voluptuous as vol +from homeassistant.util import dt as dt_util from homeassistant.core import ( HomeAssistant, callback, @@ -261,6 +262,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._total_energy = None + self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) + self.post_init(entry_infos) def post_init(self, entry_infos): @@ -1014,6 +1017,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """Get the overpowering_state""" return self._overpowering_state + @property + def window_state(self) -> bool | None: + """Get the window_state""" + return self._window_state + + @property + def motion_state(self) -> bool | None: + """Get the motion_state""" + return self._motion_state + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" if self._is_over_climate and self._underlying_climate: @@ -1321,6 +1334,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _LOGGER.debug( "Window delay condition is not satisfied. Ignore window event" ) + self._window_state = old_state.state return _LOGGER.debug("%s - Window delay condition is satisfied", self) @@ -1349,6 +1363,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._window_call_cancel = async_call_later( self.hass, timedelta(seconds=self._window_delay_sec), try_window_condition ) + # For testing purpose we need to access the inner function + return try_window_condition @callback async def _async_motion_changed(self, event): @@ -1704,7 +1720,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """ if not self._pmax_on: - _LOGGER.debug("%s - power not configured. check_overpowering not available") + _LOGGER.debug( + "%s - power not configured. check_overpowering not available", self + ) return False if ( @@ -1712,7 +1730,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): or self._device_power is None or self._current_power_max is None ): - _LOGGER.warning("%s - power not valued. check_overpowering not available") + _LOGGER.warning( + "%s - power not valued. check_overpowering not available", self + ) return False _LOGGER.debug( @@ -1773,12 +1793,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def check_security(self) -> bool: """Check if last temperature date is too long""" - now = datetime.now() + now = datetime.now(self._current_tz) delta_temp = ( - now - self._last_temperature_mesure.replace(tzinfo=None) + now - self._last_temperature_mesure.replace(tzinfo=self._current_tz) ).total_seconds() / 60.0 delta_ext_temp = ( - now - self._last_ext_temperature_mesure.replace(tzinfo=None) + now - self._last_ext_temperature_mesure.replace(tzinfo=self._current_tz) ).total_seconds() / 60.0 mode_cond = self._is_over_climate or self._hvac_mode != HVACMode.OFF @@ -1839,8 +1859,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self.send_event( EventType.TEMPERATURE_EVENT, { - "last_temperature_mesure": self._last_temperature_mesure.isoformat(), - "last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(), + "last_temperature_mesure": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), "current_temp": self._cur_temp, "current_ext_temp": self._cur_ext_temp, "target_temp": self.target_temperature, @@ -1862,8 +1886,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): EventType.SECURITY_EVENT, { "type": "start", - "last_temperature_mesure": self._last_temperature_mesure.isoformat(), - "last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(), + "last_temperature_mesure": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), "current_temp": self._cur_temp, "current_ext_temp": self._cur_ext_temp, "target_temp": self.target_temperature, @@ -1892,8 +1920,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): EventType.SECURITY_EVENT, { "type": "end", - "last_temperature_mesure": self._last_temperature_mesure.isoformat(), - "last_ext_temperature_mesure": self._last_ext_temperature_mesure.isoformat(), + "last_temperature_mesure": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_mesure": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), "current_temp": self._cur_temp, "current_ext_temp": self._cur_ext_temp, "target_temp": self.target_temperature, @@ -2101,14 +2133,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): "security_delay_min": self._security_delay_min, "security_min_on_percent": self._security_min_on_percent, "security_default_on_percent": self._security_default_on_percent, - "last_temperature_datetime": self._last_temperature_mesure.isoformat(), - "last_ext_temperature_datetime": self._last_ext_temperature_mesure.isoformat(), + "last_temperature_datetime": self._last_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_datetime": self._last_ext_temperature_mesure.replace( + tzinfo=self._current_tz + ).isoformat(), "security_state": self._security_state, "minimal_activation_delay_sec": self._minimal_activation_delay, "device_power": self._device_power, ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power, ATTR_TOTAL_ENERGY: self.total_energy, - "last_update_datetime": datetime.now().isoformat(), + "last_update_datetime": datetime.now() + .replace(tzinfo=self._current_tz) + .isoformat(), + "timezone": str(self._current_tz), } if self._is_over_climate: self._attr_extra_state_attributes[ diff --git a/custom_components/versatile_thermostat/prop_algorithm.py b/custom_components/versatile_thermostat/prop_algorithm.py index 21f8c45..3be623f 100644 --- a/custom_components/versatile_thermostat/prop_algorithm.py +++ b/custom_components/versatile_thermostat/prop_algorithm.py @@ -1,3 +1,4 @@ +""" The TPI calculation module """ import logging _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class PropAlgorithm: tpi_coef_ext, cycle_min: int, minimal_activation_delay: int, - ): + ) -> None: """Initialisation of the Proportional Algorithm""" _LOGGER.debug( "Creation new PropAlgorithm function_type: %s, tpi_coef_int: %s, tpi_coef_ext: %s, cycle_min:%d, minimal_activation_delay:%d", diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index f331930..e95da7a 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -2,11 +2,12 @@ from unittest.mock import patch from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State -from homeassistant.const import UnitOfTemperature +from homeassistant.const import UnitOfTemperature, STATE_ON, STATE_OFF from homeassistant.config_entries import ConfigEntryState -from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.util import dt as dt_util from homeassistant.helpers.entity_component import EntityComponent +from pytest_homeassistant_custom_component.common import MockConfigEntry from ..climate import VersatileThermostat from ..const import * @@ -113,7 +114,7 @@ async def send_temperature_change_event(entity: VersatileThermostat, new_temp, d ) }, ) - await entity._async_temperature_changed(temp_event) + return await entity._async_temperature_changed(temp_event) async def send_power_change_event(entity: VersatileThermostat, new_power, date): @@ -129,11 +130,11 @@ async def send_power_change_event(entity: VersatileThermostat, new_power, date): ) }, ) - await entity._async_power_changed(power_event) + return await entity._async_power_changed(power_event) async def send_max_power_change_event(entity: VersatileThermostat, new_power_max, date): - """Sending a new power event simulating a change on power max sensor""" + """Sending a new power max event simulating a change on power max sensor""" power_event = Event( EVENT_STATE_CHANGED, { @@ -145,4 +146,33 @@ async def send_max_power_change_event(entity: VersatileThermostat, new_power_max ) }, ) - await entity._async_max_power_changed(power_event) + return await entity._async_max_power_changed(power_event) + + +async def send_window_change_event(entity: VersatileThermostat, new_state: bool, date): + """Sending a new window event simulating a change on the window state""" + window_event = Event( + EVENT_STATE_CHANGED, + { + "new_state": State( + entity_id=entity.entity_id, + state=STATE_ON if new_state else STATE_OFF, + last_changed=date, + last_updated=date, + ), + "old_state": State( + entity_id=entity.entity_id, + state=STATE_ON if not new_state else STATE_OFF, + last_changed=date, + last_updated=date, + ), + }, + ) + ret = await entity._async_windows_changed(window_event) + return ret + + +def get_tz(hass): + """Get the current timezone""" + + return dt_util.get_time_zone(hass.config.time_zone) diff --git a/custom_components/versatile_thermostat/tests/test_security.py b/custom_components/versatile_thermostat/tests/test_security.py index e578ed4..9c0fa34 100644 --- a/custom_components/versatile_thermostat/tests/test_security.py +++ b/custom_components/versatile_thermostat/tests/test_security.py @@ -19,6 +19,8 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): 6. check that security is off and preset is changed to boost """ + tz = get_tz(hass) + entry = MockConfigEntry( domain=DOMAIN, title="TheOverSwitchMockName", @@ -102,8 +104,12 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): call.send_event( EventType.TEMPERATURE_EVENT, { - "last_temperature_mesure": event_timestamp.isoformat(), - "last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(), + "last_temperature_mesure": event_timestamp.replace( + tzinfo=tz + ).isoformat(), + "last_ext_temperature_mesure": entity._last_ext_temperature_mesure.replace( + tzinfo=tz + ).isoformat(), "current_temp": 15, "current_ext_temp": None, "target_temp": 18, @@ -113,8 +119,12 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): EventType.SECURITY_EVENT, { "type": "start", - "last_temperature_mesure": event_timestamp.isoformat(), - "last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(), + "last_temperature_mesure": event_timestamp.replace( + tzinfo=tz + ).isoformat(), + "last_ext_temperature_mesure": entity._last_ext_temperature_mesure.replace( + tzinfo=tz + ).isoformat(), "current_temp": 15, "current_ext_temp": None, "target_temp": 18, @@ -166,8 +176,12 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): EventType.SECURITY_EVENT, { "type": "end", - "last_temperature_mesure": event_timestamp.isoformat(), - "last_ext_temperature_mesure": entity._last_ext_temperature_mesure.isoformat(), + "last_temperature_mesure": event_timestamp.replace( + tzinfo=tz + ).isoformat(), + "last_ext_temperature_mesure": entity._last_ext_temperature_mesure.replace( + tzinfo=tz + ).isoformat(), "current_temp": 15.2, "current_ext_temp": None, "target_temp": 19, diff --git a/custom_components/versatile_thermostat/tests/test_window.py b/custom_components/versatile_thermostat/tests/test_window.py new file mode 100644 index 0000000..1f500eb --- /dev/null +++ b/custom_components/versatile_thermostat/tests/test_window.py @@ -0,0 +1,200 @@ +""" Test the Window management """ +from unittest.mock import patch, call +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import +from datetime import datetime +import time + +import logging + +logging.getLogger().setLevel(logging.DEBUG) + + +async def test_window_management_time_not_enough( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Power 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": 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: VersatileThermostat = 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 + + # Open the window, but condition of time is not satisfied and check the thermostat don't turns off + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=False + ) as mock_condition: + await send_temperature_change_event(entity, 15, datetime.now()) + try_window_condition = await send_window_change_event( + entity, True, datetime.now() + ) + # simulate the call to try_window_condition + await try_window_condition(None) + + assert mock_send_event.call_count == 0 + assert mock_heater_on.call_count == 1 + assert mock_heater_off.call_count == 0 + assert mock_condition.call_count == 1 + + assert entity.window_state == STATE_OFF + + # Close the window + try_window_condition = await send_window_change_event( + entity, False, datetime.now() + ) + # simulate the call to try_window_condition + await try_window_condition(None) + assert entity.window_state == STATE_OFF + + +async def test_window_management_time_enough( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Power 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": 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: VersatileThermostat = 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 + + # Open the window, but condition of time is not satisfied and check the thermostat don't turns off + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + return_value=True, + ): + await send_temperature_change_event(entity, 15, datetime.now()) + try_window_condition = await send_window_change_event( + entity, True, datetime.now() + ) + # simulate the call to try_window_condition + await try_window_condition(None) + + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})] + ) + assert mock_heater_on.call_count == 1 + # One call in turn_oiff and one call in the control_heating + assert mock_heater_off.call_count == 2 + assert mock_condition.call_count == 1 + + assert entity.window_state == STATE_ON + + # Close the window + try_window_condition = await send_window_change_event( + entity, False, datetime.now() + ) + # simulate the call to try_window_condition + await try_window_condition(None) + assert entity.window_state == STATE_OFF + assert mock_heater_on.call_count == 2 + assert mock_send_event.call_count == 2 + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), + call.send_event( + EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT} + ), + ], + any_order=False, + ) + assert entity.preset_mode is PRESET_BOOST