diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index edf6f16..7dbd50f 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -3,7 +3,9 @@ default_config: logger: default: info logs: - custom_components.versatile_thermostat: debug + custom_components.versatile_thermostat: info + custom_components.versatile_thermostat.underlyings: debug + custom_components.versatile_thermostat.climate: debug # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) debugpy: diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 7851153..897e2ba 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -11,7 +11,6 @@ from homeassistant.core import ( HomeAssistant, callback, CoreState, - DOMAIN as HA_DOMAIN, Event, State, ) @@ -73,8 +72,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, EVENT_HOMEASSISTANT_START, - ATTR_ENTITY_ID, - SERVICE_TURN_ON, STATE_HOME, STATE_NOT_HOME, ) @@ -143,6 +140,9 @@ from .open_window_algorithm import WindowOpenDetectionAlgorithm _LOGGER = logging.getLogger(__name__) +# TODO remove this +_LOGGER.setLevel(logging.DEBUG) + async def async_setup_entry( hass: HomeAssistant, @@ -259,7 +259,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._thermostat_type = None self._is_over_climate = False # TODO should be delegated to underlying climate - self._heater_entity_id = None + # self._heater_entity_id = None # self._climate_entity_id = None self._underlying_climate = None @@ -311,10 +311,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): else: _LOGGER.debug("value %s not found in Entry", value) - # Stop eventual cycle running - if self._async_cancel_cycle is not None: - self._async_cancel_cycle() - self._async_cancel_cycle = None if self._window_call_cancel is not None: self._window_call_cancel() self._window_call_cancel = None @@ -522,10 +518,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._total_energy = 0 _LOGGER.debug( - "%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s", + "%s - Creation of a new VersatileThermostat entity: unique_id=%s", self, self.unique_id, - self._heater_entity_id, ) async def async_added_to_hass(self): @@ -632,9 +627,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): def async_remove_thermostat(self): """Called when the thermostat will be removed""" _LOGGER.info("%s - Removing thermostat", self) - if self._async_cancel_cycle: - self._async_cancel_cycle() - self._async_cancel_cycle = None + for under in self._underlyings: + under.remove_entity() async def async_startup(self): """Triggered on startup, used to get old state and set internal states accordingly""" @@ -687,22 +681,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self, ) - if self._is_over_climate: - climate_state = self.hass.states.get(self._climate_entity_id) - if climate_state and climate_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._hvac_mode = climate_state.state - need_write_state = True - else: - switch_state = self.hass.states.get(self._heater_entity_id) - if switch_state and switch_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self.hass.create_task(self._check_switch_initial_state()) - if self._pmax_on: # try to acquire current power and power max current_power_state = self.hass.states.get(self._power_sensor_entity_id) @@ -787,6 +765,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._prop_algorithm.calculate( self._target_temp, self._cur_temp, self._cur_ext_temp ) + + self.hass.create_task(self._check_switch_initial_state()) self.hass.create_task(self._async_control_heating()) await self.get_my_previous_state() @@ -958,15 +938,20 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return self._unit @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode | None: """Return current operation.""" - if self._is_over_climate and self._underlying_climate: - return self._underlying_climate.hvac_mode + if self._is_over_climate: + # if one not OFF -> return it + # else OFF + for under in self._underlyings: + if (action := under.hvac_mode) not in [HVACMode.OFF]: + return action + return HVACMode.OFF return self._hvac_mode @property - def hvac_action(self): + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported. Need to be one of CURRENT_HVAC_*. @@ -1545,12 +1530,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _check_switch_initial_state(self): """Prevent the device from keep running if HVAC_MODE_OFF.""" _LOGGER.debug("%s - Calling _check_switch_initial_state", self) - if self._hvac_mode == HVACMode.OFF and self._is_device_active: - _LOGGER.warning( - "The climate mode is OFF, but the switch device is ON. Turning off device %s", - self._heater_entity_id, - ) - await self._async_underlying_entity_turn_off() + if self.is_over_climate: + return + for under in self._underlyings: + await under.check_initial_state(self._hvac_mode) @callback def _async_switch_changed(self, event): @@ -1838,10 +1821,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): async def _async_heater_turn_on(self): """Turn heater toggleable device on.""" - data = {ATTR_ENTITY_ID: self._heater_entity_id} - await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context - ) + # TODO should be delegated + # data = {ATTR_ENTITY_ID: self._heater_entity_id} + # await self.hass.services.async_call( + # HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context + # ) + for under in self._underlyings: + await under.turn_on() async def _async_underlying_entity_turn_off(self): """Turn heater toggleable device off.""" @@ -2240,123 +2226,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return if not self._is_over_climate: - on_time_sec: int = self._prop_algorithm.on_time_sec - off_time_sec: int = self._prop_algorithm.off_time_sec - - _LOGGER.debug( - "%s - Checking new cycle. on_time_sec=%.0f, off_time_sec=%.0f, security_state=%s, preset_mode=%s", - self, - on_time_sec, - off_time_sec, - self._security_state, - self._attr_preset_mode, - ) - - # Cancel eventual previous cycle if any - if self._async_cancel_cycle is not None: - if force: - _LOGGER.debug("%s - we force a new cycle", self) - self._async_cancel_cycle() - self._async_cancel_cycle = None - else: - _LOGGER.debug( - "%s - A previous cycle is alredy running and no force -> waits for its end", - self, - ) - self._should_relaunch_control_heating = True - _LOGGER.debug("%s - End of cycle (2)", self) - return - - if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0: - - async def _turn_on_off_later( - on: bool, # pylint: disable=invalid-name - time, - heater_action, - next_cycle_action, - ): - if self._async_cancel_cycle: - self._async_cancel_cycle() - self._async_cancel_cycle = None - _LOGGER.debug("%s - Stopping cycle during calculation", self) - - if self._hvac_mode == HVACMode.OFF: - _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self) - if self._is_device_active: - await self._async_underlying_entity_turn_off() - return - - if on: - if await self.check_overpowering(): - _LOGGER.debug("%s - End of cycle (3)", self) - return - # Security mode could have change the on_time percent - await self.check_security() - time = self._prop_algorithm.on_time_sec - - action_label = "start" if on else "stop" - if self._should_relaunch_control_heating: - _LOGGER.debug( - "Don't %s cause a cycle have to be relaunch", action_label - ) - self._should_relaunch_control_heating = False - self.hass.create_task(self._async_control_heating()) - # await self._async_control_heating() - _LOGGER.debug("%s - End of cycle (3)", self) - return - - if time > 0: - _LOGGER.info( - "%s - %s heating for %d min %d sec", - self, - action_label, - time // 60, - time % 60, - ) - await heater_action() - else: - _LOGGER.debug( - "%s - No action on heater cause duration is 0", self - ) - self._async_cancel_cycle = async_call_later( - self.hass, - time, - next_cycle_action, - ) - - async def _turn_on_later(_): - await _turn_on_off_later( - on=True, - time=self._prop_algorithm.on_time_sec, - heater_action=self._async_heater_turn_on, - next_cycle_action=_turn_off_later, - ) - self.update_custom_attributes() - - async def _turn_off_later(_): - await _turn_on_off_later( - on=False, - time=self._prop_algorithm.off_time_sec, - heater_action=self._async_underlying_entity_turn_off, - next_cycle_action=_turn_on_later, - ) - # increment energy at the end of the cycle - self.incremente_energy() - self.update_custom_attributes() - - await _turn_on_later(None) - - elif self._is_device_active: - _LOGGER.info( - "%s - stop heating (2) for %d min %d sec", - self, - off_time_sec // 60, - off_time_sec % 60, + for under in self._underlyings: + await under.start_cycle( + self._hvac_mode, + self._prop_algorithm.on_time_sec, + self._prop_algorithm.off_time_sec, + force, ) - await self._async_underlying_entity_turn_off() - - else: - _LOGGER.debug("%s - nothing to do", self) self.update_custom_attributes() diff --git a/custom_components/versatile_thermostat/tests/commons.py b/custom_components/versatile_thermostat/tests/commons.py index c975539..a0c7f55 100644 --- a/custom_components/versatile_thermostat/tests/commons.py +++ b/custom_components/versatile_thermostat/tests/commons.py @@ -1,4 +1,6 @@ """ Some common resources """ +import asyncio +import logging from unittest.mock import patch, MagicMock from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State @@ -74,6 +76,8 @@ FULL_4SWITCH_CONFIG = ( | MOCK_ADVANCED_CONFIG ) +_LOGGER = logging.getLogger(__name__) + class MockClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" @@ -195,8 +199,16 @@ def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity: return None -async def send_temperature_change_event(entity: VersatileThermostat, new_temp, date): +async def send_temperature_change_event( + entity: VersatileThermostat, new_temp, date, sleep=True +): """Sending a new temperature event simulating a change on temperature sensor""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_temp=%.2f date=%s on %s", + new_temp, + date, + entity, + ) temp_event = Event( EVENT_STATE_CHANGED, { @@ -208,13 +220,21 @@ async def send_temperature_change_event(entity: VersatileThermostat, new_temp, d ) }, ) - return await entity._async_temperature_changed(temp_event) + await entity._async_temperature_changed(temp_event) + if sleep: + await asyncio.sleep(0.1) async def send_ext_temperature_change_event( - entity: VersatileThermostat, new_temp, date + entity: VersatileThermostat, new_temp, date, sleep=True ): """Sending a new external temperature event simulating a change on temperature sensor""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_temp=%.2f date=%s on %s", + new_temp, + date, + entity, + ) temp_event = Event( EVENT_STATE_CHANGED, { @@ -226,11 +246,21 @@ async def send_ext_temperature_change_event( ) }, ) - return await entity._async_ext_temperature_changed(temp_event) + await entity._async_ext_temperature_changed(temp_event) + if sleep: + await asyncio.sleep(0.1) -async def send_power_change_event(entity: VersatileThermostat, new_power, date): +async def send_power_change_event( + entity: VersatileThermostat, new_power, date, sleep=True +): """Sending a new power event simulating a change on power sensor""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_power=%.2f date=%s on %s", + new_power, + date, + entity, + ) power_event = Event( EVENT_STATE_CHANGED, { @@ -242,11 +272,21 @@ async def send_power_change_event(entity: VersatileThermostat, new_power, date): ) }, ) - return await entity._async_power_changed(power_event) + await entity._async_power_changed(power_event) + if sleep: + await asyncio.sleep(0.1) -async def send_max_power_change_event(entity: VersatileThermostat, new_power_max, date): +async def send_max_power_change_event( + entity: VersatileThermostat, new_power_max, date, sleep=True +): """Sending a new power max event simulating a change on power max sensor""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_power_max=%.2f date=%s on %s", + new_power_max, + date, + entity, + ) power_event = Event( EVENT_STATE_CHANGED, { @@ -258,13 +298,22 @@ async def send_max_power_change_event(entity: VersatileThermostat, new_power_max ) }, ) - return await entity._async_max_power_changed(power_event) + await entity._async_max_power_changed(power_event) + if sleep: + await asyncio.sleep(0.1) async def send_window_change_event( - entity: VersatileThermostat, new_state: bool, old_state: bool, date + entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new window event simulating a change on the window state""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s", + new_state, + old_state, + date, + entity, + ) window_event = Event( EVENT_STATE_CHANGED, { @@ -283,13 +332,22 @@ async def send_window_change_event( }, ) ret = await entity._async_windows_changed(window_event) + if sleep: + await asyncio.sleep(0.1) return ret async def send_motion_change_event( - entity: VersatileThermostat, new_state: bool, old_state: bool, date + entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new motion event simulating a change on the window state""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s", + new_state, + old_state, + date, + entity, + ) motion_event = Event( EVENT_STATE_CHANGED, { @@ -308,13 +366,22 @@ async def send_motion_change_event( }, ) ret = await entity._async_motion_changed(motion_event) + if sleep: + await asyncio.sleep(0.1) return ret async def send_presence_change_event( - entity: VersatileThermostat, new_state: bool, old_state: bool, date + entity: VersatileThermostat, new_state: bool, old_state: bool, date, sleep=True ): """Sending a new presence event simulating a change on the window state""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_state=%s old_state=%s date=%s on %s", + new_state, + old_state, + date, + entity, + ) presence_event = Event( EVENT_STATE_CHANGED, { @@ -333,6 +400,8 @@ async def send_presence_change_event( }, ) ret = await entity._async_presence_changed(presence_event) + if sleep: + await asyncio.sleep(0.1) return ret @@ -349,8 +418,18 @@ async def send_climate_change_event( new_hvac_action: HVACAction, old_hvac_action: HVACAction, date, + sleep=True, ): """Sending a new climate event simulating a change on the underlying climate state""" + _LOGGER.info( + "------- Testu: sending send_temperature_change_event, new_hvac_mode=%s old_hvac_mode=%s new_hvac_action=%s old_hvac_action=%s date=%s on %s", + new_hvac_mode, + old_hvac_mode, + new_hvac_action, + old_hvac_action, + date, + entity, + ) climate_event = Event( EVENT_STATE_CHANGED, { @@ -371,4 +450,14 @@ async def send_climate_change_event( }, ) ret = await entity._async_climate_changed(climate_event) + if sleep: + await asyncio.sleep(0.1) return ret + + +def cancel_switchs_cycles(entity: VersatileThermostat): + """This method will cancel all running cycle on all underlying switch entity""" + if entity._is_over_climate: + return + for under in entity._underlyings: + under._cancel_cycle() diff --git a/custom_components/versatile_thermostat/tests/test_bugs.py b/custom_components/versatile_thermostat/tests/test_bugs.py index 7990b3e..a952db4 100644 --- a/custom_components/versatile_thermostat/tests/test_bugs.py +++ b/custom_components/versatile_thermostat/tests/test_bugs.py @@ -241,17 +241,19 @@ async def test_bug_66( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "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.climate.VersatileThermostat._is_device_active", + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): await send_temperature_change_event(entity, 15, now) - try_window_condition = await send_window_change_event(entity, True, False, now) + try_window_condition = await send_window_change_event( + entity, True, False, now, False + ) # simulate the call to try_window_condition await try_window_condition(None) @@ -267,13 +269,13 @@ async def test_bug_66( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "homeassistant.helpers.condition.state", return_value=False ) as mock_condition, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=False, ): event_timestamp = now + timedelta(minutes=1) @@ -290,13 +292,13 @@ async def test_bug_66( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "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.climate.VersatileThermostat._is_device_active", + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=False, ): try_window_condition = await send_window_change_event( @@ -313,13 +315,13 @@ async def test_bug_66( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "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.climate.VersatileThermostat._is_device_active", + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=False, ): event_timestamp = now + timedelta(minutes=2) diff --git a/custom_components/versatile_thermostat/tests/test_multiple_switch.py b/custom_components/versatile_thermostat/tests/test_multiple_switch.py new file mode 100644 index 0000000..c013a35 --- /dev/null +++ b/custom_components/versatile_thermostat/tests/test_multiple_switch.py @@ -0,0 +1,337 @@ +""" Test the Multiple switch management """ +import asyncio +from unittest.mock import patch, call, ANY +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import +from datetime import datetime, timedelta + +import logging + +logging.getLogger().setLevel(logging.DEBUG) + + +async def test_one_switch_cycle( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_send_event, +): + """Test that when multiple switch are configured the activation is distributed""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOver4SwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOver4SwitchMockName", + 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: 8, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch1", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theover4switchmockname" + ) + assert entity + assert entity.is_over_climate is False + + # 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_BOOST) + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.window_state is None + + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 15, event_timestamp) + + # Checks that all heaters are off + with patch( + "homeassistant.core.StateMachine.is_state", return_value=False + ) as mock_is_state: + assert entity._is_device_active is False + + # Should be call for the Switch + assert mock_is_state.call_count == 1 + + # Set temperature to a low level + 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=False, + ) as mock_device_active, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.call_later", + return_value=None, + ) as mock_call_later: + await send_ext_temperature_change_event(entity, 5, event_timestamp) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_off.call_count == 0 + + # The first heater should be on but because call_later is mocked heater_on is not called + # assert mock_heater_on.call_count == 1 + assert mock_heater_on.call_count == 0 + # There is no check if active + assert mock_device_active.call_count == 0 + + # 4 calls dispatched along the cycle + assert mock_call_later.call_count == 1 + mock_call_later.assert_has_calls( + [ + call.call_later(hass, 0.0, ANY), + ] + ) + + # Set a temperature at middle level + event_timestamp = now - timedelta(minutes=4) + 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=False, + ) as mock_device_active: + await send_temperature_change_event(entity, 18, event_timestamp) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_off.call_count == 0 + + # The first heater should be turned on but is already on but because above we mock call_later the heater is not on. But this time it will be really on + assert mock_heater_on.call_count == 1 + + # Set another temperature at middle level + event_timestamp = now - timedelta(minutes=3) + 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, + ) as mock_device_active: + await send_temperature_change_event(entity, 18.1, event_timestamp) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_off.call_count == 0 + + # The heater is already on cycle. So we wait that the cycle ends and no heater action is done + assert mock_heater_on.call_count == 0 + assert entity.underlying_entity(0)._should_relaunch_control_heating is True + + # Simulate the relaunch + await entity.underlying_entity(0)._turn_on_later(None) + # wait restart + await asyncio.sleep(0.1) + + assert mock_heater_on.call_count == 1 + assert entity.underlying_entity(0)._should_relaunch_control_heating is False + + # Simulate the end of heater on cycle + event_timestamp = now - timedelta(minutes=3) + 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, + ) as mock_device_active: + await entity.underlying_entity(0)._turn_off_later(None) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_on.call_count == 0 + # The heater should be turned off this time + assert mock_heater_off.call_count == 1 + assert entity.underlying_entity(0)._should_relaunch_control_heating is False + + # Simulate the start of heater on cycle + event_timestamp = now - timedelta(minutes=3) + 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, + ) as mock_device_active: + await entity.underlying_entity(0)._turn_on_later(None) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_on.call_count == 1 + # The heater should be turned off this time + assert mock_heater_off.call_count == 0 + assert entity.underlying_entity(0)._should_relaunch_control_heating is False + + +async def test_multiple_switchs( + hass: HomeAssistant, + skip_hass_states_is_state, + skip_send_event, +): + """Test that when multiple switch are configured the activation is distributed""" + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOver4SwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOver4SwitchMockName", + 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: 8, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch1", + CONF_HEATER_2: "switch.mock_switch2", + CONF_HEATER_3: "switch.mock_switch3", + CONF_HEATER_4: "switch.mock_switch4", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theover4switchmockname" + ) + assert entity + assert entity.is_over_climate is False + + # 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_BOOST) + + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.target_temperature == 19 + assert entity.window_state is None + + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 15, event_timestamp) + + # Checks that all heaters are off + with patch( + "homeassistant.core.StateMachine.is_state", return_value=False + ) as mock_is_state: + assert entity._is_device_active is False + + # Should be call for all Switch + assert mock_is_state.call_count == 4 + + # Set temperature to a low level + 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=False, + ) as mock_device_active, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.call_later", + return_value=None, + ) as mock_call_later: + await send_ext_temperature_change_event(entity, 5, event_timestamp) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_off.call_count == 0 + + # The first heater should be on but because call_later is mocked heater_on is not called + # assert mock_heater_on.call_count == 1 + assert mock_heater_on.call_count == 0 + # There is no check if active + assert mock_device_active.call_count == 0 + + # 4 calls dispatched along the cycle + assert mock_call_later.call_count == 4 + mock_call_later.assert_has_calls( + [ + call.call_later(hass, 0.0, ANY), + call.call_later(hass, 120.0, ANY), + call.call_later(hass, 240.0, ANY), + call.call_later(hass, 360.0, ANY), + ] + ) + + # Set a temperature at middle level + event_timestamp = now - timedelta(minutes=4) + 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=False, + ) as mock_device_active: + await send_temperature_change_event(entity, 18, event_timestamp) + + # No special event + assert mock_send_event.call_count == 0 + assert mock_heater_off.call_count == 0 + + # The first heater should be turned on but is already on but because call_later is mocked, it is only turned on here + assert mock_heater_on.call_count == 1 diff --git a/custom_components/versatile_thermostat/tests/test_power.py b/custom_components/versatile_thermostat/tests/test_power.py index 82e3288..165abfe 100644 --- a/custom_components/versatile_thermostat/tests/test_power.py +++ b/custom_components/versatile_thermostat/tests/test_power.py @@ -81,9 +81,9 @@ async def test_power_management_hvac_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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off: await send_max_power_change_event(entity, 149, datetime.now()) assert await entity.check_overpowering() is True @@ -160,9 +160,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off: await send_max_power_change_event(entity, 149, datetime.now()) assert await entity.check_overpowering() is True @@ -194,9 +194,9 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off: await send_power_change_event(entity, 48, datetime.now()) assert await entity.check_overpowering() is False @@ -278,13 +278,13 @@ async def test_power_management_energy_over_switch( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off: - await send_temperature_change_event(entity, 15, datetime.now()) await entity.async_set_hvac_mode(HVACMode.HEAT) await entity.async_set_preset_mode(PRESET_BOOST) + await send_temperature_change_event(entity, 15, datetime.now()) assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST @@ -307,9 +307,9 @@ async def test_power_management_energy_over_switch( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off: await send_temperature_change_event(entity, 18, datetime.now()) assert tpi_algo.on_percent == 0.3 @@ -329,9 +329,9 @@ async def test_power_management_energy_over_switch( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off: await send_temperature_change_event(entity, 20, datetime.now()) assert tpi_algo.on_percent == 0.0 diff --git a/custom_components/versatile_thermostat/tests/test_security.py b/custom_components/versatile_thermostat/tests/test_security.py index a89fca2..55cc712 100644 --- a/custom_components/versatile_thermostat/tests/test_security.py +++ b/custom_components/versatile_thermostat/tests/test_security.py @@ -87,7 +87,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: event_timestamp = now - timedelta(minutes=6) @@ -134,7 +134,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: await entity.async_set_preset_mode(PRESET_BOOST) @@ -149,7 +149,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on: event_timestamp = datetime.now() @@ -185,4 +185,5 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state): any_order=True, ) - assert mock_heater_on.call_count == 0 + # Heater is now on + assert mock_heater_on.call_count == 1 diff --git a/custom_components/versatile_thermostat/tests/test_sensors.py b/custom_components/versatile_thermostat/tests/test_sensors.py index 7f7bf58..1c7a4c5 100644 --- a/custom_components/versatile_thermostat/tests/test_sensors.py +++ b/custom_components/versatile_thermostat/tests/test_sensors.py @@ -1,4 +1,5 @@ """ Test the normal start of a Thermostat """ +import asyncio from datetime import timedelta, datetime from homeassistant.core import HomeAssistant @@ -179,6 +180,8 @@ async def test_sensors_over_switch( ) assert last_ext_temperature_sensor.device_class == SensorDeviceClass.TIMESTAMP + cancel_switchs_cycles(entity) + async def test_sensors_over_climate( hass: HomeAssistant, diff --git a/custom_components/versatile_thermostat/tests/test_window.py b/custom_components/versatile_thermostat/tests/test_window.py index a3eb43a..e99669a 100644 --- a/custom_components/versatile_thermostat/tests/test_window.py +++ b/custom_components/versatile_thermostat/tests/test_window.py @@ -66,18 +66,16 @@ async def test_window_management_time_not_enough( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.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, False, datetime.now() - ) - # simulate the call to try_window_condition - await try_window_condition(None) + await send_window_change_event(entity, True, False, datetime.now()) + # simulate the call to try_window_condition. No need due to 0 WINDOW_DELAY and sleep after event is sent + # await try_window_condition(None) assert mock_send_event.call_count == 0 assert mock_heater_on.call_count == 1 @@ -152,9 +150,9 @@ async def test_window_management_time_enough( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "homeassistant.helpers.condition.state", return_value=True ) as mock_condition, patch( @@ -162,11 +160,7 @@ async def test_window_management_time_enough( return_value=True, ): await send_temperature_change_event(entity, 15, datetime.now()) - try_window_condition = await send_window_change_event( - entity, True, False, datetime.now() - ) - # simulate the call to try_window_condition - await try_window_condition(None) + await send_window_change_event(entity, True, False, datetime.now()) assert mock_send_event.call_count == 1 mock_send_event.assert_has_calls( @@ -259,9 +253,9 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=True, @@ -281,9 +275,9 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=True, @@ -316,9 +310,9 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", new_callable=PropertyMock, @@ -341,9 +335,9 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", new_callable=PropertyMock, @@ -438,9 +432,9 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=True, @@ -459,18 +453,32 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=True, ): event_timestamp = now - timedelta(minutes=3) - await send_temperature_change_event(entity, 18, event_timestamp) + await send_temperature_change_event(entity, 18, event_timestamp, sleep=False) - # The heater turns on assert mock_send_event.call_count == 2 + # The heater turns off + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), + call.send_event( + EventType.WINDOW_AUTO_EVENT, + { + "type": "start", + "cause": "slope alert", + "curve_slope": -1.0, + }, + ), + ], + any_order=True, + ) assert mock_heater_on.call_count == 0 assert mock_heater_off.call_count >= 1 assert entity.last_temperature_slope == -1 @@ -483,9 +491,9 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=False, @@ -564,9 +572,9 @@ async def test_window_auto_no_on_percent( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=True, @@ -586,9 +594,9 @@ async def test_window_auto_no_on_percent( 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" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" ) as mock_heater_off, patch( "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", return_value=True, diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 584bb55..e9d6165 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -3,8 +3,10 @@ import logging from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.exceptions import ServiceNotFound + from homeassistant.backports.enum import StrEnum -from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN +from homeassistant.core import HomeAssistant, DOMAIN as HA_DOMAIN, CALLBACK_TYPE from homeassistant.components.climate import ( ClimateEntity, DOMAIN as CLIMATE_DOMAIN, @@ -15,14 +17,19 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_SWING_MODE, SERVICE_TURN_OFF, + SERVICE_TURN_ON, SERVICE_SET_TEMPERATURE, ) from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_call_later from .const import UnknownEntity _LOGGER = logging.getLogger(__name__) +# TODO remove this +_LOGGER.setLevel(logging.DEBUG) + class UnderlyingEntityType(StrEnum): """All underlying device type""" @@ -88,23 +95,54 @@ class UnderlyingEntity: async def turn_off(self): """Turn heater toggleable device off.""" - _LOGGER.debug("%s - Stopping underlying switch %s", self, self._entity_id) - data = {ATTR_ENTITY_ID: self._entity_id} - await self._hass.services.async_call( - HA_DOMAIN, - SERVICE_TURN_OFF, - data, # TODO needed ? context=self._context - ) + _LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id) + # This may fails if called after shutdown + try: + data = {ATTR_ENTITY_ID: self._entity_id} + await self._hass.services.async_call( + HA_DOMAIN, + SERVICE_TURN_OFF, + data, + ) + except ServiceNotFound as err: + _LOGGER.error(err) + + async def turn_on(self): + """Turn heater toggleable device on.""" + _LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id) + try: + data = {ATTR_ENTITY_ID: self._entity_id} + await self._hass.services.async_call( + HA_DOMAIN, + SERVICE_TURN_ON, + data, + ) + except ServiceNotFound as err: + _LOGGER.error(err) async def set_temperature(self, temperature, max_temp, min_temp): """Set the target temperature""" return + async def remove_entity(self): + """Remove the underlying entity""" + return + + # override to be able to mock the call + def call_later( + self, hass: HomeAssistant, delay_sec: int, called_method + ) -> CALLBACK_TYPE: + """Call the method after a delay""" + return async_call_later(hass, delay_sec, called_method) + class UnderlyingSwitch(UnderlyingEntity): """Represent a underlying switch""" _initialDelaySec: int + _on_time_sec: int + _off_time_sec: int + _hvac_mode: HVACMode def __init__( self, @@ -122,6 +160,10 @@ class UnderlyingSwitch(UnderlyingEntity): entity_id=switch_entity_id, ) self._initial_delay_sec = initial_delay_sec + self._async_cancel_cycle = None + self._should_relaunch_control_heating = False + self._on_time_sec = 0 + self._off_time_sec = 0 @property def initial_delay_sec(self): @@ -140,6 +182,185 @@ class UnderlyingSwitch(UnderlyingEntity): """If the toggleable device is currently active.""" return self._hass.states.is_state(self._entity_id, STATE_ON) + async def check_initial_state(self, hvac_mode: HVACMode): + """Prevent the heater to be on but thermostat is off""" + if hvac_mode == HVACMode.OFF and self.is_device_active: + _LOGGER.warning( + "%s - The hvac mode is OFF, but the switch device is ON. Turning off device %s", + self, + self._entity_id, + ) + await self.turn_off() + + async def start_cycle( + self, + hvac_mode: HVACMode, + on_time_sec: int, + off_time_sec: int, + force=False, + ): + """Starting cycle for switch""" + _LOGGER.debug( + "%s - Starting new cycle hvac_mode=%s on_time_sec=%d off_time_sec=%d force=%s", + self, + hvac_mode, + on_time_sec, + off_time_sec, + force, + ) + + self._on_time_sec = on_time_sec + self._off_time_sec = off_time_sec + self._hvac_mode = hvac_mode + + # Cancel eventual previous cycle if any + if self._async_cancel_cycle is not None: + if force: + _LOGGER.debug("%s - we force a new cycle", self) + await self._cancel_cycle() + else: + _LOGGER.debug( + "%s - A previous cycle is alredy running and no force -> waits for its end", + self, + ) + self._should_relaunch_control_heating = True + _LOGGER.debug("%s - End of cycle (2)", self) + return + + # If we should heat + if self._hvac_mode == HVACMode.HEAT and on_time_sec > 0: + # Starts the cycle after the initial delay + self._async_cancel_cycle = self.call_later( + self._hass, self._initial_delay_sec, self._turn_on_later + ) + _LOGGER.debug("%s - _async_cancel_cycle=%s", self, self._async_cancel_cycle) + + # if we not heat but device is active + elif self.is_device_active: + _LOGGER.info( + "%s - stop heating (2) for %d min %d sec", + self, + off_time_sec // 60, + off_time_sec % 60, + ) + await self.turn_off() + else: + _LOGGER.debug("%s - nothing to do", self) + + async def _cancel_cycle(self): + """Cancel the cycle""" + if self._async_cancel_cycle: + self._async_cancel_cycle() + self._async_cancel_cycle = None + _LOGGER.debug("%s - Stopping cycle during calculation", self) + + async def _turn_on_later(self, _): + """Turn the heater on after a delay""" + _LOGGER.debug( + "%s - calling turn_on_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d", + self, + self._hvac_mode, + self._should_relaunch_control_heating, + self._on_time_sec, + ) + + await self._cancel_cycle() + + if self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self) + if self.is_device_active: + await self.turn_off() + return + + # TODO if await self.check_overpowering(): + # _LOGGER.debug("%s - End of cycle (3)", self) + # return + # Security mode could have change the on_time percent + # TODO await self.check_security() + time = self._on_time_sec + + action_label = "start" + if self._should_relaunch_control_heating: + _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label) + self._should_relaunch_control_heating = False + # self.hass.create_task(self._async_control_heating()) + await self.start_cycle( + self._hvac_mode, self._on_time_sec, self._off_time_sec + ) + _LOGGER.debug("%s - End of cycle (3)", self) + return + + if time > 0: + _LOGGER.info( + "%s - %s heating for %d min %d sec", + self, + action_label, + time // 60, + time % 60, + ) + await self.turn_on() + else: + _LOGGER.debug("%s - No action on heater cause duration is 0", self) + self._async_cancel_cycle = self.call_later( + self._hass, + time, + self._turn_off_later, + ) + + async def _turn_off_later(self, _): + """Turn the heater off and call the next cycle after the delay""" + _LOGGER.debug( + "%s - calling turn_off_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d", + self, + self._hvac_mode, + self._should_relaunch_control_heating, + self._off_time_sec, + ) + await self._cancel_cycle() + + if self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self) + if self.is_device_active: + await self.turn_off() + return + + action_label = "stop" + if self._should_relaunch_control_heating: + _LOGGER.debug("Don't %s cause a cycle have to be relaunch", action_label) + self._should_relaunch_control_heating = False + # self.hass.create_task(self._async_control_heating()) + await self.start_cycle( + self._hvac_mode, self._on_time_sec, self._off_time_sec + ) + _LOGGER.debug("%s - End of cycle (3)", self) + return + + time = self._off_time_sec + + if time > 0: + _LOGGER.info( + "%s - %s heating for %d min %d sec", + self, + action_label, + time // 60, + time % 60, + ) + await self.turn_off() + else: + _LOGGER.debug("%s - No action on heater cause duration is 0", self) + self._async_cancel_cycle = self.call_later( + self._hass, + time, + self._turn_on_later, + ) + + # increment energy at the end of the cycle + # TODO self.incremente_energy() + + async def remove_entity(self): + """Remove the entity""" + await self._cancel_cycle() + class UnderlyingClimate(UnderlyingEntity): """Represent a underlying climate""" @@ -286,3 +507,10 @@ class UnderlyingClimate(UnderlyingEntity): if not self.is_initialized: return None return self._underlying_climate.hvac_action + + @property + def hvac_mode(self) -> HVACMode: + """Get the hvac mode of the underlying""" + if not self.is_initialized: + return None + return self._underlying_climate.hvac_mode