Refactorisation and complete #137

This commit is contained in:
Jean-Marc Collin
2023-10-29 09:20:06 +00:00
parent 4905c93a51
commit 1cc47626c7
6 changed files with 510 additions and 341 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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