From 887d59a08fd85eb18e7980fb080ce5e0c231d49a Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Mon, 23 Dec 2024 19:07:44 +0000 Subject: [PATCH] Refactor power feature --- .devcontainer/pytest.ini | 2 + .../versatile_thermostat/base_thermostat.py | 316 +++------------- .../versatile_thermostat/binary_sensor.py | 2 +- .../feature_power_manager.py | 355 ++++++++++++++++++ ...manager.py => feature_presence_manager.py} | 4 +- .../versatile_thermostat/number.py | 3 - .../versatile_thermostat/sensor.py | 15 +- .../thermostat_climate.py | 5 +- .../versatile_thermostat/thermostat_switch.py | 6 +- .../versatile_thermostat/thermostat_valve.py | 6 +- .../versatile_thermostat/underlyings.py | 2 +- tests/commons.py | 4 +- tests/test_binary_sensors.py | 12 +- tests/test_bugs.py | 14 +- tests/test_central_config.py | 14 +- tests/test_config_flow.py | 3 +- tests/test_multiple_switch.py | 16 +- tests/test_power.py | 261 ++++++++++++- tests/test_presence.py | 10 +- tests/test_tpi.py | 6 +- tests/test_window.py | 20 +- 21 files changed, 729 insertions(+), 347 deletions(-) create mode 100644 .devcontainer/pytest.ini create mode 100644 custom_components/versatile_thermostat/feature_power_manager.py rename custom_components/versatile_thermostat/{presence_manager.py => feature_presence_manager.py} (98%) diff --git a/.devcontainer/pytest.ini b/.devcontainer/pytest.ini new file mode 100644 index 00000000..6a7d1706 --- /dev/null +++ b/.devcontainer/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_default_fixture_loop_scope = function \ No newline at end of file diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 1fd2368f..70e02b70 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -27,7 +27,6 @@ from homeassistant.helpers.event import ( async_track_state_change_event, async_call_later, - EventStateChangedData, ) from homeassistant.exceptions import ConditionError @@ -72,7 +71,9 @@ from .open_window_algorithm import WindowOpenDetectionAlgorithm from .ema import ExponentialMovingAverage -from .presence_manager import FeaturePresenceManager +from .base_manager import BaseFeatureManager +from .feature_presence_manager import FeaturePresenceManager +from .feature_power_manager import FeaturePowerManager _LOGGER = logging.getLogger(__name__) @@ -124,8 +125,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "window_action", "motion_sensor_entity_id", "presence_sensor_entity_id", + "is_presence_configured", "power_sensor_entity_id", "max_power_sensor_entity_id", + "is_power_configured", "temperature_unit", "is_device_active", "device_actives", @@ -169,8 +172,6 @@ def __init__( self._fan_mode = None self._humidity = None self._swing_mode = None - self._current_power = None - self._current_power_max = None self._window_state = None self._motion_state = None self._saved_hvac_mode = None @@ -184,7 +185,6 @@ def __init__( self._last_ext_temperature_measure = None self._last_temperature_measure = None self._cur_ext_temp = None - self._overpowering_state = None self._should_relaunch_control_heating = None self._security_delay_min = None @@ -243,12 +243,22 @@ def __init__( self._hvac_off_reason: HVAC_OFF_REASONS | None = None + # Instanciate all features manager + self._managers: list[BaseFeatureManager] = [] self._presence_manager: FeaturePresenceManager = FeaturePresenceManager( self, hass ) + self._power_manager: FeaturePowerManager = FeaturePowerManager(self, hass) + + self.register_manager(self._presence_manager) + self.register_manager(self._power_manager) self.post_init(entry_infos) + def register_manager(self, manager: BaseFeatureManager): + """Register a manager""" + self._managers.append(manager) + def clean_central_config_doublon( self, config_entry: ConfigData, central_config: ConfigEntry | None ) -> dict[str, Any]: @@ -311,7 +321,9 @@ def post_init(self, config_entry: ConfigData): self._entry_infos = entry_infos - self._presence_manager.post_init(entry_infos) + # Post init all managers + for manager in self._managers: + manager.post_init(entry_infos) self._use_central_config_temperature = entry_infos.get( CONF_USE_PRESETS_CENTRAL_CONFIG @@ -346,8 +358,6 @@ def post_init(self, config_entry: ConfigData): CONF_LAST_SEEN_TEMP_SENSOR ) self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) - self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) - self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR) self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY) @@ -388,7 +398,6 @@ def post_init(self, config_entry: ConfigData): self._tpi_coef_int = entry_infos.get(CONF_TPI_COEF_INT) self._tpi_coef_ext = entry_infos.get(CONF_TPI_COEF_EXT) - self._power_temp = entry_infos.get(CONF_PRESET_POWER) if self._ac_mode: # Added by https://github.com/jmcollin78/versatile_thermostat/pull/144 @@ -412,20 +421,6 @@ def post_init(self, config_entry: ConfigData): self._attr_preset_mode = PRESET_NONE self._saved_preset_mode = PRESET_NONE - # Power management - self._device_power = entry_infos.get(CONF_DEVICE_POWER) or 0 - self._pmax_on = False - self._current_power = None - self._current_power_max = None - if ( - self._max_power_sensor_entity_id - and self._power_sensor_entity_id - and self._device_power - ): - self._pmax_on = True - else: - _LOGGER.info("%s - Power management is not fully configured", self) - # will be restored if possible self._target_temp = None self._saved_target_temp = PRESET_NONE @@ -468,7 +463,6 @@ def post_init(self, config_entry: ConfigData): # Memory synthesis state self._motion_state = None self._window_state = None - self._overpowering_state = None self._total_energy = None _LOGGER.debug("%s - post_init_ resetting energy to None", self) @@ -557,25 +551,9 @@ async def async_added_to_hass(self): ) ) - if self._power_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._power_sensor_entity_id], - self._async_power_changed, - ) - ) - - if self._max_power_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._max_power_sensor_entity_id], - self._async_max_power_changed, - ) - ) - - self._presence_manager.start_listening() + # start listening for all managers + for manager in self._managers: + manager.start_listening() self.async_on_remove(self.remove_thermostat) @@ -594,7 +572,9 @@ def remove_thermostat(self): """Called when the thermostat will be removed""" _LOGGER.info("%s - Removing thermostat", self) - self._presence_manager.stop_listening() + # stop listening for all managers + for manager in self._managers: + manager.stop_listening() for under in self._underlyings: under.remove_entity() @@ -652,37 +632,6 @@ async def async_startup(self, central_configuration): self, ) - if self._pmax_on: - # try to acquire current power and power max - current_power_state = self.hass.states.get(self._power_sensor_entity_id) - if current_power_state and current_power_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power = float(current_power_state.state) - _LOGGER.debug( - "%s - Current power have been retrieved: %.3f", - self, - self._current_power, - ) - need_write_state = True - - # Try to acquire power max - current_power_max_state = self.hass.states.get( - self._max_power_sensor_entity_id - ) - if current_power_max_state and current_power_max_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power_max = float(current_power_max_state.state) - _LOGGER.debug( - "%s - Current power max have been retrieved: %.3f", - self, - self._current_power_max, - ) - need_write_state = True - # try to acquire window entity state if self._window_sensor_entity_id: window_state = self.hass.states.get(self._window_sensor_entity_id) @@ -715,8 +664,10 @@ async def async_startup(self, central_configuration): await self._async_update_motion_temp() need_write_state = True - if await self._presence_manager.refresh_state(): - need_write_state = True + # refresh states for all managers + for manager in self._managers: + if await manager.refresh_state(): + need_write_state = True if need_write_state: self.async_write_ha_state() @@ -1001,14 +952,6 @@ def is_aux_heat(self) -> bool | None: """ return None - @property - def mean_cycle_power(self) -> float | None: - """Returns the mean power consumption during the cycle""" - if not self._device_power: - return None - - return float(self._device_power * self._prop_algorithm.on_percent) - @property def total_energy(self) -> float | None: """Returns the total energy calculated for this thermostast""" @@ -1017,15 +960,20 @@ def total_energy(self) -> float | None: else: return None - @property - def device_power(self) -> float | None: - """Returns the device_power for this thermostast""" - return self._device_power - @property def overpowering_state(self) -> bool | None: """Get the overpowering_state""" - return self._overpowering_state + return self._power_manager.overpowering_state + + @property + def power_manager(self) -> FeaturePowerManager | None: + """Get the power manager""" + return self._power_manager + + @property + def presence_manager(self) -> FeaturePresenceManager | None: + """Get the presence manager""" + return self._presence_manager @property def window_state(self) -> str | None: @@ -1079,10 +1027,7 @@ def last_ext_temperature_measure(self) -> datetime | None: @property def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires ClimateEntityFeature.PRESET_MODE. - """ + """Return the current preset mode comfort, eco, boost,...,""" return self._attr_preset_mode @property @@ -1226,9 +1171,9 @@ def save_state(): # If AC is on maybe we have to change the temperature in force mode, but not in frost mode (there is no Frost protection possible in AC mode) if self._hvac_mode in [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL] and self.preset_mode != PRESET_NONE: if self.preset_mode != PRESET_FROST_PROTECTION: - await self._async_set_preset_mode_internal(self.preset_mode, True) + await self.async_set_preset_mode_internal(self.preset_mode, True) else: - await self._async_set_preset_mode_internal(PRESET_ECO, True, False) + await self.async_set_preset_mode_internal(PRESET_ECO, True, False) if need_control_heating and sub_need_control_heating: await self.async_control_heating(force=True) @@ -1271,12 +1216,12 @@ async def async_set_preset_mode( return - await self._async_set_preset_mode_internal( + await self.async_set_preset_mode_internal( preset_mode, force=False, overwrite_saved_preset=overwrite_saved_preset ) await self.async_control_heating(force=True) - async def _async_set_preset_mode_internal( + async def async_set_preset_mode_internal( self, preset_mode: str, force=False, overwrite_saved_preset=True ): """Set new preset mode.""" @@ -1369,7 +1314,7 @@ def find_preset_temp(self, preset_mode: str): self._target_temp ) # in security just keep the current target temperature, the thermostat should be off if preset_mode == PRESET_POWER: - return self._power_temp + return self._power_manager.power_temperature if preset_mode == PRESET_ACTIVITY: if self._ac_mode and self._hvac_mode == HVACMode.COOL: motion_preset = ( @@ -1826,57 +1771,6 @@ async def _async_update_ext_temp(self, state: State): except ValueError as ex: _LOGGER.error("Unable to update external temperature from sensor: %s", ex) - @callback - async def _async_power_changed(self, event: Event[EventStateChangedData]): - """Handle power changes.""" - _LOGGER.debug("Thermostat %s - Receive new Power event", self.name) - _LOGGER.debug(event) - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if ( - new_state is None - or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or (old_state is not None and new_state.state == old_state.state) - ): - return - - try: - current_power = float(new_state.state) - if math.isnan(current_power) or math.isinf(current_power): - raise ValueError(f"Sensor has illegal state {new_state.state}") - self._current_power = current_power - - if self._attr_preset_mode == PRESET_POWER: - await self.async_control_heating() - - except ValueError as ex: - _LOGGER.error("Unable to update current_power from sensor: %s", ex) - - @callback - async def _async_max_power_changed(self, event: Event[EventStateChangedData]): - """Handle power max changes.""" - _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) - _LOGGER.debug(event) - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if ( - new_state is None - or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or (old_state is not None and new_state.state == old_state.state) - ): - return - - try: - current_power_max = float(new_state.state) - if math.isnan(current_power_max) or math.isinf(current_power_max): - raise ValueError(f"Sensor has illegal state {new_state.state}") - self._current_power_max = current_power_max - if self._attr_preset_mode == PRESET_POWER: - await self.async_control_heating() - - except ValueError as ex: - _LOGGER.error("Unable to update current_power from sensor: %s", ex) - async def _async_update_motion_temp(self): """Update the temperature considering the ACTIVITY preset and current motion state""" _LOGGER.debug( @@ -1912,7 +1806,7 @@ async def _async_update_motion_temp(self): self._target_temp, ) - async def _async_underlying_entity_turn_off(self): + async def async_underlying_entity_turn_off(self): """Turn heater toggleable device off. Used by Window, overpowering, control_heating to turn all off""" for under in self._underlyings: @@ -2041,7 +1935,7 @@ async def restore_preset_mode(self): self._saved_preset_mode not in HIDDEN_PRESETS and self._saved_preset_mode is not None ): - await self._async_set_preset_mode_internal(self._saved_preset_mode) + await self.async_set_preset_mode_internal(self._saved_preset_mode) def save_hvac_mode(self): """Save the current hvac-mode to be restored later""" @@ -2067,99 +1961,6 @@ async def restore_hvac_mode(self, need_control_heating=False): self._hvac_mode, ) - async def check_overpowering(self) -> bool: - """Check the overpowering condition - Turn the preset_mode of the heater to 'power' if power conditions are exceeded - """ - - if not self._pmax_on: - _LOGGER.debug( - "%s - power not configured. check_overpowering not available", self - ) - return False - - if ( - self._current_power is None - or self._device_power is None - or self._current_power_max is None - ): - _LOGGER.warning( - "%s - power not valued. check_overpowering not available", self - ) - return False - - _LOGGER.debug( - "%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f", - self, - self._current_power, - self._current_power_max, - self._device_power, - ) - - # issue 407 - power_consumption_max is power we need to add. If already active we don't need to add more power - if self.is_device_active: - power_consumption_max = 0 - else: - if self.is_over_climate: - power_consumption_max = self._device_power - else: - power_consumption_max = max( - self._device_power / self.nb_underlying_entities, - self._device_power * self._prop_algorithm.on_percent, - ) - - ret = (self._current_power + power_consumption_max) >= self._current_power_max - if not self._overpowering_state and ret and self._hvac_mode != HVACMode.OFF: - _LOGGER.warning( - "%s - overpowering is detected. Heater preset will be set to 'power'", - self, - ) - if self.is_over_climate: - self.save_hvac_mode() - self.save_preset_mode() - await self._async_underlying_entity_turn_off() - await self._async_set_preset_mode_internal(PRESET_POWER) - self.send_event( - EventType.POWER_EVENT, - { - "type": "start", - "current_power": self._current_power, - "device_power": self._device_power, - "current_power_max": self._current_power_max, - "current_power_consumption": power_consumption_max, - }, - ) - - # Check if we need to remove the POWER preset - if ( - self._overpowering_state - and not ret - and self._attr_preset_mode == PRESET_POWER - ): - _LOGGER.warning( - "%s - end of overpowering is detected. Heater preset will be restored to '%s'", - self, - self._saved_preset_mode, - ) - if self.is_over_climate: - await self.restore_hvac_mode(False) - await self.restore_preset_mode() - self.send_event( - EventType.POWER_EVENT, - { - "type": "end", - "current_power": self._current_power, - "device_power": self._device_power, - "current_power_max": self._current_power_max, - }, - ) - - if self._overpowering_state != ret: - self._overpowering_state = ret - self.update_custom_attributes() - - return self._overpowering_state - async def check_central_mode( self, new_central_mode: str | None, old_central_mode: str | None ): @@ -2345,7 +2146,7 @@ async def check_safety(self) -> bool: self.save_preset_mode() if self._prop_algorithm: self._prop_algorithm.set_security(self._security_default_on_percent) - await self._async_set_preset_mode_internal(PRESET_SECURITY) + await self.async_set_preset_mode_internal(PRESET_SECURITY) # Turn off the underlying climate or heater if security default on_percent is 0 if self.is_over_climate or self._security_default_on_percent <= 0.0: await self.async_set_hvac_mode(HVACMode.OFF, False) @@ -2492,7 +2293,7 @@ async def async_control_heating(self, force=False, _=None) -> bool: # check auto_window conditions await self._async_manage_window_auto(in_cycle=True) - # Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it + # In over_climate mode, if the underlying climate is not initialized, try to initialize it if not self.is_initialized: if not self.init_underlyings(): # still not found, we an stop here @@ -2500,8 +2301,8 @@ async def async_control_heating(self, force=False, _=None) -> bool: # Check overpowering condition # Not necessary for switch because each switch is checking at startup - overpowering: bool = await self.check_overpowering() - if overpowering: + overpowering = await self._power_manager.check_overpowering() + if overpowering == STATE_ON: _LOGGER.debug("%s - End of cycle (overpowering)", self) return True @@ -2515,7 +2316,7 @@ async def async_control_heating(self, force=False, _=None) -> bool: _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF)", self) # A security to force stop heater if still active if self.is_device_active: - await self._async_underlying_entity_turn_off() + await self.async_underlying_entity_turn_off() return True for under in self._underlyings: @@ -2570,20 +2371,14 @@ def update_custom_attributes(self): "comfort_away_temp": self._presets_away.get( self.get_preset_away_name(PRESET_COMFORT), 0 ), - "power_temp": self._power_temp, "target_temperature_step": self.target_temperature_step, "ext_current_temperature": self._cur_ext_temp, "ac_mode": self._ac_mode, - "current_power": self._current_power, - "current_power_max": self._current_power_max, "saved_preset_mode": self._saved_preset_mode, "saved_target_temp": self._saved_target_temp, "saved_hvac_mode": self._saved_hvac_mode, "motion_sensor_entity_id": self._motion_sensor_entity_id, "motion_state": self._motion_state, - "power_sensor_entity_id": self._power_sensor_entity_id, - "max_power_sensor_entity_id": self._max_power_sensor_entity_id, - "overpowering_state": self.overpowering_state, "window_state": self.window_state, "window_auto_state": self.window_auto_state, "window_bypass_state": self._window_bypass_state, @@ -2605,8 +2400,7 @@ def update_custom_attributes(self): ).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_MEAN_POWER_CYCLE: self._power_manager.mean_cycle_power, ATTR_TOTAL_ENERGY: self.total_energy, "last_update_datetime": self.now.isoformat(), "timezone": str(self._current_tz), @@ -2697,7 +2491,7 @@ async def service_set_preset_temperature( # If the changed preset is active, change the current temperature # Issue #119 - reload new preset temperature also in ac mode if preset.startswith(self._attr_preset_mode): - await self._async_set_preset_mode_internal( + await self.async_set_preset_mode_internal( preset.rstrip(PRESET_AC_SUFFIX), force=True ) await self.async_control_heating(force=True) @@ -2844,7 +2638,7 @@ def calculate_presets(items, use_central_conf_key): # Re-applicate the last preset if any to take change into account if self._attr_preset_mode: - await self._async_set_preset_mode_internal(self._attr_preset_mode, True) + await self.async_set_preset_mode_internal(self._attr_preset_mode, True) async def async_turn_off(self) -> None: await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 2c6c04f3..ded49dc5 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -148,7 +148,7 @@ async def async_my_climate_changed(self, event: Event = None): # _LOGGER.debug("%s - climate state change", self._attr_unique_id) old_state = self._attr_is_on - self._attr_is_on = self.my_climate.overpowering_state is True + self._attr_is_on = self.my_climate.overpowering_state is STATE_ON if old_state != self._attr_is_on: self.async_write_ha_state() return diff --git a/custom_components/versatile_thermostat/feature_power_manager.py b/custom_components/versatile_thermostat/feature_power_manager.py new file mode 100644 index 00000000..243a438f --- /dev/null +++ b/custom_components/versatile_thermostat/feature_power_manager.py @@ -0,0 +1,355 @@ +""" Implements the Power Feature Manager """ + +# pylint: disable=line-too-long + +import logging +from typing import Any + +from homeassistant.const import ( + STATE_ON, + STATE_OFF, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) + + +from homeassistant.core import ( + HomeAssistant, + callback, + Event, +) +from homeassistant.helpers.event import ( + async_track_state_change_event, + EventStateChangedData, +) +from homeassistant.components.climate import HVACMode + +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import +from .commons import ConfigData + +from .base_manager import BaseFeatureManager + +_LOGGER = logging.getLogger(__name__) + + +class FeaturePowerManager(BaseFeatureManager): + """The implementation of the Power feature""" + + def __init__(self, vtherm: Any, hass: HomeAssistant): + """Init of a featureManager""" + super().__init__(vtherm, hass) + self._power_sensor_entity_id = None + self._max_power_sensor_entity_id = None + self._current_power = None + self._current_max_power = None + self._power_temp = None + self._overpowering_state = STATE_UNAVAILABLE + self._is_configured: bool = False + self._device_power: float = 0 + + @overrides + def post_init(self, entry_infos: ConfigData): + """Reinit of the manager""" + + # Power management + self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) + self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) + self._power_temp = entry_infos.get(CONF_PRESET_POWER) + self._overpowering_state = STATE_UNKNOWN + + self._device_power = entry_infos.get(CONF_DEVICE_POWER) or 0 + self._is_configured = False + self._current_power = None + self._current_max_power = None + if ( + entry_infos.get(CONF_USE_POWER_FEATURE, False) + and self._max_power_sensor_entity_id + and self._power_sensor_entity_id + and self._device_power + ): + self._is_configured = True + else: + _LOGGER.info("%s - Power management is not fully configured", self) + + @overrides + def start_listening(self): + """Start listening the underlying entity""" + if self._is_configured: + self.stop_listening() + else: + return + + self.add_listener( + async_track_state_change_event( + self.hass, + [self._power_sensor_entity_id], + self._async_power_sensor_changed, + ) + ) + + self.add_listener( + async_track_state_change_event( + self.hass, + [self._max_power_sensor_entity_id], + self._async_max_power_sensor_changed, + ) + ) + + @overrides + async def refresh_state(self) -> bool: + """Tries to get the last state from sensor + Returns True if a change has been made""" + ret = False + if self._is_configured: + # try to acquire current power and power max + current_power_state = self.hass.states.get(self._power_sensor_entity_id) + if current_power_state and current_power_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power = float(current_power_state.state) + _LOGGER.debug( + "%s - Current power have been retrieved: %.3f", + self, + self._current_power, + ) + ret = True + + # Try to acquire power max + current_power_max_state = self.hass.states.get( + self._max_power_sensor_entity_id + ) + if current_power_max_state and current_power_max_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_max_power = float(current_power_max_state.state) + _LOGGER.debug( + "%s - Current power max have been retrieved: %.3f", + self, + self._current_max_power, + ) + ret = True + + return ret + + @callback + async def _async_power_sensor_changed(self, event: Event[EventStateChangedData]): + """Handle power changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power event", self) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if ( + new_state is None + or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (old_state is not None and new_state.state == old_state.state) + ): + return + + try: + current_power = float(new_state.state) + if math.isnan(current_power) or math.isinf(current_power): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power = current_power + + if self._vtherm.preset_mode == PRESET_POWER: + await self._vtherm.async_control_heating() + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + @callback + async def _async_max_power_sensor_changed( + self, event: Event[EventStateChangedData] + ): + """Handle power max changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if ( + new_state is None + or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (old_state is not None and new_state.state == old_state.state) + ): + return + + try: + current_power_max = float(new_state.state) + if math.isnan(current_power_max) or math.isinf(current_power_max): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_max_power = current_power_max + if self._vtherm.preset_mode == PRESET_POWER: + await self._vtherm.async_control_heating() + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + def add_custom_attributes(self, extra_state_attributes: dict[str, Any]): + """Add some custom attributes""" + extra_state_attributes.update( + { + "power_sensor_entity_id": self._power_sensor_entity_id, + "max_power_sensor_entity_id": self._max_power_sensor_entity_id, + "overpowering_state": self._overpowering_state, + "is_power_configured": self._is_configured, + "device_power": self._device_power, + "power_temp": self._power_temp, + "current_power": self._current_power, + "current_power_max": self._current_max_power, + } + ) + + async def check_overpowering(self) -> bool: + """Check the overpowering condition + Turn the preset_mode of the heater to 'power' if power conditions are exceeded + Returns True if overpowering is 'on' + """ + + if not self._is_configured: + return False + + if ( + self._current_power is None + or self._device_power is None + or self._current_max_power is None + ): + _LOGGER.warning( + "%s - power not valued. check_overpowering not available", self + ) + return False + + _LOGGER.debug( + "%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f", + self, + self._current_power, + self._current_max_power, + self._device_power, + ) + + # issue 407 - power_consumption_max is power we need to add. If already active we don't need to add more power + if self._vtherm.is_device_active: + power_consumption_max = 0 + else: + if self._vtherm.is_over_climate: + power_consumption_max = self._device_power + else: + power_consumption_max = max( + self._device_power / self._vtherm.nb_underlying_entities, + self._device_power * self._vtherm.proportional_algorithm.on_percent, + ) + + ret = (self._current_power + power_consumption_max) >= self._current_max_power + if ( + self._overpowering_state == STATE_OFF + and ret + and self._vtherm.hvac_mode != HVACMode.OFF + ): + _LOGGER.warning( + "%s - overpowering is detected. Heater preset will be set to 'power'", + self, + ) + if self._vtherm.is_over_climate: + self._vtherm.save_hvac_mode() + self._vtherm.save_preset_mode() + await self._vtherm.async_underlying_entity_turn_off() + await self._vtherm.async_set_preset_mode_internal(PRESET_POWER) + self._vtherm.send_event( + EventType.POWER_EVENT, + { + "type": "start", + "current_power": self._current_power, + "device_power": self._device_power, + "current_power_max": self._current_max_power, + "current_power_consumption": power_consumption_max, + }, + ) + + # Check if we need to remove the POWER preset + if ( + self._overpowering_state == STATE_ON + and not ret + and self._vtherm.preset_mode == PRESET_POWER + ): + _LOGGER.warning( + "%s - end of overpowering is detected. Heater preset will be restored to '%s'", + self, + self._vtherm._saved_preset_mode, # pylint: disable=protected-access + ) + if self._vtherm.is_over_climate: + await self._vtherm.restore_hvac_mode(False) + await self._vtherm.restore_preset_mode() + self._vtherm.send_event( + EventType.POWER_EVENT, + { + "type": "end", + "current_power": self._current_power, + "device_power": self._device_power, + "current_power_max": self._current_max_power, + }, + ) + + new_overpowering_state = STATE_ON if ret else STATE_OFF + if self._overpowering_state != new_overpowering_state: + self._overpowering_state = new_overpowering_state + self._vtherm.update_custom_attributes() + + return self._overpowering_state == STATE_ON + + @overrides + @property + def is_configured(self) -> bool: + """Return True of the presence is configured""" + return self._is_configured + + @property + def overpowering_state(self) -> str | None: + """Return the current overpowering state STATE_ON or STATE_OFF + or STATE_UNAVAILABLE if not configured""" + if not self._is_configured: + return STATE_UNAVAILABLE + return self._overpowering_state + + @property + def max_power_sensor_entity_id(self) -> bool: + """Return the power max entity id""" + return self._max_power_sensor_entity_id + + @property + def power_sensor_entity_id(self) -> bool: + """Return the power entity id""" + return self._power_sensor_entity_id + + @property + def power_temperature(self) -> bool: + """Return the power temperature""" + return self._power_temp + + @property + def device_power(self) -> bool: + """Return the device power""" + return self._device_power + + @property + def current_power(self) -> bool: + """Return the current power from sensor""" + return self._current_power + + @property + def current_max_power(self) -> bool: + """Return the current power from sensor""" + return self._current_max_power + + @property + def mean_cycle_power(self) -> float | None: + """Returns the mean power consumption during the cycle""" + if not self._device_power or not self._vtherm.proportional_algorithm: + return None + + return float( + self._device_power * self._vtherm.proportional_algorithm.on_percent + ) + + def __str__(self): + return f"PresenceManager-{self.name}" diff --git a/custom_components/versatile_thermostat/presence_manager.py b/custom_components/versatile_thermostat/feature_presence_manager.py similarity index 98% rename from custom_components/versatile_thermostat/presence_manager.py rename to custom_components/versatile_thermostat/feature_presence_manager.py index 479622a7..89f43d3b 100644 --- a/custom_components/versatile_thermostat/presence_manager.py +++ b/custom_components/versatile_thermostat/feature_presence_manager.py @@ -39,7 +39,7 @@ class FeaturePresenceManager(BaseFeatureManager): - """A base class for all feature""" + """The implementation of the Presence feature""" def __init__(self, vtherm: Any, hass: HomeAssistant): """Init of a featureManager""" @@ -161,7 +161,7 @@ def add_custom_attributes(self, extra_state_attributes: dict[str, Any]): { "presence_sensor_entity_id": self._presence_sensor_entity_id, "presence_state": self._presence_state, - "presence_configured": self._is_configured, + "is_presence_configured": self._is_configured, } ) diff --git a/custom_components/versatile_thermostat/number.py b/custom_components/versatile_thermostat/number.py index e155faef..d1be29d3 100644 --- a/custom_components/versatile_thermostat/number.py +++ b/custom_components/versatile_thermostat/number.py @@ -367,9 +367,6 @@ def __str__(self): @property def native_unit_of_measurement(self) -> str | None: """The unit of measurement""" - # TODO Kelvin ? It seems not because all internal values are stored in - # ° Celsius but only the render in front can be in °K depending on the - # user configuration. return self.hass.config.units.temperature_unit diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index 8d3b656f..502d6e83 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -165,7 +165,7 @@ def native_unit_of_measurement(self) -> str | None: if not self.my_climate: return None - if self.my_climate.device_power > THRESHOLD_WATT_KILO: + if self.my_climate.power_manager.device_power > THRESHOLD_WATT_KILO: return UnitOfEnergy.WATT_HOUR else: return UnitOfEnergy.KILO_WATT_HOUR @@ -190,16 +190,17 @@ async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" # _LOGGER.debug("%s - climate state change", self._attr_unique_id) - if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf( - self.my_climate.mean_cycle_power - ): + if math.isnan( + float(self.my_climate.power_manager.mean_cycle_power) + ) or math.isinf(self.my_climate.power_manager.mean_cycle_power): raise ValueError( - f"Sensor has illegal state {self.my_climate.mean_cycle_power}" + f"Sensor has illegal state {self.my_climate.power_manager.mean_cycle_power}" ) old_state = self._attr_native_value self._attr_native_value = round( - self.my_climate.mean_cycle_power, self.suggested_display_precision + self.my_climate.power_manager.mean_cycle_power, + self.suggested_display_precision, ) if old_state != self._attr_native_value: self.async_write_ha_state() @@ -222,7 +223,7 @@ def native_unit_of_measurement(self) -> str | None: if not self.my_climate: return None - if self.my_climate.device_power > THRESHOLD_WATT_KILO: + if self.my_climate.power_manager.device_power > THRESHOLD_WATT_KILO: return UnitOfPower.WATT else: return UnitOfPower.KILO_WATT diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 8f1e0cbf..373c4eae 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -602,13 +602,14 @@ def incremente_energy(self): if self.hvac_mode == HVACMode.OFF: return + device_power = self.power_manager.device_power added_energy = 0 if ( self.is_over_climate and self._underlying_climate_delta_t is not None - and self._device_power + and device_power ): - added_energy = self._device_power * self._underlying_climate_delta_t + added_energy = device_power * self._underlying_climate_delta_t if self._total_energy is None: self._total_energy = added_energy diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index 3fcb4732..6fbfda67 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -182,8 +182,10 @@ def incremente_energy(self): return added_energy = 0 - if not self.is_over_climate and self.mean_cycle_power is not None: - added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 + if not self.is_over_climate and self.power_manager.mean_cycle_power is not None: + added_energy = ( + self.power_manager.mean_cycle_power * float(self._cycle_min) / 60.0 + ) if self._total_energy is None: self._total_energy = added_energy diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index ec052544..207a2dac 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -265,8 +265,10 @@ def incremente_energy(self): return added_energy = 0 - if not self.is_over_climate and self.mean_cycle_power is not None: - added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 + if not self.is_over_climate and self.power_manager.mean_cycle_power is not None: + added_energy = ( + self.power_manager.mean_cycle_power * float(self._cycle_min) / 60.0 + ) if self._total_energy is None: self._total_energy = added_energy diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 90cac59f..84b9641d 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -409,7 +409,7 @@ async def _turn_on_later(self, _): await self.turn_off() return - if await self._thermostat.check_overpowering(): + if await self._thermostat.power_manager.check_overpowering(): _LOGGER.debug("%s - End of cycle (3)", self) return # safety mode could have change the on_time percent diff --git a/tests/commons.py b/tests/commons.py index 03d26ca3..b6ad9ad7 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -746,7 +746,7 @@ async def send_power_change_event(entity: BaseThermostat, new_power, date, sleep ) }, ) - await entity._async_power_changed(power_event) + await entity.power_manager._async_power_sensor_changed(power_event) if sleep: await asyncio.sleep(0.1) @@ -772,7 +772,7 @@ async def send_max_power_change_event( ) }, ) - await entity._async_max_power_changed(power_event) + await entity.power_manager._async_max_power_sensor_changed(power_event) if sleep: await asyncio.sleep(0.1) diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index c5a17a76..ee14fcb4 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -159,8 +159,8 @@ async def test_overpowering_binary_sensors( await entity.async_set_preset_mode(PRESET_COMFORT) await entity.async_set_hvac_mode(HVACMode.HEAT) await send_temperature_change_event(entity, 15, now) - assert await entity.check_overpowering() is False - assert entity.overpowering_state is None + assert await entity.power_manager.check_overpowering() is False + assert entity.power_manager.overpowering_state is STATE_UNKNOWN await overpowering_binary_sensor.async_my_climate_changed() assert overpowering_binary_sensor.state is STATE_OFF @@ -168,8 +168,8 @@ async def test_overpowering_binary_sensors( await send_power_change_event(entity, 100, now) await send_max_power_change_event(entity, 150, now) - assert await entity.check_overpowering() is True - assert entity.overpowering_state is True + assert await entity.power_manager.check_overpowering() is True + assert entity.power_manager.overpowering_state is STATE_ON # Simulate the event reception await overpowering_binary_sensor.async_my_climate_changed() @@ -177,8 +177,8 @@ async def test_overpowering_binary_sensors( # set max power to a low value await send_max_power_change_event(entity, 201, now) - assert await entity.check_overpowering() is False - assert entity.overpowering_state is False + assert await entity.power_manager.check_overpowering() is False + assert entity.power_manager.overpowering_state is STATE_OFF # Simulate the event reception await overpowering_binary_sensor.async_my_climate_changed() assert overpowering_binary_sensor.state == STATE_OFF diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 8389a81b..44df6019 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -334,7 +334,7 @@ async def test_bug_407(hass: HomeAssistant, skip_hass_states_is_state): await entity.async_set_preset_mode(PRESET_COMFORT) assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_COMFORT - assert entity.overpowering_state is None + assert entity.power_manager.overpowering_state is STATE_UNKNOWN assert entity.target_temperature == 18 # waits that the heater starts await asyncio.sleep(0.1) @@ -346,10 +346,10 @@ async def test_bug_407(hass: HomeAssistant, skip_hass_states_is_state): # Send power mesurement (theheater is already in the power measurement) await send_power_change_event(entity, 100, datetime.now()) # No overpowering yet - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False # All configuration is complete and power is < power_max assert entity.preset_mode is PRESET_COMFORT - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF assert entity.is_device_active is True # 2. An already active heater that switch preset will not switch to overpowering @@ -365,10 +365,10 @@ async def test_bug_407(hass: HomeAssistant, skip_hass_states_is_state): # waits that the heater starts await asyncio.sleep(0.1) - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF assert entity.target_temperature == 19 assert mock_service_call.call_count >= 1 @@ -385,10 +385,10 @@ async def test_bug_407(hass: HomeAssistant, skip_hass_states_is_state): # waits that the heater starts await asyncio.sleep(0.1) - assert await entity.check_overpowering() is True + assert await entity.power_manager.check_overpowering() is True assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_POWER - assert entity.overpowering_state is True + assert entity.power_manager.overpowering_state is STATE_ON @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/test_central_config.py b/tests/test_central_config.py index ef434f1a..2af71947 100644 --- a/tests/test_central_config.py +++ b/tests/test_central_config.py @@ -292,8 +292,11 @@ async def test_full_over_switch_wo_central_config( assert entity._motion_preset == "comfort" assert entity._no_motion_preset == "eco" - assert entity._power_sensor_entity_id == "sensor.mock_power_sensor" - assert entity._max_power_sensor_entity_id == "sensor.mock_max_power_sensor" + assert entity.power_manager.power_sensor_entity_id == "sensor.mock_power_sensor" + assert ( + entity.power_manager.max_power_sensor_entity_id + == "sensor.mock_max_power_sensor" + ) assert ( entity._presence_manager.presence_sensor_entity_id @@ -409,8 +412,11 @@ async def test_full_over_switch_with_central_config( assert entity._motion_preset == "boost" assert entity._no_motion_preset == "frost" - assert entity._power_sensor_entity_id == "sensor.mock_power_sensor" - assert entity._max_power_sensor_entity_id == "sensor.mock_max_power_sensor" + assert entity.power_manager.power_sensor_entity_id == "sensor.mock_power_sensor" + assert ( + entity.power_manager.max_power_sensor_entity_id + == "sensor.mock_max_power_sensor" + ) assert ( entity._presence_manager.presence_sensor_entity_id diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3b87b639..14c8d99d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1388,8 +1388,7 @@ async def test_user_config_flow_over_switch_bug_552_tpi( # @pytest.mark.parametrize("expected_lingering_tasks", [True]) -# @pytest.mark.parametrize("expected_lingering_timers", [True]) -# @pytest.mark.skip +@pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_user_config_flow_over_climate_valve( hass: HomeAssistant, skip_hass_states_get ): # pylint: disable=unused-argument diff --git a/tests/test_multiple_switch.py b/tests/test_multiple_switch.py index 1d781318..169de688 100644 --- a/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -776,17 +776,17 @@ async def test_multiple_switch_power_management( 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.power_manager.overpowering_state is STATE_UNKNOWN assert entity.target_temperature == 19 # 1. Send power mesurement await send_power_change_event(entity, 50, datetime.now()) # Send power max mesurement await send_max_power_change_event(entity, 300, datetime.now()) - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False # All configuration is complete and power is < power_max assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF # 2. Send power max mesurement too low and HVACMode is on with patch( @@ -798,10 +798,10 @@ async def test_multiple_switch_power_management( ) as mock_heater_off: # 100 of the device / 4 -> 25, current power 50 so max is 75 await send_max_power_change_event(entity, 74, datetime.now()) - assert await entity.check_overpowering() is True + assert await entity.power_manager.check_overpowering() is True # All configuration is complete and power is > power_max we switch to POWER preset assert entity.preset_mode is PRESET_POWER - assert entity.overpowering_state is True + assert entity.power_manager.overpowering_state is STATE_ON assert entity.target_temperature == 12 assert mock_send_event.call_count == 2 @@ -831,7 +831,7 @@ async def test_multiple_switch_power_management( await entity.async_set_preset_mode(PRESET_ECO) assert entity.preset_mode is PRESET_ECO # No change - assert entity.overpowering_state is True + assert entity.power_manager.overpowering_state is STATE_ON # 4. Send hugh power max mesurement to release overpowering with patch( @@ -843,10 +843,10 @@ async def test_multiple_switch_power_management( ) as mock_heater_off: # 100 of the device / 4 -> 25, current power 50 so max is 75. With 150 no overheating await send_max_power_change_event(entity, 150, datetime.now()) - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False # All configuration is complete and power is > power_max we switch to POWER preset assert entity.preset_mode is PRESET_ECO - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF assert entity.target_temperature == 17 assert ( diff --git a/tests/test_power.py b/tests/test_power.py index 4fdad1cb..2e212561 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -1,17 +1,240 @@ # pylint: disable=protected-access, unused-argument, line-too-long """ Test the Power management """ -from unittest.mock import patch, call +from unittest.mock import patch, call, AsyncMock, MagicMock, PropertyMock from datetime import datetime, timedelta import logging from custom_components.versatile_thermostat.thermostat_switch import ( ThermostatOverSwitch, ) +from custom_components.versatile_thermostat.feature_power_manager import ( + FeaturePowerManager, +) +from custom_components.versatile_thermostat.prop_algorithm import PropAlgorithm from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import logging.getLogger().setLevel(logging.DEBUG) +@pytest.mark.parametrize( + "is_over_climate, is_device_active, power, max_power, current_overpowering_state, overpowering_state, nb_call, changed, check_overpowering_ret", + [ + # don't switch to overpower (power is enough) + (False, False, 1000, 3000, STATE_OFF, STATE_OFF, 0, True, False), + # switch to overpower (power is not enough) + (False, False, 2000, 3000, STATE_OFF, STATE_ON, 1, True, True), + # don't switch to overpower (power is not enough but device is already on) + (False, True, 2000, 3000, STATE_OFF, STATE_OFF, 0, True, False), + # Same with a over_climate + # don't switch to overpower (power is enough) + (True, False, 1000, 3000, STATE_OFF, STATE_OFF, 0, True, False), + # switch to overpower (power is not enough) + (True, False, 2000, 3000, STATE_OFF, STATE_ON, 1, True, True), + # don't switch to overpower (power is not enough but device is already on) + (True, True, 2000, 3000, STATE_OFF, STATE_OFF, 0, True, False), + # Leave overpowering state + # switch to not overpower (power is enough) + (False, False, 1000, 3000, STATE_ON, STATE_OFF, 1, True, False), + # don't switch to overpower (power is still not enough) + (False, False, 2000, 3000, STATE_ON, STATE_ON, 0, True, True), + # keep overpower (power is not enough but device is already on) + (False, True, 3000, 3000, STATE_ON, STATE_ON, 0, True, True), + ], +) +async def test_power_feature_manager( + hass: HomeAssistant, + is_over_climate, + is_device_active, + power, + max_power, + current_overpowering_state, + overpowering_state, + nb_call, + changed, + check_overpowering_ret, +): + """Test the FeaturePresenceManager class direclty""" + + fake_vtherm = MagicMock(spec=BaseThermostat) + type(fake_vtherm).name = PropertyMock(return_value="the name") + + # 1. creation + power_manager = FeaturePowerManager(fake_vtherm, hass) + + assert power_manager is not None + assert power_manager.is_configured is False + assert power_manager.overpowering_state == STATE_UNAVAILABLE + assert power_manager.name == "the name" + + assert len(power_manager._active_listener) == 0 + + custom_attributes = {} + power_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["power_sensor_entity_id"] is None + assert custom_attributes["max_power_sensor_entity_id"] is None + assert custom_attributes["overpowering_state"] == STATE_UNAVAILABLE + assert custom_attributes["is_power_configured"] is False + assert custom_attributes["device_power"] is 0 + assert custom_attributes["power_temp"] is None + assert custom_attributes["current_power"] is None + assert custom_attributes["current_power_max"] is None + + # 2. post_init + power_manager.post_init( + { + CONF_POWER_SENSOR: "sensor.the_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.the_max_power_sensor", + CONF_USE_POWER_FEATURE: True, + CONF_PRESET_POWER: 10, + CONF_DEVICE_POWER: 1234, + } + ) + + assert power_manager.is_configured is True + assert power_manager.overpowering_state == STATE_UNKNOWN + + custom_attributes = {} + power_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["power_sensor_entity_id"] == "sensor.the_power_sensor" + assert ( + custom_attributes["max_power_sensor_entity_id"] == "sensor.the_max_power_sensor" + ) + assert custom_attributes["overpowering_state"] == STATE_UNKNOWN + assert custom_attributes["is_power_configured"] is True + assert custom_attributes["device_power"] == 1234 + assert custom_attributes["power_temp"] == 10 + assert custom_attributes["current_power"] is None + assert custom_attributes["current_power_max"] is None + + # 3. start listening + power_manager.start_listening() + assert power_manager.is_configured is True + assert power_manager.overpowering_state == STATE_UNKNOWN + + assert len(power_manager._active_listener) == 2 + + # 4. test refresh and check_overpowering with the parametrized + side_effects = SideEffects( + { + "sensor.the_power_sensor": State("sensor.the_power_sensor", power), + "sensor.the_max_power_sensor": State( + "sensor.the_max_power_sensor", max_power + ), + }, + State("unknown.entity_id", "unknown"), + ) + # fmt:off + with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()) as mock_get_state: + # fmt:on + # Finish the mock configuration + tpi_algo = PropAlgorithm(PROPORTIONAL_FUNCTION_TPI, 0.6, 0.01, 5, 0, "climate.vtherm") + tpi_algo._on_percent = 1 # pylint: disable="protected-access" + type(fake_vtherm).hvac_mode = PropertyMock(return_value=HVACMode.HEAT) + type(fake_vtherm).is_device_active = PropertyMock(return_value=is_device_active) + type(fake_vtherm).is_over_climate = PropertyMock(return_value=is_over_climate) + type(fake_vtherm).proportional_algorithm = PropertyMock(return_value=tpi_algo) + type(fake_vtherm).nb_underlying_entities = PropertyMock(return_value=1) + type(fake_vtherm).preset_mode = PropertyMock(return_value=PRESET_COMFORT if current_overpowering_state == STATE_OFF else PRESET_POWER) + type(fake_vtherm)._saved_preset_mode = PropertyMock(return_value=PRESET_ECO) + + fake_vtherm.save_hvac_mode = MagicMock() + fake_vtherm.restore_hvac_mode = AsyncMock() + fake_vtherm.save_preset_mode = MagicMock() + fake_vtherm.restore_preset_mode = AsyncMock() + fake_vtherm.async_underlying_entity_turn_off = AsyncMock() + fake_vtherm.async_set_preset_mode_internal = AsyncMock() + fake_vtherm.send_event = MagicMock() + fake_vtherm.update_custom_attributes = MagicMock() + + + ret = await power_manager.refresh_state() + assert ret == changed + assert power_manager.is_configured is True + assert power_manager.overpowering_state == STATE_UNKNOWN + assert power_manager.current_power == power + assert power_manager.current_max_power == max_power + + # check overpowering + power_manager._overpowering_state = current_overpowering_state + ret2 = await power_manager.check_overpowering() + assert ret2 == check_overpowering_ret + assert power_manager.overpowering_state == overpowering_state + assert mock_get_state.call_count == 2 + + if power_manager.overpowering_state == STATE_OFF: + assert fake_vtherm.save_hvac_mode.call_count == 0 + assert fake_vtherm.save_preset_mode.call_count == 0 + assert fake_vtherm.async_underlying_entity_turn_off.call_count == 0 + assert fake_vtherm.async_set_preset_mode_internal.call_count == 0 + assert fake_vtherm.send_event.call_count == nb_call + + if current_overpowering_state == STATE_ON: + assert fake_vtherm.update_custom_attributes.call_count == 1 + assert fake_vtherm.restore_preset_mode.call_count == 1 + if is_over_climate: + assert fake_vtherm.restore_hvac_mode.call_count == 1 + else: + assert fake_vtherm.restore_hvac_mode.call_count == 0 + else: + assert fake_vtherm.update_custom_attributes.call_count == 0 + + if nb_call == 1: + fake_vtherm.send_event.assert_has_calls( + [ + call.fake_vtherm.send_event( + EventType.POWER_EVENT, + {'type': 'end', 'current_power': power, 'device_power': 1234, 'current_power_max': max_power}), + ] + ) + + + elif power_manager.overpowering_state == STATE_ON: + if is_over_climate: + assert fake_vtherm.save_hvac_mode.call_count == 1 + else: + assert fake_vtherm.save_hvac_mode.call_count == 0 + + if current_overpowering_state == STATE_OFF: + assert fake_vtherm.save_preset_mode.call_count == 1 + assert fake_vtherm.async_underlying_entity_turn_off.call_count == 1 + assert fake_vtherm.async_set_preset_mode_internal.call_count == 1 + assert fake_vtherm.send_event.call_count == 1 + assert fake_vtherm.update_custom_attributes.call_count == 1 + else: + assert fake_vtherm.save_preset_mode.call_count == 0 + assert fake_vtherm.async_underlying_entity_turn_off.call_count == 0 + assert fake_vtherm.async_set_preset_mode_internal.call_count == 0 + assert fake_vtherm.send_event.call_count == 0 + assert fake_vtherm.update_custom_attributes.call_count == 0 + assert fake_vtherm.restore_hvac_mode.call_count == 0 + assert fake_vtherm.restore_preset_mode.call_count == 0 + + if nb_call == 1: + fake_vtherm.send_event.assert_has_calls( + [ + call.fake_vtherm.send_event( + EventType.POWER_EVENT, + {'type': 'start', 'current_power': power, 'device_power': 1234, 'current_power_max': max_power, 'current_power_consumption': 1234.0}), + ] + ) + + fake_vtherm.reset_mock() + + # 5. Check custom_attributes + custom_attributes = {} + power_manager.add_custom_attributes(custom_attributes) + assert custom_attributes["power_sensor_entity_id"] == "sensor.the_power_sensor" + assert ( + custom_attributes["max_power_sensor_entity_id"] == "sensor.the_max_power_sensor" + ) + assert custom_attributes["overpowering_state"] == overpowering_state + assert custom_attributes["is_power_configured"] is True + assert custom_attributes["device_power"] == 1234 + assert custom_attributes["power_temp"] == 10 + assert custom_attributes["current_power"] == power + assert custom_attributes["current_power_max"] == max_power + + @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_power_management_hvac_off( @@ -63,23 +286,23 @@ async def test_power_management_hvac_off( await entity.async_set_preset_mode(PRESET_BOOST) assert entity.preset_mode is PRESET_BOOST assert entity.target_temperature == 19 - assert entity.overpowering_state is None + assert entity.power_manager.overpowering_state is STATE_UNKNOWN assert entity.hvac_mode == HVACMode.OFF # Send power mesurement await send_power_change_event(entity, 50, datetime.now()) - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False # All configuration is not complete assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is None + assert entity.power_manager.overpowering_state is STATE_UNKNOWN # Send power max mesurement await send_max_power_change_event(entity, 300, datetime.now()) - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False # All configuration is complete and power is < power_max assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF # Send power max mesurement too low but HVACMode is off with patch( @@ -90,10 +313,10 @@ async def test_power_management_hvac_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 + assert await entity.power_manager.check_overpowering() is True # All configuration is complete and power is > power_max but we stay in Boost cause thermostat if Off assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is True + assert entity.power_manager.overpowering_state is STATE_ON assert mock_send_event.call_count == 0 assert mock_heater_on.call_count == 0 @@ -150,17 +373,17 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is 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.power_manager.overpowering_state is STATE_UNKNOWN assert entity.target_temperature == 19 # Send power mesurement await send_power_change_event(entity, 50, datetime.now()) # Send power max mesurement await send_max_power_change_event(entity, 300, datetime.now()) - assert await entity.check_overpowering() is False + assert await entity.power_manager.check_overpowering() is False # All configuration is complete and power is < power_max assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF # Send power max mesurement too low and HVACMode is on with patch( @@ -171,10 +394,10 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is "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 + assert await entity.power_manager.check_overpowering() is True # All configuration is complete and power is > power_max we switch to POWER preset assert entity.preset_mode is PRESET_POWER - assert entity.overpowering_state is True + assert entity.power_manager.overpowering_state is STATE_ON assert entity.target_temperature == 12 assert mock_send_event.call_count == 2 @@ -206,10 +429,10 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is "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 + assert await entity.power_manager.check_overpowering() is False # All configuration is complete and power is < power_max, we restore previous preset assert entity.preset_mode is PRESET_BOOST - assert entity.overpowering_state is False + assert entity.power_manager.overpowering_state is STATE_OFF assert entity.target_temperature == 19 assert mock_send_event.call_count == 2 @@ -303,7 +526,7 @@ async def test_power_management_energy_over_switch( assert entity.current_temperature == 15 assert tpi_algo.on_percent == 1 - assert entity.device_power == 100.0 + assert entity.power_manager.device_power == 100.0 assert mock_send_event.call_count == 2 assert mock_heater_on.call_count == 1 @@ -324,7 +547,7 @@ async def test_power_management_energy_over_switch( ) as mock_heater_off: await send_temperature_change_event(entity, 18, datetime.now()) assert tpi_algo.on_percent == 0.3 - assert entity.mean_cycle_power == 30.0 + assert entity.power_manager.mean_cycle_power == 30.0 assert mock_send_event.call_count == 0 assert mock_heater_on.call_count == 0 @@ -346,7 +569,7 @@ async def test_power_management_energy_over_switch( ) as mock_heater_off: await send_temperature_change_event(entity, 20, datetime.now()) assert tpi_algo.on_percent == 0.0 - assert entity.mean_cycle_power == 0.0 + assert entity.power_manager.mean_cycle_power == 0.0 assert mock_send_event.call_count == 0 assert mock_heater_on.call_count == 0 @@ -421,7 +644,7 @@ async def test_power_management_energy_over_climate( assert entity.current_temperature == 15 # Not initialised yet - assert entity.mean_cycle_power is None + assert entity.power_manager.mean_cycle_power is None assert entity._underlying_climate_start_hvac_action_date is None # Send a climate_change event with HVACAction=HEATING diff --git a/tests/test_presence.py b/tests/test_presence.py index 65a432d3..3adb43e9 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -7,7 +7,7 @@ # from datetime import timedelta, datetime from custom_components.versatile_thermostat.base_thermostat import BaseThermostat -from custom_components.versatile_thermostat.presence_manager import ( +from custom_components.versatile_thermostat.feature_presence_manager import ( FeaturePresenceManager, ) @@ -52,7 +52,7 @@ async def test_presence_feature_manager( presence_manager.add_custom_attributes(custom_attributes) assert custom_attributes["presence_sensor_entity_id"] is None assert custom_attributes["presence_state"] == STATE_UNAVAILABLE - assert custom_attributes["presence_configured"] is False + assert custom_attributes["is_presence_configured"] is False # 2. post_init presence_manager.post_init( @@ -72,7 +72,7 @@ async def test_presence_feature_manager( custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" ) assert custom_attributes["presence_state"] == STATE_UNKNOWN - assert custom_attributes["presence_configured"] is True + assert custom_attributes["is_presence_configured"] is True # 3. start listening presence_manager.start_listening() @@ -124,7 +124,7 @@ async def test_presence_feature_manager( presence_manager.add_custom_attributes(custom_attributes) assert custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" assert custom_attributes["presence_state"] == presence_state - assert custom_attributes["presence_configured"] is True + assert custom_attributes["is_presence_configured"] is True # 6. test _presence_sensor_changed with the parametrized fake_vtherm.find_preset_temp.return_value = temp @@ -172,4 +172,4 @@ async def test_presence_feature_manager( presence_manager.add_custom_attributes(custom_attributes) assert custom_attributes["presence_sensor_entity_id"] == "sensor.the_presence_sensor" assert custom_attributes["presence_state"] == presence_state - assert custom_attributes["presence_configured"] is True + assert custom_attributes["is_presence_configured"] is True diff --git a/tests/test_tpi.py b/tests/test_tpi.py index 33f1af90..692cbfe6 100644 --- a/tests/test_tpi.py +++ b/tests/test_tpi.py @@ -58,7 +58,7 @@ async def test_tpi_calculation( assert tpi_algo.calculated_on_percent == 1 assert tpi_algo.on_time_sec == 300 assert tpi_algo.off_time_sec == 0 - assert entity.mean_cycle_power is None # no device power configured + assert entity.power_manager.mean_cycle_power is None # no device power configured tpi_algo.calculate(15, 14, 5, HVACMode.HEAT) assert tpi_algo.on_percent == 0.4 @@ -100,7 +100,7 @@ async def test_tpi_calculation( assert tpi_algo.calculated_on_percent == 1 assert tpi_algo.on_time_sec == 300 assert tpi_algo.off_time_sec == 0 - assert entity.mean_cycle_power is None # no device power configured + assert entity.power_manager.mean_cycle_power is None # no device power configured tpi_algo.set_security(0.09) tpi_algo.calculate(25, 30, 35, HVACMode.COOL) @@ -108,7 +108,7 @@ async def test_tpi_calculation( assert tpi_algo.calculated_on_percent == 1 assert tpi_algo.on_time_sec == 0 assert tpi_algo.off_time_sec == 300 - assert entity.mean_cycle_power is None # no device power configured + assert entity.power_manager.mean_cycle_power is None # no device power configured tpi_algo.unset_security() # The calculated values for HVACMode.OFF are the same as for HVACMode.HEAT. diff --git a/tests/test_window.py b/tests/test_window.py index 05da471c..5738042a 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -65,7 +65,7 @@ async def test_window_management_time_not_enough( 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 19 assert entity.window_state is STATE_OFF @@ -154,7 +154,7 @@ async def test_window_management_time_enough( 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 19 assert entity.window_state is STATE_OFF @@ -304,7 +304,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 21 assert entity.window_state is STATE_OFF @@ -617,7 +617,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 21 assert entity.window_state is STATE_OFF @@ -775,7 +775,7 @@ async def test_window_auto_no_on_percent( 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 20 assert entity.window_state is STATE_OFF @@ -891,7 +891,7 @@ async def test_window_bypass(hass: HomeAssistant, skip_hass_states_is_state): 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 19 assert entity.window_state is STATE_OFF @@ -1034,7 +1034,7 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 21 assert entity.window_state is STATE_OFF @@ -1152,7 +1152,7 @@ async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 19 assert entity.window_state is STATE_OFF @@ -1591,7 +1591,7 @@ async def test_window_action_eco_temp(hass: HomeAssistant, skip_hass_states_is_s 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 21 assert entity.window_state is STATE_OFF @@ -1788,7 +1788,7 @@ async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is 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.power_manager.overpowering_state is STATE_UNAVAILABLE assert entity.target_temperature == 21 assert entity.window_state is STATE_OFF