Feature 234 add central boiler helper (#352)

* Creation of the central boiler config + binary_sensor entity

* Fonctional before testu. Miss the service call

* Full featured but without testu

* Documentation and release.

* Add events in README

* FIX #341 - when window state change, open_valve_percent should be resend

* Issue #343 - disable safety mode for outdoor thermometer

* Issue #255 - Specify window action on window open detection

* Add en and string translation

* central boiler - add entites to fine tune the boiler start

* With testu ok

* Add testus for valve and climate

* Add testus in pipelines

* With pip 3

* With more pytest options

* Ass coverage tests

* Add coverage report in github

* Release 5.3.0

---------

Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
This commit is contained in:
Jean-Marc Collin
2024-01-21 00:31:16 +01:00
committed by GitHub
parent 3da271b671
commit c12a91a5ff
41 changed files with 3165 additions and 207 deletions

View File

@@ -121,6 +121,7 @@ from .const import (
CENTRAL_MODE_HEAT_ONLY,
CENTRAL_MODE_COOL_ONLY,
CENTRAL_MODE_FROST_PROTECTION,
send_vtherm_event,
)
from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -196,6 +197,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"window_auto_open_threshold",
"window_auto_close_threshold",
"window_auto_max_duration",
"window_action",
"motion_sensor_entity_id",
"presence_sensor_entity_id",
"power_sensor_entity_id",
@@ -203,6 +205,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"temperature_unit",
"is_device_active",
"target_temperature_step",
"is_used_by_central_boiler",
}
)
)
@@ -266,8 +269,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._window_auto_state = False
self._window_auto_on = False
self._window_auto_algo = None
# PR - Adding Window ByPass
self._window_bypass_state = False
self._window_action = None
self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
@@ -283,6 +286,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._is_central_mode = None
self._last_central_mode = None
self._is_used_by_central_boiler = False
self.post_init(entry_infos)
def clean_central_config_doublon(self, config_entry, central_config) -> dict:
@@ -472,7 +477,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._saved_preset_mode = PRESET_NONE
# Power management
self._device_power = entry_infos.get(CONF_DEVICE_POWER)
self._device_power = entry_infos.get(CONF_DEVICE_POWER) or 0
self._pmax_on = False
self._current_power = None
self._current_power_max = None
@@ -569,6 +574,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
entry_infos.get(CONF_USE_CENTRAL_MODE) is False
) # Default value (None) is True
self._is_used_by_central_boiler = (
entry_infos.get(CONF_USED_BY_CENTRAL_BOILER) is True
)
self._window_action = (
entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF
)
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
self,
@@ -1010,6 +1023,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
action = HVACAction.HEATING
return action
@property
def is_used_by_central_boiler(self) -> HVACAction | None:
"""Return true is the VTherm is configured to be used by
central boiler"""
return self._is_used_by_central_boiler
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
@@ -1079,6 +1098,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Get the Window Bypass"""
return self._window_bypass_state
@property
def window_action(self) -> bool | None:
"""Get the Window Action"""
return self._window_action
@property
def security_state(self) -> bool | None:
"""Get the security_state"""
@@ -1142,6 +1166,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Returns the number of underlying entities"""
return len(self._underlyings)
@property
def underlying_entities(self) -> int:
"""Returns the underlying entities"""
return self._underlyings
@property
def is_on(self) -> bool:
"""True if the VTherm is on (! HVAC_OFF)"""
@@ -1468,29 +1497,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._window_state = new_state.state == STATE_ON
# PR - Adding Window ByPass
_LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state)
if self._window_bypass_state:
_LOGGER.info(
"%s - Window ByPass is activated. Ignore window event", self
)
else:
if not self._window_state:
_LOGGER.info(
"%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED",
self,
self._saved_hvac_mode,
)
if self.last_central_mode != CENTRAL_MODE_STOPPED:
await self.restore_hvac_mode(True)
elif self._window_state:
_LOGGER.info(
"%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF
)
if self.last_central_mode in [CENTRAL_MODE_AUTO, None]:
self.save_hvac_mode()
await self.change_window_detection_state(self._window_state)
await self.async_set_hvac_mode(HVACMode.OFF)
self.update_custom_attributes()
if new_state is None or old_state is None or new_state.state == old_state.state:
@@ -1619,6 +1633,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
for under in self._underlyings:
await under.check_initial_state(self._hvac_mode)
# Starts the initial control loop (don't wait for an update of temperature)
await self.async_control_heating(force=True)
@callback
async def _async_update_temp(self, state: State):
"""Update thermostat with latest state from sensor."""
@@ -1644,7 +1661,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
# try to restart if we were in safety mode
if self._security_state:
await self.check_security()
await self.check_safety()
# check window_auto
return await self._async_manage_window_auto()
@@ -1671,7 +1688,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
# try to restart if we were in safety mode
if self._security_state:
await self.check_security()
await self.check_safety()
except ValueError as ex:
_LOGGER.error("Unable to update external temperature from sensor: %s", ex)
@@ -1828,7 +1845,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
)
# Set attributes
self._window_auto_state = False
await self.restore_hvac_mode(True)
await self.change_window_detection_state(self._window_auto_state)
# await self.restore_hvac_mode(True)
if self._window_call_cancel:
self._window_call_cancel()
@@ -1855,7 +1873,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
)
if self.window_bypass_state or not self.is_window_auto_enabled:
_LOGGER.info("%s - Window auto event is ignored because bypass is ON or window auto detection is disabled", self)
_LOGGER.info(
"%s - Window auto event is ignored because bypass is ON or window auto detection is disabled",
self,
)
return
if (
@@ -1885,8 +1906,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
)
# Set attributes
self._window_auto_state = True
self.save_hvac_mode()
await self.async_set_hvac_mode(HVACMode.OFF)
await self.change_window_detection_state(self._window_auto_state)
# self.save_hvac_mode()
# await self.async_set_hvac_mode(HVACMode.OFF)
# Arm the end trigger
if self._window_call_cancel:
@@ -2106,7 +2128,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Get now. The local datetime or the overloaded _set_now date"""
return self._now if self._now is not None else datetime.now(self._current_tz)
async def check_security(self) -> bool:
async def check_safety(self) -> bool:
"""Check if last temperature date is too long"""
now = self.now
delta_temp = (
@@ -2118,9 +2140,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
mode_cond = self._hvac_mode != HVACMode.OFF
temp_cond: bool = (
delta_temp > self._security_delay_min
or delta_ext_temp > self._security_delay_min
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api()
is_outdoor_checked = (
not api.safety_mode
or api.safety_mode.get("check_outdoor_sensor") is not False
)
temp_cond: bool = delta_temp > self._security_delay_min or (
is_outdoor_checked and delta_ext_temp > self._security_delay_min
)
climate_cond: bool = self.is_over_climate and self.hvac_action not in [
HVACAction.COOLING,
@@ -2264,6 +2291,63 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
should have found the underlying climate to be operational"""
return True
async def change_window_detection_state(self, new_state):
"""Change the window detection state.
new_state is on if an open window have been detected or off else
"""
if not new_state:
_LOGGER.info(
"%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED",
self,
self._saved_hvac_mode,
)
if self._window_action in [CONF_WINDOW_FROST_TEMP, CONF_WINDOW_ECO_TEMP]:
await self._async_internal_set_temperature(self._saved_target_temp)
# default to TURN_OFF
elif self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]:
if self.last_central_mode != CENTRAL_MODE_STOPPED:
await self.restore_hvac_mode(True)
else:
_LOGGER.error(
"%s - undefined window_action %s. Please open a bug in the github of this project with this log",
self,
self._window_action,
)
else:
_LOGGER.info(
"%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF
)
if self.last_central_mode in [CENTRAL_MODE_AUTO, None]:
if self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]:
self.save_hvac_mode()
elif self._window_action in [
CONF_WINDOW_FROST_TEMP,
CONF_WINDOW_ECO_TEMP,
]:
self._saved_target_temp = self._target_temp
if (
self._window_action == CONF_WINDOW_FAN_ONLY
and HVACMode.FAN_ONLY in self.hvac_modes
):
await self.async_set_hvac_mode(HVACMode.FAN_ONLY)
elif (
self._window_action == CONF_WINDOW_FROST_TEMP
and self._presets.get(PRESET_FROST_PROTECTION) is not None
):
await self._async_internal_set_temperature(
self.find_preset_temp(PRESET_FROST_PROTECTION)
)
elif (
self._window_action == CONF_WINDOW_ECO_TEMP
and self._presets.get(PRESET_ECO) is not None
):
await self._async_internal_set_temperature(
self.find_preset_temp(PRESET_ECO)
)
else: # default is to turn_off
await self.async_set_hvac_mode(HVACMode.OFF)
async def async_control_heating(self, force=False, _=None):
"""The main function used to run the calculation at each cycle"""
@@ -2291,7 +2375,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug("%s - End of cycle (overpowering)", self)
return True
security: bool = await self.check_security()
security: bool = await self.check_safety()
if security and self.is_over_climate:
_LOGGER.debug("%s - End of cycle (security and over climate)", self)
return True
@@ -2357,8 +2441,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.get_preset_away_name(PRESET_COMFORT)
),
"power_temp": self._power_temp,
# Already in super class - "target_temp": self.target_temperature,
# Already in super class - "current_temp": self._cur_temp,
"target_temperature_step": self.target_temperature_step,
"ext_current_temperature": self._cur_ext_temp,
"ac_mode": self._ac_mode,
@@ -2367,12 +2449,23 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"saved_preset_mode": self._saved_preset_mode,
"saved_target_temp": self._saved_target_temp,
"saved_hvac_mode": self._saved_hvac_mode,
"window_state": self.window_state,
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"motion_state": self._motion_state,
"power_sensor_entity_id": self._power_sensor_entity_id,
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
"overpowering_state": self.overpowering_state,
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"presence_state": self._presence_state,
"window_state": self.window_state,
"window_auto_state": self.window_auto_state,
"window_bypass_state": self._window_bypass_state,
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec,
"window_auto_enabled": self.is_window_auto_enabled,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
"window_action": self.window_action,
"security_delay_min": self._security_delay_min,
"security_min_on_percent": self._security_min_on_percent,
"security_default_on_percent": self._security_default_on_percent,
@@ -2391,19 +2484,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
.astimezone(self._current_tz)
.isoformat(),
"timezone": str(self._current_tz),
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec,
"window_auto_enabled": self.is_window_auto_enabled,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"power_sensor_entity_id": self._power_sensor_entity_id,
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
"temperature_unit": self.temperature_unit,
"is_device_active": self.is_device_active,
"ema_temp": self._ema_temp,
"is_used_by_central_boiler": self.is_used_by_central_boiler,
}
@callback
@@ -2526,8 +2610,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
def send_event(self, event_type: EventType, data: dict):
"""Send an event"""
_LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data)
data["entity_id"] = self.entity_id
data["name"] = self.name
data["state_attributes"] = self.state_attributes
self._hass.bus.fire(event_type.value, data)
send_vtherm_event(self._hass, event_type=event_type, entity=self, data=data)
# _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data)
# data["entity_id"] = self.entity_id
# data["name"] = self.name
# data["state_attributes"] = self.state_attributes
# self._hass.bus.fire(event_type.value, data)