central boiler - add entites to fine tune the boiler start

This commit is contained in:
Jean-Marc Collin
2024-01-20 10:40:55 +00:00
parent 60de43f241
commit 49ebf9ddae
9 changed files with 377 additions and 73 deletions

View File

@@ -69,10 +69,10 @@ input_number:
icon: mdi:pipe-valve
unit_of_measurement: percentage
fake_boiler_temperature:
name: Boiler temperature
name: Central thermostat temp
min: 0
max: 30
icon: mdi:water-boiler
icon: mdi:thermostat
unit_of_measurement: °C
mode: box

View File

@@ -1166,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)"""
@@ -1628,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."""

View File

@@ -1,5 +1,5 @@
""" Implements the VersatileThermostat binary sensors component """
# pylint: disable=unused-argument
# pylint: disable=unused-argument, line-too-long
import logging
@@ -14,7 +14,6 @@ from homeassistant.core import (
from homeassistant.const import STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.components.binary_sensor import (
@@ -25,14 +24,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.climate import (
ClimateEntity,
HVACMode,
HVACAction,
DOMAIN as CLIMATE_DOMAIN,
)
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from .vtherm_api import VersatileThermostatAPI
from .commons import (
VersatileThermostatBaseEntity,
@@ -399,7 +390,7 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
self._hass
)
api.register_central_boiler(self)
await self.listen_vtherms_entities()
await self.listen_nb_active_vtherm_entity()
if self.hass.state == CoreState.running:
await _async_startup_internal()
@@ -408,29 +399,28 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
async def listen_vtherms_entities(self):
async def listen_nb_active_vtherm_entity(self):
"""Initialize the listening of state change of VTherms"""
# Listen to all VTherm state change
self._entities = []
entities_id = []
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if isinstance(entity, BaseThermostat) and entity.is_used_by_central_boiler:
self._entities.append(entity)
entities_id.append(entity.entity_id)
if len(self._entities) > 0:
# Arme l'écoute de la première entité
if (
api.nb_active_vtherm_for_boiler_entity
and api.nb_active_device_for_boiler_threshold_entity
):
listener_cancel = async_track_state_change_event(
self._hass,
entities_id,
[
api.nb_active_vtherm_for_boiler_entity.entity_id,
api.nb_active_device_for_boiler_threshold_entity.entity_id,
],
self.calculate_central_boiler_state,
)
_LOGGER.info(
"%s - VTherm that could controls the central boiler are %s",
_LOGGER.debug(
"%s - entity to get the nb of active VTherm is %s",
self,
entities_id,
api.nb_active_vtherm_for_boiler_entity.entity_id,
)
self.async_on_remove(listener_cancel)
else:
@@ -443,18 +433,20 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
controls this central boiler"""
_LOGGER.debug("%s - calculating the new central boiler state", self)
active = False
for entity in self._entities:
_LOGGER.debug(
"Examining the hvac_action of %s",
entity.name,
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
if (
api.nb_active_vtherm_for_boiler is None
or api.nb_active_device_for_boiler_threshold is None
):
_LOGGER.warning(
"%s - the entities to calculate the boiler state are not initialized. Boiler state cannot be calculated",
self,
)
if (
entity.hvac_mode == HVACMode.HEAT
and entity.hvac_action == HVACAction.HEATING
):
active = True
break
return False
active = (
api.nb_active_vtherm_for_boiler >= api.nb_active_device_for_boiler_threshold
)
if self._attr_is_on != active:
try:

View File

@@ -44,6 +44,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SELECT,
Platform.NUMBER,
]
CONF_HEATER = "heater_entity_id"

View File

@@ -0,0 +1,117 @@
# pylint: disable=unused-argument
""" Implements the VersatileThermostat select component """
import logging
# from homeassistant.const import EVENT_HOMEASSISTANT_START
from homeassistant.core import HomeAssistant, CoreState # , callback
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_ADD_CENTRAL_BOILER_CONTROL,
overrides,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat selects with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
is_central_boiler = entry.data.get(CONF_ADD_CENTRAL_BOILER_CONTROL)
if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG or not is_central_boiler:
return
entities = [
ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data),
]
async_add_entities(entities, True)
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.register_central_boiler_activation_number_threshold(entities[0])
class ActivateBoilerThresholdNumber(NumberEntity, RestoreEntity):
"""Representation of the threshold of the number of VTherm
which should be active to activate the boiler"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
self._hass = hass
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Boiler Activation threshold"
self._attr_unique_id = "boiler_activation_threshold"
self._attr_value = self._attr_native_value = 1 # default value
self._attr_native_min_value = 1
self._attr_native_max_value = 9
self._attr_step = 1 # default value
self._attr_mode = NumberMode.AUTO
@property
def icon(self) -> str | None:
if isinstance(self._attr_native_value, int):
val = int(self._attr_native_value)
return f"mdi:numeric-{val}-box-outline"
else:
return "mdi:numeric-0-box-outline"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old_state: CoreState = await self.async_get_last_state()
_LOGGER.debug(
"%s - Calling async_added_to_hass old_state is %s", self, old_state
)
if old_state is not None:
self._attr_value = self._attr_native_value = int(float(old_state.state))
@overrides
def set_native_value(self, value: float) -> None:
"""Change the value"""
int_value = int(value)
old_value = int(self._attr_native_value)
if int_value == old_value:
return
self._attr_value = self._attr_native_value = int_value
def __str__(self):
return f"VersatileThermostat-{self.name}"

View File

@@ -55,7 +55,7 @@ async def async_setup_entry(
class CentralModeSelect(SelectEntity, RestoreEntity):
"""Representation of a Energy sensor which exposes the energy"""
"""Representation of the central mode choice"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""

View File

@@ -3,9 +3,15 @@
import logging
import math
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.core import HomeAssistant, callback, Event, CoreState
from homeassistant.const import UnitOfTime, UnitOfPower, UnitOfEnergy, PERCENTAGE
from homeassistant.const import (
UnitOfTime,
UnitOfPower,
UnitOfEnergy,
PERCENTAGE,
EVENT_HOMEASSISTANT_START,
)
from homeassistant.components.sensor import (
SensorEntity,
@@ -16,9 +22,24 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.components.climate import (
ClimateEntity,
DOMAIN as CLIMATE_DOMAIN,
HVACAction,
HVACMode,
)
from .base_thermostat import BaseThermostat
from .vtherm_api import VersatileThermostatAPI
from .commons import VersatileThermostatBaseEntity
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_DEVICE_POWER,
CONF_PROP_FUNCTION,
@@ -28,6 +49,7 @@ from .const import (
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
overrides,
)
THRESHOLD_WATT_KILO = 100
@@ -49,33 +71,39 @@ async def async_setup_entry(
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
entities = None
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
return
entities = [NbActiveDeviceForBoilerSensor(hass, unique_id, name, entry.data)]
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.register_nb_vtherm_active_boiler(entities[0])
else:
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
EMATemperatureSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) in [
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE,
]:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
entities = [
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
EMATemperatureSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) in [
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE,
]:
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
entities.append(OnPercentSensor(hass, unique_id, name, entry.data))
entities.append(OnTimeSensor(hass, unique_id, name, entry.data))
entities.append(OffTimeSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI:
entities.append(OnPercentSensor(hass, unique_id, name, entry.data))
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:
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE:
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE:
entities.append(RegulatedTemperatureSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE:
entities.append(
RegulatedTemperatureSensor(hass, unique_id, name, entry.data)
)
async_add_entities(entities, True)
@@ -597,3 +625,112 @@ class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 2
class NbActiveDeviceForBoilerSensor(SensorEntity):
"""Representation of the threshold of the number of VTherm
which should be active to activate the boiler"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
self._hass = hass
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Nb device active for boiler"
self._attr_unique_id = "nb_device_active_boiler"
self._attr_value = self._attr_native_value = None # default value
self._entities = []
@property
def icon(self) -> str | None:
return "mdi:heat-wave"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 0
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
@callback
async def _async_startup_internal(*_):
_LOGGER.debug("%s - Calling async_startup_internal", self)
await self.listen_vtherms_entities()
if self.hass.state == CoreState.running:
await _async_startup_internal()
else:
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
async def listen_vtherms_entities(self):
"""Initialize the listening of state change of VTherms"""
# Listen to all VTherm state change
self._entities = []
entities_id = []
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if isinstance(entity, BaseThermostat) and entity.is_used_by_central_boiler:
self._entities.append(entity)
entities_id.append(entity.entity_id)
if len(self._entities) > 0:
# Arme l'écoute de la première entité
listener_cancel = async_track_state_change_event(
self._hass,
entities_id,
self.calculate_nb_active_devices,
)
_LOGGER.info(
"%s - VTherm that could controls the central boiler are %s",
self,
entities_id,
)
self.async_on_remove(listener_cancel)
else:
_LOGGER.debug("%s - no VTherm could controls the central boiler", self)
await self.calculate_nb_active_devices(None)
async def calculate_nb_active_devices(self, _):
"""Calculate the number of active VTherm that have an
influence on central boiler"""
_LOGGER.debug("%s - calculating the number of active VTherm", self)
nb_active = 0
for entity in self._entities:
_LOGGER.debug(
"Examining the hvac_action of %s",
entity.name,
)
if (
entity.hvac_mode == HVACMode.HEAT
and entity.hvac_action == HVACAction.HEATING
):
for under in entity.underlying_entities:
nb_active += 1 if under.is_device_active else 0
self._attr_native_value = nb_active
self.async_write_ha_state()
def __str__(self):
return f"VersatileThermostat-{self.name}"

View File

@@ -133,14 +133,14 @@ class UnderlyingEntity:
async def check_initial_state(self, hvac_mode: HVACMode):
"""Prevent the underlying to be on but thermostat is off"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
_LOGGER.warning(
_LOGGER.info(
"%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s",
self,
self._entity_id,
)
await self.set_hvac_mode(hvac_mode)
elif hvac_mode != HVACMode.OFF and not self.is_device_active:
_LOGGER.warning(
_LOGGER.info(
"%s - The hvac mode is %s, but the underlying device is not ON. Turning on device %s if needed",
self,
hvac_mode,
@@ -771,12 +771,14 @@ class UnderlyingValve(UnderlyingEntity):
async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool:
"""Set the HVACmode. Returns true if something have change"""
if hvac_mode == HVACMode.OFF and self.is_device_active:
await self.turn_off()
if hvac_mode != HVACMode.OFF and not self.is_device_active:
await self.turn_on()
if self._hvac_mode != hvac_mode:
self._hvac_mode = hvac_mode
if hvac_mode == HVACMode.OFF:
await self.turn_off()
else:
await self.turn_on()
return True
else:
return False

View File

@@ -1,6 +1,6 @@
""" The API of Versatile Thermostat"""
import logging
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, HassJob
from homeassistant.config_entries import ConfigEntry
from .const import (
@@ -49,6 +49,8 @@ class VersatileThermostatAPI(dict):
self._short_ema_params = None
self._safety_mode = None
self._central_boiler_entity = None
self._threshold_number_entity = None
self._nb_active_number_entity = None
def find_central_configuration(self):
"""Search for a central configuration"""
@@ -99,10 +101,26 @@ class VersatileThermostatAPI(dict):
class to register itself at creation"""
self._central_boiler_entity = central_boiler_entity
def register_central_boiler_activation_number_threshold(
self, threshold_number_entity
):
"""register the two number entities needed for boiler activation"""
self._threshold_number_entity = threshold_number_entity
def register_nb_vtherm_active_boiler(self, nb_active_number_entity):
"""register the two number entities needed for boiler activation"""
self._nb_active_number_entity = nb_active_number_entity
if self._central_boiler_entity:
job = HassJob(
self._central_boiler_entity.listen_nb_active_vtherm_entity,
"init listen nb active VTherm",
)
self._hass.async_run_hass_job(job)
async def reload_central_boiler_entities_list(self):
"""Reload the central boiler list of entities if a central boiler is used"""
if self._central_boiler_entity is not None:
await self._central_boiler_entity.listen_vtherms_entities()
if self._nb_active_number_entity is not None:
await self._nb_active_number_entity.listen_vtherms_entities()
@property
def self_regulation_expert(self):
@@ -119,6 +137,35 @@ class VersatileThermostatAPI(dict):
"""Get the safety_mode params"""
return self._safety_mode
@property
def nb_active_vtherm_for_boiler(self):
"""Returns the number of active VTherm which have an
influence on boiler"""
if self._nb_active_number_entity is None:
return None
else:
return self._nb_active_number_entity.native_value
@property
def nb_active_vtherm_for_boiler_entity(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
return self._nb_active_number_entity
@property
def nb_active_device_for_boiler_threshold_entity(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
return self._threshold_number_entity
@property
def nb_active_device_for_boiler_threshold(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
if self._threshold_number_entity is None:
return None
return int(self._threshold_number_entity.native_value)
@property
def hass(self):
"""Get the HomeAssistant object"""