From 4786949aeba4a7853fff68ba29d866ef807990d6 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Wed, 25 Jan 2023 23:08:20 +0100 Subject: [PATCH] Add availability to choose entity in Config Flow --- .devcontainer/configuration.yaml | 38 + .../versatile_thermostat/__init__.py | 10 +- .../versatile_thermostat/climate.py | 697 ++++++++++-------- .../versatile_thermostat/config_flow.py | 209 +++++- .../versatile_thermostat/const.py | 18 + .../versatile_thermostat/strings.json | 36 +- .../versatile_thermostat/translations/en.json | 36 +- .../versatile_thermostat/translations/fr.json | 32 +- 8 files changed, 700 insertions(+), 376 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 137b98c..a887d69 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -58,3 +58,41 @@ input_boolean: fake_presence_sensor1: name: Presence Sensor 1 icon: mdi:home + +climate: + - platform: generic_thermostat + name: Underlying thermostat1 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat2 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat3 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat4 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat5 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat6 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat7 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat8 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 + - platform: generic_thermostat + name: Underlying thermostat9 + heater: input_boolean.fake_heater_switch3 + target_sensor: input_number.fake_temperature_sensor1 \ No newline at end of file diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index 4aa2a1d..44c0abd 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -29,20 +29,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # hass.data.setdefault(DOMAIN, {}) - # TODO 1. Create API instance api: VersatileThermostatAPI = hass.data.get(DOMAIN) if api is None: api = VersatileThermostatAPI(hass) - # TODO 2. Validate the API connection (and authentication) - # TODO 3. Store an API object for your platforms to access api.add_entry(entry) + entry.async_on_unload(entry.add_update_listener(update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" api: VersatileThermostatAPI = hass.data.get(DOMAIN) diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index d7b23e4..a3c5613 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -18,6 +18,7 @@ from homeassistant.components.climate import ClimateEntity from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( async_track_state_change_event, @@ -105,6 +106,10 @@ from .const import ( CONF_TEMP_MAX, CONF_TEMP_MIN, HIDDEN_PRESETS, + CONF_THERMOSTAT_TYPE, + # CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_CLIMATE, + CONF_CLIMATE, ) from .prop_algorithm import PropAlgorithm @@ -128,7 +133,8 @@ async def async_setup_entry( entity = VersatileThermostat(hass, unique_id, name, entry.data) async_add_entities([entity], True) - VersatileThermostat.add_entity(entry.entry_id, entity) + # No more needed + # VersatileThermostat.add_entity(entry.entry_id, entity) # Add services platform = entity_platform.async_get_current_platform() @@ -152,12 +158,16 @@ async def async_setup_entry( "service_set_preset_temperature", ) + # A test to see if I'm able to get the entity + _LOGGER.error("Plaform entities are: %s", platform.entities) + class VersatileThermostat(ClimateEntity, RestoreEntity): """Representation of a Versatile Thermostat device.""" # The list of VersatileThermostat entities - _registry: dict[str, object] = {} + # No more needed + # _registry: dict[str, object] = {} def __init__(self, hass, unique_id, name, entry_infos) -> None: """Initialize the thermostat.""" @@ -197,6 +207,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._security_delay_min = None self._security_state = None + self._thermostat_type = None + self._heater_entity_id = None + self._climate_entity_id = None + self.post_init(entry_infos) def post_init(self, entry_infos): @@ -236,7 +250,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._motion_call_cancel = None # Exploit usable attributs - self._heater_entity_id = entry_infos.get(CONF_HEATER) + self._thermostat_type = entry_infos.get(CONF_THERMOSTAT_TYPE) + if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: + self._climate_entity_id = entry_infos.get(CONF_CLIMATE) + else: + self._heater_entity_id = entry_infos.get(CONF_HEATER) + self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) @@ -377,6 +396,339 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._heater_entity_id, ) + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener + if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._climate_entity_id], self._async_climate_changed + ) + ) + else: + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._heater_entity_id], self._async_switch_changed + ) + ) + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._temp_sensor_entity_id], + self._async_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, + ) + ) + + if self._window_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._window_sensor_entity_id], + self._async_windows_changed, + ) + ) + if self._motion_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._motion_sensor_entity_id], + self._async_motion_changed, + ) + ) + + if self._power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._power_sensor_entity_id], + self._async_power_changed, + ) + ) + + if self._max_power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._max_power_sensor_entity_id], + self._async_max_power_changed, + ) + ) + + if self._presence_on: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._presence_sensor_entity_id], + self._async_presence_changed, + ) + ) + + self.async_on_remove(self.async_remove_thermostat) + + await self.async_startup() + + # starts the cycle + # if self._cycle_min: + # self.async_on_remove( + # async_track_time_interval( + # self.hass, + # self._async_control_heating, + # interval=timedelta(minutes=self._cycle_min), + # ) + # ) + + def async_remove_thermostat(self): + """Called when the thermostat will be removed""" + _LOGGER.info("%s - Removing thermostat", self) + if self._async_cancel_cycle: + self._async_cancel_cycle() + self._async_cancel_cycle = None + + async def async_startup(self): + """Triggered on startup, used to get old state and set internal states accordingly""" + _LOGGER.debug("%s - Calling async_startup", self) + + @callback + async def _async_startup_internal(*_): + _LOGGER.debug("%s - Calling async_startup_internal", self) + need_write_state = False + 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) + need_write_state = True + + 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, + ) + + if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: + climate_state = self.hass.states.get(self._climate_entity_id) + if climate_state and climate_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._hvac_mode = climate_state + need_write_state = True + else: + switch_state = self.hass.states.get(self._heater_entity_id) + if switch_state and switch_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self.hass.create_task(self._check_switch_initial_state()) + + platforms = entity_platform.async_get_platforms( + self._hass, "versatile_thermostat" + ) + # A test to see if I'm able to get the entity + _LOGGER.error("Plaform entities are: %s", platforms[1].entities) + underclimate: VersatileThermostat = platforms[1].entities[ + "climate.thermostat_2" + ] + _LOGGER.error("plateform[1].entitie[thermostat_2 is: %s", underclimate) + _LOGGER.error("thermostat2.preset_modes is: %s", underclimate.preset_modes) + + component: EntityComponent[ClimateEntity] = self._hass.data["climate"] + _LOGGER.error("component.entities is: %s", component.get_entity("climate.thermostat_2")) + + if self._pmax_on: + # try to acquire current power and power max + current_power_state = self.hass.states.get(self._power_sensor_entity_id) + if current_power_state and current_power_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power = float(current_power_state.state) + _LOGGER.debug( + "%s - Current power have been retrieved: %.3f", + self, + self._current_power, + ) + need_write_state = True + + # Try to acquire power max + current_power_max_state = self.hass.states.get( + self._max_power_sensor_entity_id + ) + if current_power_max_state and current_power_max_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power_max = float(current_power_max_state.state) + _LOGGER.debug( + "%s - Current power max have been retrieved: %.3f", + self, + self._current_power_max, + ) + need_write_state = True + + # try to acquire window entity state + if self._window_sensor_entity_id: + window_state = self.hass.states.get(self._window_sensor_entity_id) + if window_state and window_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._window_state = window_state.state + _LOGGER.debug( + "%s - Window state have been retrieved: %s", + self, + self._window_state, + ) + need_write_state = True + + # try to acquire motion entity state + if self._motion_sensor_entity_id: + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + if motion_state and motion_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._motion_state = motion_state.state + _LOGGER.debug( + "%s - Motion state have been retrieved: %s", + self, + self._motion_state, + ) + # recalculate the right target_temp in activity mode + self._update_motion_temp() + need_write_state = True + + if self._presence_on: + # try to acquire presence entity state + presence_state = self.hass.states.get(self._presence_sensor_entity_id) + if presence_state and presence_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._update_presence(presence_state.state) + _LOGGER.debug( + "%s - Presence have been retrieved: %s", + self, + presence_state.state, + ) + need_write_state = True + + if need_write_state: + self.async_write_ha_state() + self._prop_algorithm.calculate( + self._target_temp, self._cur_temp, self._cur_ext_temp + ) + self.hass.create_task(self._async_control_heating()) + + await self.get_my_previous_state() + + if self.hass.state == CoreState.running: + await _async_startup_internal() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_startup_internal + ) + + async def get_my_previous_state(self): + """Try to get my previou 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: + # If we have no initial temperature, restore + if self._target_temp is None: + # If we have a previously saved temperature + if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if self._ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning( + "%s - Undefined target temperature, falling back to %s", + self, + self._target_temp, + ) + else: + self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) + + if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes: + self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + self.save_preset_mode() + + if not self._hvac_mode and old_state.state: + self._hvac_mode = old_state.state + + # is done in startup above + # self._prop_algorithm.calculate( + # self._target_temp, self._cur_temp, self._cur_ext_temp + # ) + + else: + # No previous state, try and restore defaults + if self._target_temp is None: + if self._ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning( + "No previously saved temperature, setting to %s", self._target_temp + ) + + self._saved_target_temp = self._target_temp + + # Set default state to off + if not self._hvac_mode: + self._hvac_mode = HVAC_MODE_OFF + + _LOGGER.info( + "%s - restored state is target_temp=%.1f, preset_mode=%s, hvac_mode=%s", + self, + self._target_temp, + self._attr_preset_mode, + self._hvac_mode, + ) + def __str__(self): return f"VersatileThermostat-{self.name}" @@ -561,300 +913,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """Called when the entry have changed in ConfigFlow""" _LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data) - async def async_added_to_hass(self): - """Run when entity about to be added.""" - _LOGGER.debug("Calling async_added_to_hass") - - await super().async_added_to_hass() - - # Add listener - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._heater_entity_id], self._async_switch_changed - ) - ) - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._temp_sensor_entity_id], - self._async_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, - ) - ) - - if self._window_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._window_sensor_entity_id], - self._async_windows_changed, - ) - ) - if self._motion_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._motion_sensor_entity_id], - self._async_motion_changed, - ) - ) - - if self._power_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._power_sensor_entity_id], - self._async_power_changed, - ) - ) - - if self._max_power_sensor_entity_id: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._max_power_sensor_entity_id], - self._async_max_power_changed, - ) - ) - - if self._presence_on: - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._presence_sensor_entity_id], - self._async_presence_changed, - ) - ) - - await self.async_startup() - - # starts the cycle - # if self._cycle_min: - # self.async_on_remove( - # async_track_time_interval( - # self.hass, - # self._async_control_heating, - # interval=timedelta(minutes=self._cycle_min), - # ) - # ) - - async def async_startup(self): - """Triggered on startup, used to get old state and set internal states accordingly""" - _LOGGER.debug("%s - Calling async_startup", self) - - @callback - async def _async_startup_internal(*_): - _LOGGER.debug("%s - Calling async_startup_internal", self) - need_write_state = False - 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) - need_write_state = True - - 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, - ) - - switch_state = self.hass.states.get(self._heater_entity_id) - if switch_state and switch_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self.hass.create_task(self._check_switch_initial_state()) - - if self._pmax_on: - # try to acquire current power and power max - current_power_state = self.hass.states.get(self._power_sensor_entity_id) - if current_power_state and current_power_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power = float(current_power_state.state) - _LOGGER.debug( - "%s - Current power have been retrieved: %.3f", - self, - self._current_power, - ) - need_write_state = True - - # Try to acquire power max - current_power_max_state = self.hass.states.get( - self._max_power_sensor_entity_id - ) - if current_power_max_state and current_power_max_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._current_power_max = float(current_power_max_state.state) - _LOGGER.debug( - "%s - Current power max have been retrieved: %.3f", - self, - self._current_power_max, - ) - need_write_state = True - - # try to acquire window entity state - if self._window_sensor_entity_id: - window_state = self.hass.states.get(self._window_sensor_entity_id) - if window_state and window_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._window_state = window_state.state - _LOGGER.debug( - "%s - Window state have been retrieved: %s", - self, - self._window_state, - ) - need_write_state = True - - # try to acquire motion entity state - if self._motion_sensor_entity_id: - motion_state = self.hass.states.get(self._motion_sensor_entity_id) - if motion_state and motion_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._motion_state = motion_state.state - _LOGGER.debug( - "%s - Motion state have been retrieved: %s", - self, - self._motion_state, - ) - # recalculate the right target_temp in activity mode - self._update_motion_temp() - need_write_state = True - - if self._presence_on: - # try to acquire presence entity state - presence_state = self.hass.states.get(self._presence_sensor_entity_id) - if presence_state and presence_state.state not in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - self._update_presence(presence_state.state) - _LOGGER.debug( - "%s - Presence have been retrieved: %s", - self, - presence_state.state, - ) - need_write_state = True - - if need_write_state: - self.async_write_ha_state() - self._prop_algorithm.calculate( - self._target_temp, self._cur_temp, self._cur_ext_temp - ) - self.hass.create_task(self._async_control_heating()) - - await self.get_my_previous_state() - - if self.hass.state == CoreState.running: - await _async_startup_internal() - else: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _async_startup_internal - ) - - async def get_my_previous_state(self): - """Try to get my previou 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: - # If we have no initial temperature, restore - if self._target_temp is None: - # If we have a previously saved temperature - if old_state.attributes.get(ATTR_TEMPERATURE) is None: - if self._ac_mode: - self._target_temp = self.max_temp - else: - self._target_temp = self.min_temp - _LOGGER.warning( - "%s - Undefined target temperature, falling back to %s", - self, - self._target_temp, - ) - else: - self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) - - if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes: - self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) - self.save_preset_mode() - - if not self._hvac_mode and old_state.state: - self._hvac_mode = old_state.state - - # is done in startup above - # self._prop_algorithm.calculate( - # self._target_temp, self._cur_temp, self._cur_ext_temp - # ) - - else: - # No previous state, try and restore defaults - if self._target_temp is None: - if self._ac_mode: - self._target_temp = self.max_temp - else: - self._target_temp = self.min_temp - _LOGGER.warning( - "No previously saved temperature, setting to %s", self._target_temp - ) - - self._saved_target_temp = self._target_temp - - # Set default state to off - if not self._hvac_mode: - self._hvac_mode = HVAC_MODE_OFF - - _LOGGER.info( - "%s - restored state is target_temp=%.1f, preset_mode=%s, hvac_mode=%s", - self, - self._target_temp, - self._attr_preset_mode, - self._hvac_mode, - ) - @callback async def _async_temperature_changed(self, event): """Handle temperature changes.""" @@ -1289,7 +1347,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return True - async def _async_control_heating(self, force=False, time=None): + async def _async_control_heating(self, force=False, _=None): """The main function used to run the calculation at each cycle""" overpowering: bool = await self.check_overpowering() @@ -1526,25 +1584,28 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): await self._async_set_preset_mode_internal(preset, force=True) await self._async_control_heating(force=True) - @classmethod - def add_entity(cls, entry_id, entity): - """Adds an entity into the VersatileRegistry entities""" - _LOGGER.debug("Adding entity %s", entry_id) - cls._registry[entry_id] = entity - _LOGGER.debug("Entity registry is now %s", cls._registry) + # No more needed - @classmethod - async def update_entity(cls, entry_id, infos): - """Updates an existing entity referenced by entry_id with the infos in arguments""" - entity: VersatileThermostat = cls._registry.get(entry_id) - if entity is None: - _LOGGER.warning( - "Tries to update VersatileThermostat entity %s but was not found in thermostat registry", - entry_id, - ) - return - _LOGGER.debug("We have found the entity to update") - entity.post_init(infos) - - await entity.async_added_to_hass() +# @classmethod +# def add_entity(cls, entry_id, entity): +# """Adds an entity into the VersatileRegistry entities""" +# _LOGGER.debug("Adding entity %s", entry_id) +# cls._registry[entry_id] = entity +# _LOGGER.debug("Entity registry is now %s", cls._registry) +# +# @classmethod +# async def update_entity(cls, entry_id, infos): +# """Updates an existing entity referenced by entry_id with the infos in arguments""" +# entity: VersatileThermostat = cls._registry.get(entry_id) +# if entity is None: +# _LOGGER.warning( +# "Tries to update VersatileThermostat entity %s but was not found in thermostat registry", +# entry_id, +# ) +# return +# +# _LOGGER.debug("We have found the entity to update") +# entity.post_init(infos) +# +# await entity.async_added_to_hass() diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index 486d811..02847cc 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -1,26 +1,43 @@ """Config flow for Versatile Thermostat integration.""" from __future__ import annotations +from typing import Any import logging import copy from collections.abc import Mapping import voluptuous as vol -from typing import Any -from homeassistant.core import callback +from homeassistant.core import callback, async_get_hass from homeassistant.config_entries import ( ConfigEntry, ConfigFlow as HAConfigFlow, OptionsFlow, ) -# import homeassistant.helpers.entity_registry as entity_registry from homeassistant.data_entry_flow import FlowHandler from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_registry import EntityRegistry, async_get +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.input_boolean import ( + InputBoolean, + DOMAIN as INPUT_BOOLEAN_DOMAIN, +) + +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.input_number import ( + InputNumber, + DOMAIN as INPUT_NUMBER_DOMAIN, +) + from .const import ( DOMAIN, @@ -51,12 +68,16 @@ from .const import ( CONF_MINIMAL_ACTIVATION_DELAY, CONF_TEMP_MAX, CONF_TEMP_MIN, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_SWITCH, + CONF_CLIMATE, + CONF_USE_WINDOW_FEATURE, + CONF_USE_MOTION_FEATURE, + CONF_USE_PRESENCE_FEATURE, + CONF_USE_POWER_FEATURE, + CONF_THERMOSTAT_TYPES, ) -from .climate import VersatileThermostat - -# from .climate import VersatileThermostat - _LOGGER = logging.getLogger(__name__) @@ -111,13 +132,46 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): super().__init__() _LOGGER.debug("CTOR BaseConfigFlow infos: %s", infos) self._infos = infos + self.hass = async_get_hass() + ent_reg = async_get(hass=self.hass) + + climates = [] # self.find_all_climates() + switches = [] # self.find_all_heaters() + temp_sensors = [] # self.find_all_temperature_sensors() + + k: str + for k in ent_reg.entities: + v = ent_reg.entities[k] + if k.startswith(CLIMATE_DOMAIN): + climates.append(k) + elif k.startswith(SWITCH_DOMAIN) or k.startswith(INPUT_BOOLEAN_DOMAIN): + switches.append(k) + elif k.startswith(INPUT_NUMBER_DOMAIN): + temp_sensors.append(k) + elif k.startswith(SENSOR_DOMAIN): + _LOGGER.debug("We have found sensor: %s", v) + temp_sensors.append(k) + self.STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_HEATER): cv.string, - vol.Required(CONF_TEMP_SENSOR): cv.string, - vol.Required(CONF_EXTERNAL_TEMP_SENSOR): cv.string, - vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, + vol.Required( + CONF_THERMOSTAT_TYPE, default=CONF_THERMOSTAT_SWITCH + ): vol.In(CONF_THERMOSTAT_TYPES), + vol.Required(CONF_TEMP_SENSOR): vol.In(temp_sensors), + vol.Required(CONF_EXTERNAL_TEMP_SENSOR): vol.In(temp_sensors), + vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float), + vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float), + vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_PRESENCE_FEATURE, default=False): cv.boolean, + } + ) + + self.STEP_THERMOSTAT_SWITCH = vol.Schema( + { + vol.Required(CONF_HEATER): vol.In(switches), vol.Required( CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI ): vol.In( @@ -125,8 +179,13 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): PROPORTIONAL_FUNCTION_TPI, ] ), - vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float), - vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float), + vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, + } + ) + + self.STEP_THERMOSTAT_CLIMATE = vol.Schema( + { + vol.Required(CONF_CLIMATE): vol.In(climates), } ) @@ -209,6 +268,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): CONF_POWER_SENSOR, CONF_MAX_POWER_SENSOR, CONF_PRESENCE_SENSOR, + CONF_CLIMATE, ]: d = data.get(conf, None) # pylint: disable=invalid-name if d is not None and self.hass.states.get(d) is None: @@ -268,9 +328,25 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): _LOGGER.debug("Into ConfigFlow.async_step_user user_input=%s", user_input) return await self.generic_step( - "user", self.STEP_USER_DATA_SCHEMA, user_input, self.async_step_tpi + "user", self.STEP_USER_DATA_SCHEMA, user_input, self.async_step_type ) + async def async_step_type(self, user_input: dict | None = None) -> FlowResult: + """Handle the flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_type user_input=%s", user_input) + + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_SWITCH: + return await self.generic_step( + "type", self.STEP_THERMOSTAT_SWITCH, user_input, self.async_step_tpi + ) + else: + return await self.generic_step( + "type", + self.STEP_THERMOSTAT_CLIMATE, + user_input, + self.async_step_presets, + ) + async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult: """Handle the flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_tpi user_input=%s", user_input) @@ -283,35 +359,63 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): """Handle the presets flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_presets user_input=%s", user_input) + next_step = self.async_step_advanced + if self._infos[CONF_USE_WINDOW_FEATURE]: + next_step = self.async_step_window + elif self._infos[CONF_USE_MOTION_FEATURE]: + next_step = self.async_step_motion + elif self._infos[CONF_USE_POWER_FEATURE]: + next_step = self.async_step_power + elif self._infos[CONF_USE_PRESENCE_FEATURE]: + next_step = self.async_step_presence + return await self.generic_step( - "presets", self.STEP_PRESETS_DATA_SCHEMA, user_input, self.async_step_window + "presets", self.STEP_PRESETS_DATA_SCHEMA, user_input, next_step ) async def async_step_window(self, user_input: dict | None = None) -> FlowResult: """Handle the window sensor flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_window user_input=%s", user_input) + next_step = self.async_step_advanced + if self._infos[CONF_USE_MOTION_FEATURE]: + next_step = self.async_step_motion + elif self._infos[CONF_USE_POWER_FEATURE]: + next_step = self.async_step_power + elif self._infos[CONF_USE_PRESENCE_FEATURE]: + next_step = self.async_step_presence + return await self.generic_step( - "window", self.STEP_WINDOW_DATA_SCHEMA, user_input, self.async_step_motion + "window", self.STEP_WINDOW_DATA_SCHEMA, user_input, next_step ) async def async_step_motion(self, user_input: dict | None = None) -> FlowResult: """Handle the window and motion sensor flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_motion user_input=%s", user_input) + next_step = self.async_step_advanced + if self._infos[CONF_USE_POWER_FEATURE]: + next_step = self.async_step_power + elif self._infos[CONF_USE_PRESENCE_FEATURE]: + next_step = self.async_step_presence + return await self.generic_step( - "motion", self.STEP_MOTION_DATA_SCHEMA, user_input, self.async_step_power + "motion", self.STEP_MOTION_DATA_SCHEMA, user_input, next_step ) async def async_step_power(self, user_input: dict | None = None) -> FlowResult: """Handle the power management flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_power user_input=%s", user_input) + next_step = self.async_step_advanced + if self._infos[CONF_USE_PRESENCE_FEATURE]: + next_step = self.async_step_presence + return await self.generic_step( "power", self.STEP_POWER_DATA_SCHEMA, user_input, - self.async_step_presence, + next_step, ) async def async_step_presence(self, user_input: dict | None = None) -> FlowResult: @@ -336,6 +440,45 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): self.async_finalize, # pylint: disable=no-member ) + async def async_finalize(self): + """Should be implemented by Leaf classes""" + raise HomeAssistantError( + "async_finalize not implemented on VersatileThermostat sub-class" + ) + + def find_all_climates(self) -> list(str): + """Find all climate known by HA""" + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + ret: list(str) = list() + for entity in component.entities: + ret.append(entity.entity_id) + _LOGGER.debug("Found all climate entities: %s", ret) + return ret + + def find_all_heaters(self) -> list(str): + """Find all heater known by HA""" + component: EntityComponent[SwitchEntity] = self.hass.data[SWITCH_DOMAIN] + ret: list(str) = list() + for entity in component.entities: + ret.append(entity.entity_id) + # component = self.hass.data[INPUT_BOOLEAN_DOMAIN] + # for entity in component.entities: + # ret.append(entity.entity_id) + _LOGGER.debug("Found all switch entities: %s", ret) + return ret + + def find_all_temperature_sensors(self) -> list(str): + """Find all heater known by HA""" + component: EntityComponent[SensorEntity] = self.hass.data[SENSOR_DOMAIN] + ret: list(str) = list() + for entity in component.entities: + ret.append(entity.entity_id) + # component = self.hass.data[INPUT_NUMBER_DOMAIN] + # for entity in component.entities: + # ret.append(entity.entity_id) + _LOGGER.debug("Found all temperature sensore entities: %s", ret) + return ret + class VersatileThermostatConfigFlow( VersatileThermostatBaseConfigFlow, HAConfigFlow, domain=DOMAIN @@ -479,33 +622,33 @@ class VersatileThermostatOptionsFlowHandler( async def async_end(self): """Finalization of the ConfigEntry creation""" _LOGGER.debug( - "CTOR ConfigFlow.async_finalize - updating entry with: %s", self._infos + "ConfigFlow.async_finalize - updating entry with: %s", self._infos ) # Find eventual existing entity to update it # removing entities from registry (they will be recreated) + + # No need to do that. Only the update_listener on __init__.py is necessary # ent_reg = entity_registry.async_get(self.hass) - # reg_entities = { - # ent.unique_id: ent.entity_id - # for ent in entity_registry.async_entries_for_config_entry( - # ent_reg, self.config_entry.entry_id - # ) - # } - # # for entry in entity_registry.async_entries_for_config_entry( # ent_reg, self.config_entry.entry_id # ): - # entity: VersatileThermostat = ent_reg.async_get(entry.entity_id) - # entity.async_registry_entry_updated(self._infos) + # _LOGGER.info( + # "Removing entity %s due to configuration change", entry.entity_id + # ) + # ent_reg.async_remove(entry.entity_id) - _LOGGER.debug( - "We have found entities to update: %s", self.config_entry.entry_id - ) - await VersatileThermostat.update_entity(self.config_entry.entry_id, self._infos) + # _LOGGER.debug( + # "We have found entities to update: %s", self.config_entry.entry_id + # ) + # await VersatileThermostat.update_entity(self.config_entry.entry_id, self._infos) # for entity_id in reg_entities.values(): - # _LOGGER.info("Recreating entity %s due to configuration change", entity_id) # ent_reg.async_remove(entity_id) # + _LOGGER.info( + "Recreating entry %s due to configuration change", + self.config_entry.entry_id, + ) self.hass.config_entries.async_update_entry(self.config_entry, data=self._infos) return self.async_create_entry(title=None, data=None) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 7c88af3..a77bf44 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -42,6 +42,14 @@ CONF_MINIMAL_ACTIVATION_DELAY = "minimal_activation_delay" CONF_TEMP_MIN = "temp_min" CONF_TEMP_MAX = "temp_max" CONF_SECURITY_DELAY_MIN = "security_delay_min" +CONF_THERMOSTAT_TYPE = "thermostat_type" +CONF_THERMOSTAT_SWITCH = "thermostat_over_switch" +CONF_THERMOSTAT_CLIMATE = "thermostat_over_climate" +CONF_CLIMATE = "climate_entity_id" +CONF_USE_WINDOW_FEATURE = "use_window_feature" +CONF_USE_MOTION_FEATURE = "use_motion_feature" +CONF_USE_PRESENCE_FEATURE = "use_presence_feature" +CONF_USE_POWER_FEATURE = "use_power_feature" CONF_PRESETS = { p: f"{p}_temp" @@ -92,6 +100,14 @@ ALL_CONF = ( CONF_TEMP_MIN, CONF_TEMP_MAX, CONF_SECURITY_DELAY_MIN, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_CLIMATE, + CONF_CLIMATE, + CONF_USE_WINDOW_FEATURE, + CONF_USE_MOTION_FEATURE, + CONF_USE_PRESENCE_FEATURE, + CONF_USE_POWER_FEATURE, ] + CONF_PRESETS_VALUES + CONF_PRESETS_AWAY_VALUES, @@ -101,6 +117,8 @@ CONF_FUNCTIONS = [ PROPORTIONAL_FUNCTION_TPI, ] +CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE] + SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE SERVICE_SET_PRESENCE = "set_presence" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 5c0e596..bf42a62 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -8,15 +8,25 @@ "description": "Main mandatory attributes", "data": { "name": "Name", - "heater_entity_id": "Heater entity id", + "thermostat_type": "Thermostat type", + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat", "temperature_sensor_entity_id": "Temperature sensor entity id", "external_temperature_sensor_entity_id": "External temperature sensor entity id", - "cycle_min": "Cycle duration (minutes)", - "proportional_function": "Algorithm to use (TPI is the only one for now)", "temp_min": "Minimal temperature allowed", - "temp_max": "Maximal temperature allowed" + "temp_max": "Maximal temperature allowed", + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection" } }, + "type": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "cycle_min": "Cycle duration (minutes)", + "climate_entity_id": "Underlying thermostat entity id" + }, "tpi": { "title": "TPI", "description": "Time Proportional Integral attributes", @@ -97,15 +107,25 @@ "description": "Main mandatory attributes", "data": { "name": "Name", - "heater_entity_id": "Heater entity id", + "thermostat_type": "Thermostat type", + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat", "temperature_sensor_entity_id": "Temperature sensor entity id", "external_temperature_sensor_entity_id": "External temperature sensor entity id", - "cycle_min": "Cycle duration (minutes)", - "proportional_function": "Algorithm to use (TPI is the only one for now)", "temp_min": "Minimal temperature allowed", - "temp_max": "Maximal temperature allowed" + "temp_max": "Maximal temperature allowed", + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection" } }, + "type": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "cycle_min": "Cycle duration (minutes)", + "climate_entity_id": "Underlying thermostat entity id" + }, "tpi": { "title": "TPI", "description": "Time Proportional Integral attributes", diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 5c0e596..bf42a62 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -8,15 +8,25 @@ "description": "Main mandatory attributes", "data": { "name": "Name", - "heater_entity_id": "Heater entity id", + "thermostat_type": "Thermostat type", + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat", "temperature_sensor_entity_id": "Temperature sensor entity id", "external_temperature_sensor_entity_id": "External temperature sensor entity id", - "cycle_min": "Cycle duration (minutes)", - "proportional_function": "Algorithm to use (TPI is the only one for now)", "temp_min": "Minimal temperature allowed", - "temp_max": "Maximal temperature allowed" + "temp_max": "Maximal temperature allowed", + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection" } }, + "type": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "cycle_min": "Cycle duration (minutes)", + "climate_entity_id": "Underlying thermostat entity id" + }, "tpi": { "title": "TPI", "description": "Time Proportional Integral attributes", @@ -97,15 +107,25 @@ "description": "Main mandatory attributes", "data": { "name": "Name", - "heater_entity_id": "Heater entity id", + "thermostat_type": "Thermostat type", + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat", "temperature_sensor_entity_id": "Temperature sensor entity id", "external_temperature_sensor_entity_id": "External temperature sensor entity id", - "cycle_min": "Cycle duration (minutes)", - "proportional_function": "Algorithm to use (TPI is the only one for now)", "temp_min": "Minimal temperature allowed", - "temp_max": "Maximal temperature allowed" + "temp_max": "Maximal temperature allowed", + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection" } }, + "type": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "cycle_min": "Cycle duration (minutes)", + "climate_entity_id": "Underlying thermostat entity id" + }, "tpi": { "title": "TPI", "description": "Time Proportional Integral attributes", diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index da6af51..e7c053b 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -8,15 +8,25 @@ "description": "Principaux attributs obligatoires", "data": { "name": "Nom", - "heater_entity_id": "Radiateur entity id", + "thermostat_over_switch": "Thermostat sur un switch", + "thermostat_over_climate": "Thermostat sur un autre thermostat", "temperature_sensor_entity_id": "Température sensor entity id", "external_temperature_sensor_entity_id": "Temperature exterieure sensor entity id", "cycle_min": "Durée du cycle (minutes)", - "proportional_function": "Algorithm à utiliser (Seul TPI est disponible pour l'instant)", "temp_min": "Température minimale permise", - "temp_max": "Température maximale permise" + "temp_max": "Température maximale permise", + "use_window_feature": "Avec détection des ouvertures", + "use_motion_feature": "Avec détection de mouvement", + "use_power_feature": "Avec gestion de la puissance", + "use_presence_feature": "Avec détection de présence" } }, + "type": { + "heater_entity_id": "Radiateur entity id", + "proportional_function": "Algorithm à utiliser (Seul TPI est disponible pour l'instant)", + "cycle_min": "Durée du cycle (minutes)", + "climate_entity_id": "Thermostat sous-jacent entity id" + }, "tpi": { "title": "TPI", "description": "Attributs de l'algo Time Proportional Integral", @@ -97,15 +107,25 @@ "description": "Principaux attributs obligatoires", "data": { "name": "Nom", - "heater_entity_id": "Radiateur entity id", + "thermostat_over_switch": "Thermostat sur un switch", + "thermostat_over_climate": "Thermostat sur un autre thermostat", "temperature_sensor_entity_id": "Température sensor entity id", "external_temperature_sensor_entity_id": "Temperature exterieure sensor entity id", "cycle_min": "Durée du cycle (minutes)", - "proportional_function": "Algorithm à utiliser (Seul TPI est disponible pour l'instant)", "temp_min": "Température minimale permise", - "temp_max": "Température maximale permise" + "temp_max": "Température maximale permise", + "use_window_feature": "Avec détection des ouvertures", + "use_motion_feature": "Avec détection de mouvement", + "use_power_feature": "Avec gestion de la puissance", + "use_presence_feature": "Avec détection de présence" } }, + "type": { + "heater_entity_id": "Radiateur entity id", + "proportional_function": "Algorithm à utiliser (Seul TPI est disponible pour l'instant)", + "cycle_min": "Durée du cycle (minutes)", + "climate_entity_id": "Thermostat sous-jacent entity id" + }, "tpi": { "title": "TPI", "description": "Attributs de l'algo Time Proportional Integral",