# pylint: disable=line-too-long
# pylint: disable=too-many-lines
# pylint: disable=invalid-name
""" Implements the VersatileThermostat climate component """
import math
from typing import Optional, Any, Generic
from datetime import datetime
from collections.abc import Callable

from vtherm_api.log_collector import get_vtherm_logger
from homeassistant.util import dt as dt_util

from homeassistant.exceptions import ServiceValidationError
from homeassistant.core import (
    HomeAssistant,
    callback,
    Event,
    State,
)
from homeassistant.exceptions import HomeAssistantError

from homeassistant.components.climate import ClimateEntity
from homeassistant.helpers.restore_state import (
    RestoreEntity,
    async_get as restore_async_get,
)
from homeassistant.helpers.entity import Entity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo

from homeassistant.helpers.event import (
    async_track_state_change_event,
)


from homeassistant.components.climate.const import (
    ATTR_PRESET_MODE,
    # ATTR_FAN_MODE,
    HVACMode,
    HVACAction,
    ClimateEntityFeature,
)

from homeassistant.const import (
    ATTR_TEMPERATURE,
    STATE_UNAVAILABLE,
    STATE_UNKNOWN,
)

from .const import *  # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .commons_type import ConfigData

from .config_schema import *  # pylint: disable=wildcard-import, unused-wildcard-import

from .vtherm_central_api import VersatileThermostatAPI
from .underlyings import UnderlyingEntity, T

from .ema import ExponentialMovingAverage

from .base_manager import BaseFeatureManager
from .feature_presence_manager import FeaturePresenceManager
from .feature_power_manager import FeaturePowerManager
from .feature_motion_manager import FeatureMotionManager
from .feature_window_manager import FeatureWindowManager
from .feature_safety_manager import FeatureSafetyManager
from .feature_auto_start_stop_manager import FeatureAutoStartStopManager
from .feature_lock_manager import FeatureLockManager
from .feature_timed_preset_manager import FeatureTimedPresetManager
from .feature_heating_failure_detection_manager import FeatureHeatingFailureDetectionManager
from .feature_repair_incorrect_state_manager import FeatureRepairIncorrectStateManager
from .state_manager import StateManager
from .vtherm_state import VThermState
from .vtherm_preset import VThermPreset, HIDDEN_PRESETS, PRESET_AC_SUFFIX
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF, to_legacy_ha_hvac_mode

_LOGGER = get_vtherm_logger(__name__)


