From b607fb107543ea54d417f6b6a844fda6d0b2bfc9 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 29 Jan 2023 19:53:49 +0100 Subject: [PATCH] 2.0.0-beta1 Issue #30 - security mode Issue #26 - upodate entity on config change Issue #21 - check box to select feature Issue #16 - Suggest/select entity in a list on the configuration screens Issue #5 - Thermostat over climate type --- .bashrc | 6 + .devcontainer/configuration.yaml | 19 +- .devcontainer/devcontainer.json | 13 +- .vscode/launch.json | 8 +- .vscode/settings.json | 6 +- .vscode/tasks.json | 26 +- container | 37 + custom_components/homeassistant | 1 + .../versatile_thermostat/__init__.py | 18 + .../versatile_thermostat/climate.py | 687 ++++++++++++------ .../versatile_thermostat/config_flow.py | 158 ++-- .../versatile_thermostat/const.py | 6 + .../versatile_thermostat/strings.json | 44 +- .../versatile_thermostat/translations/en.json | 44 +- .../versatile_thermostat/translations/fr.json | 40 +- 15 files changed, 787 insertions(+), 326 deletions(-) create mode 100644 .bashrc create mode 100755 container create mode 120000 custom_components/homeassistant diff --git a/.bashrc b/.bashrc new file mode 100644 index 0000000..83cda4b --- /dev/null +++ b/.bashrc @@ -0,0 +1,6 @@ + +echo "Sourcing .bashrc" +alias ll='ls -l' +export HA='/home/vscode/core' +cd $HA +source venv/bin/activate diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index a887d69..46337fe 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -7,6 +7,9 @@ logger: # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) debugpy: + start: true + wait: false + port: 5678 input_number: fake_temperature_sensor1: @@ -15,25 +18,28 @@ input_number: max: 35 step: .1 icon: mdi:thermometer + unit_of_measurement: °C fake_external_temperature_sensor1: name: Ext Temperature min: -10 max: 35 step: .1 icon: mdi:home-thermometer + unit_of_measurement: °C fake_current_power: name: Current power min: 0 max: 1000 step: 10 icon: mdi:flash + unit_of_measurement: kW fake_current_power_max: name: Current power max threshold min: 0 max: 1000 step: 10 icon: mdi:flash - + unit_of_measurement: kW input_boolean: # input_boolean to simulate the windows entity. Only for development environment. @@ -95,4 +101,13 @@ climate: - 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 + target_sensor: input_number.fake_temperature_sensor1 + +recorder: + include: + domains: + - input_boolean + - input_number + - switch + - climate + - sensor diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bc5e6ec..c7f0ae3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,18 +1,25 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. +// "image": "ghcr.io/ludeeus/devcontainer/integration:latest", { - "image": "ghcr.io/ludeeus/devcontainer/integration:latest", + "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10", "name": "Versatile Thermostat integration", "context": "..", "appPort": [ "9123:8123" ], - "postCreateCommand": "container install", + // "postCreateCommand": "container install", + "postCreateCommand": "./container install", "extensions": [ "ms-python.python", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", "ms-python.vscode-pylance" ], + "mounts": [ + "source=/Users/jmcollin/SugarSync/Projets/home-assistant/core,target=/home/vscode/core,type=bind,consistency=cached", + "source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=/home/vscode/core/config/configuration.yaml,type=bind,consistency=cached", + "source=${localWorkspaceFolder}/custom_components,target=/home/vscode/core/config/custom_components,type=bind,consistency=cached" + ], "settings": { "files.eol": "\n", "editor.tabSize": 4, @@ -25,7 +32,7 @@ "terminal.integrated.defaultProfile.linux": "Bash Profile", // "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, + "python.analysis.autoSearchPaths": true, "python.linting.pylintEnabled": true, "python.linting.enabled": true, "python.formatting.provider": "black", diff --git a/.vscode/launch.json b/.vscode/launch.json index 2489740..c0e409f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,9 +11,13 @@ "host": "localhost", "justMyCode": false, "pathMappings": [ + // { + // "localRoot": "${workspaceFolder}", + // "remoteRoot": "." + //}, { - "localRoot": "${workspaceFolder}", - "remoteRoot": "." + "localRoot": "${workspaceFolder}/../core", + "remoteRoot": "/home/vscode/core" } ] }, diff --git a/.vscode/settings.json b/.vscode/settings.json index a3d535d..1107050 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,9 @@ "python.pythonPath": "/usr/local/bin/python", "files.associations": { "*.yaml": "home-assistant" - } + }, + "python.analysis.extraPaths": [ + "/home/vscode/core", + "/workspaces/versatile_thermostat" + ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4be7b81..f01c64a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,25 +4,43 @@ { "label": "Run Home Assistant on port 9123", "type": "shell", - "command": "container start", + "command": "./container start", + "problemMatcher": [] + }, + { + "label": "Restart Home Assistant on port 9123", + "type": "shell", + "command": "./container restart", + "problemMatcher": [] + }, + { + "label": "Home Assistant translations update", + "type": "shell", + "command": "./container translations", + "problemMatcher": [] + }, + { + "label": "Home Assistant hassfest", + "type": "shell", + "command": "./container hassfest", "problemMatcher": [] }, { "label": "Run Home Assistant configuration against /config", "type": "shell", - "command": "container check-config", + "command": "./container check-config", "problemMatcher": [] }, { "label": "Upgrade Home Assistant to latest dev", "type": "shell", - "command": "container install", + "command": "./container install", "problemMatcher": [] }, { "label": "Install a specific version of Home Assistant", "type": "shell", - "command": "container set-version", + "command": "./container set-version", "problemMatcher": [] } ] diff --git a/container b/container new file mode 100755 index 0000000..cc2cb3d --- /dev/null +++ b/container @@ -0,0 +1,37 @@ +#!/bin/bash + +# set -x + +. .bashrc + +cd $HA + +echo "arguments are: "$* +# Post installation of container +command=$1 +if [ "$command" == "install" ]; then + echo "Running container post installation" + script/setup +fi + +if [ "$command" == "start" ]; then + echo "Running container start" + hass -c ./config --debug +fi + +if [ "$command" == "translations" ]; then + echo "Running container start" + python3 -m script.translations develop +fi + +if [ "$command" == "hassfest" ]; then + echo "Running container start" + python3 -m script.hassfest +fi + +if [ "$command" == "restart" ]; then + echo "Killing existing container" + pkill hass + echo "Killing existing container" + hass -c ./config +fi diff --git a/custom_components/homeassistant b/custom_components/homeassistant new file mode 120000 index 0000000..d37fb46 --- /dev/null +++ b/custom_components/homeassistant @@ -0,0 +1 @@ +/home/vscode/core/homeassistant \ No newline at end of file diff --git a/custom_components/versatile_thermostat/__init__.py b/custom_components/versatile_thermostat/__init__.py index 44c0abd..461c29f 100644 --- a/custom_components/versatile_thermostat/__init__.py +++ b/custom_components/versatile_thermostat/__init__.py @@ -93,3 +93,21 @@ def remove_entry(self, entry: ConfigEntry): def hass(self): """Get the HomeAssistant object""" return self._hass + + +# Example migration function +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + + new = {**config_entry.data} + # TODO: modify Config Entry data + + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index a3c5613..3daf2f4 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -14,15 +14,18 @@ CoreState, DOMAIN as HA_DOMAIN, ) + 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.util.unit_system import UnitOfTemperature from homeassistant.helpers.event import ( async_track_state_change_event, async_call_later, + async_track_time_interval, ) from homeassistant.exceptions import ConditionError @@ -32,6 +35,7 @@ ) # , config_validation as cv from homeassistant.components.climate.const import ( + DOMAIN as CLIMATE_DOMAIN, ATTR_PRESET_MODE, # ATTR_FAN_MODE, CURRENT_HVAC_COOL, @@ -51,12 +55,17 @@ # PRESET_SLEEP, SUPPORT_PRESET_MODE, # SUPPORT_TARGET_TEMPERATURE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + # SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, ) from homeassistant.const import ( # UnitOfTemperature, ATTR_TEMPERATURE, - TEMP_CELSIUS, # TEMP_FAHRENHEIT, CONF_NAME, # CONF_UNIQUE_ID, @@ -110,6 +119,7 @@ # CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, CONF_CLIMATE, + UnknownEntity, ) from .prop_algorithm import PropAlgorithm @@ -158,9 +168,6 @@ 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.""" @@ -210,6 +217,8 @@ def __init__(self, hass, unique_id, name, entry_infos) -> None: self._thermostat_type = None self._heater_entity_id = None self._climate_entity_id = None + self._is_over_climate = False + self._underlying_climate = None self.post_init(entry_infos) @@ -252,9 +261,11 @@ def post_init(self, entry_infos): # Exploit usable attributs self._thermostat_type = entry_infos.get(CONF_THERMOSTAT_TYPE) if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: + self._is_over_climate = True self._climate_entity_id = entry_infos.get(CONF_CLIMATE) else: self._heater_entity_id = entry_infos.get(CONF_HEATER) + self._is_over_climate = False self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) @@ -287,7 +298,7 @@ def post_init(self, entry_infos): # self.hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF] # else: self._hvac_list = [HVAC_MODE_HEAT, HVAC_MODE_OFF] - self._unit = TEMP_CELSIUS + self._unit = UnitOfTemperature.CELSIUS # Will be restored if possible self._hvac_mode = None # HVAC_MODE_OFF self._saved_hvac_mode = self._hvac_mode @@ -321,7 +332,7 @@ def post_init(self, entry_infos): self._current_power = 0 self._current_power_max = 0 else: - _LOGGER.info("%s - Power management is not fully configured.", self) + _LOGGER.info("%s - Power management is not fully configured", self) # will be restored if possible self._target_temp = None @@ -353,14 +364,15 @@ def post_init(self, entry_infos): # Initiate the ProportionalAlgorithm if self._prop_algorithm is not None: del self._prop_algorithm - self._prop_algorithm = PropAlgorithm( - self._proportional_function, - self._tpi_coef_int, - self._tpi_coef_ext, - self._cycle_min, - self._minimal_activation_delay, - ) - self._should_relaunch_control_heating = False + if not self._is_over_climate: + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + ) + self._should_relaunch_control_heating = False # Memory synthesis state self._motion_state = None @@ -481,15 +493,15 @@ async def async_added_to_hass(self): 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), - # ) - # ) + # starts a cycle if we are in over_climate type + if self._is_over_climate: + 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""" @@ -506,6 +518,32 @@ async def async_startup(self): async def _async_startup_internal(*_): _LOGGER.debug("%s - Calling async_startup_internal", self) need_write_state = False + + # Get the underlying thermostat + if self._is_over_climate: + component: EntityComponent[ClimateEntity] = self.hass.data[ + CLIMATE_DOMAIN + ] + for entity in component.entities: + if self._climate_entity_id == entity.entity_id: + _LOGGER.info( + "%s - The underlying climate entity: %s have been succesfully found", + self, + entity, + ) + self._underlying_climate = entity + break + if self._underlying_climate is None: + _LOGGER.error( + "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational", + self, + self._climate_entity_id, + ) + self._is_over_climate = False + raise UnknownEntity( + f"Underlying thermostat {self._climate_entity_id} not found" + ) + temperature_state = self.hass.states.get(self._temp_sensor_entity_id) if temperature_state and temperature_state.state not in ( STATE_UNAVAILABLE, @@ -544,13 +582,13 @@ async def _async_startup_internal(*_): self, ) - if self._thermostat_type == CONF_THERMOSTAT_CLIMATE: + if self._is_over_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 + self._hvac_mode = climate_state.state need_write_state = True else: switch_state = self.hass.states.get(self._heater_entity_id) @@ -560,20 +598,6 @@ async def _async_startup_internal(*_): ): 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) @@ -634,7 +658,7 @@ async def _async_startup_internal(*_): self._motion_state, ) # recalculate the right target_temp in activity mode - self._update_motion_temp() + await self._async_update_motion_temp() need_write_state = True if self._presence_on: @@ -644,7 +668,7 @@ async def _async_startup_internal(*_): STATE_UNAVAILABLE, STATE_UNKNOWN, ): - self._update_presence(presence_state.state) + await self._async_update_presence(presence_state.state) _LOGGER.debug( "%s - Presence have been retrieved: %s", self, @@ -654,9 +678,10 @@ async def _async_startup_internal(*_): if need_write_state: self.async_write_ha_state() - self._prop_algorithm.calculate( - self._target_temp, self._cur_temp, self._cur_ext_temp - ) + if self._prop_algorithm: + 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() @@ -681,16 +706,18 @@ async def get_my_previous_state(self): # 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 + await self._async_internal_set_temperature(self.max_temp) else: - self._target_temp = self.min_temp + await self._async_internal_set_temperature(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]) + await self._async_internal_set_temperature( + 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) @@ -699,18 +726,13 @@ async def get_my_previous_state(self): 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 + await self._async_internal_set_temperature(self.max_temp) else: - self._target_temp = self.min_temp + await self._async_internal_set_temperature(self.min_temp) _LOGGER.warning( "No previously saved temperature, setting to %s", self._target_temp ) @@ -747,6 +769,9 @@ def name(self): @property def hvac_modes(self): """List of available operation modes.""" + if self._is_over_climate and self._underlying_climate: + return self._underlying_climate.hvac_modes + return self._hvac_list @property @@ -757,6 +782,9 @@ def temperature_unit(self): @property def hvac_mode(self): """Return current operation.""" + if self._is_over_climate and self._underlying_climate: + return self._underlying_climate.hvac_mode + return self._hvac_mode @property @@ -765,6 +793,9 @@ def hvac_action(self): Need to be one of CURRENT_HVAC_*. """ + if self._is_over_climate and self._underlying_climate: + return self._underlying_climate.hvac_action + if self._hvac_mode == HVAC_MODE_OFF: return CURRENT_HVAC_OFF if not self._is_device_active: @@ -781,12 +812,15 @@ def target_temperature(self): @property def supported_features(self): """Return the list of supported features.""" + if self._is_over_climate and self._underlying_climate: + return self._underlying_climate.supported_features | self._support_flags + return self._support_flags @property def _is_device_active(self): """If the toggleable device is currently active.""" - if not self.hass.states.get(self._heater_entity_id): + if self._is_over_climate or not self.hass.states.get(self._heater_entity_id): return None return self.hass.states.is_state(self._heater_entity_id, STATE_ON) @@ -799,21 +833,31 @@ def current_temperature(self): async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) - if hvac_mode == HVAC_MODE_HEAT: - self._hvac_mode = HVAC_MODE_HEAT - await self._async_control_heating(force=True) - elif hvac_mode == HVAC_MODE_COOL: - self._hvac_mode = HVAC_MODE_COOL - await self._async_control_heating(force=True) - elif hvac_mode == HVAC_MODE_OFF: - self._hvac_mode = HVAC_MODE_OFF - if self._is_device_active: - await self._async_heater_turn_off() - await self._async_control_heating(force=True) + + if self._is_over_climate and self._underlying_climate: + data = {ATTR_ENTITY_ID: self._climate_entity_id, "hvac_mode": hvac_mode} + await self.hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, context=self._context + ) + # await self._underlying_climate.async_set_hvac_mode(hvac_mode) + self._hvac_mode = hvac_mode # self._underlying_climate.hvac_mode else: - _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) - return + if hvac_mode == HVAC_MODE_HEAT: + self._hvac_mode = HVAC_MODE_HEAT + await self._async_control_heating(force=True) + elif hvac_mode == HVAC_MODE_COOL: + self._hvac_mode = HVAC_MODE_COOL + await self._async_control_heating(force=True) + elif hvac_mode == HVAC_MODE_OFF: + self._hvac_mode = HVAC_MODE_OFF + if self._is_device_active: + await self._async_underlying_entity_turn_off() + await self._async_control_heating(force=True) + else: + _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) + return # Ensure we update the current operation after changing the mode + self.update_custom_attributes() self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode): @@ -838,15 +882,17 @@ async def _async_set_preset_mode_internal(self, preset_mode, force=False): if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE if self._saved_target_temp: - self._target_temp = self._saved_target_temp + await self._async_internal_set_temperature(self._saved_target_temp) elif preset_mode == PRESET_ACTIVITY: self._attr_preset_mode = PRESET_ACTIVITY - self._update_motion_temp() + await self._async_update_motion_temp() else: if self._attr_preset_mode == PRESET_NONE: self._saved_target_temp = self._target_temp self._attr_preset_mode = preset_mode - self._target_temp = self.find_preset_temp(preset_mode) + await self._async_internal_set_temperature( + self.find_preset_temp(preset_mode) + ) self.save_preset_mode() @@ -878,6 +924,16 @@ async def async_set_fan_mode(self, fan_mode): if fan_mode is None: return self._fan_mode = fan_mode + + if self._is_over_climate and self._underlying_climate: + data = { + ATTR_ENTITY_ID: self._climate_entity_id, + "fan_mode": fan_mode, + } + + await self.hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, data, context=self._context + ) self.async_write_ha_state() async def async_set_humidity(self, humidity: int): @@ -886,6 +942,15 @@ async def async_set_humidity(self, humidity: int): if humidity is None: return self._humidity = humidity + if self._is_over_climate and self._underlying_climate: + data = { + ATTR_ENTITY_ID: self._climate_entity_id, + "humidity": humidity, + } + + await self.hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, data, context=self._context + ) async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" @@ -893,6 +958,15 @@ async def async_set_swing_mode(self, swing_mode): if swing_mode is None: return self._swing_mode = swing_mode + if self._is_over_climate and self._underlying_climate: + data = { + ATTR_ENTITY_ID: self._climate_entity_id, + "swing_mode": swing_mode, + } + + await self.hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, data, context=self._context + ) self.async_write_ha_state() async def async_set_temperature(self, **kwargs): @@ -901,11 +975,26 @@ async def async_set_temperature(self, **kwargs): _LOGGER.info("%s - Set target temp: %s", self, temperature) if temperature is None: return - self._target_temp = temperature + await self._async_internal_set_temperature(temperature) self._attr_preset_mode = PRESET_NONE self.recalculate() await self._async_control_heating(force=True) + async def _async_internal_set_temperature(self, temperature): + """Set the target temperature and the target temperature of underlying climate if any""" + self._target_temp = temperature + if self._is_over_climate and self._underlying_climate: + data = { + ATTR_ENTITY_ID: self._climate_entity_id, + "temperature": temperature, + "target_temp_high": self._attr_max_temp, + "target_temp_low": self._attr_min_temp, + } + + await self.hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, context=self._context + ) + @callback async def entry_update_listener( self, _, config_entry: ConfigEntry # hass: HomeAssistant, @@ -978,8 +1067,8 @@ async def try_window_condition(_): return _LOGGER.debug("%s - Window delay condition is satisfied", self) - if not self._saved_hvac_mode: - self._saved_hvac_mode = self._hvac_mode + # if not self._saved_hvac_mode: + # self._saved_hvac_mode = self._hvac_mode self._window_state = new_state.state if self._window_state == STATE_OFF: @@ -988,13 +1077,14 @@ async def try_window_condition(_): self, self._saved_hvac_mode, ) - await self.async_set_hvac_mode(self._saved_hvac_mode) + await self.restore_hvac_mode() elif self._window_state == STATE_ON: _LOGGER.info( "%s - Window is open. Set hvac_mode to '%s'", self, HVAC_MODE_OFF ) - self._saved_hvac_mode = self._hvac_mode + self.save_hvac_mode() await self.async_set_hvac_mode(HVAC_MODE_OFF) + self.update_custom_attributes() if self._window_call_cancel: self._window_call_cancel() @@ -1050,7 +1140,7 @@ async def try_motion_condition(_): new_preset, ) # We do not change the preset which is kept to ACTIVITY but only the target_temperature - self._target_temp = self._presets[new_preset] + await self._async_internal_set_temperature(self._presets[new_preset]) self.recalculate() await self._async_control_heating(force=True) @@ -1070,7 +1160,7 @@ async def _check_switch_initial_state(self): "The climate mode is OFF, but the switch device is ON. Turning off device %s", self._heater_entity_id, ) - await self._async_heater_turn_off() + await self._async_underlying_entity_turn_off() @callback def _async_switch_changed(self, event): @@ -1083,6 +1173,27 @@ def _async_switch_changed(self, event): self.hass.create_task(self._check_switch_initial_state()) self.async_write_ha_state() + @callback + async def _async_climate_changed(self, event): + """Handle unerdlying climate state changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Underlying climate changed. Event.new_state is %s, hvac_mode=%s", + self, + new_state, + self._hvac_mode, + ) + # old_state = event.data.get("old_state") + if new_state is None or new_state.state not in [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + ]: + return + self._hvac_mode = new_state.state + self.update_custom_attributes() + await self._async_control_heating(True) + @callback async def _async_update_temp(self, state): """Update thermostat with latest state from sensor.""" @@ -1094,8 +1205,7 @@ async def _async_update_temp(self, state): self._last_temperature_mesure = datetime.now() # try to restart if we were in security mode if self._security_state: - await self.async_set_hvac_mode(self._saved_hvac_mode) - await self.restore_preset_mode() + await self.check_security() except ValueError as ex: _LOGGER.error("Unable to update temperature from sensor: %s", ex) @@ -1111,8 +1221,7 @@ async def _async_update_ext_temp(self, state): self._last_ext_temperature_mesure = datetime.now() # try to restart if we were in security mode if self._security_state: - await self.async_set_hvac_mode(self._saved_hvac_mode) - await self.restore_preset_mode() + await self.check_security() except ValueError as ex: _LOGGER.error("Unable to update external temperature from sensor: %s", ex) @@ -1181,10 +1290,10 @@ async def _async_presence_changed(self, event): if new_state is None: return - self._update_presence(new_state.state) + await self._async_update_presence(new_state.state) await self._async_control_heating(force=True) - def _update_presence(self, new_state): + async def _async_update_presence(self, new_state): _LOGGER.debug("%s - Updating presence. New state is %s", self, new_state) self._presence_state = new_state if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False: @@ -1228,10 +1337,10 @@ def _update_presence(self, new_state): self, new_temp, ) - self._target_temp = new_temp + await self._async_internal_set_temperature(new_temp) self.recalculate() - def _update_motion_temp(self): + async def _async_update_motion_temp(self): """Update the temperature considering the ACTIVITY preset and current motion state""" _LOGGER.debug( "%s - Calling _update_motion_temp preset_mode=%s, motion_state=%s", @@ -1245,11 +1354,13 @@ def _update_motion_temp(self): ): return - self._target_temp = self._presets[ - self._motion_preset - if self._motion_state == STATE_ON - else self._no_motion_preset - ] + await self._async_internal_set_temperature( + self._presets[ + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ] + ) _LOGGER.debug( "%s - regarding motion, target_temp have been set to %.2f", self, @@ -1263,12 +1374,18 @@ async def _async_heater_turn_on(self): HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context ) - async def _async_heater_turn_off(self): + async def _async_underlying_entity_turn_off(self): """Turn heater toggleable device off.""" - data = {ATTR_ENTITY_ID: self._heater_entity_id} - await self.hass.services.async_call( - HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context - ) + if not self._is_over_climate: + data = {ATTR_ENTITY_ID: self._heater_entity_id} + await self.hass.services.async_call( + HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context + ) + else: + data = {ATTR_ENTITY_ID: self._climate_entity_id} + await self.hass.services.async_call( + HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context + ) def save_preset_mode(self): """Save the current preset mode to be restored later @@ -1290,6 +1407,26 @@ async def restore_preset_mode(self): ): await self._async_set_preset_mode_internal(self._saved_preset_mode) + def save_hvac_mode(self): + """Save the current hvac-mode to be restored later""" + self._saved_hvac_mode = self._hvac_mode + _LOGGER.debug( + "%s - Saved hvac mode - saved_hvac_mode is %s, hvac_mode is %s", + self, + self._saved_hvac_mode, + self._hvac_mode, + ) + + async def restore_hvac_mode(self): + """Restore a previous hvac_mod""" + await self.async_set_hvac_mode(self._saved_hvac_mode) + _LOGGER.debug( + "%s - Restored hvac_mode - saved_hvac_mode is %s, hvac_mode is %s", + self, + self._saved_hvac_mode, + self._hvac_mode, + ) + async def check_overpowering(self) -> bool: """Check the overpowering condition Turn the preset_mode of the heater to 'power' if power conditions are exceeded @@ -1305,170 +1442,249 @@ async def check_overpowering(self) -> bool: self._current_power_max, self._device_power, ) - self._overpowering_state = ( - self._current_power + self._device_power >= self._current_power_max - ) - if self._overpowering_state: + ret = self._current_power + self._device_power >= self._current_power_max + if ( + not self._overpowering_state + and ret + and not self._hvac_mode == HVAC_MODE_OFF + ): _LOGGER.warning( "%s - overpowering is detected. Heater preset will be set to 'power'", self, ) + if self._is_over_climate: + self.save_hvac_mode() + self.save_preset_mode() + await self._async_underlying_entity_turn_off() await self._async_set_preset_mode_internal(PRESET_POWER) - return self._overpowering_state # Check if we need to remove the POWER preset - if self._attr_preset_mode == PRESET_POWER and not self._overpowering_state: + if ( + self._overpowering_state + and not ret + and self._attr_preset_mode == PRESET_POWER + ): _LOGGER.warning( "%s - end of overpowering is detected. Heater preset will be restored to '%s'", self, self._saved_preset_mode, ) + if self._is_over_climate: + await self.restore_hvac_mode() await self.restore_preset_mode() - def check_date_temperature(self) -> bool: + self._overpowering_state = ret + return self._overpowering_state + + async def check_security(self) -> bool: """Check if last temperature date is too long""" now = datetime.now() delta_temp = (now - self._last_temperature_mesure).total_seconds() / 60.0 delta_ext_temp = ( now - self._last_ext_temperature_mesure ).total_seconds() / 60.0 - if ( + _LOGGER.debug( + "%s - checking security delta_temp=%.1f delta_ext_temp=%.1f", + self, + delta_temp, + delta_ext_temp, + ) + + temp_cond: bool = ( delta_temp > self._security_delay_min or delta_ext_temp > self._security_delay_min + ) + climate_cond: bool = self._is_over_climate and self.hvac_action not in [ + CURRENT_HVAC_COOL, + CURRENT_HVAC_IDLE, + ] + switch_cond: bool = ( + not self._is_over_climate + and self._prop_algorithm is not None + and self._prop_algorithm.on_percent > 0.75 + ) + + ret = False + if temp_cond and climate_cond: + if not self._security_state: + _LOGGER.warning( + "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and underlying climate is %s. Set it into security mode", + self, + self._security_delay_min, + delta_temp, + delta_ext_temp, + self.hvac_action, + ) + ret = True + + if temp_cond and switch_cond: + if not self._security_state: + _LOGGER.warning( + "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and on_percent is high (%.2f). Set it into security mode", + self, + self._security_delay_min, + delta_temp, + delta_ext_temp, + self._prop_algorithm.on_percent, + ) + ret = True + + if not self._security_state and ret: + self._security_state = ret + self.save_hvac_mode() + self.save_preset_mode() + await self._async_set_preset_mode_internal(PRESET_SECURITY) + await self.async_set_hvac_mode(HVAC_MODE_OFF) + + if ( + self._security_state + and self._attr_preset_mode == PRESET_SECURITY + and not ret ): _LOGGER.warning( - "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f)", + "%s - End of security mode. restoring hvac_mode to %s and preset_mode to %s", self, - self._security_delay_min, - delta_temp, - delta_ext_temp, + self._saved_hvac_mode, + self._saved_preset_mode, ) - return False + self._security_state = ret + await self.restore_hvac_mode() + await self.restore_preset_mode() - return True + return ret 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() - if overpowering: - _LOGGER.debug( - "%s - The max power is exceeded. Heater will not be started. preset_mode is now '%s'", # pylint: disable=line-too-long - self, - self._attr_preset_mode, - ) - await self._async_heater_turn_off() - _LOGGER.debug("%s - End of cycle (0)", self) - return - - on_time_sec: int = self._prop_algorithm.on_time_sec - off_time_sec: int = self._prop_algorithm.off_time_sec _LOGGER.info( - "%s - Checking new cycle. on_time_sec=%.0f, off_time_sec=%.0f, security_state=%s, preset_mode=%s", + "%s - Checking new cycle. hvac_mode=%s, security_state=%s, preset_mode=%s", self, - on_time_sec, - off_time_sec, + self._hvac_mode, self._security_state, self._attr_preset_mode, ) - # Cancel eventual previous cycle if any - if self._async_cancel_cycle is not None: - if force: - _LOGGER.debug("%s - we force a new cycle", self) - self._async_cancel_cycle() - self._async_cancel_cycle = None - else: - _LOGGER.debug( - "%s - A previous cycle is alredy running and no force -> waits for its end", - self, - ) - self._should_relaunch_control_heating = True - _LOGGER.debug("%s - End of cycle (1)", self) - return + # Check overpowering condition + overpowering: bool = await self.check_overpowering() + if overpowering: + _LOGGER.debug("%s - End of cycle (0)", self) + return - if self._hvac_mode == HVAC_MODE_HEAT and on_time_sec > 0: + security: bool = await self.check_security() + if security: + _LOGGER.debug("%s - End of cycle (1)", self) + return - async def _turn_on_off_later( - on: bool, time, heater_action, next_cycle_action - ): - if self._async_cancel_cycle: + # Stop here if we are off + if self._hvac_mode == HVAC_MODE_OFF: + return + + if not self._is_over_climate: + on_time_sec: int = self._prop_algorithm.on_time_sec + off_time_sec: int = self._prop_algorithm.off_time_sec + _LOGGER.info( + "%s - Checking new cycle. on_time_sec=%.0f, off_time_sec=%.0f, security_state=%s, preset_mode=%s", + self, + on_time_sec, + off_time_sec, + self._security_state, + self._attr_preset_mode, + ) + + # Cancel eventual previous cycle if any + if self._async_cancel_cycle is not None: + if force: + _LOGGER.debug("%s - we force a new cycle", self) self._async_cancel_cycle() self._async_cancel_cycle = None - _LOGGER.debug("%s - Stopping cycle during calculation", self) - - check_dates = self.check_date_temperature() - if time > 0 and on is True and check_dates is False: - _LOGGER.warning("%s - Set the thermostat into security mode", self) - self._security_state = True - self._saved_hvac_mode = self.hvac_mode - self.save_preset_mode() - await self._async_set_preset_mode_internal(PRESET_SECURITY) - await self.async_set_hvac_mode(HVAC_MODE_OFF) - # The cycle is not restarted in security mode. It will be restarted by a condition changes - _LOGGER.debug("%s - End of cycle (2)", self) - return - if check_dates: - self._security_state = False - - action_label = "start" if on else "stop" - if self._should_relaunch_control_heating: + else: _LOGGER.debug( - "Don't %s cause a cycle have to be relaunch", action_label + "%s - A previous cycle is alredy running and no force -> waits for its end", + self, ) - self._should_relaunch_control_heating = False - self.hass.create_task(self._async_control_heating()) - # await self._async_control_heating() - _LOGGER.debug("%s - End of cycle (3)", self) + self._should_relaunch_control_heating = True + _LOGGER.debug("%s - End of cycle (2)", self) return - if time > 0: - _LOGGER.info( - "%s - !!! %s heating for %d min %d sec", - self, - action_label, - time // 60, - time % 60, + if self._hvac_mode == HVAC_MODE_HEAT and on_time_sec > 0: + + async def _turn_on_off_later( + on: bool, time, heater_action, next_cycle_action + ): + if self._async_cancel_cycle: + self._async_cancel_cycle() + self._async_cancel_cycle = None + _LOGGER.debug("%s - Stopping cycle during calculation", self) + + if on: + security = ( + await self.check_security() + or await self.check_overpowering() + ) + if security: + _LOGGER.debug("%s - End of cycle (3)", self) + return + + action_label = "start" if on else "stop" + if self._should_relaunch_control_heating: + _LOGGER.debug( + "Don't %s cause a cycle have to be relaunch", action_label + ) + self._should_relaunch_control_heating = False + self.hass.create_task(self._async_control_heating()) + # await self._async_control_heating() + _LOGGER.debug("%s - End of cycle (3)", self) + return + + if time > 0: + _LOGGER.info( + "%s - !!! %s heating for %d min %d sec", + self, + action_label, + time // 60, + time % 60, + ) + await heater_action() + else: + _LOGGER.debug( + "%s - No action on heater cause duration is 0", self + ) + self.update_custom_attributes() + self._async_cancel_cycle = async_call_later( + self.hass, + time, + next_cycle_action, ) - await heater_action() - else: - _LOGGER.debug("%s - No action on heater cause duration is 0", self) - self.update_custom_attributes() - self._async_cancel_cycle = async_call_later( - self.hass, - time, - next_cycle_action, - ) - async def _turn_on_later(_): - await _turn_on_off_later( - on=True, - time=self._prop_algorithm.on_time_sec, - heater_action=self._async_heater_turn_on, - next_cycle_action=_turn_off_later, - ) + async def _turn_on_later(_): + await _turn_on_off_later( + on=True, + time=self._prop_algorithm.on_time_sec, + heater_action=self._async_heater_turn_on, + next_cycle_action=_turn_off_later, + ) - async def _turn_off_later(_): - await _turn_on_off_later( - on=False, - time=self._prop_algorithm.off_time_sec, - heater_action=self._async_heater_turn_off, - next_cycle_action=_turn_on_later, - ) + async def _turn_off_later(_): + await _turn_on_off_later( + on=False, + time=self._prop_algorithm.off_time_sec, + heater_action=self._async_underlying_entity_turn_off, + next_cycle_action=_turn_on_later, + ) - await _turn_on_later(None) + await _turn_on_later(None) - elif self._is_device_active: - _LOGGER.info( - "%s - stop heating (2) for %d min %d sec", - self, - off_time_sec // 60, - off_time_sec % 60, - ) - await self._async_heater_turn_off() + elif self._is_device_active: + _LOGGER.info( + "%s - stop heating (2) for %d min %d sec", + self, + off_time_sec // 60, + off_time_sec % 60, + ) + await self._async_underlying_entity_turn_off() - else: - _LOGGER.debug("%s - nothing to do", self) + else: + _LOGGER.debug("%s - nothing to do", self) self.update_custom_attributes() @@ -1476,6 +1692,10 @@ def recalculate(self): """A utility function to force the calculation of a the algo and update the custom attributes and write the state """ + if self._is_over_climate: + self.update_custom_attributes() + return + _LOGGER.debug("%s - recalculate all", self) self._prop_algorithm.calculate( self._target_temp, self._cur_temp, self._cur_ext_temp @@ -1486,28 +1706,25 @@ def recalculate(self): def update_custom_attributes(self): """Update the custom extra attributes for the entity""" - self._attr_extra_state_attributes = { + self._attr_extra_state_attributes: dict(str, str) = { + "hvac_mode": self._hvac_mode, + "type": self._thermostat_type, "eco_temp": self._presets[PRESET_ECO], "boost_temp": self._presets[PRESET_BOOST], "comfort_temp": self._presets[PRESET_COMFORT], - "eco_away_temp": self._presets_away[self.get_preset_away_name(PRESET_ECO)], - "boost_away_temp": self._presets_away[ + "eco_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_ECO) + ), + "boost_away_temp": self._presets_away.get( self.get_preset_away_name(PRESET_BOOST) - ], - "comfort_away_temp": self._presets_away[ + ), + "comfort_away_temp": self._presets_away.get( self.get_preset_away_name(PRESET_COMFORT) - ], + ), "power_temp": self._power_temp, - "on_percent": self._prop_algorithm.on_percent, - "on_time_sec": self._prop_algorithm.on_time_sec, - "off_time_sec": self._prop_algorithm.off_time_sec, "ext_current_temperature": self._cur_ext_temp, "current_power": self._current_power, "current_power_max": self._current_power_max, - "cycle_min": self._cycle_min, - "function": self._proportional_function, - "tpi_coef_int": self._tpi_coef_int, - "tpi_coef_ext": self._tpi_coef_ext, "saved_preset_mode": self._saved_preset_mode, "saved_target_temp": self._saved_target_temp, "saved_hvac_mode": self._saved_hvac_mode, @@ -1522,6 +1739,28 @@ def update_custom_attributes(self): "minimal_activation_delay_sec": self._minimal_activation_delay, "last_update_datetime": datetime.now().isoformat(), } + if self._is_over_climate: + self._attr_extra_state_attributes[ + "underlying_climate" + ] = self._climate_entity_id + else: + self._attr_extra_state_attributes[ + "underlying_switch" + ] = self._heater_entity_id + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -1534,7 +1773,7 @@ def async_registry_entry_updated(self): """update the entity if the config entry have been updated Note: this don't work either """ - _LOGGER.info("%s - The config entry have been updated.") + _LOGGER.info("%s - The config entry have been updated") async def service_set_presence(self, presence): """Called by a service call: @@ -1545,7 +1784,7 @@ async def service_set_presence(self, presence): entity_id: climate.thermostat_1 """ _LOGGER.info("%s - Calling service_set_presence, presence: %s", self, presence) - self._update_presence(presence) + await self._async_update_presence(presence) await self._async_control_heating(force=True) async def service_set_preset_temperature( diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index 02847cc..5c8a52b 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -7,6 +7,9 @@ from collections.abc import Mapping import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import TEMPERATURE, UnitOfPower +from homeassistant.util.unit_system import TEMPERATURE_UNITS from homeassistant.core import callback, async_get_hass from homeassistant.config_entries import ( @@ -17,27 +20,27 @@ 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.helpers.entity_registry import ( + RegistryEntry, + async_get, +) from homeassistant.components.climate.const import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.climate import ClimateEntity 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 homeassistant.components.person import DOMAIN as PERSON_DOMAIN + from .const import ( DOMAIN, @@ -76,6 +79,7 @@ CONF_USE_PRESENCE_FEATURE, CONF_USE_POWER_FEATURE, CONF_THERMOSTAT_TYPES, + UnknownEntity, ) _LOGGER = logging.getLogger(__name__) @@ -122,6 +126,37 @@ def add_suggested_values_to_schema( return vol.Schema(schema) +def is_temperature_sensor(sensor: RegistryEntry): + """Check if a registryEntry is a temperature sensor or assimilable to a temperature sensor""" + if not sensor.entity_id.startswith( + INPUT_NUMBER_DOMAIN + ) and not sensor.entity_id.startswith(SENSOR_DOMAIN): + return False + return ( + sensor.device_class == TEMPERATURE + or sensor.original_device_class == TEMPERATURE + or sensor.unit_of_measurement in TEMPERATURE_UNITS + ) + + +def is_power_sensor(sensor: RegistryEntry): + """Check if a registryEntry is a power sensor or assimilable to a temperature sensor""" + if not sensor.entity_id.startswith( + INPUT_NUMBER_DOMAIN + ) and not sensor.entity_id.startswith(SENSOR_DOMAIN): + return False + return ( + # sensor.device_class == TEMPERATURE + # or sensor.original_device_class == TEMPERATURE + sensor.unit_of_measurement + in [ + UnitOfPower.KILO_WATT, + UnitOfPower.WATT, + UnitOfPower.BTU_PER_HOUR, + ] + ) + + class VersatileThermostatBaseConfigFlow(FlowHandler): """The base Config flow class. Used to put some code in commons.""" @@ -135,22 +170,43 @@ def __init__(self, infos) -> None: 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() + climates = [] + switches = [] + temp_sensors = [] + power_sensors = [] + window_sensors = [] + presence_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): + v: RegistryEntry = ent_reg.entities[k] + _LOGGER.debug("Looking entity: %s", k) + # if k.startswith(CLIMATE_DOMAIN) and ( + # infos is None or k != infos.get("entity_id") + # ): + # _LOGGER.debug("Climate !") + # climates.append(k) + if k.startswith(SWITCH_DOMAIN) or k.startswith(INPUT_BOOLEAN_DOMAIN): + _LOGGER.debug("Switch !") 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) + elif is_temperature_sensor(v): + _LOGGER.debug("Temperature sensor !") temp_sensors.append(k) + elif is_power_sensor(v): + _LOGGER.debug("Power sensor !") + power_sensors.append(k) + elif k.startswith(PERSON_DOMAIN): + _LOGGER.debug("Presence sensor !") + presence_sensors.append(k) + + # window sensor + if k.startswith(INPUT_BOOLEAN_DOMAIN): + _LOGGER.debug("Window or presence sensor !") + window_sensors.append(k) + presence_sensors.append(k) + + # Special case for climates which are not in EntityRegistry + climates = self.find_all_climates() self.STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -160,6 +216,7 @@ def __init__(self, infos) -> None: ): 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_CYCLE_MIN, default=5): cv.positive_int, 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, @@ -179,7 +236,6 @@ def __init__(self, infos) -> None: PROPORTIONAL_FUNCTION_TPI, ] ), - vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, } ) @@ -205,14 +261,14 @@ def __init__(self, infos) -> None: self.STEP_WINDOW_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_WINDOW_SENSOR): cv.string, + vol.Optional(CONF_WINDOW_SENSOR): vol.In(window_sensors), vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int, } ) self.STEP_MOTION_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_MOTION_SENSOR): cv.string, + vol.Optional(CONF_MOTION_SENSOR): vol.In(window_sensors), vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int, vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In( CONF_PRESETS_SELECTIONABLE @@ -225,16 +281,16 @@ def __init__(self, infos) -> None: self.STEP_POWER_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_POWER_SENSOR): cv.string, - vol.Optional(CONF_MAX_POWER_SENSOR): cv.string, - vol.Optional(CONF_DEVICE_POWER): vol.Coerce(float), - vol.Optional(CONF_PRESET_POWER): vol.Coerce(float), + vol.Optional(CONF_POWER_SENSOR): vol.In(power_sensors), + vol.Optional(CONF_MAX_POWER_SENSOR): vol.In(power_sensors), + vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float), + vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float), } ) self.STEP_PRESENCE_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_PRESENCE_SENSOR): cv.string, + vol.Optional(CONF_PRESENCE_SENSOR): vol.In(presence_sensors), } ).extend( { @@ -455,30 +511,6 @@ def find_all_climates(self) -> list(str): _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 @@ -502,16 +534,12 @@ async def async_finalize(self): return self.async_create_entry(title=self._infos[CONF_NAME], data=self._infos) -class UnknownEntity(HomeAssistantError): - """Error to indicate there is an unknown entity_id given.""" - - class VersatileThermostatOptionsFlowHandler( VersatileThermostatBaseConfigFlow, OptionsFlow ): """Handle options flow for Versatile Thermostat integration.""" - def __init__(self, config_entry: ConfigEntry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" super().__init__(config_entry.data.copy()) self.config_entry = config_entry @@ -537,9 +565,27 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: ) 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 OptionsFlowHandler.async_step_user 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 tpi flow steps""" _LOGGER.debug( diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index a77bf44..5d1acfd 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -9,6 +9,8 @@ SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.exceptions import HomeAssistantError + from .prop_algorithm import ( PROPORTIONAL_FUNCTION_TPI, ) @@ -123,3 +125,7 @@ SERVICE_SET_PRESENCE = "set_presence" SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" + + +class UnknownEntity(HomeAssistantError): + """Error to indicate there is an unknown entity_id given.""" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index bf42a62..3b6b3d1 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -9,10 +9,9 @@ "data": { "name": "Name", "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)", "temp_min": "Minimal temperature allowed", "temp_max": "Maximal temperature allowed", "use_window_feature": "Use window detection", @@ -22,10 +21,13 @@ } }, "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" + "title": "Linked entity", + "description": "Linked entity attributes", + "data": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying thermostat entity id" + } }, "tpi": { "title": "TPI", @@ -91,6 +93,14 @@ } } }, + "selectors": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat" + } + } + }, "error": { "unknown": "Unexpected error", "unknown_entity": "Unknown entity id" @@ -108,10 +118,9 @@ "data": { "name": "Name", "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)", "temp_min": "Minimal temperature allowed", "temp_max": "Maximal temperature allowed", "use_window_feature": "Use window detection", @@ -121,10 +130,13 @@ } }, "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" + "title": "Linked entity", + "description": "Linked entity attributes", + "data": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying thermostat entity id" + } }, "tpi": { "title": "TPI", @@ -190,6 +202,14 @@ } } }, + "selectors": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat" + } + } + }, "error": { "unknown": "Unexpected error", "unknown_entity": "Unknown entity id" diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index bf42a62..3b6b3d1 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -9,10 +9,9 @@ "data": { "name": "Name", "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)", "temp_min": "Minimal temperature allowed", "temp_max": "Maximal temperature allowed", "use_window_feature": "Use window detection", @@ -22,10 +21,13 @@ } }, "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" + "title": "Linked entity", + "description": "Linked entity attributes", + "data": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying thermostat entity id" + } }, "tpi": { "title": "TPI", @@ -91,6 +93,14 @@ } } }, + "selectors": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat" + } + } + }, "error": { "unknown": "Unexpected error", "unknown_entity": "Unknown entity id" @@ -108,10 +118,9 @@ "data": { "name": "Name", "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)", "temp_min": "Minimal temperature allowed", "temp_max": "Maximal temperature allowed", "use_window_feature": "Use window detection", @@ -121,10 +130,13 @@ } }, "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" + "title": "Linked entity", + "description": "Linked entity attributes", + "data": { + "heater_entity_id": "Heater entity id", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying thermostat entity id" + } }, "tpi": { "title": "TPI", @@ -190,6 +202,14 @@ } } }, + "selectors": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over another thermostat" + } + } + }, "error": { "unknown": "Unexpected error", "unknown_entity": "Unknown entity id" diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index e7c053b..dd95a2a 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -8,8 +8,6 @@ "description": "Principaux attributs obligatoires", "data": { "name": "Nom", - "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)", @@ -22,10 +20,13 @@ } }, "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" + "title": "Entité liée", + "description": "Attributs de l'entité liée", + "data": { + "heater_entity_id": "Radiateur entity id", + "proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)", + "climate_entity_id": "Thermostat sous-jacent entity id" + } }, "tpi": { "title": "TPI", @@ -91,6 +92,14 @@ } } }, + "selectors": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Thermostat sur un switch", + "thermostat_over_climate": "Thermostat sur un autre thermostat" + } + } + }, "error": { "unknown": "Erreur inattendue", "unknown_entity": "entity id inconnu" @@ -121,10 +130,13 @@ } }, "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" + "title": "Entité liée", + "description": "Attributs de l'entité liée", + "data": { + "heater_entity_id": "Radiateur entity id", + "proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)", + "climate_entity_id": "Thermostat sous-jacent entity id" + } }, "tpi": { "title": "TPI", @@ -190,6 +202,14 @@ } } }, + "selectors": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Thermostat sur un switch", + "thermostat_over_climate": "Thermostat sur un autre thermostat" + } + } + }, "error": { "unknown": "Erreur inattendue", "unknown_entity": "entity id inconnu"