From ea6f2d5579caabcd737f5ad1481899e706f7ad15 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 3 Mar 2024 15:03:20 +0000 Subject: [PATCH] Init temperature number for central configuration + testus ok --- .devcontainer/devcontainer.json | 12 +- .vscode/settings.json | 4 +- .../versatile_thermostat/__init__.py | 20 +- .../versatile_thermostat/base_thermostat.py | 199 +++++---- .../versatile_thermostat/climate.py | 3 +- .../versatile_thermostat/number.py | 262 ++++++++++-- .../versatile_thermostat/strings.json | 50 +++ .../versatile_thermostat/temp_number.py | 197 +++++++++ .../versatile_thermostat/translations/en.json | 50 +++ .../versatile_thermostat/translations/fr.json | 82 +++- .../versatile_thermostat/vtherm_api.py | 74 +++- scripts/starts_ha.sh | 4 + tests/commons.py | 1 + tests/test_temp_number.py | 396 ++++++++++++++++++ 14 files changed, 1201 insertions(+), 153 deletions(-) create mode 100644 custom_components/versatile_thermostat/temp_number.py create mode 100644 tests/test_temp_number.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e41ffc3..3d45d97 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,11 +11,11 @@ // "postCreateCommand": "container install", "postCreateCommand": "./container dev-setup", - "mounts": [ - "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", - // uncomment this to get the versatile-thermostat-ui-card - "source=${localEnv:HOME}/SugarSync/Projets/home-assistant/versatile-thermostat-ui-card/dist,target=/workspaces/versatile_thermostat/config/www/community/versatile-thermostat-ui-card,type=bind,consistency=cached" - ], + "mounts": [ + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", + // uncomment this to get the versatile-thermostat-ui-card + "source=${localEnv:HOME}/SugarSync/Projets/home-assistant/versatile-thermostat-ui-card/dist,target=/workspaces/versatile_thermostat/config/www/community/versatile-thermostat-ui-card,type=bind,consistency=cached" + ], "customizations": { "vscode": { @@ -65,4 +65,4 @@ } } } -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 772c973..e0b805e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,7 +15,5 @@ // "/home/vscode/core", "/workspaces/versatile_thermostat/custom_components/versatile_thermostat", "/home/vscode/.local/lib/python3.12/site-packages/homeassistant" - ], - "python.experiments.optOutFrom": ["pythonTestAdapter"], - "python.formatting.provider": "none" + ] } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index 77d6c68..6d1de7e 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -8,10 +8,10 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import SERVICE_RELOAD, EVENT_HOMEASSISTANT_STARTED from homeassistant.config_entries import ConfigEntry, ConfigType -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, CoreState, callback from .base_thermostat import BaseThermostat @@ -82,15 +82,27 @@ async def async_setup( hass.data.setdefault(DOMAIN, {}) + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) # L'argument config contient votre fichier configuration.yaml vtherm_config = config.get(DOMAIN) - if vtherm_config is not None: - api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) api.set_global_config(vtherm_config) else: _LOGGER.info("No global config from configuration.yaml available") + # Listen HA starts to initialize all links between + @callback + async def _async_startup_internal(*_): + _LOGGER.info( + "VersatileThermostat - HA is started, initialize all links between VTherm entities" + ) + await api.init_vtherm_links() + + if hass.state == CoreState.running: + await _async_startup_internal() + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_startup_internal) + hass.helpers.service.async_register_admin_service( DOMAIN, SERVICE_RELOAD, diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index d122b26..372a6ac 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -82,9 +82,9 @@ from .const import ( CONF_NO_MOTION_PRESET, CONF_DEVICE_POWER, CONF_PRESETS, - CONF_PRESETS_AWAY, - CONF_PRESETS_WITH_AC, - CONF_PRESETS_AWAY_WITH_AC, + # CONF_PRESETS_AWAY, + # CONF_PRESETS_WITH_AC, + # CONF_PRESETS_AWAY_WITH_AC, CONF_CYCLE_MIN, CONF_PROP_FUNCTION, CONF_TPI_COEF_INT, @@ -137,6 +137,8 @@ from .prop_algorithm import PropAlgorithm from .open_window_algorithm import WindowOpenDetectionAlgorithm from .ema import ExponentialMovingAverage +from .temp_number import TemperatureNumber + _LOGGER = logging.getLogger(__name__) ConfigData = MappingProxyType[str, Any] @@ -285,6 +287,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._last_central_mode = None self._is_used_by_central_boiler = False + self._support_flags = None + self._attr_preset_modes: list[str] | None + self.post_init(entry_infos) def clean_central_config_doublon( @@ -358,33 +363,36 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._attr_target_temperature_step = step # convert entry_infos into usable attributes - presets: dict[str, Any] = {} - items = CONF_PRESETS_WITH_AC.items() if self._ac_mode else CONF_PRESETS.items() - for key, value in items: - _LOGGER.debug("looking for key=%s, value=%s", key, value) - if value in entry_infos: - presets[key] = entry_infos.get(value) - else: - _LOGGER.debug("value %s not found in Entry", value) - presets[key] = ( - self._attr_max_temp if self._ac_mode else self._attr_min_temp - ) + # 354 - presets are now initializesd by number entities + # presets: dict[str, Any] = {} + # items = CONF_PRESETS_WITH_AC.items() if self._ac_mode else CONF_PRESETS.items() + # for key, value in items: + # _LOGGER.debug("looking for key=%s, value=%s", key, value) + # if value in entry_infos: + # presets[key] = entry_infos.get(value) + # else: + # _LOGGER.debug("value %s not found in Entry", value) + # presets[key] = ( + # self._attr_max_temp if self._ac_mode else self._attr_min_temp + # ) - presets_away: dict[str, Any] = {} - items = ( - CONF_PRESETS_AWAY_WITH_AC.items() - if self._ac_mode - else CONF_PRESETS_AWAY.items() - ) - for key, value in items: - _LOGGER.debug("looking for key=%s, value=%s", key, value) - if value in entry_infos: - presets_away[key] = entry_infos.get(value) - else: - _LOGGER.debug("value %s not found in Entry", value) - presets_away[key] = ( - self._attr_max_temp if self._ac_mode else self._attr_min_temp - ) + # presets_away: dict[str, Any] = {} + # items = ( + # CONF_PRESETS_AWAY_WITH_AC.items() + # if self._ac_mode + # else CONF_PRESETS_AWAY.items() + # ) + # for key, value in items: + # _LOGGER.debug("looking for key=%s, value=%s", key, value) + # if value in entry_infos: + # presets_away[key] = entry_infos.get(value) + # else: + # _LOGGER.debug("value %s not found in Entry", value) + # presets_away[key] = ( + # self._attr_max_temp if self._ac_mode else self._attr_min_temp + # ) + + self._attr_preset_modes: list[str] | None if self._window_call_cancel is not None: self._window_call_cancel() @@ -462,15 +470,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._support_flags = SUPPORT_FLAGS - self._presets = presets - self._presets_away = presets_away + # Preset will be initialized from Number entities + self._presets: dict[str, Any] = {} # presets + self._presets_away: dict[str, Any] = {} # presets_away - _LOGGER.debug( - "%s - presets are set to: %s, away: %s", - self, - self._presets, - self._presets_away, - ) # Will be restored if possible self._attr_preset_mode = PRESET_NONE self._saved_preset_mode = PRESET_NONE @@ -534,24 +537,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._overpowering_state = None self._presence_state = None - # Calculate all possible presets - self._attr_preset_modes = [PRESET_NONE] - if len(presets): - self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE - - for key, _ in CONF_PRESETS.items(): - if self.find_preset_temp(key) > 0: - self._attr_preset_modes.append(key) - - _LOGGER.debug( - "After adding presets, preset_modes to %s", self._attr_preset_modes - ) - else: - _LOGGER.debug("No preset_modes") - - if self._motion_on: - self._attr_preset_modes.append(PRESET_ACTIVITY) - self._total_energy = 0 # Read the parameter from configuration.yaml if it exists @@ -658,6 +643,41 @@ class BaseThermostat(ClimateEntity, RestoreEntity): await self.async_startup() + # TODO remove this + def init_temperature_preset(self, preset, temperature, is_ac, is_away): + """Initialize the internal temperature preset + from the Number entity which holds the temperature""" + + if temperature is None or preset is None: + return + + if is_away: + self._presets_away[preset] = temperature + else: + self._presets[preset] = temperature + + _LOGGER.debug( + "%s - presets are set to: %s, away: %s", + self, + self._presets, + self._presets_away, + ) + + # Calculate all possible presets + self._attr_preset_modes = [PRESET_NONE] + if len(self._presets): + self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE + + for key, _ in CONF_PRESETS.items(): + if self.find_preset_temp(key) > 0: + self._attr_preset_modes.append(key) + + _LOGGER.debug( + "After adding presets, preset_modes to %s", self._attr_preset_modes + ) + else: + _LOGGER.debug("No preset_modes") + def remove_thermostat(self): """Called when the thermostat will be removed""" _LOGGER.info("%s - Removing thermostat", self) @@ -852,10 +872,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): old_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) # Never restore a Power or Security preset - if ( - old_preset_mode in self._attr_preset_modes - and old_preset_mode not in HIDDEN_PRESETS - ): + if old_preset_mode not in HIDDEN_PRESETS: + # old_preset_mode in self._attr_preset_modes self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) self.save_preset_mode() else: @@ -1323,9 +1341,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._attr_preset_mode not in HIDDEN_PRESETS and old_preset_mode not in HIDDEN_PRESETS ): - self._last_temperature_measure = ( - self._last_ext_temperature_measure - ) = datetime.now(tz=self._current_tz) + self._last_temperature_measure = self._last_ext_temperature_measure = ( + datetime.now(tz=self._current_tz) + ) def find_preset_temp(self, preset_mode: str): """Find the right temperature of a preset considering the presence if configured""" @@ -1344,9 +1362,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): return self._power_temp if preset_mode == PRESET_ACTIVITY: return self._presets[ - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset + ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ) ] else: # Select _ac presets if in COOL Mode (or over_switch with _ac_mode) @@ -1359,9 +1379,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): STATE_ON, STATE_HOME, ]: - return self._presets[preset_mode] + return self._presets.get(preset_mode, 0) else: - return self._presets_away[self.get_preset_away_name(preset_mode)] + return self._presets_away.get(self.get_preset_away_name(preset_mode), 0) def get_preset_away_name(self, preset_mode: str) -> str: """Get the preset name in away mode (when presence is off)""" @@ -1812,9 +1832,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): await self._async_internal_set_temperature( self._presets[ - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset + ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ) ] ) _LOGGER.debug( @@ -2431,21 +2453,21 @@ class BaseThermostat(ClimateEntity, RestoreEntity): "type": self._thermostat_type, "is_controlled_by_central_mode": self.is_controlled_by_central_mode, "last_central_mode": self.last_central_mode, - "frost_temp": self._presets[PRESET_FROST_PROTECTION], - "eco_temp": self._presets[PRESET_ECO], - "boost_temp": self._presets[PRESET_BOOST], - "comfort_temp": self._presets[PRESET_COMFORT], + "frost_temp": self._presets.get(PRESET_FROST_PROTECTION, 0), + "eco_temp": self._presets.get(PRESET_ECO, 0), + "boost_temp": self._presets.get(PRESET_BOOST, 0), + "comfort_temp": self._presets.get(PRESET_COMFORT, 0), "frost_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_FROST_PROTECTION) + self.get_preset_away_name(PRESET_FROST_PROTECTION), 0 ), "eco_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_ECO) + self.get_preset_away_name(PRESET_ECO), 0 ), "boost_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_BOOST) + self.get_preset_away_name(PRESET_BOOST), 0 ), "comfort_away_temp": self._presets_away.get( - self.get_preset_away_name(PRESET_COMFORT) + self.get_preset_away_name(PRESET_COMFORT), 0 ), "power_temp": self._power_temp, "target_temperature_step": self.target_temperature_step, @@ -2626,8 +2648,19 @@ class BaseThermostat(ClimateEntity, RestoreEntity): 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) - # _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data) - # data["entity_id"] = self.entity_id - # data["name"] = self.name - # data["state_attributes"] = self.state_attributes - # self._hass.bus.fire(event_type.value, data) + + def get_temperature_number_entities(self, config_entry: ConfigData): + """Creates all TemperatureNumber depending of the configuration of the Climate""" + + # TODO add the list of preset we want to use in the VTherm. Here we will suppose all preset will be available + entity = TemperatureNumber( + self._hass, + unique_id=config_entry.entry_id, + name=config_entry.data.get(CONF_NAME), + preset_name="comfort", + is_ac=False, + is_away=False, + entry_infos=config_entry.data, + ) + + return entity diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 3b41c72..151826c 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -1,4 +1,5 @@ """ Implements the VersatileThermostat climate component """ + import logging @@ -75,7 +76,7 @@ async def async_setup_entry( elif vt_type == CONF_THERMOSTAT_VALVE: entity = ThermostatOverValve(hass, unique_id, name, entry.data) - async_add_entities([entity], True) + async_add_entities([entity, entity.get_temperature_number_entities(entry)], True) # Add services platform = entity_platform.async_get_current_platform() diff --git a/custom_components/versatile_thermostat/number.py b/custom_components/versatile_thermostat/number.py index 0ccd1f7..831bbc1 100644 --- a/custom_components/versatile_thermostat/number.py +++ b/custom_components/versatile_thermostat/number.py @@ -2,7 +2,6 @@ """ Implements the VersatileThermostat select component """ import logging -from typing import Any # from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant, CoreState # , callback @@ -11,10 +10,7 @@ from homeassistant.components.number import ( NumberEntity, NumberMode, NumberDeviceClass, - ATTR_MAX, - ATTR_MIN, - ATTR_STEP, - ATTR_MODE, + DOMAIN as NUMBER_DOMAIN, ) from homeassistant.components.climate import ( PRESET_BOOST, @@ -29,7 +25,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI +from .vtherm_api import VersatileThermostatAPI from .commons import VersatileThermostatBaseEntity from .const import ( @@ -50,8 +46,8 @@ from .const import ( PRESET_AC_SUFFIX, CONF_PRESETS_VALUES, CONF_PRESETS_WITH_AC_VALUES, - # CONF_PRESETS_AWAY_VALUES, - # CONF_PRESETS_AWAY_WITH_AC_VALUES, + CONF_PRESETS_AWAY_VALUES, + CONF_PRESETS_AWAY_WITH_AC_VALUES, overrides, ) @@ -63,6 +59,13 @@ PRESET_ICON_MAPPING = { PRESET_ECO_AC + "_temp": "mdi:leaf-circle-outline", PRESET_COMFORT_AC + "_temp": "mdi:sofa-outline", PRESET_BOOST_AC + "_temp": "mdi:rocket-launch-outline", + PRESET_FROST_PROTECTION + "_away_temp": "mdi:snowflake-thermometer", + PRESET_ECO + "_away_temp": "mdi:leaf", + PRESET_COMFORT + "_away_temp": "mdi:sofa", + PRESET_BOOST + "_away_temp": "mdi:rocket-launch", + PRESET_ECO_AC + "_away_temp": "mdi:leaf-circle-outline", + PRESET_COMFORT_AC + "_away_temp": "mdi:sofa-outline", + PRESET_BOOST_AC + "_away_temp": "mdi:rocket-launch-outline", } _LOGGER = logging.getLogger(__name__) @@ -85,21 +88,54 @@ async def async_setup_entry( entities = [] - if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG or not is_central_boiler: - for preset in CONF_PRESETS_VALUES: - entities.append( - TemperatureNumber(hass, unique_id, preset, preset, entry.data) - ) + if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG: + if not is_central_boiler: + pass + # for preset in CONF_PRESETS_VALUES: + # entities.append( + # TemperatureNumber( + # hass, unique_id, preset, preset, False, False, entry.data + # ) + # ) - if entry.data.get(CONF_AC_MODE, False): - for preset in CONF_PRESETS_WITH_AC_VALUES: - entities.append( - TemperatureNumber(hass, unique_id, preset, preset, entry.data) - ) + # TODO + # if entry.data.get(CONF_AC_MODE, False): + # for preset in CONF_PRESETS_WITH_AC_VALUES: + # entities.append( + # TemperatureNumber( + # hass, unique_id, preset, preset, True, False, entry.data + # ) + # ) else: entities.append( ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data) ) + for preset in CONF_PRESETS_VALUES: + entities.append( + CentralConfigTemperatureNumber( + hass, unique_id, preset, preset, False, False, entry.data + ) + ) + for preset in CONF_PRESETS_WITH_AC_VALUES: + entities.append( + CentralConfigTemperatureNumber( + hass, unique_id, preset, preset, True, False, entry.data + ) + ) + + for preset in CONF_PRESETS_AWAY_VALUES: + entities.append( + CentralConfigTemperatureNumber( + hass, unique_id, preset, preset, False, True, entry.data + ) + ) + + for preset in CONF_PRESETS_AWAY_WITH_AC_VALUES: + entities.append( + CentralConfigTemperatureNumber( + hass, unique_id, preset, preset, True, True, entry.data + ) + ) async_add_entities(entities, True) @@ -171,25 +207,173 @@ class ActivateBoilerThresholdNumber( return f"VersatileThermostat-{self.name}" +class CentralConfigTemperatureNumber(NumberEntity, RestoreEntity): + """Representation of one temperature number""" + + _attr_has_entity_name = True + # _attr_translation_key = "temperature" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, + preset_name, + is_ac, + is_away, + entry_infos, + ) -> None: + """Initialize the temperature with entry_infos if available. Else + the restoration will do the trick.""" + + self._config_id = unique_id + self._device_name = entry_infos.get(CONF_NAME) + # self._attr_name = name + + self._attr_translation_key = preset_name + # self._attr_translation_placeholders = { + # "preset": preset_name, + # "ac": "-AC" if is_ac else "", + # "away": "-AWAY" if is_away else "", + # } + self.entity_id = f"{NUMBER_DOMAIN}.central_configuration_{preset_name}" + self._attr_unique_id = f"central_configuration_{preset_name}" + self._attr_device_class = NumberDeviceClass.TEMPERATURE + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + self._attr_native_step = entry_infos.get(CONF_STEP_TEMPERATURE, 0.5) + self._attr_native_min_value = entry_infos.get(CONF_TEMP_MIN) + self._attr_native_max_value = entry_infos.get(CONF_TEMP_MAX) + + # Initialize the values if included into the entry_infos. This will do + # the temperature migration. Else the temperature will be restored from + # previous value + # TODO remove this after the next major release and just keep the init min/max + temp = None + if temp := entry_infos.get(preset_name, None): + self._attr_value = self._attr_native_value = temp + else: + if entry_infos.get(CONF_AC_MODE) is True: + self._attr_native_value = self._attr_native_max_value + else: + self._attr_native_value = self._attr_native_min_value + + self._attr_mode = NumberMode.BOX + self._preset_name = preset_name + self._is_away = is_away + self._is_ac = is_ac + + @property + def icon(self) -> str | None: + return PRESET_ICON_MAPPING[self._preset_name] + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + # register the temp entity for this device and preset + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + api.register_temperature_number(self._config_id, self._preset_name, self) + + # Restore value from previous one if exists + old_state: CoreState = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling async_added_to_hass old_state is %s", self, old_state + ) + try: + if old_state is not None and ((value := float(old_state.state)) > 0): + self._attr_value = self._attr_native_value = value + except ValueError: + pass + + @overrides + async def async_set_native_value(self, value: float) -> None: + """Change the value""" + + # TODO implements the native value change -> reload values for all central config + # based VTherm + # if self.my_climate is None: + # _LOGGER.warning( + # "%s - cannot change temperature because VTherm is not initialized", self + # ) + # return + + # + # float_value = float(value) + # old_value = float(self._attr_native_value) + # + # if float_value == old_value: + # return + # + # self._attr_value = self._attr_native_value = float_value + # + # self.async_write_ha_state() + # + # # Update the VTherm + # self.hass.create_task( + # self.my_climate.service_set_preset_temperature( + # self._preset_name.replace("_temp", ""), self._attr_native_value, None + # ) + + # ) + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + @property + def native_unit_of_measurement(self) -> str | None: + """The unit of measurement""" + # TODO Kelvin ? It seems not because all internal values are stored in + # ° Celsius but only the render in front can be in °K depending on the + # user configuration. + return UnitOfTemperature.CELSIUS + + class TemperatureNumber( # pylint: disable=abstract-method VersatileThermostatBaseEntity, NumberEntity, RestoreEntity ): """Representation of one temperature number""" + _attr_has_entity_name = True + _attr_translation_key = "temperature" + def __init__( - self, hass: HomeAssistant, unique_id, name, preset_name, entry_infos + self, + hass: HomeAssistant, + unique_id, + name, + preset_name, + is_ac, + is_away, + entry_infos, ) -> None: """Initialize the temperature with entry_infos if available. Else the restoration will do the trick.""" super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) split = name.split("_") - self._attr_name = split[0] - if "_" + split[1] == PRESET_AC_SUFFIX: - self._attr_name = self._attr_name + " AC" + # self._attr_name = split[0] + # if "_" + split[1] == PRESET_AC_SUFFIX: + # self._attr_name = self._attr_name + " AC" - self._attr_name = self._attr_name + " temperature" + # self._attr_name = self._attr_name + " temperature" + self._attr_translation_placeholders = { + "preset": preset_name, + "ac": "-AC" if is_ac else "", + "away": "-AWAY" if is_away else "", + } self._attr_unique_id = f"{self._device_name}_{name}" self._attr_device_class = NumberDeviceClass.TEMPERATURE self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @@ -203,6 +387,8 @@ class TemperatureNumber( # pylint: disable=abstract-method self._attr_mode = NumberMode.BOX self._preset_name = preset_name + self._is_away = is_away + self._is_ac = is_ac self._attr_native_step = entry_infos.get(CONF_STEP_TEMPERATURE, 0.5) self._attr_native_min_value = entry_infos.get(CONF_TEMP_MIN) @@ -220,8 +406,11 @@ class TemperatureNumber( # pylint: disable=abstract-method _LOGGER.debug( "%s - Calling async_added_to_hass old_state is %s", self, old_state ) - if old_state is not None: - self._attr_value = self._attr_native_value = float(old_state.state) + try: + if old_state is not None and (value := float(old_state.state) > 0): + self._attr_value = self._attr_native_value = value + except ValueError: + pass @overrides def my_climate_is_initialized(self): @@ -229,6 +418,11 @@ class TemperatureNumber( # pylint: disable=abstract-method self._attr_native_step = self.my_climate.target_temperature_step self._attr_native_min_value = self.my_climate.min_temp self._attr_native_max_value = self.my_climate.max_temp + + # Initialize the internal temp value of VTherm + self.my_climate.init_temperature_preset( + self._preset_name, self._attr_native_value, self._is_ac, self._is_away + ) return # @overrides @@ -238,8 +432,15 @@ class TemperatureNumber( # pylint: disable=abstract-method # return self.my_climate.target_temperature_step @overrides - def set_native_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Change the value""" + + if self.my_climate is None: + _LOGGER.warning( + "%s - cannot change temperature because VTherm is not initialized", self + ) + return + float_value = float(value) old_value = float(self._attr_native_value) @@ -248,6 +449,15 @@ class TemperatureNumber( # pylint: disable=abstract-method self._attr_value = self._attr_native_value = float_value + self.async_write_ha_state() + + # Update the VTherm + self.hass.create_task( + self.my_climate.service_set_preset_temperature( + self._preset_name.replace("_temp", ""), self._attr_native_value, None + ) + ) + def __str__(self): return f"VersatileThermostat-{self.name}" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index e6193ea..a6d80ba 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -537,6 +537,56 @@ } } } + }, + "number": { + "temperature": { + "name": "{preset}{ac}-{away} [%key:component::sensor::entity_component::temperature::name%]" + }, + "frost_temp": { + "name": "Frost" + }, + "eco_temp": { + "name": "Eco" + }, + "comfort_temp": { + "name": "Comfort" + }, + "boost_temp": { + "name": "Boost" + }, + "frost_ac_temp": { + "name": "Frost ac" + }, + "eco_ac_temp": { + "name": "Eco ac" + }, + "comfort_ac_temp": { + "name": "Comfort ac" + }, + "boost_ac_temp": { + "name": "Boost ac" + }, + "frost_away_temp": { + "name": "Frost away" + }, + "eco_away_temp": { + "name": "Eco away" + }, + "comfort_away_temp": { + "name": "Comfort away" + }, + "boost_away_temp": { + "name": "Boost away" + }, + "eco_ac_away_temp": { + "name": "Eco ac away" + }, + "comfort_ac_away_temp": { + "name": "Comfort ac away" + }, + "boost_ac_away_temp": { + "name": "Boost ac away" + } } } } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/temp_number.py b/custom_components/versatile_thermostat/temp_number.py new file mode 100644 index 0000000..9603ba1 --- /dev/null +++ b/custom_components/versatile_thermostat/temp_number.py @@ -0,0 +1,197 @@ +# pylint: disable=unused-argument + +""" Implements the VersatileThermostat select component """ +import logging + +# from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant, CoreState # , callback + +from homeassistant.components.number import ( + NumberEntity, + NumberMode, + NumberDeviceClass, +) +from homeassistant.components.climate import ( + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, +) +from homeassistant.components.sensor import UnitOfTemperature + +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_NAME, + CONF_TEMP_MIN, + CONF_TEMP_MAX, + CONF_STEP_TEMPERATURE, + CONF_AC_MODE, + PRESET_FROST_PROTECTION, + PRESET_ECO_AC, + PRESET_COMFORT_AC, + PRESET_BOOST_AC, + PRESET_AC_SUFFIX, + CONF_PRESETS_VALUES, + CONF_PRESETS_WITH_AC_VALUES, + # CONF_PRESETS_AWAY_VALUES, + # CONF_PRESETS_AWAY_WITH_AC_VALUES, + overrides, +) + +PRESET_ICON_MAPPING = { + PRESET_FROST_PROTECTION + "_temp": "mdi:snowflake-thermometer", + PRESET_ECO + "_temp": "mdi:leaf", + PRESET_COMFORT + "_temp": "mdi:sofa", + PRESET_BOOST + "_temp": "mdi:rocket-launch", + PRESET_ECO_AC + "_temp": "mdi:leaf-circle-outline", + PRESET_COMFORT_AC + "_temp": "mdi:sofa-outline", + PRESET_BOOST_AC + "_temp": "mdi:rocket-launch-outline", +} + +_LOGGER = logging.getLogger(__name__) + + +class TemperatureNumber(NumberEntity, RestoreEntity): + """Representation of one temperature number""" + + _attr_has_entity_name = True + _attr_translation_key = "temperature" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, + preset_name, + is_ac, + is_away, + entry_infos: ConfigEntry, + ) -> None: + """Initialize the temperature with entry_infos if available. Else + the restoration will do the trick.""" + # super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + + self.my_climate = None + self._unique_id = unique_id + self._device_name = entry_infos.get(CONF_NAME) + + # split = name.split("_") + # self._attr_name = split[0] + # if "_" + split[1] == PRESET_AC_SUFFIX: + # self._attr_name = self._attr_name + " AC" + + self._attr_name = preset_name + " new temperature" + + # self._attr_translation_placeholders = { + # "preset": preset_name, + # "ac": "-AC" if is_ac else "", + # "away": "-AWAY" if is_away else "", + # } + self._attr_unique_id = f"{self._device_name}_{self._attr_name}" + self._attr_device_class = NumberDeviceClass.TEMPERATURE + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + # Initialize the values if included into the entry_infos. This will do + # the temperature migration. + # TODO see if this should be replace by the central config if any + temp = None + # if temp := entry_infos.get(preset_name, None): + # self._attr_value = self._attr_native_value = temp + + self._attr_mode = NumberMode.BOX + self._preset_name = preset_name + self._is_away = is_away + self._is_ac = is_ac + + self._attr_native_step = entry_infos.get(CONF_STEP_TEMPERATURE, 0.5) + self._attr_native_min_value = entry_infos.get(CONF_TEMP_MIN) + self._attr_native_max_value = entry_infos.get(CONF_TEMP_MAX) + + @property + def icon(self) -> str | None: + return PRESET_ICON_MAPPING[self._preset_name] + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._unique_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + old_state: CoreState = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling async_added_to_hass old_state is %s", self, old_state + ) + try: + if old_state is not None and (value := float(old_state.state) > 0): + self._attr_value = self._attr_native_value = value + except ValueError: + pass + + @overrides + def my_climate_is_initialized(self): + """Called when the associated climate is initialized""" + self._attr_native_step = self.my_climate.target_temperature_step + self._attr_native_min_value = self.my_climate.min_temp + self._attr_native_max_value = self.my_climate.max_temp + + # Initialize the internal temp value of VTherm + self.my_climate.init_temperature_preset( + self._preset_name, self._attr_native_value, self._is_ac, self._is_away + ) + return + + # @overrides + # @property + # def native_step(self) -> float | None: + # """The native step""" + # return self.my_climate.target_temperature_step + + @overrides + async def async_set_native_value(self, value: float) -> None: + """Change the value""" + + if self.my_climate is None: + _LOGGER.warning( + "%s - cannot change temperature because VTherm is not initialized", self + ) + return + + float_value = float(value) + old_value = float(self._attr_native_value) + + if float_value == old_value: + return + + self._attr_value = self._attr_native_value = float_value + + self.async_write_ha_state() + + # Update the VTherm + self.hass.create_task( + self.my_climate.service_set_preset_temperature( + self._preset_name.replace("_temp", ""), self._attr_native_value, None + ) + ) + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + @property + def native_unit_of_measurement(self) -> str | None: + """The unit of measurement""" + if not self.my_climate: + return UnitOfTemperature.CELSIUS + return self.my_climate.temperature_unit diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index e6193ea..a6d80ba 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -537,6 +537,56 @@ } } } + }, + "number": { + "temperature": { + "name": "{preset}{ac}-{away} [%key:component::sensor::entity_component::temperature::name%]" + }, + "frost_temp": { + "name": "Frost" + }, + "eco_temp": { + "name": "Eco" + }, + "comfort_temp": { + "name": "Comfort" + }, + "boost_temp": { + "name": "Boost" + }, + "frost_ac_temp": { + "name": "Frost ac" + }, + "eco_ac_temp": { + "name": "Eco ac" + }, + "comfort_ac_temp": { + "name": "Comfort ac" + }, + "boost_ac_temp": { + "name": "Boost ac" + }, + "frost_away_temp": { + "name": "Frost away" + }, + "eco_away_temp": { + "name": "Eco away" + }, + "comfort_away_temp": { + "name": "Comfort away" + }, + "boost_away_temp": { + "name": "Boost away" + }, + "eco_ac_away_temp": { + "name": "Eco ac away" + }, + "comfort_ac_away_temp": { + "name": "Comfort ac away" + }, + "boost_ac_away_temp": { + "name": "Boost ac away" + } } } } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 59c6209..b9b2c88 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -186,7 +186,7 @@ }, "presence": { "title": "Gestion de la présence", - "description": "Donnez un capteur de présence (true si quelqu'un est présent) et les températures cibles à utiliser en cas d'absence.", + "description": "Donnez un capteur de présence (true si quelqu'un est présent) et les températures cibles à utiliser en cas d'abs.", "data": { "presence_sensor_entity_id": "Capteur de présence", "eco_away_temp": "preset Eco", @@ -200,13 +200,13 @@ }, "data_description": { "presence_sensor_entity_id": "Id d'entité du capteur de présence", - "eco_away_temp": "Température en preset Eco en cas d'absence", - "comfort_away_temp": "Température en preset Comfort en cas d'absence", - "boost_away_temp": "Température en preset Boost en cas d'absence", - "frost_away_temp": "Température en preset Hors-gel en cas d'absence", - "eco_ac_away_temp": "Température en preset Eco en cas d'absence en mode AC", - "comfort_ac_away_temp": "Température en preset Comfort en cas d'absence en mode AC", - "boost_ac_away_temp": "Température en preset Boost en cas d'absence en mode AC", + "eco_away_temp": "Température en preset Eco en cas d'abs", + "comfort_away_temp": "Température en preset Comfort en cas d'abs", + "boost_away_temp": "Température en preset Boost en cas d'abs", + "frost_away_temp": "Température en preset Hors-gel en cas d'abs", + "eco_ac_away_temp": "Température en preset Eco en cas d'abs en mode AC", + "comfort_ac_away_temp": "Température en preset Comfort en cas d'abs en mode AC", + "boost_ac_away_temp": "Température en preset Boost en cas d'abs en mode AC", "use_presence_central_config": "Cochez pour utiliser la configuration centrale de la présence. Décochez et saisissez les attributs pour utiliser une configuration spécifique de la présence" } }, @@ -431,7 +431,7 @@ }, "presence": { "title": "Présence - {name}", - "description": "Donnez un capteur de présence (true si quelqu'un est présent) et les températures cibles à utiliser en cas d'absence.", + "description": "Donnez un capteur de présence (true si quelqu'un est présent) et les températures cibles à utiliser en cas d'abs.", "data": { "presence_sensor_entity_id": "Capteur de présence", "eco_away_temp": "preset Eco", @@ -445,13 +445,13 @@ }, "data_description": { "presence_sensor_entity_id": "Id d'entité du capteur de présence", - "eco_away_temp": "Température en preset Eco en cas d'absence", - "comfort_away_temp": "Température en preset Comfort en cas d'absence", - "boost_away_temp": "Température en preset Boost en cas d'absence", - "frost_away_temp": "Température en preset Hors-gel en cas d'absence", - "eco_ac_away_temp": "Température en preset Eco en cas d'absence en mode AC", - "comfort_ac_away_temp": "Température en preset Comfort en cas d'absence en mode AC", - "boost_ac_away_temp": "Température en preset Boost en cas d'absence en mode AC", + "eco_away_temp": "Température en preset Eco en cas d'abs", + "comfort_away_temp": "Température en preset Comfort en cas d'abs", + "boost_away_temp": "Température en preset Boost en cas d'abs", + "frost_away_temp": "Température en preset Hors-gel en cas d'abs", + "eco_ac_away_temp": "Température en preset Eco en cas d'abs en mode AC", + "comfort_ac_away_temp": "Température en preset Comfort en cas d'abs en mode AC", + "boost_ac_away_temp": "Température en preset Boost en cas d'abs en mode AC", "use_presence_central_config": "Cochez pour utiliser la configuration centrale de la présence. Décochez et saisissez les attributs pour utiliser une configuration spécifique de la présence" } }, @@ -555,6 +555,56 @@ } } } + }, + "number": { + "temperature": { + "name": "{preset}{ac}-{away} [%key:component::sensor::entity_component::temperature::name%]" + }, + "frost_temp": { + "name": "Hors gel " + }, + "eco_temp": { + "name": "Eco" + }, + "comfort_temp": { + "name": "Confort" + }, + "boost_temp": { + "name": "Boost" + }, + "frost_ac_temp": { + "name": "Hors gel clim" + }, + "eco_ac_temp": { + "name": "Eco clim" + }, + "comfort_ac_temp": { + "name": "Confort clim" + }, + "boost_ac_temp": { + "name": "Boost clim" + }, + "frost_away_temp": { + "name": "Hors gel abs" + }, + "eco_away_temp": { + "name": "Eco abs" + }, + "comfort_away_temp": { + "name": "Confort abs" + }, + "boost_away_temp": { + "name": "Boost abs" + }, + "eco_ac_away_temp": { + "name": "Eco clim abs" + }, + "comfort_ac_away_temp": { + "name": "Confort clim abs" + }, + "boost_ac_away_temp": { + "name": "Boost clim abs" + } } } } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/vtherm_api.py b/custom_components/versatile_thermostat/vtherm_api.py index 9116097..8c7ba18 100644 --- a/custom_components/versatile_thermostat/vtherm_api.py +++ b/custom_components/versatile_thermostat/vtherm_api.py @@ -4,6 +4,10 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.number import NumberEntity, DOMAIN as NUMBER_DOMAIN + from .const import ( DOMAIN, CONF_AUTO_REGULATION_EXPERT, @@ -52,19 +56,24 @@ class VersatileThermostatAPI(dict): self._central_boiler_entity = None self._threshold_number_entity = None self._nb_active_number_entity = None + self._central_configuration = None + # A dict that will store all Number entities which holds the temperature + self._number_temperatures = dict() def find_central_configuration(self): """Search for a central configuration""" - for config_entry in VersatileThermostatAPI._hass.config_entries.async_entries( - DOMAIN - ): - if ( - config_entry.data.get(CONF_THERMOSTAT_TYPE) - == CONF_THERMOSTAT_CENTRAL_CONFIG - ): - central_config = config_entry - return central_config - return None + if not self._central_configuration: + for ( + config_entry + ) in VersatileThermostatAPI._hass.config_entries.async_entries(DOMAIN): + if ( + config_entry.data.get(CONF_THERMOSTAT_TYPE) + == CONF_THERMOSTAT_CENTRAL_CONFIG + ): + self._central_configuration = config_entry + break + # return self._central_configuration + return self._central_configuration def add_entry(self, entry: ConfigEntry): """Add a new entry""" @@ -108,14 +117,51 @@ class VersatileThermostatAPI(dict): """register the two number entities needed for boiler activation""" self._threshold_number_entity = threshold_number_entity # If sensor and threshold number are initialized, reload the listener - if self._nb_active_number_entity and self._central_boiler_entity: - self._hass.async_add_job(self.reload_central_boiler_binary_listener) + # if self._nb_active_number_entity and self._central_boiler_entity: + # self._hass.async_add_job(self.reload_central_boiler_binary_listener) def register_nb_device_active_boiler(self, nb_active_number_entity): """register the two number entities needed for boiler activation""" self._nb_active_number_entity = nb_active_number_entity - if self._threshold_number_entity and self._central_boiler_entity: - self._hass.async_add_job(self.reload_central_boiler_binary_listener) + # if self._threshold_number_entity and self._central_boiler_entity: + # self._hass.async_add_job(self.reload_central_boiler_binary_listener) + + def register_temperature_number( + self, + config_id: str, + preset_name: str, + number_entity: NumberEntity, + ): + """Register the NumberEntity for a particular device / preset.""" + # Search for device_name into the _number_temperatures dict + if not self._number_temperatures.get(config_id): + self._number_temperatures[config_id] = dict() + + self._number_temperatures.get(config_id)[preset_name] = number_entity + + def get_temperature_number_value(self, config_id, preset_name) -> float | None: + """Returns the value of a previously registred NumberEntity which represent + a temperature. If no NumberEntity was previously registred, then returns None""" + entities = self._number_temperatures.get(config_id, None) + if entities: + entity = entities.get(preset_name, None) + if entity: + return entity.state + return None + + async def init_vtherm_links(self): + """INitialize all VTherms entities links + This method is called when HA is fully started (and all entities should be initialized) + """ + await self.reload_central_boiler_binary_listener() + await self.reload_central_boiler_entities_list() + # Initialization of all preset for all VTherm + component: EntityComponent[ClimateEntity] = self._hass.data.get( + CLIMATE_DOMAIN, None + ) + if component: + for entity in component.entities: + await entity.init_presets(self.find_central_configuration()) async def reload_central_boiler_binary_listener(self): """Reloads the BinarySensor entity which listen to the number of diff --git a/scripts/starts_ha.sh b/scripts/starts_ha.sh index 28263bf..c5180b7 100755 --- a/scripts/starts_ha.sh +++ b/scripts/starts_ha.sh @@ -25,5 +25,9 @@ fi ## without resulting to symlinks. export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" +## Link custom_components into config +rm -f ${PWD}/config/custom_components +ln -s ${PWD}/custom_components ${PWD}/config/ + # Start Home Assistant hass --config "${PWD}/config" --debug \ No newline at end of file diff --git a/tests/commons.py b/tests/commons.py index 68bdfda..a57baa5 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -528,6 +528,7 @@ def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity: """Search and return the entity in the domain""" component = hass.data[domain] for entity in component.entities: + _LOGGER.debug("Found %s entity: %s", domain, entity.entity_id) if entity.entity_id == entity_id: return entity return None diff --git a/tests/test_temp_number.py b/tests/test_temp_number.py new file mode 100644 index 0000000..1d0357b --- /dev/null +++ b/tests/test_temp_number.py @@ -0,0 +1,396 @@ +""" Test the NumberEntity taht holds the temperature of a VTherm or of a Central configuration """ + +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + +# from unittest.mock import patch, call +# from datetime import datetime, timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState + +# from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.number import NumberEntity, DOMAIN as NUMBER_DOMAIN + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +# from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI + +from .commons import * + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_add_number_for_central_config( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the construction of a central configuration and the + creation and registration of the NumberEntity which holds + the temperature initialized from config_entry""" + + vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + temps = { + "frost_temp": 10, + "eco_temp": 17.1, + "comfort_temp": 18.1, + "boost_temp": 19.1, + "eco_ac_temp": 25.1, + "comfort_ac_temp": 23.1, + "boost_ac_temp": 21.1, + "frost_away_temp": 15.1, + "eco_away_temp": 15.2, + "comfort_away_temp": 15.3, + "boost_away_temp": 15.4, + "eco_ac_away_temp": 30.5, + "comfort_ac_away_temp": 30.6, + "boost_ac_away_temp": 30.7, + } + + central_config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheCentralConfigMockName", + unique_id="centralConfigUniqueId", + data={ + CONF_NAME: CENTRAL_CONFIG_NAME, + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_central_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_TPI_COEF_INT: 0.5, + CONF_TPI_COEF_EXT: 0.02, + CONF_WINDOW_DELAY: 15, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, + CONF_WINDOW_AUTO_MAX_DURATION: 31, + CONF_MOTION_DELAY: 31, + CONF_MOTION_OFF_DELAY: 301, + CONF_MOTION_PRESET: "boost", + CONF_NO_MOTION_PRESET: "frost", + CONF_POWER_SENSOR: "sensor.mock_central_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_central_max_power_sensor", + CONF_PRESET_POWER: 14, + CONF_MINIMAL_ACTIVATION_DELAY: 11, + CONF_SECURITY_DELAY_MIN: 61, + CONF_SECURITY_MIN_ON_PERCENT: 0.5, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.2, + CONF_ADD_CENTRAL_BOILER_CONTROL: False, + } + | temps, + ) + + central_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(central_config_entry.entry_id) + assert central_config_entry.state is ConfigEntryState.LOADED + + # We search for NumberEntities + for preset_name, value in temps.items(): + temp_entity = search_entity( + hass, + "number.central_configuration_" + preset_name, + NUMBER_DOMAIN, + ) + assert temp_entity + assert temp_entity.state == value + + # This test is dependent to translation en.json. If translations change + # this may fails + assert ( + temp_entity.name.lower() + == preset_name.replace("_temp", "") + .replace("_ac", " ac") + .replace("_away", " away") + .lower() + ) + + # Find temp Number into vtherm_api + val = vtherm_api.get_temperature_number_value( + config_id=central_config_entry.entry_id, preset_name=preset_name + ) + assert val == value + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_add_number_for_central_config_without_temp( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the construction of a central configuration and the + creation and registration of the NumberEntity which holds + the temperature not intialized from confif_entry. + In non AC_MODE the value should be initialized to the MIN""" + + vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + # Default is min Value in non AC_MODE + temps = { + "frost_temp": 15.0, + "eco_temp": 15.0, + "comfort_temp": 15.0, + "boost_temp": 15.0, + "eco_ac_temp": 15.0, + "comfort_ac_temp": 15.0, + "boost_ac_temp": 15.0, + "frost_away_temp": 15.0, + "eco_away_temp": 15.0, + "comfort_away_temp": 15.0, + "boost_away_temp": 15.0, + "eco_ac_away_temp": 15.0, + "comfort_ac_away_temp": 15.0, + "boost_ac_away_temp": 15.0, + } + + central_config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheCentralConfigMockName", + unique_id="centralConfigUniqueId", + data={ + CONF_NAME: CENTRAL_CONFIG_NAME, + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_central_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_TPI_COEF_INT: 0.5, + CONF_TPI_COEF_EXT: 0.02, + CONF_WINDOW_DELAY: 15, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, + CONF_WINDOW_AUTO_MAX_DURATION: 31, + CONF_MOTION_DELAY: 31, + CONF_MOTION_OFF_DELAY: 301, + CONF_MOTION_PRESET: "boost", + CONF_NO_MOTION_PRESET: "frost", + CONF_POWER_SENSOR: "sensor.mock_central_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_central_max_power_sensor", + CONF_PRESET_POWER: 14, + CONF_MINIMAL_ACTIVATION_DELAY: 11, + CONF_SECURITY_DELAY_MIN: 61, + CONF_SECURITY_MIN_ON_PERCENT: 0.5, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.2, + CONF_ADD_CENTRAL_BOILER_CONTROL: False, + }, + # | temps, + ) + + central_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(central_config_entry.entry_id) + assert central_config_entry.state is ConfigEntryState.LOADED + + # We search for NumberEntities + for preset_name, value in temps.items(): + temp_entity = search_entity( + hass, + "number.central_configuration_" + preset_name, + NUMBER_DOMAIN, + ) + assert temp_entity + assert temp_entity.state == value + + # This test is dependent to translation en.json. If translations change + # this may fails + assert ( + temp_entity.name.lower() + == preset_name.replace("_temp", "") + .replace("_ac", " ac") + .replace("_away", " away") + .lower() + ) + + # Find temp Number into vtherm_api + val = vtherm_api.get_temperature_number_value( + config_id=central_config_entry.entry_id, preset_name=preset_name + ) + assert val == value + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_add_number_for_central_config_without_temp_ac_mode( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the construction of a central configuration and the + creation and registration of the NumberEntity which holds + the temperature not intialized from confif_entry. + In AC_MODE the defaul value should the MAX""" + + vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + # Default is min Value in non AC_MODE + temps = { + "frost_temp": 30.0, + "eco_temp": 30.0, + "comfort_temp": 30.0, + "boost_temp": 30.0, + "eco_ac_temp": 30.0, + "comfort_ac_temp": 30.0, + "boost_ac_temp": 30.0, + "frost_away_temp": 30.0, + "eco_away_temp": 30.0, + "comfort_away_temp": 30.0, + "boost_away_temp": 30.0, + "eco_ac_away_temp": 30.0, + "comfort_ac_away_temp": 30.0, + "boost_ac_away_temp": 30.0, + } + + central_config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheCentralConfigMockName", + unique_id="centralConfigUniqueId", + data={ + CONF_NAME: CENTRAL_CONFIG_NAME, + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_central_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_AC_MODE: True, + CONF_TPI_COEF_INT: 0.5, + CONF_TPI_COEF_EXT: 0.02, + CONF_WINDOW_DELAY: 15, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, + CONF_WINDOW_AUTO_MAX_DURATION: 31, + CONF_MOTION_DELAY: 31, + CONF_MOTION_OFF_DELAY: 301, + CONF_MOTION_PRESET: "boost", + CONF_NO_MOTION_PRESET: "frost", + CONF_POWER_SENSOR: "sensor.mock_central_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_central_max_power_sensor", + CONF_PRESET_POWER: 14, + CONF_MINIMAL_ACTIVATION_DELAY: 11, + CONF_SECURITY_DELAY_MIN: 61, + CONF_SECURITY_MIN_ON_PERCENT: 0.5, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.2, + CONF_ADD_CENTRAL_BOILER_CONTROL: False, + }, + # | temps, + ) + + central_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(central_config_entry.entry_id) + assert central_config_entry.state is ConfigEntryState.LOADED + + # We search for NumberEntities + for preset_name, value in temps.items(): + temp_entity = search_entity( + hass, + "number.central_configuration_" + preset_name, + NUMBER_DOMAIN, + ) + assert temp_entity + assert temp_entity.state == value + + # This test is dependent to translation en.json. If translations change + # this may fails + assert ( + temp_entity.name.lower() + == preset_name.replace("_temp", "") + .replace("_ac", " ac") + .replace("_away", " away") + .lower() + ) + + # Find temp Number into vtherm_api + val = vtherm_api.get_temperature_number_value( + config_id=central_config_entry.entry_id, preset_name=preset_name + ) + assert val == value + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_add_number_for_central_config_without_temp_restore( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the construction of a central configuration and the + creation and registration of the NumberEntity which holds + the temperature not intialized from confif_entry""" + + vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + # Default is min Value in non AC_MODE + temps = { + "frost_temp": 23.0, + "eco_temp": 23.0, + "comfort_temp": 23.0, + "boost_temp": 23.0, + "eco_ac_temp": 23.0, + "comfort_ac_temp": 23.0, + "boost_ac_temp": 23.0, + "frost_away_temp": 23.0, + "eco_away_temp": 23.0, + "comfort_away_temp": 23.0, + "boost_away_temp": 23.0, + "eco_ac_away_temp": 23.0, + "comfort_ac_away_temp": 23.0, + "boost_ac_away_temp": 23.0, + } + + central_config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheCentralConfigMockName", + unique_id="centralConfigUniqueId", + data={ + CONF_NAME: CENTRAL_CONFIG_NAME, + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_central_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_AC_MODE: False, + CONF_TPI_COEF_INT: 0.5, + CONF_TPI_COEF_EXT: 0.02, + CONF_WINDOW_DELAY: 15, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, + CONF_WINDOW_AUTO_MAX_DURATION: 31, + CONF_MOTION_DELAY: 31, + CONF_MOTION_OFF_DELAY: 301, + CONF_MOTION_PRESET: "boost", + CONF_NO_MOTION_PRESET: "frost", + CONF_POWER_SENSOR: "sensor.mock_central_power_sensor", + CONF_MAX_POWER_SENSOR: "sensor.mock_central_max_power_sensor", + CONF_PRESET_POWER: 14, + CONF_MINIMAL_ACTIVATION_DELAY: 11, + CONF_SECURITY_DELAY_MIN: 61, + CONF_SECURITY_MIN_ON_PERCENT: 0.5, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.2, + CONF_ADD_CENTRAL_BOILER_CONTROL: False, + }, + # | temps, + ) + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=State(entity_id="number.mock_valve", state="23"), + ): + central_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(central_config_entry.entry_id) + + assert central_config_entry.state is ConfigEntryState.LOADED + + # We search for NumberEntities + for preset_name, value in temps.items(): + temp_entity = search_entity( + hass, + "number.central_configuration_" + preset_name, + NUMBER_DOMAIN, + ) + assert temp_entity + assert temp_entity.state == value + + # This test is dependent to translation en.json. If translations change + # this may fails + assert ( + temp_entity.name.lower() + == preset_name.replace("_temp", "") + .replace("_ac", " ac") + .replace("_away", " away") + .lower() + ) + + # Find temp Number into vtherm_api + val = vtherm_api.get_temperature_number_value( + config_id=central_config_entry.entry_id, preset_name=preset_name + ) + assert val == value