class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
    """Representation of a base class for all Versatile Thermostat device."""

    # breaking change with 2024.12 climate workaround
    _attr_swing_horizontal_modes = []
    _attr_swing_horizontal_mode = ""

    _entity_component_unrecorded_attributes = (
        ClimateEntity._entity_component_unrecorded_attributes.union(frozenset({"configuration", "preset_temperatures", "specific_states"}))
        .union(FeaturePresenceManager.unrecorded_attributes)
        .union(FeaturePowerManager.unrecorded_attributes)
        .union(FeatureMotionManager.unrecorded_attributes)
        .union(FeatureWindowManager.unrecorded_attributes)
        .union(FeatureSafetyManager.unrecorded_attributes)
        .union(FeatureLockManager.unrecorded_attributes)
        .union(FeatureTimedPresetManager.unrecorded_attributes)
        .union(FeatureHeatingFailureDetectionManager.unrecorded_attributes)
        .union(FeatureRepairIncorrectStateManager.unrecorded_attributes)
    )

    ##
    ## Startup functions
    ##

    def __init__(
        self,
        hass: HomeAssistant,
        unique_id: str,
        name: str,
        entry_infos: ConfigData,
    ):
        """Initialize the thermostat."""

        super().__init__()

        # To remove some silly warning event if code is fixed
        self._enable_turn_on_off_backwards_compatibility = False
        self._is_removed = False
        self._is_startup_done = False

        self._hass = hass
        self._entry_infos = None
        self._attr_extra_state_attributes = {}

        self._unique_id = unique_id
        self._name = name

        self._is_ready: bool = False

        self._cycle_scheduler = None

        # Callbacks for TPI cycle events
        self._on_cycle_start_callbacks: list[Callable] = []

        self._state_manager = StateManager()
        self._fan_mode = None
        self._humidity = None
        self._swing_mode = None
        self._swing_horizontal_mode = None
        self._ac_mode = None

        self._cur_temp = None

        self._temp_sensor_entity_id = None
        self._last_seen_temp_sensor_entity_id = None
        self._ext_temp_sensor_entity_id = None
        self._last_ext_temperature_measure = None
        self._last_temperature_measure = None
        self._cur_ext_temp = None

        self._should_relaunch_control_heating = None

        self._attr_translation_key = "versatile_thermostat"

        self._total_energy = None
        _LOGGER.debug("%s - _init_ resetting energy to None", self)

        # Because energy of climate is calculated in the thermostat we have to keep
        # that here and not in underlying entity
        self._underlying_climate_start_hvac_action_date = None
        self._underlying_climate_delta_t = 0
        self._underlying_climate_mean_power_cycle = 0.0

        self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone)

        # Last change time is the datetime of the last change sent by
        # VTherm to the device it is used in `over_climate` when a
        # state changes from underlying to avoid loops
        self._last_change_time_from_vtherm = None

        self._underlyings: list[T] = []

        self._ema_temp = None
        self._ema_algo = None
        self._prop_algorithm = None
        self._proportional_function = None
        self._tpi_coef_int = None
        self._tpi_coef_ext = None
        self._minimal_activation_delay = None
        self._minimal_deactivation_delay = None
        self._tpi_threshold_low = None
        self._tpi_threshold_high = None

        self._attr_fan_mode = None

        self._is_central_mode = None
        self._last_central_mode = None
        self._is_used_by_central_boiler = False

        self._support_flags = None
        # Preset will be initialized from Number entities
        self._presets: dict[str, Any] = {}  # presets
        self._presets_away: dict[str, Any] = {}  # presets_away

        self._attr_preset_modes: list[str] = []
        self._vtherm_preset_modes: list[VThermPreset] = []

        self._use_central_config_temperature = False

        self._hvac_off_reason: str | None = None
        self._hvac_mode_reason: str | None = None
        self._hvac_list: list[VThermHvacMode] = []
        self._str_hvac_list: list[str] = []
        self._temperature_reason: str | None = None

        # Instantiate all features manager
        self._managers: list[BaseFeatureManager] = []

        # Names of feature managers provided by external plugins that have
        # already been instantiated for this thermostat. Used to avoid
        # registering the same external manager twice (post_init + startup retry).
        self._external_manager_names: set[str] = set()

        # Instances of feature managers provided by external plugins. They are
        # also present in ``self._managers`` (for lifecycle) but are tracked
        # separately so they can be refreshed on every control cycle (internal
        # managers have their own dedicated per-cycle mechanisms).
        self._external_managers: list[BaseFeatureManager] = []

        self._presence_manager: FeaturePresenceManager = FeaturePresenceManager(
            self, hass
        )
        self._power_manager: FeaturePowerManager = FeaturePowerManager(self, hass)
        self._motion_manager: FeatureMotionManager = FeatureMotionManager(self, hass)
        self._window_manager: FeatureWindowManager = FeatureWindowManager(self, hass)
        self._safety_manager: FeatureSafetyManager = FeatureSafetyManager(self, hass)
        # Auto start/stop is only for over_climate
        self._auto_start_stop_manager: FeatureAutoStartStopManager | None = None
        self._lock_manager: FeatureLockManager = FeatureLockManager(self, hass)
        self._timed_preset_manager: FeatureTimedPresetManager = FeatureTimedPresetManager(self, hass)
        self._heating_failure_detection_manager: FeatureHeatingFailureDetectionManager = FeatureHeatingFailureDetectionManager(self, hass)
        self._repair_incorrect_state_manager: FeatureRepairIncorrectStateManager = FeatureRepairIncorrectStateManager(self, hass)

        self.register_manager(self._presence_manager)
        self.register_manager(self._power_manager)
        self.register_manager(self._motion_manager)
        self.register_manager(self._window_manager)
        self.register_manager(self._safety_manager)
        self.register_manager(self._lock_manager)
        self.register_manager(self._timed_preset_manager)
        self.register_manager(self._heating_failure_detection_manager)
        self.register_manager(self._repair_incorrect_state_manager)

        self._cancel_recalculate_later: Callable[[], None] | None = None

        self.post_init(entry_infos)

    def register_manager(self, manager: BaseFeatureManager):
        """Register a manager"""
        self._managers.append(manager)

    def _load_external_feature_managers(self):
        """Instantiate feature managers provided by external plugins.

        Query the VTherm API registry and create one manager instance per
        eligible thermostat (the factory decides eligibility through its
        ``supports`` method). Managers whose plugin registers after this
        thermostat has been built are picked up later during ``async_startup``,
        mirroring the external proportional algorithm retry behavior.
        """
        api = VersatileThermostatAPI.get_vtherm_api(self.hass)
        if api is None or not hasattr(api, "get_feature_manager_factories"):
            return

        for factory in api.get_feature_manager_factories():
            name = factory.name
            if name in self._external_manager_names:
                continue
            try:
                if not factory.supports(self):
                    continue
                manager = factory.create(self)
                manager.post_init(self._entry_infos)
            except Exception as exc:  # pylint: disable=broad-except
                _LOGGER.error(
                    "%s - Error while creating external feature manager '%s': %s",
                    self,
                    name,
                    exc,
                )
                continue

            self.register_manager(manager)
            self._external_manager_names.add(name)
            self._external_managers.append(manager)
            _LOGGER.info("%s - Registered external feature manager '%s'", self, name)

    def clean_central_config_doublon(
        self, config_entry: ConfigData, central_config: ConfigEntry | None
    ) -> dict[str, Any]:
        """Removes all values from config with are concerned by central_config"""

        def clean_one(cfg, schema: vol.Schema):
            """Clean one schema"""
            for marker in schema.schema:
                # Extract the actual key from Voluptuous Marker objects
                key = marker.schema if hasattr(marker, 'schema') else marker
                if key in cfg:
                    del cfg[key]

        cfg = config_entry.copy()
        if central_config and central_config.data:
            # Removes config if central is used
            if cfg.get(CONF_USE_MAIN_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_MAIN_DATA_SCHEMA)

            if cfg.get(CONF_USE_TPI_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_TPI_DATA_SCHEMA)

            if cfg.get(CONF_USE_WINDOW_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_WINDOW_DATA_SCHEMA)

            if cfg.get(CONF_USE_MOTION_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_MOTION_DATA_SCHEMA)

            if cfg.get(CONF_USE_POWER_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_POWER_DATA_SCHEMA)

            if cfg.get(CONF_USE_PRESENCE_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_PRESENCE_DATA_SCHEMA)

            if cfg.get(CONF_USE_ADVANCED_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_ADVANCED_DATA_SCHEMA)

            if cfg.get(CONF_USE_LOCK_CENTRAL_CONFIG) is True:
                clean_one(cfg, STEP_CENTRAL_LOCK_DATA_SCHEMA)

            # take all central config
            entry_infos = central_config.data.copy()
            # and merge with cleaned config_entry
            entry_infos.update(cfg)
        else:
            entry_infos = cfg

        return entry_infos

    def post_init(self, config_entry: ConfigData):
        """Finish the initialization of the thermostat"""

        _LOGGER.info(
            "%s - Updating VersatileThermostat with infos %s",
            self,
            config_entry,
        )

        api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
        central_config = api.find_central_configuration()

        entry_infos = self.clean_central_config_doublon(config_entry, central_config)

        _LOGGER.info("%s - The merged configuration is %s", self, entry_infos)

        self._entry_infos = entry_infos

        # Post init all managers
        for manager in self._managers:
            manager.post_init(entry_infos)

        # Instantiate feature managers provided by external plugins that are
        # already registered at this point. Late-registered plugins are handled
        # by a retry in async_startup.
        self._load_external_feature_managers()

        self._use_central_config_temperature = entry_infos.get(
            CONF_USE_PRESETS_CENTRAL_CONFIG
        ) or (
            entry_infos.get(CONF_USE_PRESENCE_CENTRAL_CONFIG)
            and entry_infos.get(CONF_USE_PRESENCE_FEATURE)
        )

        self._ac_mode = entry_infos.get(CONF_AC_MODE) is True
        self._attr_max_temp = float(entry_infos.get(CONF_TEMP_MAX, 0.0))
        self._attr_min_temp = float(entry_infos.get(CONF_TEMP_MIN, 0.0))
        if (step := entry_infos.get(CONF_STEP_TEMPERATURE)) is not None:
            self._attr_target_temperature_step = step

        self._attr_preset_modes = []
        self._vtherm_preset_modes = []

        self._cycle_min = max(1, entry_infos.get(CONF_CYCLE_MIN, 1))

        # Initialize underlying entities (will be done in subclasses)
        self._underlyings = []

        self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR)
        self._last_seen_temp_sensor_entity_id = entry_infos.get(
            CONF_LAST_SEEN_TEMP_SENSOR
        )
        self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR)

        self.set_hvac_list()

        self._unit = self._hass.config.units.temperature_unit

        # Will be restored if possible
        self._state_manager = StateManager()

        self._support_flags = SUPPORT_FLAGS

        # Preset will be initialized from Number entities
        self._presets: dict[str, Any] = {}  # presets
        self._presets_away: dict[str, Any] = {}  # presets_away

        self._humidity = None
        self._fan_mode = None
        self._swing_mode = None
        self._swing_horizontal_mode = None
        self._cur_temp = None
        self._cur_ext_temp = None

        self._last_temperature_measure = self.now
        self._last_ext_temperature_measure = self.now

        self._total_energy = None
        _LOGGER.debug("%s - post_init_ resetting energy to None", self)

        # Read the parameter from configuration.yaml if it exists
        short_ema_params = DEFAULT_SHORT_EMA_PARAMS
        if api and api.short_ema_params:
            short_ema_params = api.short_ema_params

        self._ema_algo = ExponentialMovingAverage(
            self.name,
            short_ema_params.get("halflife_sec"),
            # Needed for time calculation
            get_tz(self._hass),
            # two digits after the coma for temperature slope calculation
            short_ema_params.get("precision"),
            short_ema_params.get("max_alpha"),
        )

        self._is_central_mode = not (
            entry_infos.get(CONF_USE_CENTRAL_MODE) is False
        )  # Default value (None) is True

        self._is_used_by_central_boiler = (
            entry_infos.get(CONF_USED_BY_CENTRAL_BOILER) is True
        )

        self._max_on_percent = api.max_on_percent

        _LOGGER.debug(
            "%s - Creation of a new VersatileThermostat entity: unique_id=%s",
            self,
            self.unique_id,
        )

    async def async_added_to_hass(self):
        """Run when entity about to be added."""
        _LOGGER.debug("%s - Calling async_added_to_hass", self)

        await super().async_added_to_hass()

        self.async_on_remove(
            async_track_state_change_event(
                self.hass,
                [self._temp_sensor_entity_id],
                self._async_temperature_changed,
            )
        )

        if self._last_seen_temp_sensor_entity_id:
            self.async_on_remove(
                async_track_state_change_event(
                    self.hass,
                    [self._last_seen_temp_sensor_entity_id],
                    self._async_last_seen_temperature_changed,
                )
            )

        if self._ext_temp_sensor_entity_id:
            self.async_on_remove(
                async_track_state_change_event(
                    self.hass,
                    [self._ext_temp_sensor_entity_id],
                    self._async_ext_temperature_changed,
                )
            )

        self.async_on_remove(self.remove_thermostat)

        # issue 428. Link to others entities will start at link
        # await self.async_startup()

    async def async_will_remove_from_hass(self):
        """Try to force backup of entity"""
        self._is_removed = True
        _LOGGER.debug(
            "%s - force write before remove. Energy is %s", self, self.total_energy
        )
        # Force dump in background
        await restore_async_get(self.hass).async_dump_states()

    def remove_thermostat(self):
        """Called when the thermostat will be removed"""
        _LOGGER.info("%s - Removing thermostat", self)

        self.stop_recalculate_later()

        # Cancel scheduler timers so leftover async_call_later handles cannot fire
        # after the entity has been unloaded (e.g. on cycle duration config change).
        if self._cycle_scheduler:
            self._cycle_scheduler.shutdown()

        # stop listening for all managers
        for manager in self._managers:
            manager.stop_listening()

        for under in self._underlyings:
            under.remove_entity()

    def register_cycle_callback(
        self,
        on_start: Callable | None = None,
    ):
        """Register callbacks for TPI cycle events.

        Args:
            on_start: Callback called at the start of each TPI cycle
                      Signature: async def callback(on_time_sec, off_time_sec, on_percent, hvac_mode)
        """
        if on_start:
            self._on_cycle_start_callbacks.append(on_start)
            _LOGGER.debug("%s - Registered cycle start callback: %s", self, on_start)
            # Register on the CycleScheduler if available
            if self._cycle_scheduler:
                self._cycle_scheduler.register_cycle_start_callback(on_start)

    def stop_recalculate_later(self):
        """Stop any scheduled call later tasks if any."""
        if self._cancel_recalculate_later:
            self._cancel_recalculate_later()  # pylint: disable=not-callable
            self._cancel_recalculate_later = None

    async def async_startup(self, central_configuration):
        """Triggered on startup, used to get old state and set internal states
         accordingly. This is triggered by VTherm API"""
        write_event_log(_LOGGER, self, "Start up VTherm")

        _LOGGER.debug("%s - Calling async_startup_internal", self)
        # need_write_state = False

        # Retry loading external feature managers: a plugin may have registered
        # its factory after this thermostat was built (load order between the
        # core and the plugin is not guaranteed). Newly created managers are
        # then started in the loop below.
        self._load_external_feature_managers()

        # start listening for all managers
        for manager in self._managers:
            await manager.start_listening()

        await self.get_my_previous_state()

        # Start underlyings and register cycle callbacks on the scheduler
        for under in self._underlyings:
            under.startup()
        if self._cycle_scheduler:
            for cb in self._on_cycle_start_callbacks:
                self._cycle_scheduler.register_cycle_start_callback(cb)

        # init presets. Should be after underlyings init because for over_climate it uses the hvac_modes
        await self.init_presets(central_configuration)

        temperature_state = self.hass.states.get(self._temp_sensor_entity_id)
        if temperature_state and temperature_state.state not in (
            STATE_UNAVAILABLE,
            STATE_UNKNOWN,
        ):
            _LOGGER.debug(
                "%s - temperature sensor have been retrieved: %.1f",
                self,
                float(temperature_state.state),
            )
            await self._async_update_temp(temperature_state)

        if self._ext_temp_sensor_entity_id:
            ext_temperature_state = self.hass.states.get(
                self._ext_temp_sensor_entity_id
            )
            if ext_temperature_state and ext_temperature_state.state not in (
                STATE_UNAVAILABLE,
                STATE_UNKNOWN,
            ):
                _LOGGER.debug(
                    "%s - external temperature sensor have been retrieved: %.1f",
                    self,
                    float(ext_temperature_state.state),
                )
                await self._async_update_ext_temp(ext_temperature_state)
            else:
                _LOGGER.debug(
                    "%s - external temperature sensor have NOT been retrieved "
                    "cause unknown or unavailable",
                    self,
                )
        else:
            _LOGGER.debug(
                "%s - external temperature sensor have NOT been retrieved "
                "cause no external sensor",
                self,
            )
        self._is_startup_done = True

        if self.is_ready:
            await self.init_underlyings_completed()

    async def init_underlyings_completed(self, under_entity_id: Optional[str] = None):
        """All underlyings have been initialized. Then we can finish our initialization"""
        _LOGGER.debug("%s - Calling init_underlyings_completed", self)
        if not self.is_ready:
            return

        # We:
        # - refresh all managers states,
        # - calculate the current state of the VTherm (it depends on the managers states and the requested state)
        # - check if the initial conditions are met
        # - force the first cycle if changes has been detected

        # in over_climate, we have to remove the FROST preset for a COOL only device
        if self._ac_mode and VThermPreset.FROST in self._vtherm_preset_modes and HVACMode.HEAT not in self.hvac_modes:
            self._vtherm_preset_modes.remove(VThermPreset.FROST)
            if VThermPreset.FROST in self._attr_preset_modes:
                self._attr_preset_modes.remove(VThermPreset.FROST)
            _LOGGER.debug(
                "%s - removed FROST preset for COOL only device, new presets: %s",
                self,
                self._attr_preset_modes,
            )

        # refresh states for all managers
        for manager in self._managers:
            await manager.refresh_state()
            #    need_write_state = True

        await self.update_states(force=True)
        self.recalculate()

        # check initial state should be done after the current state has been calculated and so after the manager has been updated
        # issue 1654 - initial state check should be done after the underlyings has come to life
        # await self._check_initial_state()
        self.reset_last_change_time_from_vtherm()

    def restore_specific_previous_state(self, old_state: State):
        """Should be overridden in each specific thermostat
        if a specific previous state or attribute should be
        restored
        """

    async def get_my_previous_state(self):
        """Try to get my previous state"""
        # Check If we have an old state
        old_state = await self.async_get_last_state()
        _LOGGER.debug(
            "%s - Calling get_my_previous_state old_state is %s", self, old_state
        )
        if old_state is not None:
            # Restore current_state
            if current_state_attr := old_state.attributes.get(ATTR_CURRENT_STATE, None):
                current_state = VThermState.from_dict(current_state_attr)
                self._state_manager.current_state.set_state(
                    hvac_mode=current_state.hvac_mode,
                    target_temperature=current_state.target_temperature,
                    preset=current_state.preset,
                )
            else:
                # Try to init current_state with old temperature, preset and mode
                self._state_manager.current_state.set_state(
                    target_temperature=old_state.attributes.get(ATTR_TEMPERATURE, None),
                    preset=VThermPreset(old_state.attributes.get(ATTR_PRESET_MODE, None) or VThermPreset.NONE),
                    hvac_mode=old_state.state if isinstance(old_state.state, VThermHvacMode) else from_ha_hvac_mode(old_state.state),
                )
            # If we have no initial temperature set with min (or max depending on ac_mode)
            if self._state_manager.current_state.target_temperature is None:
                self._state_manager.current_state.set_target_temperature(self.max_temp if self._ac_mode else self.min_temp)
                _LOGGER.warning(
                    "%s - Undefined target temperature, falling back to %s",
                    self,
                    self._state_manager.current_state.target_temperature,
                )

            # Restore requested_state or default with current_state
            if requested_state_attr := old_state.attributes.get(ATTR_REQUESTED_STATE, None):
                requested_state = VThermState.from_dict(requested_state_attr)
                self._state_manager.requested_state.set_state(
                    hvac_mode=requested_state.hvac_mode,
                    target_temperature=requested_state.target_temperature,
                    preset=requested_state.preset,
                )
            else:
                # Try to init requested_state with old temperature, preset and mode
                self._state_manager.requested_state.set_state(
                    target_temperature=old_state.attributes.get(ATTR_TEMPERATURE, None),
                    preset=VThermPreset(old_state.attributes.get(ATTR_PRESET_MODE, None) or VThermPreset.NONE),
                    hvac_mode=old_state.state if isinstance(old_state.state, VThermHvacMode) else from_ha_hvac_mode(old_state.state),
                )

            self._hvac_off_reason = old_state.attributes.get(HVAC_OFF_REASON_NAME, None)

            # Try to get total_energy from specific_states (new format) or root level (old format)
            specific_states = old_state.attributes.get("specific_states", {})
            self._hvac_mode_reason = specific_states.get(HVAC_MODE_REASON_NAME, self._hvac_off_reason)
            old_total_energy = specific_states.get(ATTR_TOTAL_ENERGY)
            if old_total_energy is None:
                # Fallback to root level for backward compatibility
                old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
            self._total_energy = old_total_energy if old_total_energy is not None else 0
            _LOGGER.debug(
                "%s - get_my_previous_state restored energy is %s",
                self,
                self._total_energy,
            )

            self.restore_specific_previous_state(old_state)

            # Restore all managers states from previous state
            for manager in self._managers:
                manager.restore_state(old_state)
        else:
            # No previous state, try and restore defaults
            if self._state_manager.current_state.target_temperature is None:
                self._state_manager.current_state.set_target_temperature(self.max_temp if self._ac_mode else self.min_temp)
            _LOGGER.warning("No previously saved temperature, setting to %s", self._state_manager.current_state.target_temperature)
            self._total_energy = 0
            _LOGGER.debug(
                "%s - get_my_previous_state  no previous state energy is %s",
                self,
                self._total_energy,
            )

        if not self.is_on and self.hvac_off_reason is None:
            self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL)

        _LOGGER.info(
            "%s - restored current state is %s, requested state is %s",
            self,
            self._state_manager.current_state,
            self._state_manager.requested_state,
        )

    def __str__(self) -> str:
        return self.name

    def set_hvac_list(self):
        """Set the hvac list depending on ac_mode"""
        self._hvac_list = self.build_hvac_list()
        self._str_hvac_list = [to_ha_hvac_mode(mode) for mode in self._hvac_list if to_ha_hvac_mode(mode) is not None]

    @callback
    def async_registry_entry_updated(self):
        """update the entity if the config entry have been updated
        Note: this don't work either
        """
        write_event_log(_LOGGER, self, "The config entry have been updated")

    @callback
    async def entry_update_listener(self, _, config_entry: ConfigEntry) -> None:  # hass: HomeAssistant,
        """Called when the entry have changed in ConfigFlow"""
        _LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)

    async def init_presets(self, central_config):
        """Init all presets of the VTherm"""
        # If preset central config is used and central config is set,
        # take the presets from central config
        vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api()

        presets: dict[str, Any] = {}
        presets_away: dict[str, Any] = {}

        def calculate_presets(items, use_central_conf_key):
            presets: dict[str, Any] = {}
            config_id = self._unique_id
            if central_config and self._entry_infos.get(use_central_conf_key, False) is True:
                config_id = central_config.entry_id

            for key, preset_name in items:
                _LOGGER.debug("%s - looking for key=%s, preset_name=%s", self, key, preset_name)
                # removes preset_name frost if heat is not in hvac_modes. vtherm_hvac_modes is initialized when the underlyings are initialized.
                # So it may be not be ready yet here. In that case, the FROST is added anyway. So it is possible that FROST preset in a COOL only device
                if len(self.vtherm_hvac_modes) == 0 and key == VThermPreset.FROST and VThermHvacMode_HEAT not in self.vtherm_hvac_modes:
                    _LOGGER.debug("%s - removing preset_name %s which reserved for HEAT devices", self, preset_name)
                    continue
                value = vtherm_api.get_temperature_number_value(config_id=config_id, preset_name=preset_name)
                if value is not None:
                    presets[key] = value
                else:
                    _LOGGER.debug("%s - preset_name %s not found in VTherm API", self, preset_name)
                    presets[key] = self._attr_max_temp if self._ac_mode else self._attr_min_temp
            return presets

        # Calculate all presets
        presets = calculate_presets(
            CONF_PRESETS_WITH_AC.items() if self._ac_mode else CONF_PRESETS.items(),
            CONF_USE_PRESETS_CENTRAL_CONFIG,
        )

        # refacto
        # if self._entry_infos.get(CONF_USE_PRESENCE_FEATURE) is True:
        if self._presence_manager.is_configured:
            presets_away = calculate_presets(
                (CONF_PRESETS_AWAY_WITH_AC.items() if self._ac_mode else CONF_PRESETS_AWAY.items()),
                CONF_USE_PRESETS_CENTRAL_CONFIG,
            )

        # aggregate all available presets now
        self._presets: dict[str, Any] = presets
        self._presets_away: dict[str, Any] = presets_away

        # Calculate all possible presets
        self._attr_preset_modes = [VThermPreset.NONE]
        if len(self._presets):
            self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE

            for key, _ in CONF_PRESETS.items():
                preset_value = self.find_preset_temp(key)
                if preset_value is not None and preset_value > 0:
                    self._attr_preset_modes.append(key)

            _LOGGER.debug("%s - After adding presets, preset_modes to %s", self, self._attr_preset_modes)
        else:
            _LOGGER.debug("%s - No preset_modes", self)

        if self._motion_manager.is_configured:
            self._attr_preset_modes.append(VThermPreset.ACTIVITY)

        # transform _attr_preset_modes into _vtherm_preset_modes
        self._vtherm_preset_modes = [VThermPreset(mode) for mode in self._attr_preset_modes]

        # Re-applicate the last preset if any to take change into account
        if self._state_manager.current_state.preset and self._state_manager.current_state.preset != VThermPreset.NONE:
            await self.async_set_preset_mode_internal(self._state_manager.current_state.preset)

    ##
    ## Properties overriden from ClimateEntity (or not)
    ##

    @property
    def is_initialized(self) -> bool:
        """Check if all underlyings are initialized
        This is useful only for over_climate in which we
        should have found the underlying climate to be operational"""
        for under in self._underlyings:
            if not under.is_initialized:
                return False
        return True

    @property
    def is_ready(self) -> bool:
        """Check if all underlyings are ready (initialized and startup is complete)"""
        if not self._is_ready:
            self._is_ready = self._is_startup_done and self.is_initialized
        return self._is_ready

    @property
    def vtherm_hvac_modes(self) -> list[VThermHvacMode]:
        """List of available VTherm operation modes."""
        return self._hvac_list

    @property
    def hvac_modes(self) -> list[str]:
        """List of available VTherm operation modes."""
        return self._str_hvac_list

    def build_hvac_list(self) -> list[VThermHvacMode]:
        """Build the hvac list depending on ac_mode"""
        if self._ac_mode:
            return [VThermHvacMode_HEAT, VThermHvacMode_COOL, VThermHvacMode_OFF]
        else:
            return [VThermHvacMode_HEAT, VThermHvacMode_OFF]

    @property
    def is_over_climate(self) -> bool:
        """True if the Thermostat is over_climate"""
        return False

    @property
    def is_over_switch(self) -> bool:
        """True if the Thermostat is over_switch"""
        return False

    @property
    def has_prop(self) -> bool:
        """True if the Thermostat uses a proportional algorithm (TPI, SmartPI)"""
        return False

    @property
    def safe_on_percent(self) -> float:
        """Return the on_percent safe value"""
        return 0

    @property
    def is_over_valve(self) -> bool:
        """True if the Thermostat is over_valve"""
        return False

    @property
    def device_info(self) -> DeviceInfo:
        """Return the device info."""
        return DeviceInfo(
            entry_type=None,
            identifiers={(DOMAIN, self._unique_id)},
            name=self._name,
            manufacturer=DEVICE_MANUFACTURER,
            model=DOMAIN,
        )

    @property
    def unique_id(self) -> str:
        return self._unique_id

    @property
    def should_poll(self) -> bool:
        return False

    @property
    def name(self) -> str:
        return self._name

    @property
    def ac_mode(self) -> bool | None:
        """Get the ac_mode of the Themostat"""
        return self._ac_mode

    @property
    def temperature_unit(self) -> str:
        """Return the unit of measurement."""
        return self._unit

    @property
    def ema_temperature(self) -> float | None:
        """Return the EMA temperature."""
        return self._ema_temp

    @property
    def hvac_mode(self) -> HVACMode | None:
        """Return current operation."""
        return to_legacy_ha_hvac_mode(self._state_manager.current_state.hvac_mode)

    @property
    def vtherm_hvac_mode(self) -> VThermHvacMode | None:
        """Return current operation."""
        return self._state_manager.current_state.hvac_mode

    @property
    def is_recalculate_scheduled(self) -> bool:
        """Return true if a recalculation is scheduled."""
        return self._cancel_recalculate_later is not None

    @property
    def is_used_by_central_boiler(self) -> HVACAction | None:
        """Return true is the VTherm is configured to be used by
        central boiler"""
        return self._is_used_by_central_boiler

    @property
    def target_temperature(self) -> float | None:
        """Return the temperature we try to reach."""
        return self._state_manager.current_state.target_temperature

    @property
    def regulated_target_temperature(self) -> float | None:
        """Return the regulated target temperature used to drive the underlying.

        The base implementation returns the plain target temperature. Over
        climate thermostats override this to expose their regulated value.
        """
        return self.target_temperature

    @property
    def underlying_fan_modes(self) -> list[str] | None:
        """Return the fan modes exposed by the underlying climate(s).

        The base implementation returns None because most thermostats do not
        expose an underlying climate with fan control.
        """
        return None

    async def async_set_underlying_fan_mode(self, fan_mode: str) -> None:
        """Send a fan mode to the underlying climate(s).

        The base implementation is a no-op for thermostats without fan control.
        """

    @property
    def supported_features(self) -> ClimateEntityFeature:
        """Return the list of supported features."""
        return self._support_flags

    @property
    def should_device_be_active(self) -> bool:
        """Returns true if one underlying is active"""
        for under in self._underlyings:
            if under.should_device_be_active:
                return True
        return False

    @property
    def is_device_active(self) -> bool:
        """Returns true if one underlying is active"""
        for under in self._underlyings:
            if under.is_device_active:
                return True
        return False

    @property
    def device_actives(self) -> int:
        """Calculate the active devices"""
        ret = []
        for under in self._underlyings:
            if under.is_device_active:
                ret.append(under.entity_id)
        return ret

    @property
    def nb_device_actives(self) -> int:
        """Calculate the number of active devices"""
        return len(self.device_actives)

    @property
    def current_temperature(self) -> float | None:
        """Return the sensor temperature."""
        return self._cur_temp

    @property
    def current_outdoor_temperature(self) -> float | None:
        """Return the outdoor sensor temperature."""
        return self._cur_ext_temp

    @property
    def total_energy(self) -> float | None:
        """Returns the total energy calculated for this thermostat"""
        if self._total_energy is not None:
            return round(self._total_energy, 2)
        else:
            return None

    @property
    def overpowering_state(self) -> bool | None:
        """Get the overpowering_state"""
        return self._power_manager.overpowering_state

    @property
    def power_manager(self) -> FeaturePowerManager | None:
        """Get the power manager"""
        return self._power_manager

    @property
    def is_overpowering_detected(self) -> bool:
        """Return True when power shedding is currently active."""
        return (
            self._power_manager.is_overpowering_detected
            if self._power_manager is not None
            else False
        )

    @property
    def presence_manager(self) -> FeaturePresenceManager | None:
        """Get the presence manager"""
        return self._presence_manager

    @property
    def motion_manager(self) -> FeatureMotionManager | None:
        """Get the motion manager"""
        return self._motion_manager

    @property
    def window_manager(self) -> FeatureWindowManager | None:
        """Get the window manager"""
        return self._window_manager

    @property
    def safety_manager(self) -> FeatureSafetyManager | None:
        """Get the safety manager"""
        return self._safety_manager

    @property
    def auto_start_stop_manager(self) -> FeatureAutoStartStopManager | None:
        """Get the auto start/stop manager (only implemented in over_climate)"""
        return self._auto_start_stop_manager

    @property
    def lock_manager(self) -> FeatureLockManager | None:
        """Get the lock manager"""
        return self._lock_manager

    @property
    def timed_preset_manager(self) -> FeatureTimedPresetManager:
        """Get the timed preset manager"""
        return self._timed_preset_manager

    @property
    def heating_failure_detection_manager(self) -> FeatureHeatingFailureDetectionManager:
        """Get the heating failure detection manager"""
        return self._heating_failure_detection_manager

    @property
    def repair_incorrect_state_manager(self) -> FeatureRepairIncorrectStateManager:
        """Get the repair incorrect state manager"""
        return self._repair_incorrect_state_manager

    @property
    def current_state(self) -> VThermState | None:
        """Get the current state"""
        return self._state_manager.current_state

    @property
    def requested_state(self) -> VThermState | None:
        """Get the requested state"""
        return self._state_manager.requested_state

    @property
    def window_state(self) -> str | None:
        """Get the window_state"""
        return self._window_manager.window_state

    @property
    def window_auto_state(self) -> str | None:
        """Get the window_auto_state"""
        return self._window_manager.window_auto_state

    @property
    def is_window_bypass(self) -> bool | None:
        """Get the Window Bypass"""
        return self._window_manager.is_window_bypass

    @property
    def safety_state(self) -> str | None:
        """Get the safety_state"""
        return self._safety_manager.safety_state

    @property
    def motion_state(self) -> str | None:
        """Get the motion_state"""
        return self._motion_manager.motion_state

    @property
    def presence_state(self) -> str | None:
        """Get the presence_state"""
        return self._presence_manager.presence_state

    # =========================================================================
    # PUBLIC PROPERTIES FOR HANDLER ACCESS
    # These properties expose internal state to handlers without breaking encapsulation
    # =========================================================================

    @property
    def cycle_min(self) -> int:
        """Return the cycle duration in minutes."""
        return self._cycle_min

    @property
    def entry_infos(self) -> ConfigData:
        """Return the configuration entry data."""
        return self._entry_infos

    @property
    def hass(self) -> HomeAssistant:
        """Return the Home Assistant instance.

        This overrides the Entity.hass property to return our stored _hass
        reference during initialization (before async_added_to_hass is called).
        """
        return self._hass

    @hass.setter
    def hass(self, value: HomeAssistant):
        """Set the Home Assistant instance.

        This is called by the entity platform when adding the entity.
        """
        self._hass = value

    @property
    def is_removed(self) -> bool:
        """Return True if the thermostat has been removed."""
        return self._is_removed

    @property
    def ext_temp_sensor_entity_id(self) -> str | None:
        """Return the external temperature sensor entity ID."""
        return self._ext_temp_sensor_entity_id

    @property
    def max_on_percent(self) -> float | None:
        """Return the maximum on percentage."""
        return self._max_on_percent

    @property
    def tpi_coef_int(self) -> float | None:
        """Return the TPI internal coefficient."""
        return self._tpi_coef_int

    @tpi_coef_int.setter
    def tpi_coef_int(self, value: float):
        """Set the TPI internal coefficient."""
        self._tpi_coef_int = value

    @property
    def tpi_coef_ext(self) -> float | None:
        """Return the TPI external coefficient."""
        return self._tpi_coef_ext

    @tpi_coef_ext.setter
    def tpi_coef_ext(self, value: float):
        """Set the TPI external coefficient."""
        self._tpi_coef_ext = value

    @property
    def tpi_threshold_low(self) -> float:
        """Return the TPI low threshold."""
        return self._tpi_threshold_low or 0.0

    @tpi_threshold_low.setter
    def tpi_threshold_low(self, value: float):
        """Set the TPI low threshold."""
        self._tpi_threshold_low = value

    @property
    def tpi_threshold_high(self) -> float:
        """Return the TPI high threshold."""
        return self._tpi_threshold_high or 0.0

    @tpi_threshold_high.setter
    def tpi_threshold_high(self, value: float):
        """Set the TPI high threshold."""
        self._tpi_threshold_high = value

    @property
    def minimal_activation_delay(self) -> int:
        """Return the minimal activation delay in seconds."""
        return self._minimal_activation_delay or 0

    @minimal_activation_delay.setter
    def minimal_activation_delay(self, value: int):
        """Set the minimal activation delay in seconds."""
        self._minimal_activation_delay = value

    @property
    def minimal_deactivation_delay(self) -> int:
        """Return the minimal deactivation delay in seconds."""
        return self._minimal_deactivation_delay or 0

    @minimal_deactivation_delay.setter
    def minimal_deactivation_delay(self, value: int):
        """Set the minimal deactivation delay in seconds."""
        self._minimal_deactivation_delay = value

    @property
    def proportional_function(self) -> str | None:
        """Return the proportional function type (TPI, SmartPI)."""
        return self._proportional_function

    @property
    def prop_algorithm(self):
        """Return the proportional algorithm instance."""
        return self._prop_algorithm

    @prop_algorithm.setter
    def prop_algorithm(self, value):
        """Set the proportional algorithm instance."""
        self._prop_algorithm = value

    @property
    def cycle_scheduler(self):
        """Return the CycleScheduler instance, if any."""
        return self._cycle_scheduler

    @property
    def underlyings(self) -> list:
        """Return the list of underlying entities."""
        return self._underlyings

    @property
    def proportional_algorithm(self):
        """Get the eventual ProportionalAlgorithm"""
        return None

    @property
    def last_temperature_measure(self) -> datetime | None:
        """Get the last temperature datetime"""
        return self._last_temperature_measure

    @property
    def last_ext_temperature_measure(self) -> datetime | None:
        """Get the last external temperature datetime"""
        return self._last_ext_temperature_measure

    @property
    def preset_mode(self) -> str | None:
        """Return the current preset mode comfort, eco, boost,...,"""
        return str(self._state_manager.current_state.preset)

    @property
    def preset_modes(self) -> list[str] | None:
        """Return a list of available preset modes.
        Requires ClimateEntityFeature.PRESET_MODE.
        """
        return self._attr_preset_modes

    @property
    def vtherm_preset_mode(self) -> VThermPreset | None:
        """Return the current preset mode comfort, eco, boost,...,"""
        return self._state_manager.current_state.preset

    @property
    def vtherm_preset_modes(self) -> list[str] | None:
        """Return a list of available preset modes.
        Requires ClimateEntityFeature.PRESET_MODE.
        """
        return self._vtherm_preset_modes

    @property
    def last_temperature_slope(self) -> float | None:
        """Return the last temperature slope curve if any"""
        return self._window_manager.last_slope

    @property
    def nb_underlying_entities(self) -> int:
        """Returns the number of underlying entities"""
        return len(self._underlyings)

    @property
    def underlying_entities(self) -> list | None:
        """Returns the underlying entities"""
        return self._underlyings

    @property
    def activable_underlying_entities(self) -> list | None:
        """Returns the activable underlying entities for controlling
         the central boiler"""
        return self.underlying_entities

    @property
    def all_underlying_entities(self) -> list | None:
        """Returns all underlying entities for controling the central boiler"""
        return self.activable_underlying_entities

    def find_underlying_by_entity_id(self, entity_id: str) -> Entity | None:
        """Get the underlying entity by a entity_id"""
        for under in self._underlyings:
            if under.entity_id == entity_id:
                return under
        return None

    @property
    def is_on(self) -> bool:
        """True if the VTherm is on (! HVAC_OFF)"""
        return self.vtherm_hvac_mode and self.vtherm_hvac_mode != VThermHvacMode_OFF

    @property
    def is_controlled_by_central_mode(self) -> bool:
        """Returns True if this VTherm can be controlled by the central_mode"""
        return self._is_central_mode

    @property
    def last_central_mode(self) -> str | None:
        """Returns the last central_mode taken into account.
        Is None if the VTherm is not controlled by central_mode"""
        return self._last_central_mode

    @property
    def use_central_config_temperature(self):
        """True if this VTHerm uses the central configuration temperature"""
        return self._use_central_config_temperature

    @property
    def hvac_off_reason(self) -> str | None:
        """Returns the reason of the last switch to HVAC_OFF
        This is useful for features that turns off the VTherm like
        window detection or auto-start-stop"""
        return self._hvac_off_reason

    @property
    def hvac_mode_reason(self) -> str | None:
        """Returns the reason why the current hvac_mode is forced.
        Unlike hvac_off_reason, this is set whatever the forced hvac_mode is
        (off, fan_only, dry, ...) so the UI can explain why the VTherm is not
        in the requested mode."""
        return self._hvac_mode_reason

    @property
    def temperature_reason(self) -> str | None:
        """Returns the reason of the target temperature
        This is useful for features that changes the VTherm like window detection or power management"""
        return self._temperature_reason

    @property
    def is_sleeping(self) -> bool:
        """True if the thermostat is in sleep mode. Only for over_climate with valve regulation"""
        return False

    @property
    def have_valve_regulation(self) -> bool:
        """True if the Thermostat is regulated by valve"""
        return False

    @property
    def power_percent(self) -> float | None:
        """Get the current on_percent as a percentage value. valid only for Vtherm with a TPI algo
        Get the current on_percent value"""
        prop_algo = getattr(self, '_prop_algorithm', None)
        if prop_algo and prop_algo.on_percent is not None:
            return round(prop_algo.on_percent * 100, 0)
        else:
            return None

    @property
    def on_percent(self) -> float | None:
        """Get the current on_percent value. valid only for Vtherm with a TPI algo"""
        prop_algo = getattr(self, '_prop_algorithm', None)
        if prop_algo and prop_algo.on_percent is not None:
            return prop_algo.on_percent
        else:
            return None

    @property
    def vtherm_type(self) -> str | None:
        """Return the type of thermostat"""
        return None

    @property
    def config_entry(self) -> ConfigEntry | None:
        """Return the config entry associated with the thermostat."""
        return self._hass.config_entries.async_get_entry(self.unique_id)

    def underlying_entity_id(self, index=0) -> str | None:
        """The climate_entity_id. Added for retrocompatibility reason"""
        if index < self.nb_underlying_entities:
            return self.underlying_entity(index).entity_id
        else:
            return None

    def underlying_entity(self, index=0) -> UnderlyingEntity | None:
        """Get the underlying entity at specified index"""
        if index < self.nb_underlying_entities:
            return self._underlyings[index]
        else:
            return None

    def turn_aux_heat_on(self) -> None:
        """Turn auxiliary heater on."""
        raise NotImplementedError()

    @overrides
    async def async_turn_aux_heat_on(self) -> None:
        """Turn auxiliary heater on."""
        raise NotImplementedError()

    @overrides
    def turn_aux_heat_off(self) -> None:
        """Turn auxiliary heater off."""
        raise NotImplementedError()

    @overrides
    async def async_turn_aux_heat_off(self) -> None:
        """Turn auxiliary heater off."""
        raise NotImplementedError()

    ##
    ## Entry events (from linked devices or users)
    ##

    @staticmethod
    def check_lock(func):
        """Decorator to check if the thermostat is locked."""
        async def wrapper(self, *args, **kwargs):
            if self.lock_manager.check_is_locked(func.__name__):
                return
            return await func(self, *args, **kwargs)
        return wrapper

    @overrides
    @check_lock
    async def async_set_hvac_mode(self, hvac_mode: VThermHvacMode):  # , need_control_heating=True):
        """Set new target hvac mode. Uses the HA VThermHvacMode enum to respect the original type."""
        write_event_log(_LOGGER, self, f"Set hvac mode: {hvac_mode}")

        if hvac_mode is None:
            return

        # Change the requested state
        self._state_manager.requested_state.set_hvac_mode(hvac_mode)
        await self.update_states(force=False)

    @overrides
    @check_lock
    async def async_set_preset_mode(self, preset_mode: str):
        """Set new preset mode."""
        # We accept a new preset when:
        # 1. last_central_mode is not set,
        # 2. or last_central_mode is AUTO,
        # 3. or last_central_mode is CENTRAL_MODE_FROST_PROTECTION and preset_mode is
        #    VThermPreset.FROST (to be abel to re-set the preset_mode)

        write_event_log(_LOGGER, self, f"Set preset mode: {preset_mode}")

        vtherm_preset_mode = VThermPreset(preset_mode)
        accept = self._last_central_mode in [
            None,
            CENTRAL_MODE_AUTO,
            CENTRAL_MODE_COOL_ONLY,
            CENTRAL_MODE_HEAT_ONLY,
            CENTRAL_MODE_STOPPED,
        ] or (self._last_central_mode == CENTRAL_MODE_FROST_PROTECTION and preset_mode == VThermPreset.FROST)
        if not accept:
            _LOGGER.info(
                "%s - Impossible to change the preset to %s because central mode is %s",
                self,
                preset_mode,
                self._last_central_mode,
            )

            return

        await self.async_set_preset_mode_internal(vtherm_preset_mode)  # overwrite_saved_preset=overwrite_saved_preset)
        await self.update_states(force=True)

    async def async_set_preset_mode_internal(self, preset_mode: VThermPreset):  # , overwrite_saved_preset=True):
        """Set new preset mode."""
        _LOGGER.info("%s - Set requested preset_mode: %s", self, preset_mode)

        if preset_mode not in (self._vtherm_preset_modes or []) and preset_mode not in HIDDEN_PRESETS:
            raise ValueError(f"Got unsupported preset_mode {preset_mode}. Must be one of {self._vtherm_preset_modes}")

        self._state_manager.requested_state.set_preset(preset_mode)

    async def async_set_fan_mode(self, fan_mode: str):
        """Set new target fan mode."""
        _LOGGER.info("%s - Set fan mode: %s", self, fan_mode)
        return

    async def async_set_humidity(self, humidity: int):
        """Set new target humidity."""
        write_event_log(_LOGGER, self, f"Set humidity: {humidity}")
        return

    @check_lock
    async def async_set_temperature(self, **kwargs):
        """Set new requested target temperature and turn off any active presets."""
        temperature = kwargs.get(ATTR_TEMPERATURE)
        write_event_log(_LOGGER, self, f"Set target temp: {temperature}")
        if temperature is None:
            return

        self._state_manager.requested_state.set_target_temperature(temperature)
        self._state_manager.requested_state.set_preset(VThermPreset.NONE)
        await self.update_states(force=True)

    async def set_preset_temperature(
        self,
        preset: str,
        temperature: float | None = None,
        temperature_away: float | None = None,
    ):
        """Called by a to change the temperature of a preset
        data:
            preset: boost
            temperature: 17.8
            temperature_away: 15
        target:
            entity_id: climate.thermostat_2
        """
        write_event_log(_LOGGER, self, f"Calling SERVICE_SET_PRESET_TEMPERATURE, preset: {preset}, temperature: {temperature}, temperature_away: {temperature_away}")

        if preset in self._presets:
            if temperature is not None:
                self._presets[preset] = temperature
            if self._presence_manager.is_configured and temperature_away is not None:
                self._presets_away[self.get_preset_away_name(preset)] = temperature_away
        else:
            _LOGGER.warning("%s - No preset %s configured for this thermostat. ", self, preset)

        if preset in self._presets:
            if temperature is not None:
                self._presets[preset] = temperature
            if self._presence_manager.is_configured and temperature_away is not None:
                self._presets_away[self.get_preset_away_name(preset)] = temperature_away
        else:
            _LOGGER.warning(
                "%s - No preset %s configured for this thermostat. Ignoring set_preset_temperature call",
                self,
                preset,
            )

        # If the changed preset is active, change the current temperature
        # Issue #119 - reload new preset temperature also in ac mode
        is_active_preset = preset.startswith(self.preset_mode)

        # Also check if activity is the current preset and the changed preset
        # is the current motion sub-preset (e.g. comfort or boost)
        if not is_active_preset and self.preset_mode == VThermPreset.ACTIVITY and self._motion_manager.is_configured:
            current_motion_preset = self._motion_manager.get_current_motion_preset()
            is_active_preset = preset.startswith(current_motion_preset)

        if is_active_preset:
            self.requested_state.force_changed()
            await self.update_states(force=True)

    ##
    ## Calculation and utility functions
    ##
    async def update_states(self, force=False):
        """Update the states of the thermostat considering the requested state and the current state"""
        if not self.is_ready:
            _LOGGER.debug("%s - update_states is called but the entity is not initialized yet. Skip the update", self)
            return False

        changed = False
        if self._state_manager.requested_state.is_changed:
            if changed := await self._state_manager.calculate_current_state(self):
                _LOGGER.info("%s - current state changed to %s", self, self._state_manager.current_state)
                sub_need_control_heating = False
                # Apply preset
                if self._state_manager.current_state.is_preset_changed:
                    _LOGGER.info("%s - Applying new preset: %s", self, self.vtherm_preset_mode)
                    self.send_event(EventType.PRESET_EVENT, {"preset": self.preset_mode})
                    if self.preset_mode not in HIDDEN_PRESETS:
                        self._attr_preset_mode = self.preset_mode
                    # Reset auto_start_stop switch delay to allow immediate restart when preset changes
                    if self.auto_start_stop_manager:
                        self.auto_start_stop_manager.reset_switch_delay()

                # Apply temperature
                if self._state_manager.current_state.is_target_temperature_changed:
                    _LOGGER.info("%s - Applying new target temperature: %s", self, self.target_temperature)
                    self._attr_target_temperature = self.target_temperature
                    # Reset auto_start_stop switch delay to allow immediate restart when target temp changes
                    if self.auto_start_stop_manager:
                        self.auto_start_stop_manager.reset_switch_delay()

                # Apply hvac_mode
                if self._state_manager.current_state.is_hvac_mode_changed:
                    _LOGGER.info("%s - Applying new hvac mode: %s", self, self.vtherm_hvac_mode)
                    # Delegate to all underlying
                    for under in self._underlyings:
                        sub_need_control_heating = await under.set_hvac_mode(self.vtherm_hvac_mode) or sub_need_control_heating
                    self._attr_hvac_mode = to_legacy_ha_hvac_mode(self.vtherm_hvac_mode)
                    self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": str(self.vtherm_hvac_mode)})
                    # Remove eventual overpowering if we want to turn-off
                    if self.hvac_mode in [VThermHvacMode_OFF, VThermHvacMode_SLEEP] and self.power_manager.is_overpowering_detected:
                        await self.power_manager.set_overpowering(False)

                if changed:
                    self.recalculate(force=force)
                    self.reset_last_change_time_from_vtherm()
                    await self.async_control_heating(force=force or sub_need_control_heating)

            self.calculate_hvac_action()
            self.update_custom_attributes()
            self.async_write_ha_state()
        else:
            _LOGGER.debug("%s - current state did not change. Still %s", self, self._state_manager.current_state)

        self._state_manager.requested_state.reset_changed()
        self._state_manager.current_state.reset_changed()

        return changed

    async def async_control_heating(self, timestamp=None, force=False) -> bool:
        """The main function used to run the calculation at each cycle
        Returns True if the cycle was successfully calculated, False otherwise."""

        if timestamp:
            # Timestamp is set only on periodical calls
            write_event_log(_LOGGER, self, "Periodical control cycle started")

        _LOGGER.debug(
            "%s - Checking new cycle. hvac_mode=%s, safety_state=%s, preset_mode=%s, force=%s",
            self,
            self.vtherm_hvac_mode,
            self._safety_manager.safety_state,
            self.vtherm_preset_mode,
            force,
        )

        if not self.is_ready:
            _LOGGER.info("%s - async_control_heating is called but the entity is not ready yet (not initialized or startup not done). Skip the cycle", self)
            return False

        # check auto_window conditions
        await self._window_manager.manage_window_auto(in_cycle=True)

        if timestamp and await self._safety_manager.refresh_and_update_if_changed():
            return False

        if self._safety_manager.is_safety_detected and self.is_over_climate:
            _LOGGER.debug("%s - End of cycle (safety and over climate)", self)

            return True

        previous_hvac_action = self.hvac_action
        # Call specific control heating
        await self._control_heating_specific(timestamp, force)

        # Refresh external feature managers (provided by plugins) on every cycle.
        # This is done after the specific control heating so that a manager can
        # react to the regulated target temperature (e.g. drive the underlying
        # fan mode), and before the publication block below so that any custom
        # attributes updated here are written to HA within the same cycle.
        # Only external managers are refreshed here: internal managers have their
        # own dedicated per-cycle mechanisms. A per-manager guard prevents a
        # faulty plugin from breaking the control loop.
        for manager in self._external_managers:
            try:
                await manager.refresh_state()
            except Exception as exc:  # pylint: disable=broad-except
                _LOGGER.error(
                    "%s - Error while refreshing external feature manager '%s': %s",
                    self,
                    manager.name,
                    exc,
                )

        # Check for heating/cooling failures (only for TPI VTherms)
        await self._heating_failure_detection_manager.refresh_state()

        # Check and repair state discrepancies
        await self._repair_incorrect_state_manager.check_and_repair()

        self.calculate_hvac_action()
        should_publish = True
        algo_handler = getattr(self, "_algo_handler", None)
        if algo_handler and hasattr(algo_handler, "should_publish_intermediate"):
            should_publish = algo_handler.should_publish_intermediate()
            if not should_publish and previous_hvac_action != self.hvac_action:
                should_publish = True

        if should_publish:
            self.update_custom_attributes()
            self.async_write_ha_state()

        # For each manager display the manager state in debug and send an event with the manager state
        current_state = self._state_manager.current_state.to_dict()
        current_state["room_temperature"] = self.current_temperature
        current_state["outdoor_temperature"] = self.current_outdoor_temperature
        current_state["hvac_action"] = str(self.hvac_action)
        current_state["power_percent"] = self.power_percent
        manager_states = {}
        for manager in self._managers:
            try:
                manager_states[manager.__class__.__name__] = manager.is_detected
            except RuntimeError as e:
                _LOGGER.error("%s - Error while getting is_detected state of manager %s: %s", self, manager.__class__.__name__, e)

        _LOGGER.debug("%s - End of cycle. current_state: %s, managers_states: %s", self, current_state, manager_states)
        return True

    async def _control_heating_specific(self, timestamp=None, force=False):
        """To be overridden by subclasses."""
        pass

    def reset_last_change_time_from_vtherm(self, old_preset_mode: VThermPreset | None = None):  # pylint: disable=unused-argument
        """Reset to now the last change time"""
        self._last_change_time_from_vtherm = self.now
        _LOGGER.debug(
            "%s - last_change_time is now %s", self, self._last_change_time_from_vtherm
        )

    def reset_last_temperature_time(self, old_preset_mode: VThermPreset | str | None = None):
        """Reset to now the last temperature time if conditions are satisfied"""
        if self._state_manager.current_state.preset not in HIDDEN_PRESETS and old_preset_mode not in HIDDEN_PRESETS:
            self._last_temperature_measure = self._last_ext_temperature_measure = (
                self.now
            )

    def find_preset_temp(self, preset_mode: VThermPreset):
        """Find the right temperature of a preset considering
         the presence if configured"""
        if preset_mode is None or preset_mode == VThermPreset.NONE:
            return self._attr_max_temp if self._ac_mode and self.vtherm_hvac_mode == VThermHvacMode_COOL else self._attr_min_temp

        if preset_mode == VThermPreset.SAFETY:
            return self._state_manager.current_state.target_temperature
            # In safety just keep the current target temperature,
            # the thermostat should be off

        if preset_mode == VThermPreset.POWER:
            return self._power_manager.power_temperature
        if preset_mode == VThermPreset.ACTIVITY:
            motion_preset = self._motion_manager.get_current_motion_preset()
            if self._ac_mode and self.vtherm_hvac_mode == VThermHvacMode_COOL:
                motion_preset = motion_preset + PRESET_AC_SUFFIX

            if motion_preset in self._presets:
                if self._presence_manager.is_absence_detected:
                    return self._presets_away[motion_preset + PRESET_AWAY_SUFFIX]
                else:
                    return self._presets[motion_preset]
            else:
                return None
        else:
            # Select _ac presets if in COOL Mode (or over_switch with _ac_mode) or OFF but requested is cool
            preset_name = preset_mode
            if (
                (self._ac_mode and self.vtherm_hvac_mode == VThermHvacMode_COOL)
                or (self.vtherm_hvac_mode == VThermHvacMode_OFF and self.requested_state.hvac_mode == VThermHvacMode_COOL)
                # issue #1958 - when window_action=fan_only temporarily switches the mode to FAN_ONLY,
                # the user's intent (requested_state) is still COOL, so we must use AC presets.
                or (self.vtherm_hvac_mode == VThermHvacMode_FAN_ONLY and self.requested_state.hvac_mode == VThermHvacMode_COOL)
                # when auto-start/stop temporarily switches the mode to DRY as its stop mode,
                # the user's intent (requested_state) is still COOL, so we must use AC presets.
                or (self.vtherm_hvac_mode == VThermHvacMode_DRY and self.requested_state.hvac_mode == VThermHvacMode_COOL)
                #                (self.is_over_switch and self._ac_mode)
                #                or self.vtherm_hvac_mode == VThermHvacMode_COOL
                #                or (self.vtherm_hvac_mode == VThermHvacMode_OFF and self.requested_state.hvac_mode == VThermHvacMode_COOL)
            ):
                # if self._ac_mode and self.vtherm_hvac_mode == VThermHvacMode_COOL:
                preset_name += PRESET_AC_SUFFIX

            _LOGGER.info("%s - find preset temp: %s", self, preset_mode)

            temp_val = self._presets.get(preset_name, 0)
            # if not self._presence_on or self._presence_state in [
            #     None,
            #     STATE_ON,
            #     STATE_HOME,
            # ]:
            if self._presence_manager.is_absence_detected:
                # We should return the preset_away temp val but if
                # preset temp is 0, that means the user don't want to use
                # the preset so we return 0, even if there is a value is preset_away
                return self._presets_away.get(self.get_preset_away_name(preset_name), 0) if temp_val > 0 else temp_val
            else:
                return temp_val

    def get_preset_away_name(self, preset_mode: str) -> str:
        """Get the preset name in away mode (when presence is off)"""
        return preset_mode + PRESET_AWAY_SUFFIX

    def get_state_date_or_now(self, state: State) -> datetime:
        """Extract the last_updated state from State or return now if not available"""
        return state.last_updated.astimezone(self._current_tz) if isinstance(state.last_updated, datetime) else self.now

    def get_last_updated_date_or_now(self, state: State) -> datetime:
        """Extract the last_updated state from State or return now if not available"""
        return (
            state.last_updated.astimezone(self._current_tz)
            if isinstance(state.last_updated, datetime)
            else self.now
        )

    async def async_underlying_entity_turn_off(self):
        """Turn heater toggleable device off. Used by Window, overpowering,
        control_heating to turn all off"""

        if self._cycle_scheduler:
            await self._cycle_scheduler.cancel_cycle()

        for under in self._underlyings:
            await under.turn_off()

    def set_hvac_off_reason(self, hvac_off_reason: str | None):
        """Set the reason of hvac_off"""
        self._hvac_off_reason = hvac_off_reason

    def set_hvac_mode_reason(self, hvac_mode_reason: str | None):
        """Set the reason why the current hvac_mode is forced"""
        self._hvac_mode_reason = hvac_mode_reason

    def set_temperature_reason(self, temperature_reason: str | None):
        """Set the reason of temperature"""
        self._temperature_reason = temperature_reason

    async def check_central_mode(self, new_central_mode: str | None, old_central_mode: str | None):
        """Take into account a central mode change"""
        if not self.is_controlled_by_central_mode:
            self._last_central_mode = None
            return

        _LOGGER.info(
            "%s - Central mode have change from %s to %s",
            self,
            old_central_mode,
            new_central_mode,
        )

        self._last_central_mode = new_central_mode

        self.requested_state.force_changed()
        await self.update_states()
        return

    def recalculate(self, force=False):
        """A utility function to force the calculation of a the algo and
        update the custom attributes and write the state.
        Should be overridden by super class
        """
        raise NotImplementedError()

    def incremente_energy(self):
        """increment the energy counter if device is active
        Should be overridden by super class
        """
        raise NotImplementedError()

    def calculate_hvac_action(self, _: list = None) -> HVACAction | None:
        """Calculate the HVAC action based on the current state and underlying devices"""
        if self.vtherm_hvac_mode == VThermHvacMode_OFF:
            action = HVACAction.OFF
        elif not self.is_device_active:
            action = HVACAction.IDLE
        elif self.vtherm_hvac_mode == VThermHvacMode_COOL:
            action = HVACAction.COOLING
        else:
            action = HVACAction.HEATING
        self._attr_hvac_action = action

    def update_custom_attributes(self):
        """Update the custom extra attributes for the entity"""

        messages: list[str] = []
        if self.safety_manager.is_safety_detected:
            messages.append(MSG_SAFETY_DETECTED)
        if self.power_manager.is_overpowering_detected:
            messages.append(MSG_OVERPOWERING_DETECTED)
        if self.hvac_off_reason:
            messages.append(self.hvac_off_reason)
        if self.temperature_reason:
            messages.append(self.temperature_reason)

        not_initialized_entities = []
        if not self.is_initialized:
            messages.append(MSG_NOT_INITIALIZED)
            # Find all underlying entities that are not initialized
            for under in self._underlyings:
                if not under.is_initialized:
                    not_initialized_entities.extend(under.state_manager.get_uninitialized_entities())

        self._attr_extra_state_attributes: dict[str, Any] = {
            "hvac_action": self.hvac_action,
            "hvac_mode": self.hvac_mode,
            "preset_mode": self.preset_mode,
            "ema_temp": self._ema_temp,
            "is_ready": self.is_ready,
            "specific_states": {
                "is_initialized": self.is_initialized,
                "is_on": self.is_on,
                "last_central_mode": self.last_central_mode,
                "last_update_datetime": self.now.isoformat(),
                "ext_current_temperature": self._cur_ext_temp,
                "last_temperature_datetime": self._last_temperature_measure.astimezone(self._current_tz).isoformat(),
                "last_ext_temperature_datetime": self._last_ext_temperature_measure.astimezone(self._current_tz).isoformat(),
                "should_device_be_active": self.should_device_be_active,
                "is_device_active": self.is_device_active,
                "device_actives": self.device_actives,
                "nb_device_actives": self.nb_device_actives,
                "ema_temp": self._ema_temp,
                "temperature_slope": round(self.last_temperature_slope or 0, 3),
                "hvac_off_reason": self.hvac_off_reason,
                "hvac_mode_reason": self.hvac_mode_reason,
                ATTR_TOTAL_ENERGY: self.total_energy,
                "last_change_time_from_vtherm": (
                    self._last_change_time_from_vtherm.astimezone(self._current_tz).isoformat() if self._last_change_time_from_vtherm is not None else None
                ),
                "is_sleeping": self.is_sleeping,
                "is_locked": self.lock_manager.is_locked,
                "is_recalculate_scheduled": self.is_recalculate_scheduled,
                "not_initialized_entities": not_initialized_entities,
                "messages": messages,
            },
            "configuration": {
                "ac_mode": self._ac_mode,
                "type": self.vtherm_type,
                "proportional_function": self.proportional_function,
                "is_controlled_by_central_mode": self.is_controlled_by_central_mode,
                "target_temperature_step": self.target_temperature_step,
                "timezone": str(self._current_tz),
                "temperature_unit": self.temperature_unit,
                "is_used_by_central_boiler": self.is_used_by_central_boiler,
                "max_on_percent": self._max_on_percent,
                "have_valve_regulation": self.have_valve_regulation,
                "cycle_min": self._cycle_min,
            },
            "preset_temperatures": {
                "frost_temp": self._presets.get(VThermPreset.FROST, 0),
                "eco_temp": self._presets.get(VThermPreset.ECO, 0),
                "boost_temp": self._presets.get(VThermPreset.BOOST, 0),
                "comfort_temp": self._presets.get(VThermPreset.COMFORT, 0),
                "frost_away_temp": self._presets_away.get(self.get_preset_away_name(VThermPreset.FROST), 0),
                "eco_away_temp": self._presets_away.get(self.get_preset_away_name(VThermPreset.ECO), 0),
                "boost_away_temp": self._presets_away.get(self.get_preset_away_name(VThermPreset.BOOST), 0),
                "comfort_away_temp": self._presets_away.get(self.get_preset_away_name(VThermPreset.COMFORT), 0),
            },
        }

        self._state_manager.add_custom_attributes(self._attr_extra_state_attributes)

        for manager in self._managers:
            # add_custom_attributes is optional for external feature managers
            # (it is not part of the InterfaceFeatureManager contract), so call
            # it defensively.
            publish_attributes = getattr(manager, "add_custom_attributes", None)
            if callable(publish_attributes):
                publish_attributes(self._attr_extra_state_attributes)

    def send_event(self, event_type: EventType, data: dict):
        """Send an event"""
        send_vtherm_event(self._hass, event_type=event_type, entity=self, data=data)

    async def async_turn_off(self) -> None:
        await self.async_set_hvac_mode(VThermHvacMode_OFF)

    async def async_turn_on(self) -> None:
        if self._ac_mode:
            await self.async_set_hvac_mode(VThermHvacMode_COOL)
        else:
            await self.async_set_hvac_mode(VThermHvacMode_HEAT)

    def is_preset_configured(self, preset) -> bool:
        """Returns True if the preset in argument is configured"""
        return self._presets.get(preset, None) is not None

    ##
    ## Device changes events
    ##

    @callback
    async def _async_temperature_changed(self, event: Event) -> callable:
        """Handle temperature of the temperature sensor changes.
        Return the function to dearm (clear) the window auto check"""
        new_state: State = event.data.get("new_state")
        write_event_log(_LOGGER, self, f"Temperature changed to state {new_state.state if new_state else None}")

        if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
            return

        dearm_window_auto = await self._async_update_temp(new_state)
        self.recalculate()

        # Potentially it generates a safety event
        if await self._safety_manager.refresh_and_update_if_changed() or (self.auto_start_stop_manager and await self.auto_start_stop_manager.refresh_and_update_if_changed()):
            _LOGGER.info("%s - Change in safety alert or auto_start_stopis detected. Force update states", self)
            return dearm_window_auto

        await self.async_control_heating(force=False)

        return dearm_window_auto

    @callback
    async def _async_last_seen_temperature_changed(self, event: Event):
        """Handle last seen temperature sensor changes."""
        new_state: State = event.data.get("new_state")
        write_event_log(_LOGGER, self, f"Last seen temperature changed to state {new_state.state if new_state else None}")

        if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
            return

        # try to extract the datetime (from state)
        try:
            old_safety: bool = self._safety_manager.is_safety_detected
            safety: bool = old_safety

            # Convert ISO 8601 string to datetime object
            self._last_temperature_measure = self.get_last_updated_date_or_now(new_state)
            # issue 690 - don't reset the last change time on lastSeen
            # self.reset_last_change_time_from_vtherm()
            _LOGGER.debug(
                "%s - new last_temperature_measure is now: %s",
                self,
                self._last_temperature_measure,
            )

            # try to restart if we were in safety mode
            if self._safety_manager.is_safety_detected:
                safety = await self._safety_manager.refresh_state()

            if safety != old_safety:
                _LOGGER.debug("%s - Change in safety alert is detected. Force update states", self)
                self.requested_state.force_changed()
                await self.update_states(force=True)

        except ValueError as err:
            # La conversion a échoué, la chaîne n'est pas au format ISO 8601
            _LOGGER.warning(
                "%s - impossible to convert last seen datetime %s. Error is: %s",
                self,
                new_state.state,
                err,
            )

    async def _async_ext_temperature_changed(self, event: Event):
        """Handle external temperature of the sensor changes."""
        new_state: State = event.data.get("new_state")
        write_event_log(_LOGGER, self, f"Outdoor temperature changed to state {new_state.state if new_state else None}")

        if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
            return

        await self._async_update_ext_temp(new_state)
        self.recalculate()

        # Potentially it generates a safety event
        if await self._safety_manager.refresh_and_update_if_changed() or (self.auto_start_stop_manager and await self.auto_start_stop_manager.refresh_and_update_if_changed()):
            _LOGGER.info("%s - Change in safety alert or auto_start_stopis detected. Force update states", self)
            return

        await self.async_control_heating(force=False)

    @callback
    async def _async_update_temp(self, state: State):
        """Update thermostat with latest state from sensor."""
        try:
            cur_temp = float(state.state)
            if math.isnan(cur_temp) or math.isinf(cur_temp):
                raise ValueError(f"Sensor has illegal state {state.state}")
            self._cur_temp = cur_temp

            self._last_temperature_measure = self.get_state_date_or_now(state)

            # calculate the smooth_temperature with EMA calculation
            self._ema_temp = self._ema_algo.calculate_ema(self._cur_temp, self._last_temperature_measure)

            _LOGGER.debug(
                "%s - After setting _last_temperature_measure %s, state.last_changed.replace=%s",
                self,
                self._last_temperature_measure,
                state.last_changed.astimezone(self._current_tz),
            )

            # try to restart if we were in safety mode
            # if self._safety_manager.is_safety_detected:
            #     await self._safety_manager.refresh_state()

            # check window_auto
            return await self._window_manager.manage_window_auto()

        except ValueError as ex:
            _LOGGER.error("Unable to update temperature from sensor: %s", ex)

    @callback
    async def _async_update_ext_temp(self, state: State):
        """Update thermostat with latest state from sensor."""
        try:
            cur_ext_temp = float(state.state)
            if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp):
                raise ValueError(f"Sensor has illegal state {state.state}")
            self._cur_ext_temp = cur_ext_temp
            self._last_ext_temperature_measure = self.get_state_date_or_now(state)

            _LOGGER.debug(
                "%s - After setting _last_ext_temperature_measure %s, state.last_changed.replace=%s",
                self,
                self._last_ext_temperature_measure,
                state.last_changed.astimezone(self._current_tz),
            )

            # try to restart if we were in safety mode
            # if self._safety_manager.is_safety_detected:
            #     await self._safety_manager.refresh_state()
        except ValueError as ex:
            _LOGGER.error("Unable to update external temperature from sensor: %s", ex)

    ##
    ## Services (actions)
    ##

    async def service_set_presence(self, presence: str):
        """Called by a service call:
        service: versatile_thermostat.set_presence
        data:
            presence: "off"
        target:
            entity_id: climate.thermostat_1
        """
        if self.lock_manager.check_is_locked("service_set_presence"):
            return
        write_event_log(_LOGGER, self, f"Calling SERVICE_SET_PRESENCE, presence: {presence}")
        await self._presence_manager.update_presence(presence)
        await self.async_control_heating(force=True)

    async def service_set_safety(
        self,
        delay_min: int,
        min_on_percent: float,
        default_on_percent: float,
    ):
        """Called by a service call:
        service: versatile_thermostat.set_safety
        data:
            delay_min: 15
            min_on_percent: 0.5
            default_on_percent: 0.2
        target:
            entity_id: climate.thermostat_2
        """
        if self.lock_manager.check_is_locked("service_set_safety"):
            return
        write_event_log(
            _LOGGER, self, f"Calling SERVICE_SET_SAFETY, delay_min: {delay_min}, " f"min_on_percent: {min_on_percent * 100} %, default_on_percent: {default_on_percent * 100} %"
        )
        if delay_min:
            self._safety_manager.set_safety_delay_min(delay_min)
        if min_on_percent:
            self._safety_manager.set_safety_min_on_percent(min_on_percent)
        if default_on_percent:
            self._safety_manager.set_safety_default_on_percent(default_on_percent)

        await self.async_control_heating()
        self.update_custom_attributes()
        self.async_write_ha_state()

    async def service_set_window_bypass_state(self, window_bypass: bool):
        """Called by a service call:
        service: versatile_thermostat.set_window_bypass
        data:
            window_bypass: True
        target:
            entity_id: climate.thermostat_1
        """
        if self.lock_manager.check_is_locked("service_set_window_bypass_state"):
            return
        write_event_log(_LOGGER, self, f"Calling SERVICE_SET_WINDOW_BYPASS, window_bypass: {window_bypass}")
        if await self._window_manager.set_window_bypass(window_bypass):
            self.requested_state.force_changed()
            await self.update_states(force=True)

    async def service_set_hvac_mode_sleep(self):
        """Set the hvac_mode to SLEEP mode (valid only for over_climate with valve regulation):
        service: versatile_thermostat.set_hvac_mode_sleep
        target:
            entity_id: climate.thermostat_1
        """
        if self.lock_manager.check_is_locked("service_set_hvac_mode_sleep"):
            return
        write_event_log(_LOGGER, self, "Calling SERVICE_SET_HVAC_MODE_SLEEP")
        raise NotImplementedError("service_set_hva_mode_sleep not implemented for this kind of thermostat. Only for over_climate with valve regulation is supported")

    async def service_lock(self, code: str | None = None):
        """Handle the lock service call."""

        if not self.lock_manager.has_lock_settings_enabled:
            _LOGGER.error("%s - Cannot lock thermostat: no lock settings enabled (neither users nor automations)", self)
            raise HomeAssistantError("Cannot lock thermostat: no lock settings enabled. At least one of 'lock users' or 'lock automations' must be enabled.")

        if self.lock_manager.change_lock_state(True, code):
            self.update_custom_attributes()
            self.async_write_ha_state()

    async def service_unlock(self, code: str | None = None):
        """Handle the unlock service call."""
        if self.lock_manager.change_lock_state(False, code):
            self.update_custom_attributes()
            self.async_write_ha_state()

    async def service_set_tpi_parameters(
        self,
        tpi_coef_int: float | None = None,
        tpi_coef_ext: float | None = None,
        minimal_activation_delay: int | None = None,
        minimal_deactivation_delay: int | None = None,
        tpi_threshold_low: float | None = None,
        tpi_threshold_high: float | None = None,
    ):
        """Stub method for TPI parameter service on non-TPI thermostats.

        This service is only available for switch/valve type thermostats that use TPI algorithm.
        For over_climate thermostats, this service is not supported.

        Raises:
            ServiceValidationError: Always raised to indicate the service is not available
        """
        raise ServiceValidationError(
            f"{self} - The set_tpi_parameters service is only available for switch/valve type thermostats. "
            "This thermostat does not use TPI algorithm."
        )

    async def service_set_auto_tpi_mode(
        self,
        auto_tpi_mode: bool,
        reinitialise: bool = True,
        allow_kint_boost_on_stagnation: bool = False,
        allow_kext_compensation_on_overshoot: bool = False,
    ):
        """Stub method for Auto TPI mode service on non-TPI thermostats.

        This service is only available for switch/valve type thermostats that use TPI algorithm.
        For over_climate thermostats, this service is not supported.

        Raises:
            ServiceValidationError: Always raised to indicate the service is not available
        """
        raise ServiceValidationError(
            f"{self} - The set_auto_tpi_mode service is only available for switch/valve type thermostats. "
            "This thermostat does not use TPI algorithm."
        )

    async def service_auto_tpi_calibrate_capacity(
        self,
        save_to_config: bool,
        min_power_threshold: int,
        start_date: datetime | None = None,
        end_date: datetime | None = None,
    ):
        """Stub method for Auto TPI capacity calibration service on non-TPI thermostats.

        This service is only available for switch/valve type thermostats that use TPI algorithm.
        For over_climate thermostats, this service is not supported.

        Raises:
            ServiceValidationError: Always raised to indicate the service is not available
        """
        raise ServiceValidationError(
            f"{self} - The auto_tpi_calibrate_capacity service is only available for switch/valve type thermostats. "
            "This thermostat does not use TPI algorithm."
        )

    async def service_set_timed_preset(self, preset: str, duration_minutes: float):
        """Called by a service call:
        service: versatile_thermostat.set_timed_preset
        data:
            preset: "boost"
            duration_minutes: 30
        target:
            entity_id: climate.thermostat_1

        Force a preset for a given duration. After the duration expires,
        the original preset (from requested_state) will be restored.
        """
        if self.lock_manager.check_is_locked("service_set_timed_preset"):
            return

        write_event_log(
            _LOGGER,
            self,
            f"Calling SERVICE_SET_TIMED_PRESET, preset: {preset}, duration: {duration_minutes} min",
        )

        # Validate that the preset is in the list of accepted presets
        preset_enum = VThermPreset(preset) if preset else None
        if preset_enum not in self._vtherm_preset_modes:
            raise ServiceValidationError(
                f"{self} - The preset '{preset}' is not available for this thermostat. " f"Available presets are: {[str(p) for p in self._vtherm_preset_modes]}"
            )

        # Validate duration
        if duration_minutes <= 0:
            raise ServiceValidationError(f"{self} - The duration must be a positive number, got {duration_minutes}")

        # Set the timed preset
        success = await self._timed_preset_manager.set_timed_preset(preset_enum, duration_minutes)
        if not success:
            raise ServiceValidationError(f"{self} - Failed to set timed preset '{preset}' for {duration_minutes} minutes")

    async def service_cancel_timed_preset(self):
        """Called by a service call:
        service: versatile_thermostat.cancel_timed_preset
        target:
            entity_id: climate.thermostat_1

        Cancel any active timed preset and restore the original preset.
        """
        if self.lock_manager.check_is_locked("service_cancel_timed_preset"):
            return

        write_event_log(_LOGGER, self, "Calling SERVICE_CANCEL_TIMED_PRESET")

        await self._timed_preset_manager.cancel_timed_preset()

    async def service_recalibrate_valves(self, delay_seconds: int):
        """Stub method for recalibrate_valves service on unsupported thermostat types.

        Raises:
            ServiceValidationError: Always raised to indicate the service is not available
        """
        raise ServiceValidationError(
            f"{self} - The recalibrate_valves service is only available for ThermostatClimateValve thermostats."
        )

    async def service_download_logs(
        self,
        log_level: str = "DEBUG",
        period_start: datetime | None = None,
        period_end: datetime | None = None,
    ):
        """Called by the download_logs entity service."""
        from vtherm_api.log_collector import async_export_logs  # pylint: disable=import-outside-toplevel

        handler = self._hass.data.get(DOMAIN, {}).get("log_handler")
        if handler is None:
            _LOGGER.warning("%s - Log collector is not initialized", self._name)
            return

        await async_export_logs(
            hass=self._hass,
            handler=handler,
            thermostat_name=self._name,
            entity_id=self.entity_id,
            log_level=log_level,
            period_start=period_start,
            period_end=period_end,
            config_entry=self._entry_infos,
        )

    ##
    ## For testing purpose
    ##
    # @deprecated
    def _set_now(self, now: datetime):
        """Set the now timestamp. This is only for tests purpose
        This method should be replaced by the vthermAPI equivalent"""
        VersatileThermostatAPI.get_vtherm_api(self._hass)._set_now(now)  # pylint: disable=protected-access

    # @deprecated
    @property
    def now(self) -> datetime:
        """Get now. The local datetime or the overloaded _set_now date
        This method should be replaced by the vthermAPI equivalent"""
        return VersatileThermostatAPI.get_vtherm_api(self._hass).now
