1101 lines
40 KiB
Python
1101 lines
40 KiB
Python
# pylint: disable=unused-argument
|
|
""" Implements the VersatileThermostat sensors component """
|
|
import logging
|
|
from vtherm_api.log_collector import get_vtherm_logger
|
|
import math
|
|
from collections.abc import Callable
|
|
|
|
from homeassistant.core import HomeAssistant, callback, Event, State
|
|
|
|
from homeassistant.const import (
|
|
UnitOfTime,
|
|
UnitOfPower,
|
|
UnitOfEnergy,
|
|
PERCENTAGE,
|
|
)
|
|
|
|
from homeassistant.components.sensor import (
|
|
SensorEntity,
|
|
SensorDeviceClass,
|
|
SensorStateClass,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
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,
|
|
)
|
|
|
|
|
|
from .base_thermostat import BaseThermostat
|
|
from .vtherm_central_api import VersatileThermostatAPI
|
|
from .base_entity import VersatileThermostatBaseEntity
|
|
from .commons import cleanup_orphan_entity
|
|
from .const import (
|
|
DOMAIN,
|
|
DEVICE_MANUFACTURER,
|
|
CONF_NAME,
|
|
CONF_DEVICE_POWER,
|
|
CONF_PROP_FUNCTION,
|
|
PROPORTIONAL_FUNCTION_TPI,
|
|
CONF_THERMOSTAT_SWITCH,
|
|
CONF_THERMOSTAT_VALVE,
|
|
CONF_THERMOSTAT_CLIMATE,
|
|
CONF_THERMOSTAT_TYPE,
|
|
CONF_THERMOSTAT_CENTRAL_CONFIG,
|
|
CONF_USE_CENTRAL_BOILER_FEATURE,
|
|
CONF_AUTO_REGULATION_VALVE,
|
|
CONF_AUTO_REGULATION_MODE,
|
|
CONF_TPI_COEF_INT,
|
|
CONF_TPI_COEF_EXT,
|
|
CONF_AUTO_TPI_MODE,
|
|
overrides,
|
|
)
|
|
|
|
THRESHOLD_WATT_KILO = 100
|
|
|
|
_LOGGER = get_vtherm_logger(__name__)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the VersatileThermostat sensors with config flow."""
|
|
unique_id = entry.entry_id
|
|
name = entry.data.get(CONF_NAME)
|
|
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
|
|
|
|
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
|
|
have_valve_regulation = (
|
|
entry.data.get(CONF_AUTO_REGULATION_MODE) == CONF_AUTO_REGULATION_VALVE
|
|
)
|
|
|
|
entities = None
|
|
|
|
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
|
|
if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE):
|
|
entities = [
|
|
NbActiveDeviceForBoilerSensor(hass, unique_id, name, entry.data),
|
|
TotalPowerActiveDeviceForBoilerSensor(hass, unique_id, name, entry.data),
|
|
]
|
|
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 have_valve_regulation or entry.data.get(CONF_THERMOSTAT_TYPE) in [
|
|
CONF_THERMOSTAT_SWITCH,
|
|
CONF_THERMOSTAT_VALVE,
|
|
]:
|
|
entities.append(MeanPowerSensor(hass, unique_id, name, entry.data))
|
|
|
|
is_tpi_capable = entry.data.get(CONF_THERMOSTAT_TYPE) in [
|
|
CONF_THERMOSTAT_SWITCH,
|
|
CONF_THERMOSTAT_VALVE,
|
|
] or (
|
|
entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE
|
|
and have_valve_regulation
|
|
)
|
|
has_proportional_algorithm = (
|
|
is_tpi_capable and entry.data.get(CONF_PROP_FUNCTION) is not None
|
|
)
|
|
|
|
if has_proportional_algorithm:
|
|
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
|
|
or have_valve_regulation
|
|
):
|
|
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
|
|
|
|
if (
|
|
entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE
|
|
and not have_valve_regulation
|
|
):
|
|
entities.append(
|
|
RegulatedTemperatureSensor(hass, unique_id, name, entry.data)
|
|
)
|
|
|
|
# Add Auto TPI Sensor only if:
|
|
# 1. Thermostat is TPI-capable
|
|
# 2. TPI algorithm is selected
|
|
# 3. Auto TPI mode is enabled in configuration
|
|
should_have_auto_tpi = (
|
|
is_tpi_capable
|
|
and entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI
|
|
and entry.data.get(CONF_AUTO_TPI_MODE, False)
|
|
)
|
|
|
|
if should_have_auto_tpi:
|
|
entities.append(AutoTpiSensor(hass, unique_id, name, entry.data))
|
|
else:
|
|
# Cleanup: Remove orphan Auto TPI sensor from registry if it exists
|
|
await cleanup_orphan_entity(hass, entry, "sensor", name, "auto_tpi_learning")
|
|
|
|
if entities:
|
|
async_add_entities(entities, True)
|
|
|
|
|
|
class EnergySensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a Energy sensor which exposes the energy"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Energy"
|
|
self._attr_unique_id = f"{self._device_name}_energy"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
energy = self.my_climate.total_energy
|
|
if energy is None:
|
|
return
|
|
|
|
if math.isnan(energy) or math.isinf(energy):
|
|
raise ValueError(f"Sensor has illegal state {self.my_climate.total_energy}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(energy, self.suggested_display_precision)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:lightning-bolt"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
if not self.my_climate:
|
|
return None
|
|
return SensorDeviceClass.ENERGY
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
if not self.my_climate:
|
|
return None
|
|
return SensorStateClass.TOTAL_INCREASING
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
if not self.my_climate:
|
|
return None
|
|
|
|
if self.my_climate.power_manager.device_power > THRESHOLD_WATT_KILO:
|
|
return UnitOfEnergy.WATT_HOUR
|
|
else:
|
|
return UnitOfEnergy.KILO_WATT_HOUR
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 3
|
|
|
|
|
|
class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a power sensor which exposes the mean power in a cycle"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Mean power cycle"
|
|
self._attr_unique_id = f"{self._device_name}_mean_power_cycle"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
mean_cycle_power = self.my_climate.power_manager.mean_cycle_power
|
|
if mean_cycle_power is None:
|
|
return
|
|
|
|
if math.isnan(float(mean_cycle_power)) or math.isinf(float(mean_cycle_power)):
|
|
raise ValueError(f"Sensor has illegal state {mean_cycle_power}")
|
|
|
|
mean_cycle_power = float(mean_cycle_power)
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(
|
|
mean_cycle_power,
|
|
self.suggested_display_precision,
|
|
)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:flash-outline"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.POWER
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
if not self.my_climate:
|
|
return None
|
|
|
|
if self.my_climate.power_manager.device_power > THRESHOLD_WATT_KILO:
|
|
return UnitOfPower.WATT
|
|
else:
|
|
return UnitOfPower.KILO_WATT
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 3
|
|
|
|
|
|
class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a on percent sensor which exposes the on_percent in a cycle"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Power percent"
|
|
self._attr_unique_id = f"{self._device_name}_power_percent"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
raw_on_percent = (
|
|
self.my_climate.proportional_algorithm.on_percent
|
|
if self.my_climate
|
|
and self.my_climate.has_prop
|
|
and self.my_climate.proportional_algorithm is not None
|
|
else None
|
|
)
|
|
if raw_on_percent is None:
|
|
return
|
|
on_percent = float(raw_on_percent)
|
|
|
|
if math.isnan(on_percent) or math.isinf(on_percent):
|
|
raise ValueError(f"Sensor has illegal state {on_percent}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(
|
|
on_percent * 100.0, self.suggested_display_precision
|
|
)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:meter-electric-outline"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.POWER_FACTOR
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
return PERCENTAGE
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 1
|
|
|
|
|
|
class AutoTpiSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of the Auto TPI Learning state"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the Auto TPI sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Auto TPI Learning State"
|
|
self._attr_unique_id = f"{self._device_name}_auto_tpi_learning"
|
|
self._attr_icon = "mdi:brain"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
|
|
# Verify has_prop and proportional_algorithm
|
|
# proportional_algorithm can be None during initialization even if has_prop is True
|
|
if not self.my_climate or not self.my_climate.has_prop:
|
|
self._attr_native_value = "disabled"
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
if not hasattr(self.my_climate, "auto_tpi_manager") or not self.my_climate.auto_tpi_manager:
|
|
self._attr_native_value = "disabled"
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
manager = self.my_climate.auto_tpi_manager
|
|
|
|
# Determine state
|
|
if manager.learning_active:
|
|
if manager.is_in_bootstrap:
|
|
self._attr_native_value = "Bootstrap"
|
|
else:
|
|
self._attr_native_value = "Active"
|
|
else:
|
|
self._attr_native_value = "Off" # Or "Completed" / "Idle" depending on context, but "Off" implies not learning.
|
|
|
|
# Update attributes
|
|
# Update attributes
|
|
self._attr_extra_state_attributes = {
|
|
"coeff_int_cycles": manager.int_cycles,
|
|
"coeff_ext_cycles": manager.ext_cycles,
|
|
"heating_cycles_count": manager.heating_cycles_count, # Total heating cycles
|
|
"thermal_time_constant_hours": manager.time_constant, # Building's thermal time constant τ
|
|
"model_confidence": manager.confidence, # Model confidence (R² or percentage)
|
|
"last_learning_status": manager.state.last_learning_status, # Reason for last learning outcome
|
|
"learning_start_dt": manager.state.learning_start_date,
|
|
"allow_kint_boost_on_stagnation": manager.state.allow_kint_boost,
|
|
"allow_kext_compensation_on_overshoot": manager.state.allow_kext_overshoot,
|
|
"bootstrap_failure_count": manager.state.bootstrap_failure_count,
|
|
}
|
|
|
|
# Add mode-specific attributes
|
|
if manager._current_hvac_mode == "cool":
|
|
self._attr_extra_state_attributes.update({
|
|
"max_capacity_cool": manager.state.max_capacity_cool,
|
|
})
|
|
else:
|
|
self._attr_extra_state_attributes.update({
|
|
"max_capacity_heat": manager.state.max_capacity_heat,
|
|
"capacity_heat_status": "learning" if manager.is_in_bootstrap else "learned",
|
|
"capacity_heat_value": manager.state.max_capacity_heat,
|
|
"capacity_heat_count": manager.state.capacity_heat_learn_count,
|
|
})
|
|
|
|
# Add calculated TPI coefficients
|
|
calculated = manager.get_calculated_params()
|
|
self._attr_extra_state_attributes.update({
|
|
"calculated_coef_int": calculated.get(CONF_TPI_COEF_INT),
|
|
"calculated_coef_ext": calculated.get(CONF_TPI_COEF_EXT),
|
|
})
|
|
|
|
self.async_write_ha_state()
|
|
|
|
|
|
class ValveOpenPercentSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a on percent sensor which exposes the on_percent in a cycle"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Valve open percent"
|
|
self._attr_unique_id = f"{self._device_name}_valve_open_percent"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
if not self.my_climate or not hasattr(self.my_climate, "valve_open_percent"):
|
|
_LOGGER.warning("%s - my_climate not found or no valve_open_percent property found. This could be normal at startup. Ignore the underlying device change.", self)
|
|
return
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = self.my_climate.valve_open_percent
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:pipe-valve"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.POWER_FACTOR
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
return PERCENTAGE
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 0
|
|
|
|
|
|
class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a on time sensor which exposes the on_time_sec in a cycle"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "On time"
|
|
self._attr_unique_id = f"{self._device_name}_on_time"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
on_time = (
|
|
float(self.my_climate.on_time_sec)
|
|
if self.my_climate and hasattr(self.my_climate, "on_time_sec")
|
|
else None
|
|
)
|
|
|
|
if on_time is None:
|
|
return
|
|
|
|
if math.isnan(on_time) or math.isinf(on_time):
|
|
raise ValueError(f"Sensor has illegal state {on_time}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(on_time)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:timer-play"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.DURATION
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
return UnitOfTime.SECONDS
|
|
|
|
|
|
class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a on time sensor which exposes the off_time_sec in a cycle"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Off time"
|
|
self._attr_unique_id = f"{self._device_name}_off_time"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
off_time = (
|
|
float(self.my_climate.off_time_sec)
|
|
if self.my_climate and hasattr(self.my_climate, "off_time_sec")
|
|
else None
|
|
)
|
|
if off_time is None:
|
|
return
|
|
|
|
if math.isnan(off_time) or math.isinf(off_time):
|
|
raise ValueError(f"Sensor has illegal state {off_time}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(off_time)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:timer-off-outline"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.DURATION
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
return UnitOfTime.SECONDS
|
|
|
|
|
|
class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a last temperature datetime sensor"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the last temperature datetime sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Last temperature date"
|
|
self._attr_entity_registry_enabled_default = False
|
|
self._attr_unique_id = f"{self._device_name}_last_temp_datetime"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = self.my_climate.last_temperature_measure
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:home-clock"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.TIMESTAMP
|
|
|
|
|
|
class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a last external temperature datetime sensor"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the last temperature datetime sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Last external temperature date"
|
|
self._attr_entity_registry_enabled_default = False
|
|
self._attr_unique_id = f"{self._device_name}_last_ext_temp_datetime"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = self.my_climate.last_ext_temperature_measure
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:sun-clock"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.TIMESTAMP
|
|
|
|
|
|
class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a sensor which exposes the temperature slope curve"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the slope sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Temperature slope"
|
|
self._attr_unique_id = f"{self._device_name}_temperature_slope"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
last_slope = self.my_climate.last_temperature_slope
|
|
if last_slope is None:
|
|
return
|
|
|
|
if math.isnan(last_slope) or math.isinf(last_slope):
|
|
raise ValueError(f"Sensor has illegal state {last_slope}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(last_slope, self.suggested_display_precision)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
if (
|
|
self._attr_native_value is None
|
|
or not isinstance(self._attr_native_value, (int, float))
|
|
or self._attr_native_value == 0
|
|
):
|
|
return "mdi:thermometer"
|
|
elif self._attr_native_value > 0:
|
|
return "mdi:thermometer-chevron-up"
|
|
else:
|
|
return "mdi:thermometer-chevron-down"
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
if not self.my_climate:
|
|
return None
|
|
|
|
return self.my_climate.temperature_unit + "/hour"
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 2
|
|
|
|
|
|
class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a Energy sensor which exposes the energy"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the regulated temperature sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "Regulated temperature"
|
|
self._attr_unique_id = f"{self._device_name}_regulated_temperature"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
new_temp = self.my_climate.regulated_target_temp
|
|
if new_temp is None:
|
|
return
|
|
|
|
if math.isnan(new_temp) or math.isinf(new_temp):
|
|
raise ValueError(f"Sensor has illegal state {new_temp}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = round(new_temp, self.suggested_display_precision)
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:thermometer-auto"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.TEMPERATURE
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
if not self.my_climate:
|
|
return self.hass.config.units.temperature_unit
|
|
return self.my_climate.temperature_unit
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 1
|
|
|
|
|
|
class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|
"""Representation of a Exponential Moving Average temp"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the regulated temperature sensor"""
|
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
|
self._attr_name = "EMA temperature"
|
|
self._attr_unique_id = f"{self._device_name}_ema_temperature"
|
|
|
|
@callback
|
|
async def async_my_climate_changed(self, event: Event = None):
|
|
"""Called when my climate have change"""
|
|
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
|
|
|
new_ema = self.my_climate.ema_temperature
|
|
if new_ema is None:
|
|
return
|
|
|
|
if math.isnan(new_ema) or math.isinf(new_ema):
|
|
raise ValueError(f"Sensor has illegal state {new_ema}")
|
|
|
|
old_state = self._attr_native_value
|
|
self._attr_native_value = new_ema
|
|
if old_state != self._attr_native_value:
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:thermometer-lines"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.TEMPERATURE
|
|
|
|
@property
|
|
def state_class(self) -> SensorStateClass | None:
|
|
return SensorStateClass.MEASUREMENT
|
|
|
|
@property
|
|
def native_unit_of_measurement(self) -> str | None:
|
|
if not self.my_climate:
|
|
return self.hass.config.units.temperature_unit
|
|
return self.my_climate.temperature_unit
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 2
|
|
|
|
|
|
class NbActiveDeviceForBoilerSensor(SensorEntity):
|
|
"""Representation of the number of VTherm
|
|
which are active and configured to activate the boiler"""
|
|
|
|
# TODO remove all listener mecanisms
|
|
_entity_component_unrecorded_attributes = SensorEntity._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
|
|
frozenset({"active_device_ids"})
|
|
)
|
|
|
|
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 = []
|
|
self._attr_active_device_ids = [] # Holds the entity ids of active devices``
|
|
self._cancel_listener_nb_active: Callable | None = None
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict:
|
|
"""Return additional attributes for the sensor."""
|
|
return {
|
|
"active_device_ids": self._attr_active_device_ids,
|
|
}
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:heat-wave"
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Return the device info."""
|
|
return DeviceInfo(
|
|
entry_type=None,
|
|
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()
|
|
|
|
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
|
|
api.central_boiler_manager.register_nb_device_active_boiler(self)
|
|
|
|
async def listen_vtherms_entities(self):
|
|
"""Initialize the listening of state change of VTherms"""
|
|
|
|
# Listen to all VTherm underlying state change
|
|
self._entities = []
|
|
# underlying_entities_id = []
|
|
|
|
component: EntityComponent[ClimateEntity] = self.hass.data.get(CLIMATE_DOMAIN)
|
|
if component is None:
|
|
_LOGGER.warning("%s - No climate component found in hass.data", self)
|
|
return
|
|
|
|
self.cancel_listening_nb_active()
|
|
|
|
for entity in list(component.entities):
|
|
if isinstance(entity, BaseThermostat) and entity.is_used_by_central_boiler:
|
|
self._entities.append(entity)
|
|
# for under in entity.activable_underlying_entities:
|
|
# underlying_entities_id.append(under.entity_id)
|
|
# if len(underlying_entities_id) > 0:
|
|
# Arme l'écoute de la première entité
|
|
# self._cancel_listener_nb_active = async_track_state_change_event(
|
|
# self._hass,
|
|
# underlying_entities_id,
|
|
# self.calculate_nb_active_devices,
|
|
# )
|
|
# _LOGGER.info(
|
|
# "%s - the underlyings that could control the central boiler are %s",
|
|
# self,
|
|
# underlying_entities_id,
|
|
# )
|
|
# Fix 1406
|
|
# self.async_on_remove(self._cancel_listener_nb_active)
|
|
# else:
|
|
# _LOGGER.debug("%s - no VTherm could control the central boiler", self)
|
|
|
|
await self.calculate_nb_active_devices(None)
|
|
|
|
async def calculate_nb_active_devices(self, event: Event):
|
|
"""Calculate the number of active VTherm and the total active power that have an
|
|
influence on the central boiler and update the list of active device names."""
|
|
|
|
# _LOGGER.debug("%s- calculate_nb_active_devices_or_power - the event is %s ", self, event)
|
|
|
|
if event is not None:
|
|
new_state: State = event.data.get("new_state")
|
|
# _LOGGER.debug(
|
|
# "%s - calculate_nb_active_devices_or_power new_state is %s", self, new_state
|
|
# )
|
|
if not new_state:
|
|
return
|
|
|
|
old_state: State = event.data.get("old_state")
|
|
|
|
# For underlying climate, we need to observe also the hvac_action if available
|
|
new_hvac_action = new_state.attributes.get("hvac_action")
|
|
old_hvac_action = old_state.attributes.get("hvac_action") if old_state is not None else None
|
|
|
|
# Filter events that are not interesting for us
|
|
if (
|
|
old_state is not None
|
|
and new_state.state == old_state.state
|
|
and new_hvac_action == old_hvac_action
|
|
# issue 698 - force recalculation when underlying climate doesn't have any hvac_action
|
|
and new_hvac_action is not None
|
|
):
|
|
# A false state change
|
|
return
|
|
|
|
_LOGGER.debug(
|
|
"%s - calculating the number of active underlying device for boiler activation. change change from %s to %s",
|
|
self,
|
|
old_state,
|
|
new_state,
|
|
)
|
|
else:
|
|
_LOGGER.debug(
|
|
"%s - calculating the number of active underlying device for boiler activation. First time calculation",
|
|
self,
|
|
)
|
|
|
|
nb_active = 0
|
|
active_device_ids = []
|
|
|
|
for entity in self._entities:
|
|
device_actives = entity.device_actives
|
|
_LOGGER.debug(
|
|
"%s - After examining the device_actives, device_actives is %s",
|
|
entity.name,
|
|
device_actives,
|
|
)
|
|
|
|
nb_active += len(device_actives)
|
|
active_device_ids.extend(device_actives)
|
|
|
|
self._attr_native_value = nb_active
|
|
self._attr_active_device_ids = active_device_ids
|
|
|
|
self.async_write_ha_state()
|
|
await VersatileThermostatAPI.get_vtherm_api(self._hass).central_boiler_manager.refresh_central_boiler_custom_attributes()
|
|
|
|
@property
|
|
def active_device_ids(self) -> list:
|
|
"""Get the list of active device id"""
|
|
return self._attr_active_device_ids
|
|
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
def cancel_listening_nb_active(self):
|
|
"""Cancel the listening of underlying VTherm state changes"""
|
|
if self._cancel_listener_nb_active is not None:
|
|
try:
|
|
self._cancel_listener_nb_active() # pylint: disable=not-callable
|
|
except (ValueError, TypeError): # the listener could be already cancelled
|
|
pass
|
|
self._cancel_listener_nb_active = None
|
|
|
|
@overrides
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
"""Called when entity is about to be removed from hass"""
|
|
self.cancel_listening_nb_active()
|
|
|
|
|
|
class TotalPowerActiveDeviceForBoilerSensor(NbActiveDeviceForBoilerSensor):
|
|
"""Representation of the total power of VTherm
|
|
which are active and configured to activate the boiler"""
|
|
|
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
|
"""Initialize the energy sensor"""
|
|
super().__init__(hass, unique_id, name, entry_infos)
|
|
self._attr_name = "Total power active for boiler"
|
|
self._attr_unique_id = "total_power_active_boiler"
|
|
self._attr_value = self._attr_native_value = None # default value
|
|
self._cancel_listener_power: callable | None = None
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
return "mdi:flash-auto"
|
|
|
|
@property
|
|
def device_class(self) -> SensorDeviceClass | None:
|
|
return SensorDeviceClass.POWER
|
|
|
|
@property
|
|
def suggested_display_precision(self) -> int | None:
|
|
"""Return the suggested number of decimal digits for display."""
|
|
return 2
|
|
|
|
@overrides
|
|
async def async_added_to_hass(self) -> None:
|
|
# do not ! await super().async_added_to_hass()
|
|
|
|
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
|
|
api.central_boiler_manager.register_total_power_active_boiler(self)
|
|
|
|
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.get(CLIMATE_DOMAIN)
|
|
if component is None:
|
|
_LOGGER.warning("%s - No climate component found in hass.data", self)
|
|
return
|
|
|
|
self.cancel_listening_power()
|
|
|
|
for entity in list(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é
|
|
self._cancel_listener_power = async_track_state_change_event(
|
|
self._hass,
|
|
entities_id,
|
|
self.calculate_total_power,
|
|
)
|
|
_LOGGER.info(
|
|
"%s - the VTherm that could control the central boiler are %s",
|
|
self,
|
|
entities_id,
|
|
)
|
|
# Fix 1406
|
|
# self.async_on_remove(self._cancel_listener_power)
|
|
else:
|
|
_LOGGER.debug("%s - no VTherm could control the central boiler", self)
|
|
|
|
await self.calculate_total_power(None)
|
|
|
|
async def calculate_total_power(self, event: Event):
|
|
"""Calculate the total active power that have an
|
|
influence on the central boiler and update the list of active device names."""
|
|
|
|
# _LOGGER.debug("%s- calculate_nb_active_devices_or_power - the event is %s ", self, event)
|
|
|
|
if event is not None:
|
|
new_state: State = event.data.get("new_state")
|
|
# _LOGGER.debug(
|
|
# "%s - calculate_nb_active_devices_or_power new_state is %s", self, new_state
|
|
# )
|
|
if not new_state:
|
|
return
|
|
|
|
old_state: State = event.data.get("old_state")
|
|
|
|
# For underlying climate, we need to observe also the hvac_action if available
|
|
new_mean_cycle_power = new_state.attributes.get("power_manager", {}).get("mean_cycle_power")
|
|
old_mean_cycle_power = old_state.attributes.get("power_manager", {}).get("mean_cycle_power") if old_state is not None else None
|
|
|
|
# Filter events that are not interesting for us
|
|
if old_state is not None and new_mean_cycle_power == old_mean_cycle_power:
|
|
# A false state change
|
|
return
|
|
|
|
_LOGGER.debug(
|
|
"%s - calculating the total power of active underlying device for boiler activation. mean_power_cycle change from %s to %s",
|
|
self,
|
|
old_mean_cycle_power,
|
|
new_mean_cycle_power,
|
|
)
|
|
else:
|
|
_LOGGER.debug(
|
|
"%s - calculating the total power of active underlying device for boiler activation. First time calculation",
|
|
self,
|
|
)
|
|
|
|
total_active_power = 0
|
|
active_device_ids = []
|
|
|
|
for entity in self._entities:
|
|
mean_cycle_power = entity.power_manager.mean_cycle_power
|
|
if mean_cycle_power is None or mean_cycle_power <= 0:
|
|
continue
|
|
|
|
_LOGGER.debug(
|
|
"%s - After examining the mean_cycle_power, mean_cycle_power is %s",
|
|
entity.name,
|
|
mean_cycle_power,
|
|
)
|
|
|
|
active_device_ids.extend([under.entity_id for under in entity.activable_underlying_entities])
|
|
|
|
total_active_power += mean_cycle_power
|
|
|
|
self._attr_native_value = total_active_power
|
|
self._attr_active_device_ids = active_device_ids
|
|
|
|
self.async_write_ha_state()
|
|
await VersatileThermostatAPI.get_vtherm_api(self._hass).central_boiler_manager.refresh_central_boiler_custom_attributes()
|
|
|
|
@property
|
|
def active_device_ids(self) -> list:
|
|
"""Get the list of active device id"""
|
|
return self._attr_active_device_ids
|
|
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
@overrides
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
"""Called when entity is about to be removed from hass"""
|
|
self.cancel_listening_power()
|
|
|
|
def cancel_listening_power(self):
|
|
"""Cancel the listening of underlying VTherm state changes"""
|
|
if self._cancel_listener_power is not None:
|
|
self._cancel_listener_power()
|
|
self._cancel_listener_power = None
|