# 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