diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 86cc91b..5b11b2e 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -37,11 +37,13 @@ from .const import ( CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_VALVE, CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_SONOFF_TRZB_MODE, ) from .thermostat_switch import ThermostatOverSwitch from .thermostat_climate import ThermostatOverClimate from .thermostat_valve import ThermostatOverValve +from .thermostat_sonoff_trvzb import ThermostatOverSonoffTRVZB _LOGGER = logging.getLogger(__name__) @@ -60,6 +62,7 @@ async def async_setup_entry( unique_id = entry.entry_id name = entry.data.get(CONF_NAME) vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + is_sonoff_trvzb = entry.data.get(CONF_SONOFF_TRZB_MODE) if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG: return @@ -69,7 +72,10 @@ async def async_setup_entry( if vt_type == CONF_THERMOSTAT_SWITCH: entity = ThermostatOverSwitch(hass, unique_id, name, entry.data) elif vt_type == CONF_THERMOSTAT_CLIMATE: - entity = ThermostatOverClimate(hass, unique_id, name, entry.data) + if is_sonoff_trvzb is True: + entity = ThermostatOverSonoffTRVZB(hass, unique_id, name, entry.data) + else: + entity = ThermostatOverClimate(hass, unique_id, name, entry.data) elif vt_type == CONF_THERMOSTAT_VALVE: entity = ThermostatOverValve(hass, unique_id, name, entry.data) else: diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index f3018fc..183b725 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -17,7 +17,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorDeviceClass, SensorStateClass, - UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry @@ -50,6 +49,7 @@ from .const import ( CONF_THERMOSTAT_TYPE, CONF_THERMOSTAT_CENTRAL_CONFIG, CONF_USE_CENTRAL_BOILER_FEATURE, + CONF_SONOFF_TRZB_MODE, overrides, ) @@ -71,6 +71,7 @@ async def async_setup_entry( unique_id = entry.entry_id name = entry.data.get(CONF_NAME) vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + is_sonoff_trvzb = entry.data.get(CONF_SONOFF_TRZB_MODE) entities = None @@ -99,10 +100,16 @@ async def async_setup_entry( entities.append(OnTimeSensor(hass, unique_id, name, entry.data)) entities.append(OffTimeSensor(hass, unique_id, name, entry.data)) - if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE: + if ( + entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE + or is_sonoff_trvzb + ): entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data)) - if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE: + if ( + entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE + and not is_sonoff_trvzb + ): entities.append( RegulatedTemperatureSensor(hass, unique_id, name, entry.data) ) diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 66bd390..b6b0862 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -454,7 +454,7 @@ } }, "central_boiler": { - "title": "Control of the central boiler", + "title": "Control of the central boiler - {name}", "description": "Enter the services to call to turn on/off the central boiler. Leave blank if no service call is to be made (in this case, you will have to manage the turning on/off of your central boiler yourself). The service called must be formatted as follows: `entity_id/service_name[/attribute:value]` (/attribute:value is optional)\nFor example:\n- to turn on a switch: `switch.controle_chaudiere/switch.turn_on`\n- to turn off a switch: `switch.controle_chaudiere/switch.turn_off`\n- to program the boiler to 25° and thus force its ignition: `climate.thermostat_chaudiere/climate.set_temperature/temperature:25`\n- to send 10° to the boiler and thus force its extinction: `climate.thermostat_chaudiere/climate.set_temperature/temperature:10`", "data": { "central_boiler_activation_service": "Command to turn-on", @@ -466,7 +466,7 @@ } }, "sonoff_trvzb": { - "title": "Sonoff TRVZB configuration", + "title": "Sonoff TRVZB configuration - {name}", "description": "Specific Sonoff TRVZB configuration", "data": { "offset_calibration_entity_ids": "Offset calibration entities", diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index c903473..993831c 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -1,4 +1,4 @@ -# pylint: disable=line-too-long, too-many-lines +# pylint: disable=line-too-long, too-many-lines, abstract-method """ A climate over climate classe """ import logging from datetime import timedelta, datetime @@ -60,28 +60,26 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): _is_auto_start_stop_enabled: bool = False _follow_underlying_temp_change: bool = False - _entity_component_unrecorded_attributes = ( - BaseThermostat._entity_component_unrecorded_attributes.union( - frozenset( - { - "is_over_climate", - "start_hvac_action_date", - "underlying_entities", - "regulation_accumulated_error", - "auto_regulation_mode", - "auto_fan_mode", - "current_auto_fan_mode", - "auto_activated_fan_mode", - "auto_deactivated_fan_mode", - "auto_regulation_use_device_temp", - "auto_start_stop_level", - "auto_start_stop_dtmin", - "auto_start_stop_enable", - "auto_start_stop_accumulated_error", - "auto_start_stop_accumulated_error_threshold", - "follow_underlying_temp_change", - } - ) + _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access + frozenset( + { + "is_over_climate", + "start_hvac_action_date", + "underlying_entities", + "regulation_accumulated_error", + "auto_regulation_mode", + "auto_fan_mode", + "current_auto_fan_mode", + "auto_activated_fan_mode", + "auto_deactivated_fan_mode", + "auto_regulation_use_device_temp", + "auto_start_stop_level", + "auto_start_stop_dtmin", + "auto_start_stop_enable", + "auto_start_stop_accumulated_error", + "auto_start_stop_accumulated_error_threshold", + "follow_underlying_temp_change", + } ) ) diff --git a/custom_components/versatile_thermostat/thermostat_sonoff_trvzb.py b/custom_components/versatile_thermostat/thermostat_sonoff_trvzb.py index 22bacf2..4a00748 100644 --- a/custom_components/versatile_thermostat/thermostat_sonoff_trvzb.py +++ b/custom_components/versatile_thermostat/thermostat_sonoff_trvzb.py @@ -1,7 +1,8 @@ -# pylint: disable=line-too-long, too-many-lines +# pylint: disable=line-too-long, too-many-lines, abstract-method """ A climate over Sonoff TRVZB classe """ import logging +from datetime import datetime from homeassistant.core import HomeAssistant from homeassistant.components.climate import HVACMode @@ -9,7 +10,8 @@ from homeassistant.components.climate import HVACMode from .underlyings import UnderlyingSonoffTRVZB # from .commons import NowClass, round_to_nearest -from .base_thermostat import BaseThermostat, ConfigData +from .base_thermostat import ConfigData +from .thermostat_climate import ThermostatOverClimate from .prop_algorithm import PropAlgorithm from .const import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -19,27 +21,30 @@ from .const import * # pylint: disable=wildcard-import, unused-wildcard-import _LOGGER = logging.getLogger(__name__) -class ThermostatOverSonoffTRVZB(BaseThermostat[UnderlyingSonoffTRVZB]): +class ThermostatOverSonoffTRVZB(ThermostatOverClimate): """This class represent a VTherm over a Sonoff TRVZB climate""" - _entity_component_unrecorded_attributes = ( - BaseThermostat._entity_component_unrecorded_attributes.union( - frozenset( - { - "is_over_climate", - "is_over_sonoff_trvzb", - "underlying_entities", - "on_time_sec", - "off_time_sec", - "cycle_min", - "function", - "tpi_coef_int", - "tpi_coef_ext", - "power_percent", - } - ) + _entity_component_unrecorded_attributes = ThermostatOverClimate._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access + frozenset( + { + "is_over_climate", + "is_over_sonoff_trvzb", + "underlying_entities", + "on_time_sec", + "off_time_sec", + "cycle_min", + "function", + "tpi_coef_int", + "tpi_coef_ext", + "power_percent", + } ) ) + _underlyings_sonoff_trvzb: list[UnderlyingSonoffTRVZB] = [] + _valve_open_percent: int = 0 + _last_calculation_timestamp: datetime | None = None + _auto_regulation_dpercent: float | None = None + _auto_regulation_period_min: int | None = None def __init__( self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData @@ -47,13 +52,40 @@ class ThermostatOverSonoffTRVZB(BaseThermostat[UnderlyingSonoffTRVZB]): """Initialize the ThermostatOverSonoffTRVZB class""" _LOGGER.debug("%s - creating a ThermostatOverSonoffTRVZB VTherm", name) super().__init__(hass, unique_id, name, entry_infos) + # self._valve_open_percent: int = 0 + # self._last_calculation_timestamp: datetime | None = None + # self._auto_regulation_dpercent: float | None = None + # self._auto_regulation_period_min: int | None = None @overrides def post_init(self, config_entry: ConfigData): - """Initialize the Thermostat""" + """Initialize the Thermostat and underlyings + Beware that the underlyings list contains the climate which represent the Sonoff TRVZB + but also the UnderlyingSonoff which reprensent the valve""" super().post_init(config_entry) + self._auto_regulation_dpercent = ( + config_entry.get(CONF_AUTO_REGULATION_DTEMP) + if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None + else 0.0 + ) + self._auto_regulation_period_min = ( + config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) + if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None + else 0 + ) + + # Initialization of the TPI algo + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + self.name, + ) + for idx, _ in enumerate(config_entry.get(CONF_UNDERLYING_LIST)): offset = config_entry.get(CONF_OFFSET_CALIBRATION_LIST)[idx] opening = config_entry.get(CONF_OPENING_DEGREE_LIST)[idx] @@ -65,31 +97,19 @@ class ThermostatOverSonoffTRVZB(BaseThermostat[UnderlyingSonoffTRVZB]): opening_degree_entity_id=opening, closing_degree_entity_id=closing, ) - self._underlyings.append(under) - - # Initialization of the TPI algo - self._prop_algorithm = PropAlgorithm( - self._proportional_function, - self._tpi_coef_int, - self._tpi_coef_ext, - self._cycle_min, - self._minimal_activation_delay, - self.name, - ) + self._underlyings_sonoff_trvzb.append(under) @overrides def update_custom_attributes(self): """Custom attributes""" super().update_custom_attributes() - under0: UnderlyingSonoffTRVZB = self._underlyings[0] self._attr_extra_state_attributes["is_over_sonoff_trvzb"] = ( self.is_over_sonoff_trvzb ) - self._attr_extra_state_attributes["keep_alive_sec"] = under0.keep_alive_sec - self._attr_extra_state_attributes["underlying_entities"] = [ - underlying.entity_id for underlying in self._underlyings + self._attr_extra_state_attributes["underlying_sonoff_trvzb_entities"] = [ + underlying.entity_id for underlying in self._underlyings_sonoff_trvzb ] self._attr_extra_state_attributes["on_percent"] = ( @@ -107,6 +127,22 @@ class ThermostatOverSonoffTRVZB(BaseThermostat[UnderlyingSonoffTRVZB]): self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + self._attr_extra_state_attributes["valve_open_percent"] = ( + self.valve_open_percent + ) + + self._attr_extra_state_attributes["auto_regulation_dpercent"] = ( + self._auto_regulation_dpercent + ) + self._attr_extra_state_attributes["auto_regulation_period_min"] = ( + self._auto_regulation_period_min + ) + self._attr_extra_state_attributes["last_calculation_timestamp"] = ( + self._last_calculation_timestamp.astimezone(self._current_tz).isoformat() + if self._last_calculation_timestamp + else None + ) + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -119,13 +155,64 @@ class ThermostatOverSonoffTRVZB(BaseThermostat[UnderlyingSonoffTRVZB]): """A utility function to force the calculation of a the algo and update the custom attributes and write the state """ - _LOGGER.debug("%s - recalculate all", self) + _LOGGER.debug("%s - recalculate the open percent", self) + + # TODO this is exactly the same method as the thermostat_valve recalculate. Put that in common + + # For testing purpose. Should call _set_now() before + now = self.now + + if self._last_calculation_timestamp is not None: + period = (now - self._last_calculation_timestamp).total_seconds() / 60 + if period < self._auto_regulation_period_min: + _LOGGER.info( + "%s - do not calculate TPI because regulation_period (%d) is not exceeded", + self, + period, + ) + return + self._prop_algorithm.calculate( self._target_temp, self._cur_temp, self._cur_ext_temp, self._hvac_mode or HVACMode.OFF, ) + + new_valve_percent = round( + max(0, min(self.proportional_algorithm.on_percent, 1)) * 100 + ) + + # Issue 533 - don't filter with dtemp if valve should be close. Else it will never close + if new_valve_percent < self._auto_regulation_dpercent: + new_valve_percent = 0 + + dpercent = new_valve_percent - self.valve_open_percent + if ( + new_valve_percent > 0 + and -1 * self._auto_regulation_dpercent + <= dpercent + < self._auto_regulation_dpercent + ): + _LOGGER.debug( + "%s - do not calculate TPI because regulation_dpercent (%.1f) is not exceeded", + self, + dpercent, + ) + + return + + if self._valve_open_percent == new_valve_percent: + _LOGGER.debug("%s - no change in valve_open_percent.", self) + return + + self._valve_open_percent = new_valve_percent + + for under in self._underlyings_sonoff_trvzb: + under.set_valve_open_percent() + + self._last_calculation_timestamp = now + self.update_custom_attributes() @property @@ -140,3 +227,16 @@ class ThermostatOverSonoffTRVZB(BaseThermostat[UnderlyingSonoffTRVZB]): return round(self._prop_algorithm.on_percent * 100, 0) else: return None + + # @property + # def hvac_modes(self) -> list[HVACMode]: + # """Get the hvac_modes""" + # return self._hvac_list + + @property + def valve_open_percent(self) -> int: + """Gives the percentage of valve needed""" + if self._hvac_mode == HVACMode.OFF: + return 0 + else: + return self._valve_open_percent diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index 0cfdeb7..6218b91 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -1,4 +1,4 @@ -# pylint: disable=line-too-long +# pylint: disable=line-too-long, abstract-method """ A climate over switch classe """ import logging diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index 67a1382..35579fe 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -1,4 +1,4 @@ -# pylint: disable=line-too-long +# pylint: disable=line-too-long, abstract-method """ A climate over switch classe """ import logging from datetime import timedelta, datetime diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 8d636db..432a146 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -259,7 +259,7 @@ } }, "menu": { - "title": "Menu", + "title": "Menu - {name}", "description": "Paramétrez votre thermostat. Vous pourrez finaliser la configuration quand tous les paramètres auront été saisis.", "menu_options": { "main": "Principaux Attributs", @@ -316,7 +316,7 @@ } }, "type": { - "title": "Entité(s) liée(s)", + "title": "Entité(s) liée(s) - {name}", "description": "Attributs de(s) l'entité(s) liée(s)", "data": { "underlying_entity_ids": "Les équipements à controller", @@ -460,7 +460,7 @@ } }, "sonoff_trvzb": { - "title": "Configuration Sonoff TRVZB", + "title": "Configuration Sonoff TRVZB - {name}", "description": "Configuration spécifique des Sonoff TRVZB", "data": { "offset_calibration_entity_ids": "Entités de 'Offset calibration'", diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 1598c10..32a6995 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -1,4 +1,4 @@ -# pylint: disable=unused-argument, line-too-long +# pylint: disable=unused-argument, line-too-long, too-many-lines """ Underlying entities classes """ import logging @@ -65,6 +65,7 @@ class UnderlyingEntity: _thermostat: Any _entity_id: str _type: UnderlyingEntityType + _hvac_mode: HVACMode | None def __init__( self, @@ -103,13 +104,24 @@ class UnderlyingEntity: async def set_hvac_mode(self, hvac_mode: HVACMode): """Set the HVACmode""" + self._hvac_mode = hvac_mode return + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current hvac_mode""" + return self._hvac_mode + @property def is_device_active(self) -> bool | None: """If the toggleable device is currently active.""" return None + @property + def hvac_action(self) -> HVACAction: + """Calculate a hvac_action""" + return HVACAction.HEATING if self.is_device_active is True else HVACAction.OFF + async def set_temperature(self, temperature, max_temp, min_temp): """Set the target temperature""" return @@ -184,7 +196,6 @@ class UnderlyingSwitch(UnderlyingEntity): _initialDelaySec: int _on_time_sec: int _off_time_sec: int - _hvac_mode: HVACMode def __init__( self, @@ -207,7 +218,6 @@ class UnderlyingSwitch(UnderlyingEntity): self._should_relaunch_control_heating = False self._on_time_sec = 0 self._off_time_sec = 0 - self._hvac_mode = None self._keep_alive = IntervalCaller(hass, keep_alive_sec) @property @@ -240,8 +250,8 @@ class UnderlyingSwitch(UnderlyingEntity): await self.turn_off() self._cancel_cycle() - if self._hvac_mode != hvac_mode: - self._hvac_mode = hvac_mode + if self.hvac_mode != hvac_mode: + super().set_hvac_mode(hvac_mode) return True else: return False @@ -857,6 +867,7 @@ class UnderlyingValve(UnderlyingEntity): _hvac_mode: HVACMode # This is the percentage of opening int integer (from 0 to 100) _percent_open: int + _last_sent_temperature = None def __init__( self, hass: HomeAssistant, thermostat: Any, valve_entity_id: str @@ -875,13 +886,12 @@ class UnderlyingValve(UnderlyingEntity): self._percent_open = self._thermostat.valve_open_percent self._valve_entity_id = valve_entity_id - async def send_percent_open(self): - """Send the percent open to the underlying valve""" - # This may fails if called after shutdown + async def _send_value_to_number(self, number_entity_id: str, value: int): + """Send a value to a number entity""" try: - data = {"value": self._percent_open} - target = {ATTR_ENTITY_ID: self._entity_id} - domain = self._entity_id.split(".")[0] + data = {"value": value} + target = {ATTR_ENTITY_ID: number_entity_id} + domain = number_entity_id.split(".")[0] await self._hass.services.async_call( domain=domain, service=SERVICE_SET_VALUE, @@ -893,6 +903,11 @@ class UnderlyingValve(UnderlyingEntity): # This could happens in unit test if input_number domain is not yet loaded # raise err + async def send_percent_open(self): + """Send the percent open to the underlying valve""" + # This may fails if called after shutdown + return await self._send_value_to_number(self._entity_id, self._percent_open) + async def turn_off(self): """Turn heater toggleable device off.""" _LOGGER.debug("%s - Stopping underlying valve entity %s", self, self._entity_id) @@ -1020,6 +1035,47 @@ class UnderlyingSonoffTRVZB(UnderlyingValve): self._offset_calibration_entity_id = offset_calibration_entity_id self._opening_degree_entity_id = opening_degree_entity_id self._closing_degree_entity_id = closing_degree_entity_id + self._is_min_max_initialized = False + self._max_opening_degree = None + self._min_offset_calibration = None + + async def send_percent_open(self): + """Send the percent open to the underlying valve""" + if not self._is_min_max_initialized: + _LOGGER.debug( + "%s - initialize min offset_calibration and max open_degree", self + ) + self._max_opening_degree = self._hass.states.get( + self._opening_degree_entity_id + ).attributes.get("max") + self._min_offset_calibration = self._hass.states.get( + self._offset_calibration_entity_id + ).attributes.get("min") + + self._is_min_max_initialized = ( + self._max_opening_degree is not None + and self._min_offset_calibration is not None + ) + + if not self._is_min_max_initialized: + _LOGGER.warning( + "%s - impossible to initialize max_opening_degree or min_offset_calibration. Abort sending percent open to the valve. This could be a temporary message at startup." + ) + return + + # Send opening_degree + await super().send_percent_open() + + # Send closing_degree. TODO 100 hard-coded or take the max of the _closing_degree_entity_id ? + await self._send_value_to_number( + self._closing_degree_entity_id, + self._max_opening_degree - self._percent_open, + ) + + # send offset_calibration to the min value + await self._send_value_to_number( + self._offset_calibration_entity_id, self._min_offset_calibration + ) @property def offset_calibration_entity_id(self) -> str: @@ -1035,3 +1091,10 @@ class UnderlyingSonoffTRVZB(UnderlyingValve): def closing_degree_entity_id(self) -> str: """The offset_calibration_entity_id""" return self._closing_degree_entity_id + + @property + def hvac_modes(self) -> list[HVACMode]: + """Get the hvac_modes""" + if not self.is_initialized: + return [] + return [HVACMode.OFF, HVACMode.HEAT]