diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 74cb9c7..724f8cb 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -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 diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 4973707..2bc070c 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -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.""" diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index a6c2405..9ff8eb1 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -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: diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 871912b..16200e5 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -44,6 +44,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SELECT, + Platform.NUMBER, ] CONF_HEATER = "heater_entity_id" diff --git a/custom_components/versatile_thermostat/number.py b/custom_components/versatile_thermostat/number.py new file mode 100644 index 0000000..654e777 --- /dev/null +++ b/custom_components/versatile_thermostat/number.py @@ -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}" diff --git a/custom_components/versatile_thermostat/select.py b/custom_components/versatile_thermostat/select.py index 8c77ec6..0ed16bd 100644 --- a/custom_components/versatile_thermostat/select.py +++ b/custom_components/versatile_thermostat/select.py @@ -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""" diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index 4c8bf05..7e38da2 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -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}" diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index eb2a61f..befc515 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -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 diff --git a/custom_components/versatile_thermostat/vtherm_api.py b/custom_components/versatile_thermostat/vtherm_api.py index cd1fed9..10a6a49 100644 --- a/custom_components/versatile_thermostat/vtherm_api.py +++ b/custom_components/versatile_thermostat/vtherm_api.py @@ -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"""