diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 8fcf55a..d4d483b 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -411,15 +411,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): # Initiate the ProportionalAlgorithm if self._prop_algorithm is not None: del self._prop_algorithm - if not self.is_over_climate: - self._prop_algorithm = PropAlgorithm( - self._proportional_function, - self._tpi_coef_int, - self._tpi_coef_ext, - self._cycle_min, - self._minimal_activation_delay, - ) - self._should_relaunch_control_heating = False # Memory synthesis state self._motion_state = None @@ -677,7 +668,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._hvac_mode == HVACMode.COOL, ) - self.hass.create_task(self._check_switch_initial_state()) + self.hass.create_task(self._check_initial_state()) self.reset_last_change_time() @@ -820,9 +811,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.FAN_MODE. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).fan_mode - return None @property @@ -831,9 +819,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.FAN_MODE. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).fan_modes - return [] @property @@ -842,9 +827,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.SWING_MODE. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).swing_mode - return None @property @@ -853,17 +835,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.SWING_MODE. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).swing_modes - return None @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).temperature_unit - return self._unit @property @@ -904,9 +880,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): @property def supported_features(self): """Return the list of supported features.""" - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).supported_features | self._support_flags - return self._support_flags @property @@ -925,9 +898,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).target_temperature_step - return None @property @@ -936,9 +906,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).target_temperature_high - return None @property @@ -947,9 +914,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).target_temperature_low - return None @property @@ -958,15 +922,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity): Requires ClimateEntityFeature.AUX_HEAT. """ - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).is_aux_heat - return None @property def mean_cycle_power(self) -> float | None: """Returns the mean power consumption during the cycle""" - if not self._device_power or self.is_over_climate: + if not self._device_power: return None return float( @@ -978,7 +939,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): @property def total_energy(self) -> float | None: """Returns the total energy calculated for this thermostast""" - return self._total_energy + return round(self._total_energy, 2) @property def device_power(self) -> float | None: @@ -1080,33 +1041,18 @@ class BaseThermostat(ClimateEntity, RestoreEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - if self.is_over_climate and self.underlying_entity(0): - return self.underlying_entity(0).turn_aux_heat_on() - raise NotImplementedError() async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - if self.is_over_climate: - for under in self._underlyings: - await under.async_turn_aux_heat_on() - raise NotImplementedError() def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if self.is_over_climate: - for under in self._underlyings: - return under.turn_aux_heat_off() - raise NotImplementedError() async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if self.is_over_climate: - for under in self._underlyings: - await under.async_turn_aux_heat_off() - raise NotImplementedError() async def async_set_hvac_mode(self, hvac_mode, need_control_heating=True): @@ -1239,33 +1185,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity): async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) - if fan_mode is None or not self.is_over_climate: - return - - for under in self._underlyings: - await under.set_fan_mode(fan_mode) - self._fan_mode = fan_mode - self.async_write_ha_state() + return async def async_set_humidity(self, humidity: int): """Set new target humidity.""" _LOGGER.info("%s - Set fan mode: %s", self, humidity) - if humidity is None or not self.is_over_climate: - return - for under in self._underlyings: - await under.set_humidity(humidity) - self._humidity = humidity - self.async_write_ha_state() + return async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) - if swing_mode is None or not self.is_over_climate: - return - for under in self._underlyings: - await under.set_swing_mode(swing_mode) - self._swing_mode = swing_mode - self.async_write_ha_state() + return async def async_set_temperature(self, **kwargs): """Set new target temperature.""" @@ -1282,13 +1212,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): async def _async_internal_set_temperature(self, temperature): """Set the target temperature and the target temperature of underlying climate if any""" self._target_temp = temperature - if not self.is_over_climate: - return - - for under in self._underlyings: - await under.set_temperature( - temperature, self._attr_max_temp, self._attr_min_temp - ) + return def get_state_date_or_now(self, state: State): """Extract the last_changed state from State or return now if not available""" @@ -1520,197 +1444,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity): return None @callback - async def _check_switch_initial_state(self): + async def _check_initial_state(self): """Prevent the device from keep running if HVAC_MODE_OFF.""" - _LOGGER.debug("%s - Calling _check_switch_initial_state", self) - # We need to do the same check for over_climate underlyings - # if self.is_over_climate: - # return + _LOGGER.debug("%s - Calling _check_initial_state", self) for under in self._underlyings: await under.check_initial_state(self._hvac_mode) - @callback - def _async_switch_changed(self, event): - """Handle heater switch state changes.""" - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - if new_state is None: - return - if old_state is None: - self.hass.create_task(self._check_switch_initial_state()) - self.async_write_ha_state() - - @callback - async def _async_climate_changed(self, event): - """Handle unerdlying climate 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) - @callback async def _async_update_temp(self, state: State): """Update thermostat with latest state from sensor.""" @@ -2029,7 +1768,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): async def restore_hvac_mode(self, need_control_heating=False): """Restore a previous hvac_mod""" - old_hvac_mode = self.hvac_mode await self.async_set_hvac_mode(self._saved_hvac_mode, need_control_heating) _LOGGER.debug( "%s - Restored hvac_mode - saved_hvac_mode is %s, hvac_mode is %s", @@ -2037,16 +1775,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._saved_hvac_mode, self._hvac_mode, ) - # Issue 133 - force the temperature in over_climate mode if unerlying are turned on - if ( - old_hvac_mode == HVACMode.OFF - and self.hvac_mode != HVACMode.OFF - and self.is_over_climate - ): - _LOGGER.info( - "%s - force resent target temp cause we turn on some over climate" - ) - await self._async_internal_set_temperature(self._target_temp) async def check_overpowering(self) -> bool: """Check the overpowering condition @@ -2333,38 +2061,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity): def recalculate(self): """A utility function to force the calculation of a the algo and - update the custom attributes and write the state + update the custom attributes and write the state. + Should be overriden by super class """ - _LOGGER.debug("%s - recalculate all", self) - if not self.is_over_climate: - self._prop_algorithm.calculate( - self._target_temp, - self._cur_temp, - self._cur_ext_temp, - self._hvac_mode == HVACMode.COOL, - ) - self.update_custom_attributes() - self.async_write_ha_state() + raise NotImplementedError() def incremente_energy(self): - """increment the energy counter if device is active""" - if self.hvac_mode == HVACMode.OFF: - return - - added_energy = 0 - if self.is_over_climate and self._underlying_climate_delta_t is not None: - added_energy = self._device_power * self._underlying_climate_delta_t - - 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 - - self._total_energy += added_energy - _LOGGER.debug( - "%s - added energy is %.3f . Total energy is now: %.3f", - self, - added_energy, - self._total_energy, - ) + """increment the energy counter if device is active + Should be overriden by super class + """ + raise NotImplementedError() def update_custom_attributes(self): """Update the custom extra attributes for the entity""" @@ -2387,8 +2093,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self.get_preset_away_name(PRESET_COMFORT) ), "power_temp": self._power_temp, - "target_temp": self.target_temperature, - "current_temp": self._cur_temp, + # Already in super class - "target_temp": self.target_temperature, + # Already in super class - "current_temp": self._cur_temp, "ext_current_temperature": self._cur_ext_temp, "ac_mode": self._ac_mode, "current_power": self._current_power, diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 74eb6fe..ae669e7 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -3,14 +3,14 @@ import logging from datetime import timedelta -from homeassistant.core import HomeAssistant +from homeassistant.core import callback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval -from homeassistant.components.climate import HVACAction +from homeassistant.components.climate import HVACAction, HVACMode from .base_thermostat import BaseThermostat -from .const import CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4 +from .const import CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4, overrides from .underlyings import UnderlyingClimate @@ -25,9 +25,10 @@ class ThermostatOverClimate(BaseThermostat): "underlying_climate_2", "underlying_climate_3" })) - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: - """Initialize the thermostat over switch.""" - super().__init__(hass, unique_id, name, entry_infos) + # Useless for now + # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + # """Initialize the thermostat over switch.""" + # super().__init__(hass, unique_id, name, entry_infos) @property def is_over_climate(self) -> bool: @@ -62,6 +63,183 @@ class ThermostatOverClimate(BaseThermostat): else: return super.hvac_modes + @property + def mean_cycle_power(self) -> float | None: + """Returns the mean power consumption during the cycle""" + return None + + @property + def fan_mode(self) -> str | None: + """Return the fan setting. + + Requires ClimateEntityFeature.FAN_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).fan_mode + + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes. + + Requires ClimateEntityFeature.FAN_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).fan_modes + + return [] + + @property + def swing_mode(self) -> str | None: + """Return the swing setting. + + Requires ClimateEntityFeature.SWING_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).swing_mode + + return None + + @property + def swing_modes(self) -> list[str] | None: + """Return the list of available swing modes. + + Requires ClimateEntityFeature.SWING_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).swing_modes + + return None + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if self.underlying_entity(0): + return self.underlying_entity(0).temperature_unit + + return self._unit + + @property + def supported_features(self): + """Return the list of supported features.""" + if self.underlying_entity(0): + return self.underlying_entity(0).supported_features | self._support_flags + + return self._support_flags + + @property + def target_temperature_step(self) -> float | None: + """Return the supported step of target temperature.""" + if self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_step + + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the highbound target temperature we try to reach. + + Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_high + + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach. + + Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_low + + return None + + @property + def is_aux_heat(self) -> bool | None: + """Return true if aux heater. + + Requires ClimateEntityFeature.AUX_HEAT. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).is_aux_heat + + return None + + @overrides + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + if self.underlying_entity(0): + return self.underlying_entity(0).turn_aux_heat_on() + + raise NotImplementedError() + + @overrides + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + for under in self._underlyings: + await under.async_turn_aux_heat_on() + + @overrides + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + for under in self._underlyings: + return under.turn_aux_heat_off() + + @overrides + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + for under in self._underlyings: + await under.async_turn_aux_heat_off() + + @overrides + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) + if fan_mode is None: + return + + for under in self._underlyings: + await under.set_fan_mode(fan_mode) + self._fan_mode = fan_mode + self.async_write_ha_state() + + @overrides + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + if humidity is None: + return + for under in self._underlyings: + await under.set_humidity(humidity) + self._humidity = humidity + self.async_write_ha_state() + + @overrides + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + if swing_mode is None: + return + for under in self._underlyings: + await under.set_swing_mode(swing_mode) + self._swing_mode = swing_mode + self.async_write_ha_state() + + @overrides + async def _async_internal_set_temperature(self, temperature): + """Set the target temperature and the target temperature of underlying climate if any""" + await super()._async_internal_set_temperature(temperature) + + for under in self._underlyings: + await under.set_temperature( + temperature, self._attr_max_temp, self._attr_min_temp + ) + + @overrides def post_init(self, entry_infos): """ Initialize the Thermostat""" @@ -81,6 +259,7 @@ class ThermostatOverClimate(BaseThermostat): ) ) + @overrides async def async_added_to_hass(self): """Run when entity about to be added.""" _LOGGER.debug("Calling async_added_to_hass") @@ -105,6 +284,7 @@ class ThermostatOverClimate(BaseThermostat): ) ) + @overrides def update_custom_attributes(self): """ Custom attributes """ super().update_custom_attributes() @@ -131,6 +311,7 @@ class ThermostatOverClimate(BaseThermostat): self._attr_extra_state_attributes, ) + @overrides def recalculate(self): """A utility function to force the calculation of a the algo and update the custom attributes and write the state @@ -138,3 +319,207 @@ class ThermostatOverClimate(BaseThermostat): _LOGGER.debug("%s - recalculate all", self) self.update_custom_attributes() self.async_write_ha_state() + + @overrides + async def restore_hvac_mode(self, need_control_heating=False): + """Restore a previous hvac_mod""" + old_hvac_mode = self.hvac_mode + + await super().restore_hvac_mode(need_control_heating=need_control_heating) + + # Issue 133 - force the temperature in over_climate mode if unerlying are turned on + if old_hvac_mode == HVACMode.OFF and self.hvac_mode != HVACMode.OFF: + _LOGGER.info( + "%s - Force resent target temp cause we turn on some over climate" + ) + await self._async_internal_set_temperature(self._target_temp) + + @overrides + def incremente_energy(self): + """increment the energy counter if device is active""" + + if self.hvac_mode == HVACMode.OFF: + return + + added_energy = 0 + if self.is_over_climate and self._underlying_climate_delta_t is not None: + added_energy = self._device_power * self._underlying_climate_delta_t + + self._total_energy += added_energy + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) + + @callback + async def _async_climate_changed(self, event): + """Handle unerdlying climate 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/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index db4b305..f088a47 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -2,7 +2,7 @@ """ A climate over switch classe """ import logging -from homeassistant.core import HomeAssistant +from homeassistant.core import callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.components.climate import HVACMode @@ -10,12 +10,13 @@ from .const import ( CONF_HEATER, CONF_HEATER_2, CONF_HEATER_3, - CONF_HEATER_4 + CONF_HEATER_4, + overrides ) from .base_thermostat import BaseThermostat - from .underlyings import UnderlyingSwitch +from .prop_algorithm import PropAlgorithm _LOGGER = logging.getLogger(__name__) @@ -29,19 +30,30 @@ class ThermostatOverSwitch(BaseThermostat): "cycle_min", "function", "tpi_coef_int", "tpi_coef_ext" })) - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: - """Initialize the thermostat over switch.""" - super().__init__(hass, unique_id, name, entry_infos) + # useless for now + # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + # """Initialize the thermostat over switch.""" + # super().__init__(hass, unique_id, name, entry_infos) @property def is_over_switch(self) -> bool: """ True if the Thermostat is over_switch""" return True + @overrides def post_init(self, entry_infos): """ Initialize the Thermostat""" super().post_init(entry_infos) + + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + ) + lst_switches = [entry_infos.get(CONF_HEATER)] if entry_infos.get(CONF_HEATER_2): lst_switches.append(entry_infos.get(CONF_HEATER_2)) @@ -61,6 +73,9 @@ class ThermostatOverSwitch(BaseThermostat): ) ) + self._should_relaunch_control_heating = False + + @overrides async def async_added_to_hass(self): """Run when entity about to be added.""" _LOGGER.debug("Calling async_added_to_hass") @@ -77,6 +92,7 @@ class ThermostatOverSwitch(BaseThermostat): self.hass.create_task(self.async_control_heating()) + @overrides def update_custom_attributes(self): """ Custom attributes """ super().update_custom_attributes() @@ -115,6 +131,7 @@ class ThermostatOverSwitch(BaseThermostat): self._attr_extra_state_attributes, ) + @overrides def recalculate(self): """A utility function to force the calculation of a the algo and update the custom attributes and write the state @@ -128,3 +145,32 @@ class ThermostatOverSwitch(BaseThermostat): ) self.update_custom_attributes() self.async_write_ha_state() + + @overrides + def incremente_energy(self): + """increment the energy counter if device is active""" + if self.hvac_mode == HVACMode.OFF: + 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 + + self._total_energy += added_energy + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) + + @callback + def _async_switch_changed(self, event): + """Handle heater switch state changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if new_state is None: + return + if old_state is None: + self.hass.create_task(self._check_initial_state()) + self.async_write_ha_state() diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index 69542af..17d1933 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -3,14 +3,14 @@ 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 homeassistant.components.climate import HVACMode from .base_thermostat import BaseThermostat +from .prop_algorithm import PropAlgorithm -from .const import CONF_VALVE, CONF_VALVE_2, CONF_VALVE_3, CONF_VALVE_4 +from .const import CONF_VALVE, CONF_VALVE_2, CONF_VALVE_3, CONF_VALVE_4, overrides from .underlyings import UnderlyingValve @@ -26,9 +26,10 @@ class ThermostatOverValve(BaseThermostat): "cycle_min", "function", "tpi_coef_int", "tpi_coef_ext" })) - def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: - """Initialize the thermostat over switch.""" - super().__init__(hass, unique_id, name, entry_infos) + # Useless for now + # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + # """Initialize the thermostat over switch.""" + # super().__init__(hass, unique_id, name, entry_infos) @property def is_over_valve(self) -> bool: @@ -43,10 +44,19 @@ class ThermostatOverValve(BaseThermostat): else: return round(max(0, min(self.proportional_algorithm.on_percent, 1)) * 100) + @overrides def post_init(self, entry_infos): """ Initialize the Thermostat""" super().post_init(entry_infos) + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + ) + lst_valves = [entry_infos.get(CONF_VALVE)] if entry_infos.get(CONF_VALVE_2): lst_valves.append(entry_infos.get(CONF_VALVE_2)) @@ -64,6 +74,9 @@ class ThermostatOverValve(BaseThermostat): ) ) + self._should_relaunch_control_heating = False + + @overrides async def async_added_to_hass(self): """Run when entity about to be added.""" _LOGGER.debug("Calling async_added_to_hass") @@ -96,6 +109,7 @@ class ThermostatOverValve(BaseThermostat): new_state = event.data.get("new_state") _LOGGER.debug("%s - _async_valve_changed new_state is %s", self, new_state.state) + @overrides def update_custom_attributes(self): """ Custom attributes """ super().update_custom_attributes() @@ -134,6 +148,7 @@ class ThermostatOverValve(BaseThermostat): self._attr_extra_state_attributes, ) + @overrides def recalculate(self): """A utility function to force the calculation of a the algo and update the custom attributes and write the state @@ -153,3 +168,21 @@ class ThermostatOverValve(BaseThermostat): self.update_custom_attributes() self.async_write_ha_state() + + @overrides + def incremente_energy(self): + """increment the energy counter if device is active""" + if self.hvac_mode == HVACMode.OFF: + 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 + + self._total_energy += added_energy + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) \ No newline at end of file diff --git a/tests/test_power.py b/tests/test_power.py index ace8603..a4a8f2d 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -1,12 +1,11 @@ +# pylint: disable=protected-access, unused-argument, line-too-long """ Test the Power management """ from unittest.mock import patch, call -from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from datetime import datetime, timedelta - -from homeassistant.const import UnitOfTemperature - import logging +from custom_components.versatile_thermostat.thermostat_switch import ThermostatOverSwitch +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import logging.getLogger().setLevel(logging.DEBUG) @@ -50,7 +49,7 @@ async def test_power_management_hvac_off( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: ThermostatOverSwitch = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -136,7 +135,7 @@ async def test_power_management_hvac_on(hass: HomeAssistant, skip_hass_states_is }, ) - entity: VersatileThermostat = await create_thermostat( + entity: ThermostatOverSwitch = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -270,7 +269,7 @@ async def test_power_management_energy_over_switch( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: ThermostatOverSwitch = await create_thermostat( hass, entry, "climate.theoverswitchmockname" ) assert entity @@ -305,9 +304,9 @@ async def test_power_management_energy_over_switch( assert mock_heater_off.call_count == 0 entity.incremente_energy() - assert entity.total_energy == 100 * 5 / 60.0 + assert entity.total_energy == round(100 * 5 / 60.0, 2) entity.incremente_energy() - assert entity.total_energy == 2 * 100 * 5 / 60.0 + assert entity.total_energy == round(2 * 100 * 5 / 60.0, 2) # change temperature to a higher value with patch( @@ -398,7 +397,7 @@ async def test_power_management_energy_over_climate( }, ) - entity: VersatileThermostat = await create_thermostat( + entity: ThermostatOverSwitch = await create_thermostat( hass, entry, "climate.theoverclimatemockname" ) assert entity diff --git a/tests/test_sensors.py b/tests/test_sensors.py index c6467ef..44279d4 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -140,7 +140,7 @@ async def test_sensors_over_switch( entity.incremente_energy() await energy_sensor.async_my_climate_changed() - assert energy_sensor.state == 16.667 + assert energy_sensor.state == round(16.667, 2) assert energy_sensor.device_class == SensorDeviceClass.ENERGY assert energy_sensor.state_class == SensorStateClass.TOTAL_INCREASING # because device_power is 200