From 7afa67336be331d26822b94c439543f0163abc8c Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 22 Oct 2023 21:11:45 +0000 Subject: [PATCH] Add ThermostatValve. All tests ok but test_valve --- .../versatile_thermostat/base_thermostat.py | 101 +------- .../versatile_thermostat/climate.py | 1 - .../thermostat_climate.py | 83 +++++++ .../versatile_thermostat/thermostat_switch.py | 52 +++- .../versatile_thermostat/thermostat_valve.py | 230 ++++++++++++++++++ .../versatile_thermostat/underlyings.py | 184 ++++++++++++++ tests/test_valve.py | 36 ++- 7 files changed, 577 insertions(+), 110 deletions(-) diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 38a7919..0511c7e 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -24,7 +24,6 @@ from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType from homeassistant.helpers.event import ( async_track_state_change_event, async_call_later, - async_track_time_interval, ) from homeassistant.exceptions import ConditionError @@ -63,10 +62,6 @@ from homeassistant.const import ( from .const import ( DOMAIN, DEVICE_MANUFACTURER, - CONF_HEATER, - CONF_HEATER_2, - CONF_HEATER_3, - CONF_HEATER_4, CONF_POWER_SENSOR, CONF_TEMP_SENSOR, CONF_EXTERNAL_TEMP_SENSOR, @@ -106,13 +101,6 @@ from .const import ( CONF_TEMP_MAX, CONF_TEMP_MIN, HIDDEN_PRESETS, - CONF_THERMOSTAT_TYPE, - # CONF_THERMOSTAT_SWITCH, - CONF_THERMOSTAT_CLIMATE, - CONF_CLIMATE, - CONF_CLIMATE_2, - CONF_CLIMATE_3, - CONF_CLIMATE_4, CONF_AC_MODE, UnknownEntity, EventType, @@ -121,7 +109,7 @@ from .const import ( PRESET_AC_SUFFIX, ) -from .underlyings import UnderlyingSwitch, UnderlyingClimate, UnderlyingEntity +from .underlyings import UnderlyingEntity from .prop_algorithm import PropAlgorithm from .open_window_algorithm import WindowOpenDetectionAlgorithm @@ -257,43 +245,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) - # Initialize underlying entities + # Initialize underlying entities (will be done in subclasses) self._underlyings = [] - self._thermostat_type = entry_infos.get(CONF_THERMOSTAT_TYPE) - if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: - for climate in [ - CONF_CLIMATE, - CONF_CLIMATE_2, - CONF_CLIMATE_3, - CONF_CLIMATE_4, - ]: - if entry_infos.get(climate): - self._underlyings.append( - UnderlyingClimate( - hass=self._hass, - thermostat=self, - climate_entity_id=entry_infos.get(climate), - ) - ) - else: - lst_switches = [entry_infos.get(CONF_HEATER)] - if entry_infos.get(CONF_HEATER_2): - lst_switches.append(entry_infos.get(CONF_HEATER_2)) - if entry_infos.get(CONF_HEATER_3): - lst_switches.append(entry_infos.get(CONF_HEATER_3)) - if entry_infos.get(CONF_HEATER_4): - lst_switches.append(entry_infos.get(CONF_HEATER_4)) - - delta_cycle = self._cycle_min * 60 / len(lst_switches) - for idx, switch in enumerate(lst_switches): - self._underlyings.append( - UnderlyingSwitch( - hass=self._hass, - thermostat=self, - switch_entity_id=switch, - initial_delay_sec=idx * delta_cycle, - ) - ) self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) @@ -419,7 +372,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._last_temperature_mesure = datetime.now(tz=self._current_tz) self._last_ext_temperature_mesure = datetime.now(tz=self._current_tz) self._security_state = False - self._saved_hvac_mode = None # Initiate the ProportionalAlgorithm if self._prop_algorithm is not None: @@ -472,22 +424,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): await super().async_added_to_hass() - # Add listener to all underlying entities - if self.is_over_climate: - for climate in self._underlyings: - self.async_on_remove( - async_track_state_change_event( - self.hass, [climate.entity_id], self._async_climate_changed - ) - ) - else: - for switch in self._underlyings: - self.async_on_remove( - async_track_state_change_event( - self.hass, [switch.entity_id], self._async_switch_changed - ) - ) - self.async_on_remove( async_track_state_change_event( self.hass, @@ -707,18 +643,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): ) self.hass.create_task(self._check_switch_initial_state()) - # Start the control_heating - # starts a cycle if we are in over_climate type - if self.is_over_climate: - self.async_on_remove( - async_track_time_interval( - self.hass, - self._async_control_heating, - interval=timedelta(minutes=self._cycle_min), - ) - ) - else: - self.hass.create_task(self._async_control_heating()) self.reset_last_change_time() @@ -848,9 +772,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): @property def hvac_modes(self): """List of available operation modes.""" - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).hvac_modes - return self._hvac_list @property @@ -928,26 +849,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported. - Need to be one of CURRENT_HVAC_*. """ - if self.is_over_climate: - # if one not IDLE or OFF -> return it - # else if one IDLE -> IDLE - # else OFF - one_idle = False - for under in self._underlyings: - if (action := under.hvac_action) not in [ - HVACAction.IDLE, - HVACAction.OFF, - ]: - return action - if under.hvac_action == HVACAction.IDLE: - one_idle = True - if one_idle: - return HVACAction.IDLE - return HVACAction.OFF - if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF if not self._is_device_active: diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index f453b8d..d10ae71 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -1,5 +1,4 @@ # pylint: disable=line-too-long -# pylint: disable=too-many-lines # pylint: disable=invalid-name """ Implements the VersatileThermostat climate component """ import logging diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index dbc1c2d..a8d7d75 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -1,8 +1,20 @@ """ A climate over switch classe """ +import logging +from datetime import timedelta from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval + +from homeassistant.components.climate import HVACAction + from .base_thermostat import BaseThermostat +from .const import CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4 + +from .underlyings import UnderlyingClimate + +_LOGGER = logging.getLogger(__name__) + class ThermostatOverClimate(BaseThermostat): """Representation of a base class for a Versatile Thermostat over a climate""" @@ -14,3 +26,74 @@ class ThermostatOverClimate(BaseThermostat): def is_over_climate(self): """ True if the Thermostat is over_climate""" return True + + @property + def hvac_action(self) -> HVACAction | None: + """ Returns the current hvac_action by checking all hvac_action of the underlyings """ + + # if one not IDLE or OFF -> return it + # else if one IDLE -> IDLE + # else OFF + one_idle = False + for under in self._underlyings: + if (action := under.hvac_action) not in [ + HVACAction.IDLE, + HVACAction.OFF, + ]: + return action + if under.hvac_action == HVACAction.IDLE: + one_idle = True + if one_idle: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def hvac_modes(self): + """List of available operation modes.""" + if self.underlying_entity(0): + return self.underlying_entity(0).hvac_modes + else: + return super.hvac_modes + + def post_init(self, entry_infos): + """ Initialize the Thermostat""" + + super().post_init(entry_infos) + for climate in [ + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + ]: + if entry_infos.get(climate): + self._underlyings.append( + UnderlyingClimate( + hass=self._hass, + thermostat=self, + climate_entity_id=entry_infos.get(climate), + ) + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for climate in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [climate.entity_id], self._async_climate_changed + ) + ) + + # Start the control_heating + # starts a cycle + self.async_on_remove( + async_track_time_interval( + self.hass, + self._async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index d47c0d7..14f9ee1 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -1,10 +1,21 @@ """ A climate over switch classe """ - +import logging from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event +from .const import ( + CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4 +) from .base_thermostat import BaseThermostat +from .underlyings import UnderlyingSwitch + +_LOGGER = logging.getLogger(__name__) + class ThermostatOverSwitch(BaseThermostat): """Representation of a base class for a Versatile Thermostat over a switch.""" @@ -16,3 +27,42 @@ class ThermostatOverSwitch(BaseThermostat): def is_over_switch(self): """ True if the Thermostat is over_switch""" return True + + def post_init(self, entry_infos): + """ Initialize the Thermostat""" + + super().post_init(entry_infos) + lst_switches = [entry_infos.get(CONF_HEATER)] + if entry_infos.get(CONF_HEATER_2): + lst_switches.append(entry_infos.get(CONF_HEATER_2)) + if entry_infos.get(CONF_HEATER_3): + lst_switches.append(entry_infos.get(CONF_HEATER_3)) + if entry_infos.get(CONF_HEATER_4): + lst_switches.append(entry_infos.get(CONF_HEATER_4)) + + delta_cycle = self._cycle_min * 60 / len(lst_switches) + for idx, switch in enumerate(lst_switches): + self._underlyings.append( + UnderlyingSwitch( + hass=self._hass, + thermostat=self, + switch_entity_id=switch, + initial_delay_sec=idx * delta_cycle, + ) + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for switch in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [switch.entity_id], self._async_switch_changed + ) + ) + + self.hass.create_task(self._async_control_heating()) diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index fa1f67f..7282918 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -1,8 +1,21 @@ +# pylint: disable=line-too-long """ A climate over switch classe """ +import logging +from datetime import timedelta from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval +from homeassistant.core import callback +from homeassistant.components.climate import HVACMode, HVACAction + from .base_thermostat import BaseThermostat +from .const import CONF_VALVE, CONF_VALVE_2, CONF_VALVE_3, CONF_VALVE_4 + +from .underlyings import UnderlyingValve + +_LOGGER = logging.getLogger(__name__) + class ThermostatOverValve(BaseThermostat): """Representation of a class for a Versatile Thermostat over a Valve""" @@ -14,3 +27,220 @@ class ThermostatOverValve(BaseThermostat): def is_over_valve(self): """ True if the Thermostat is over_valve""" return True + + def post_init(self, entry_infos): + """ Initialize the Thermostat""" + + super().post_init(entry_infos) + lst_valves = [entry_infos.get(CONF_VALVE)] + if entry_infos.get(CONF_VALVE_2): + lst_valves.append(entry_infos.get(CONF_VALVE_2)) + if entry_infos.get(CONF_VALVE_3): + lst_valves.append(entry_infos.get(CONF_VALVE_3)) + if entry_infos.get(CONF_VALVE_4): + lst_valves.append(entry_infos.get(CONF_VALVE_4)) + + for valve in enumerate(lst_valves): + self._underlyings.append( + UnderlyingValve( + hass=self._hass, + thermostat=self, + valve_entity_id=valve + ) + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for valve in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [valve.entity_id], self._async_valve_changed + ) + ) + + # Start the control_heating + # starts a cycle + self.async_on_remove( + async_track_time_interval( + self.hass, + self._async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) + + + @callback + async def _async_valve_changed(self, event): + """Handle unerdlying valve state changes. + This method takes the underlying values and update the VTherm with them. + To avoid loops (issues #121 #101 #95 #99), we discard the event if it is received + less than 10 sec after the last command. What we want here is to take the values + from underlyings ONLY if someone have change directly on the underlying and not + as a return of the command. The only thing we take all the time is the HVACAction + which is important for feedaback and which cannot generates loops. + """ + + async def end_climate_changed(changes): + """To end the event management""" + if changes: + self.async_write_ha_state() + self.update_custom_attributes() + await self._async_control_heating() + + new_state = event.data.get("new_state") + _LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state) + if not new_state: + return + + changes = False + new_hvac_mode = new_state.state + + old_state = event.data.get("old_state") + old_hvac_action = ( + old_state.attributes.get("hvac_action") + if old_state and old_state.attributes + else None + ) + new_hvac_action = ( + new_state.attributes.get("hvac_action") + if new_state and new_state.attributes + else None + ) + + old_state_date_changed = ( + old_state.last_changed if old_state and old_state.last_changed else None + ) + old_state_date_updated = ( + old_state.last_updated if old_state and old_state.last_updated else None + ) + new_state_date_changed = ( + new_state.last_changed if new_state and new_state.last_changed else None + ) + new_state_date_updated = ( + new_state.last_updated if new_state and new_state.last_updated else None + ) + + # Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command + # Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is + # if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE: + # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") + # new_hvac_mode = HVACMode.OFF + + _LOGGER.info( + "%s - Underlying climate changed. Event.new_hvac_mode is %s, current_hvac_mode=%s, new_hvac_action=%s, old_hvac_action=%s", + self, + new_hvac_mode, + self._hvac_mode, + new_hvac_action, + old_hvac_action, + ) + + _LOGGER.debug( + "%s - last_change_time=%s old_state_date_changed=%s old_state_date_updated=%s new_state_date_changed=%s new_state_date_updated=%s", + self, + self._last_change_time, + old_state_date_changed, + old_state_date_updated, + new_state_date_changed, + new_state_date_updated, + ) + + # Interpretation of hvac action + HVAC_ACTION_ON = [ # pylint: disable=invalid-name + HVACAction.COOLING, + HVACAction.DRYING, + HVACAction.FAN, + HVACAction.HEATING, + ] + if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: + self._underlying_climate_start_hvac_action_date = ( + self.get_last_updated_date_or_now(new_state) + ) + _LOGGER.info( + "%s - underlying just switch ON. Set power and energy start date %s", + self, + self._underlying_climate_start_hvac_action_date.isoformat(), + ) + changes = True + + if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON: + stop_power_date = self.get_last_updated_date_or_now(new_state) + if self._underlying_climate_start_hvac_action_date: + delta = ( + stop_power_date - self._underlying_climate_start_hvac_action_date + ) + self._underlying_climate_delta_t = delta.total_seconds() / 3600.0 + + # increment energy at the end of the cycle + self.incremente_energy() + + self._underlying_climate_start_hvac_action_date = None + + _LOGGER.info( + "%s - underlying just switch OFF at %s. delta_h=%.3f h", + self, + stop_power_date.isoformat(), + self._underlying_climate_delta_t, + ) + changes = True + + # Issue #120 - Some TRV are chaning target temperature a very long time (6 sec) after the change. + # In that case a loop is possible if a user change multiple times during this 6 sec. + if new_state_date_updated and self._last_change_time: + delta = (new_state_date_updated - self._last_change_time).total_seconds() + if delta < 10: + _LOGGER.info( + "%s - underlying event is received less than 10 sec after command. Forget it to avoid loop", + self, + ) + await end_climate_changed(changes) + return + + if ( + new_hvac_mode + in [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.DRY, + HVACMode.AUTO, + HVACMode.FAN_ONLY, + None, + ] + and self._hvac_mode != new_hvac_mode + ): + changes = True + self._hvac_mode = new_hvac_mode + # Update all underlyings state + if self.is_over_climate: + for under in self._underlyings: + await under.set_hvac_mode(new_hvac_mode) + + if not changes: + # try to manage new target temperature set if state + _LOGGER.debug( + "Do temperature check. temperature is %s, new_state.attributes is %s", + self.target_temperature, + new_state.attributes, + ) + if ( + self.is_over_climate + and new_state.attributes + and (new_target_temp := new_state.attributes.get("temperature")) + and new_target_temp != self.target_temperature + ): + _LOGGER.info( + "%s - Target temp in underlying have change to %s", + self, + new_target_temp, + ) + await self.async_set_temperature(temperature=new_target_temp) + changes = True + + await end_climate_changed(changes) diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index b8580a2..553d397 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -1,3 +1,5 @@ +# pylint: disable=unused-argument, line-too-long + """ Underlying entities classes """ import logging from typing import Any @@ -42,6 +44,9 @@ class UnderlyingEntityType(StrEnum): # a climate CLIMATE = "climate" + # a valve + VALVE = "valve" + class UnderlyingEntity: """Represent a underlying device which could be a switch or a climate""" @@ -626,3 +631,182 @@ class UnderlyingClimate(UnderlyingEntity): if not self.is_initialized: return None return self._underlying_climate.turn_aux_heat_off() + +class UnderlyingValve(UnderlyingEntity): + """Represent a underlying switch""" + + _hvac_mode: HVACMode + # The percentage of opening the valve + _percent_open: int + + def __init__( + self, + hass: HomeAssistant, + thermostat: Any, + valve_entity_id: str + ) -> None: + """Initialize the underlying switch""" + + super().__init__( + hass=hass, + thermostat=thermostat, + entity_type=UnderlyingEntityType.VALVE, + entity_id=valve_entity_id, + ) + self._async_cancel_cycle = None + self._should_relaunch_control_heating = False + self._hvac_mode = None + self._percent_open = 0 + + async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool: + """Set the HVACmode. Returns true if something have change""" + + if hvac_mode == HVACMode.OFF: + if self.is_device_active: + await self.turn_off() + self._cancel_cycle() + + if self._hvac_mode != hvac_mode: + self._hvac_mode = hvac_mode + return True + else: + return False + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + try: + return float(self._hass.states.get(self._entity_id)) > 0 + except Exception: # pylint: disable=broad-exception-caught + return False + + async def start_cycle( + self, + hvac_mode: HVACMode, + force=False, + ): + """Starting cycle for switch""" + _LOGGER.debug( + "%s - Starting new cycle hvac_mode=%s percent_open=%d force=%s", + self, + hvac_mode, + self._percent_open, + force, + ) + + 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) + 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, starts the cycle with delay + if self._hvac_mode in [HVACMode.HEAT, HVACMode.COOL] and self._percent_open > 0: + # Starts the cycle after the initial delay + self._async_cancel_cycle = self.call_later( + self._hass, 0, 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)", + self, + ) + await self.turn_off() + else: + _LOGGER.debug("%s - nothing to do", self) + + 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", + self, + self._hvac_mode, + self._should_relaunch_control_heating, + ) + + 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 + + if await self._thermostat.check_overpowering(): + _LOGGER.debug("%s - End of cycle (3)", self) + return + # Security mode could have change the on_time percent + await self._thermostat.check_security() + + action_label = "start" + + _LOGGER.info( + "%s - %s heating", + self, + action_label, + ) + await self.turn_on() + + self._async_cancel_cycle = self.call_later( + self._hass, + 0, + 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", + self, + self._hvac_mode, + self._should_relaunch_control_heating, + ) + 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" + + _LOGGER.info( + "%s - %s heating", + self, + action_label + ) + await self.turn_off() + + self._async_cancel_cycle = self.call_later( + self._hass, + 0, + self._turn_on_later + ) + + # increment energy at the end of the cycle + self._thermostat.incremente_energy() + + def remove_entity(self): + """Remove the entity after stopping its cycle""" + self._cancel_cycle() diff --git a/tests/test_valve.py b/tests/test_valve.py index 2bb1310..88300a8 100644 --- a/tests/test_valve.py +++ b/tests/test_valve.py @@ -30,21 +30,39 @@ async def test_over_valve_full_start(hass: HomeAssistant, skip_hass_states_is_st CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_VALVE, CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_VALVE: "number.mock_valve", CONF_CYCLE_MIN: 5, CONF_TEMP_MIN: 15, CONF_TEMP_MAX: 30, - CONF_USE_WINDOW_FEATURE: False, - CONF_USE_MOTION_FEATURE: False, - CONF_USE_POWER_FEATURE: False, - CONF_USE_PRESENCE_FEATURE: False, - CONF_VALVE: "number.mock_valve", + PRESET_ECO + "_temp": 17, + PRESET_COMFORT + "_temp": 19, + PRESET_BOOST + "_temp": 21, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: True, + CONF_USE_POWER_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: True, CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, CONF_TPI_COEF_INT: 0.3, CONF_TPI_COEF_EXT: 0.01, + CONF_MOTION_SENSOR: "input_boolean.motion_sensor", + CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", + CONF_WINDOW_DELAY: 10, + CONF_MOTION_DELAY: 10, + CONF_MOTION_OFF_DELAY: 30, + CONF_MOTION_PRESET: PRESET_COMFORT, + CONF_NO_MOTION_PRESET: PRESET_ECO, + CONF_POWER_SENSOR: "sensor.power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.power_max_sensor", + CONF_PRESENCE_SENSOR: "person.presence_sensor", + PRESET_ECO + PRESET_AWAY_SUFFIX + "_temp": 17.1, + PRESET_COMFORT + PRESET_AWAY_SUFFIX + "_temp": 17.2, + PRESET_BOOST + PRESET_AWAY_SUFFIX + "_temp": 17.3, + CONF_PRESET_POWER: 10, CONF_MINIMAL_ACTIVATION_DELAY: 30, CONF_SECURITY_DELAY_MIN: 5, CONF_SECURITY_MIN_ON_PERCENT: 0.3, - # CONF_DEVICE_POWER: 100, + CONF_DEVICE_POWER: 100, + CONF_AC_MODE: False }, ) @@ -76,10 +94,10 @@ async def test_over_valve_full_start(hass: HomeAssistant, skip_hass_states_is_st assert entity.is_over_switch is False assert entity.is_over_valve is True assert entity.ac_mode is False - assert entity.hvac_action is HVACAction.OFF assert entity.hvac_mode is HVACMode.OFF - assert entity.hvac_modes == [HVACMode.COOL, HVACMode.OFF] - assert entity.target_temperature == entity.max_temp + assert entity.hvac_action is HVACAction.OFF + assert entity.hvac_modes == [HVACMode.HEAT, HVACMode.OFF] + assert entity.target_temperature == entity.min_temp assert entity.preset_modes == [ PRESET_NONE, PRESET_ECO,