Compare commits

...

8 Commits

Author SHA1 Message Date
Jean-Marc Collin
eabcc64fb1 Add tips in README 2023-01-14 18:52:18 +01:00
Jean-Marc Collin
903d7d1709 Update documentation for 1.0 release 2023-01-14 18:26:38 +01:00
Jean-Marc Collin
10189a59a7 Issue #12 - Cannot remove entity_id in configuration
Issue #09 - Hide preset that are not configured
Issue #04 - Add service to update the temperature of a preset
Issue #02 - Limits the usable presets
2023-01-14 12:28:26 +01:00
Jean-Marc Collin
c605c5e3ae issue #11 - Rename coeff_c and coeff_t to coeff_int et coeff_ext
issue #10 - Add a service to force the presence or non presence
issue #07 - Make external temperature sensor mandatory for TPI mode
issue #06 - Use person.xxx as presence detector directly
issue #02 - Limits the usable presets
issue #01 - Add a temperature configuration for present / not present for each preset
2023-01-11 23:11:04 +01:00
Jean-Marc Collin
018343ec53 FIX power float conversion when state unknown 2023-01-08 13:15:02 +01:00
Jean-Marc Collin
89984b3dfc Add presence management
Fix many bugs on states actualisation
Add extra attributes with all internal states
2023-01-08 12:29:50 +01:00
Jean-Marc Collin
5fb148d445 FIX restore preset after power preset 2023-01-07 19:44:43 +01:00
Jean-Marc Collin
62a5e05a7a FIX power management not intialized 2023-01-07 18:30:46 +01:00
23 changed files with 1066 additions and 472 deletions

View File

@@ -13,13 +13,13 @@ input_number:
name: Temperature
min: 0
max: 35
step: .5
step: .1
icon: mdi:thermometer
fake_external_temperature_sensor1:
name: Ext Temperature
min: -10
max: 35
step: .5
step: .1
icon: mdi:home-thermometer
fake_current_power:
name: Current power
@@ -45,9 +45,16 @@ input_boolean:
name: Heater 1 (Linear)
icon: mdi:radiator
fake_heater_switch2:
name: Heater (TPI)
name: Heater (TPI with presence preset)
icon: mdi:radiator
fake_heater_switch3:
name: Heater (TPI with offset)
icon: mdi:radiator
# input_boolean to simulate the motion sensor entity. Only for development environment.
fake_motion_sensor1:
name: Motion Sensor 1
icon: mdi:run
# input_boolean to simulate the presence sensor entity. Only for development environment.
fake_presence_sensor1:
name: Presence Sensor 1
icon: mdi:home

247
README.md
View File

@@ -3,8 +3,9 @@
[![License][license-shield]](LICENSE)
[![hacs][hacs_badge]][hacs]
> ![Tip](images/tips.png) This thermostat integration aims to drastically simplify your automations around climate management. Because all classical events in climate are natively handled by the thermostat (nobody at home ?, activity detected in a room ?, window open ?, power shedding ?), you don't have to build over complicated scripts and automations to manage your climates ;-).
_Component developed by using the amazing development template [blueprint][blueprint]._
_Component developed by using the amazing development template [[blueprint](https://github.com/custom-components/integration_blueprint)]._
This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features.
@@ -17,19 +18,22 @@ This thermostat aims to command a heater which works only in on/off mode. The mi
Because this integration aims to command the heater considering the preset configured and the room temperature, those informations are mandatory.
## Why another thermostat implementation ?
For my personnal usage, I needed to add a couple of features and also to update the behavior that I implemented in my previous component "Awesome thermostat".
This new component "Versatile thermostat" now manage the following use cases :
- Configuration through GUI using Config Entry flow,
- Explicitely define the temperature for all presets mode,
- Unset the preset mode when the temperature is manually defined on a thermostat,
- Turn off/on a thermostat when a door or windows is opened/closed after a certain delay,
- Set a preset when an activity is detected in a room, and another one after no activity has been detected for a defined time,
- Use a proportional algorithm with two function (see below),
- Add power management to avoid exceeding a defined total power. When max power is exceeded, a new 'power' preset is set on the climate entity. When power goes below the max, the previous preset is restored.
## How to install this incredible thermostat
For my personnal usage, I needed to add a couple of features and also to update the behavior that implemented in the previous component "Awesome thermostat".
This component named __Versatile thermostat__ manage the following use cases :
- Configuration through standard integration GUI (using Config Entry flow),
- Full uses of **presets mode**,
- Unset the preset mode when the temperature is **manually defined** on a thermostat,
- Turn off/on a thermostat when a **door or windows is opened/closed** after a certain delay,
- Change preset when an **activity is detected** or not in a room for a defined time,
- Use a **TPI (Time Proportional Interval) algorithm** thank's to [[Argonaute](https://forum.hacf.fr/u/argonaute/summary)] algorithm ,
- Add **power shedding management** or regulation to avoid exceeding a defined total power. When max power is exceeded, a hidden 'power' preset is set on the climate entity. When power goes below the max, the previous preset is restored.
- Add **home presence management**. This feature allows you to dynamically change the temperature of preset considering a occupancy sensor of your home.
- Add **services to interact with the thermostat** from others integration: you can force the presence / un-presence using a service, and you can dynamically change the temperature of the presets.
### HACS installation
## How to install this incredible Versatile Thermostat ?
### HACS installation (recommended)
1. Install [HACS](https://hacs.xyz/). That way you get updates automatically.
2. Add this Github repository as custom repository in HACS settings.
@@ -47,68 +51,80 @@ This new component "Versatile thermostat" now manage the following use cases :
6. Restart Home Assistant
7. Configure new Versatile Thermostat integration
## Minimum requirements
* This implementation can override or superseed the core generic thermostat
## Configuration
No configuration in configuration.yaml is needed because all configuration is done through the standard GUI when adding the integration.
Click on Add integration button in the integration page
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/add-an-integration.png?raw=true)
Note: no configuration in configuration.yaml is needed because all configuration is done through the standard GUI when adding the integration.
Follow the configurations steps as follow:
Click on Add integration button in the integration page
![image](images/add-an-integration.png?raw=true)
The configuration can be change through the same interface. Simply select the thermostat to change, hit "Configure" and you will be able to change some parameters or configuration. Don't forget to reload (dot menu / reload) to take the new configuration into account.
Then follow the configurations steps as follow:
### Minimal configuration update
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/config-page-1.png?raw=true)
![image](images/config-main.png?raw=true)
Give the main mandatory attributes:
1. a name (will be the integration name and also the climate entity name)
2. an equipment entity id which represent the heater. This equipment should be able to switch on or off,
3. a sensor entity id which gives the temperature of the room in which the heater is installed,
4. a cycle duration in minutes. At each cycle, the heater will be turned on then off for a calculated period in order to reach the targeted temperature (see presents below)
5. a function used by the algorithm. 'linear' is the most common function. 'atan' is more aggressive and the targeted temperature will be reach sooner (but the power consumption is greater). Use it for room badly isolated,
6. a bias value of type float. Proportional algorithm are known to never reach the targeted temperature. Depending of the room and heater configuration set a bias to reach the target. To evaluate the correct value, set it to 0, set the preset to a target temperature and see the current temperature reach. If it is below the target temperature, set the bias accordingly.
3. a temporature sensor entity id which gives the temperature of the room in which the heater is installed,
4. a temperature sensor entity giving the external temperature. If don't have any external sensor, you can use the local meteo integration
5. a cycle duration in minutes. At each cycle, the heater will be turned on then off for a calculated period in order to reach the targeted temperature (see [preset](#configure-the-preset-temperature) below),
6. Algorithm to use. Today only the TPI algorithm is available. See [algorithm](#algorithm)
> ![Tip](images/tips.png) _*Notes*_
1. Calculation are done at each cycle. So in case of conditions change, you will have to wait for the next cycle to see a change. For this reason, the cycle should not be too long. **5 min is a good value**,
2. if the cycle is too short, the heater could never reach the target temperature indeed for heater with accumulation features and it will be unnecessary solicited
### Configure the TPI algorithm coefficients
Click on 'Validate' on the previous page and you will get there:
![image](images/config-tpi.png?raw=true)
For more informations on the TPI algorithm and tuned please refer to [algorithm](#algorithm).
### Configure the preset temperature
Click on 'Validate' on the previous page and you will get there:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/config-page-2.png?raw=true)
![image](images/config-presets.png?raw=true)
Concerning the preset modes, you first have to know that, as defined in the core development documentation (https://developers.home-assistant.io/docs/core/entity/climate/), the preset mode handled are the following :
- ECO : Device is running an energy-saving mode
- AWAY : Device is in away mode
- BOOST : Device turn all valve full up
- COMFORT : Device is in comfort mode
- POWER : An extra preset used when the power management detects an overpowering situation
The preset mode allows you to pre-configurate targeted temperature. Used in conjonction with Scheduler (see [scheduler](#even-better-with-scheduler-component) you will have a powerfull and simple way to optimize the temperature vs electrical consumption of your hous. Preset handled are the following :
- **Eco** : device is running an energy-saving mode
- **Comfort** : device is in comfort mode
- **Boost** : device turn all valve full up
'None' is always added in the list of modes, as it is a way to not use the presets modes but a manual temperature instead.
**None** is always added in the list of modes, as it is a way to not use the presets modes but a **manual temperature** instead.
!!! IMPORTANT !!! Changing manually the target temperature, set the preset to None (no preset). This way you can always set a target temperature even if no preset are available.
> ![Tip](images/tips.png) _*Notes*_
1. Changing manually the target temperature, set the preset to None (no preset). This way you can always set a target temperature even if no preset are available.
2. standard **Away** preset is a hidden preset which is not directly selectable. Versatile Thermostat uses the presence management or movement management to set automatically and dynamically the target temperature depending on a presence in the home or an activity in the room. See [presence management](#configure-the-presence-management).
3. if you uses the power shedding management, you will see a hidden preset named **power**. The heater preset is set to **power** when overpowering conditions are encountered and shedding is active for this heater. See [power management](#configure-the-power-management).
4. If you don't want to use the preseet, give 0 as temperature. The preset will then been ignored and will not displayed in the front component
### Configure the doors/windows turning on/off the thermostats
Click on 'Validate' on the previous page and you will get there:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/config-page-3.png?raw=true)
![image](images/config-window.png?raw=true)
Give the following attributes:
1. an entity id of a window/door sensor. This should be a binary_sensor or a input_boolean. The state of the entity should be 'on' or 'off'
2. a delay in secondes before any change. This allow to quickly open a window without stopping the heater.
1. an entity id of a **window/door sensor**. This should be a binary_sensor or a input_boolean. The state of the entity should be 'on' when the window is open or 'off' when closed
2. a **delay in seconds** before any change. This allow to quickly open a window without stopping the heater.
And that's it ! your thermostat will turn off when the windows is open and be turned back on when it's closed afer the delay.
Note 1 : this implementation is based on 'normal' door/windows behavior, that's mean it considers it's closed when the state is 'off' and open when the state is 'on'
Note 2 : If you want to use several door/windows sensors to automatize your thermostat, just create a group with the regular behavior (https://www.home-assistant.io/integrations/binary_sensor.group/).
> ![Tip](images/tips.png) _*Notes*_
1. If you want to use **several door/windows sensors** to automatize your thermostat, just create a group with the regular behavior (https://www.home-assistant.io/integrations/binary_sensor.group/)
2. If you don't have any window/door sensor in your room, just leave the sensor entity id empty
### Configure the activity mode or motion detection
Click on 'Validate' on the previous page and you will get there:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/config-page-4.png?raw=true)
![image](images/config-motion.png?raw=true)
We will now see how to configure the new Activity mode.
What we need:
- a motion sensor. The entity id of a motion sensor. Motion sensor states should be 'on' (motion detected) or 'off' (no motion detected)
- a "motion delay" duration defining how many time we leave the temperature like in "motion" mode after the last motion is detected.
- a target "motion" preset. We will used the same temperature than this preset when an activity is detected.
- a target "no motion" preset. We will used the same temperature than this preset when no activity is detected.
- a **motion sensor**. The entity id of a motion sensor. Motion sensor states should be 'on' (motion detected) or 'off' (no motion detected)
- a **motion delay** (in seconds) duration defining how long we wait for motion confirmation before considering the motion
- a **target "motion" preset**. We will used the temperature of this preset when an activity is detected.
- a **target "no motion" preset**. We will used the temperature of this second preset when no activity is detected.
So imagine we want to have the following behavior :
- we have room with a thermostat set in activity mode, the "motion" mode chosen is comfort (21.5C), the "no motion" mode chosen is Eco (18.5 C) and the motion delay is 5 min.
@@ -116,34 +132,114 @@ So imagine we want to have the following behavior :
- somebody enters into the room, an activity is detected the temperature is set to 21.5 C
- the person leaves the room, after 5 min the temperature is set back to 18.5 C
For this to work, the climate thermostat should be in 'activity' preset mode.
For this to work, the climate thermostat should be in ``Activity`` preset mode.
Be aware that as for the others preset modes, Activity will only be proposed if it's correctly configure. In other words, the 4 configuration keys have to be set if you want to see Activity in home assistant Interface
> ![Tip](images/tips.png) _*Notes*_
1. Be aware that as for the others preset modes, ``Activity`` will only be proposed if it's correctly configure. In other words, the 4 configuration keys have to be set if you want to see Activity in home assistant Interface
### Configure the power management
This feature allows you to regulate the power consumption of your radiators. Give a sensor to the current power consumption of your house, a sensor to the max power that should not be exceeded, the power consumption of your radiator and the algorithm will not start a radiator if the max power will be exceeded after radiator starts.
Click on 'Validate' on the previous page and you will get there:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/config-page-5.png?raw=true)
This feature allows you to regulate the power consumption of your radiators. Known as shedding, this feature allows you to limit the electrical power consumption of your heater if overpowering conditions are detected. Give a **sensor to the current power consumption of your house**, a **sensor to the max power** that should not be exceeded, the **power consumption of your heater** and the algorithm will not start a radiator if the max power will be exceeded after radiator starts.
![image](images/config-power.png?raw=true)
Note that all power values should have the same units (kW or W for example).
This allows you to change the max power along time using a Sceduler or whatever you like.
This allows you to change the max power along time using a Scheduler or whatever you like.
> ![Tip](images/tips.png) _*Notes*_
1. When shedding is encountered, the heater is set to the preset named ``power``. This is a hidden preset, you cannot select it manually.
2. I use this to avoid exceeded the limit of my electrical power contract when an electrical vehicle is charging. This makes a kind of auto-regulation.
3. Always keep a margin, because max power can be briefly exceeded while waiting for the next cycle calculation typically or by not regulated equipement.
4. If you don't want to use this feature, just leave the entities id empty
### Configure the presence or occupancy
This feature allows you to dynamically changes the temperature of all configured Versatile thermostat's presets when nobody is at home or when someone comes back home. For this, you have to configure the temperature that will be used for each preset when presence is off. When the occupancy sensor turns to off, those tempoeratures will be used. When it turns on again the "normal" temperature configured for the preset is used. See [preset management](#configure-the-preset-temperature).
For this you need to configure:
1. A **occupancy sensor** which state should be 'on' or 'home' if someone is present or 'off' or 'not_home' else,
2. The **temperature used in Eco** preset when absent,
3. The **temperature used in Comfort** preset when absent,
4. The **temperature used in Boost** preset when absent
> ![Tip](images/tips.png) _*Notes*_
1. the switch of temperature is immediate and is reflected on the front component. The calculation will take the new target temperature into account at the next cycle calculation,
2. you can use direct person.xxxx sensor or group of sensors of Home Assistant. The presence sensor handles ``on`` or ``home`` states as present and ``off`` or ``not_home`` state as absent.
## Algorithm
This integration uses a proportional algorithm. A Proportional algorithm is useful to avoid the oscillation around the target temperature. This algorithm is based on a cycle which alternate heating and stop heating. The proportion of heating vs not heating is determined by the difference between the temperature and the target temperature. Bigger the difference is and bigger is the proportion of heating inside the cycle.
This algorithm make the temperature converge and stop oscillating.
Depending of your area and heater, the convergente temperature can be under the targeted temperature. So a bias parameter is available to fix this. To find the right value of biais, just set it to 0 (no biais), let the temperature converge and see if it is near the targeted temperature. If not adjust the biais. A good value is 0.25 with my accumulator radiator (which are long to heat but keeps the heat for a long time).
## TPI algorithm
The TPI algorithm consist in the calculation at each cycle of a percentage of On state vs Off state for the heater using the target temperature, the current temperature in the room and the current external temperature.
A function parameter is available. Set it to "Linear" to have a linéar growth of temperature or set it to "Atan" to have a more aggressive curve to target temperature depending of your need.
The percentage is calculated with this formula:
on_percent = coef_int * (target temperature - current temperature) + coef_ext * (target temperature - external temperature)
Then make 0 <= on_percent <= 1
Defaults values for coef_int and coef_ext are respectively: ``0.6`` and ``0.01``. Those defaults values are suitable for a standard well isolated room.
To tune those coefficients keep in mind that:
1. **if target temperature is not reach** after stable situation, you have to augment the ``coef_ext`` (the ``on_percent`` is too high),
2. **if target temperature is exceeded** after stable situation, you have to decrease the ``coef_ext`` (the ``on_percent`` is too low),
3. **if reaching the target temperature is too slow**, you can increase the ``coef_int`` to give more power to the heater,
4. **if reaching the target temperature is too fast and some oscillations appears** around the target, you can decrease the ``coef_int`` to give less power to the heater
See some situations at [examples](#some-results).
### Custom attributes
To tune the algorithm you have access to all context seen and calculted by the thermostat through dedicated attributes. You can see (and use) those attributes in the "Development tools / states" HMI of HA. Enter your thermostat and you will see something like this:
![image](images/dev-tools-climate.png?raw=true)
Custom attributes are the following:
| Attribute | Meaning |
| ----------| --------|
| ``hvac_modes`` | The list of modes supported by the thermostat |
| ``min_temp`` | The minimal temperature |
| ``max_temp`` | The maximal temperature |
| ``preset_modes`` | The presets visible for this thermostat. Hidden presets are not showed here |
| ``current_temperature`` | The current temperature as reported by the sensor |
| ``temperature`` | The target temperature |
| ``hvac_action`` | The action currently running by the heater. Can be idle, heating |
| ``preset_mode`` | The currently selected preset. Can be one of the 'preset_modes' or a hidden preset like power |
| ``[eco/comfort/boost]_temp`` | The temperature configured for the preset xxx |
| ``[eco/comfort/boost]_away_temp`` | The temperature configured for the preset xxx when presence is off or not_home |
| ``power_temp`` | The temperature used when shedding is detected |
| ``on_percent`` | The percentage on calculated by the TPI algorithm |
| ``on_time_sec`` | The On period in sec. Should be ```on_percent * cycle_min``` |
| ``off_time_sec`` | The Off period in sec. Should be ```(1 - on_percent) * cycle_min``` |
| ``cycle_min`` | The calculation cycle in minutes |
| ``function`` | The algorithm used for cycle calculation |
| ``tpi_coef_int`` | The ``coef_int`` of the TPI algorithm |
| ``tpi_coef_ext`` | The ``coef_ext`` of the TPI algorithm |
| ``saved_preset_mode`` | The last preset used before automatic switch of the preset |
| ``saved_target_temp`` | The last temperature used before automatic switching |
| ``window_state`` | The last known state of the window sensor. None if window is not configured |
| ``motion_state`` | The last known state of the motion sensor. None if motion is not configured |
| ``overpowering_state`` | The last known state of the overpowering sensor. None if power management is not configured |
| ``presence_state`` | The last known state of the presence sensor. None if presence management is not configured |
| ``last_update_datetime`` | The date and time in ISO8866 format of this state |
| ``friendly_name`` | The name of the thermostat |
| ``supported_features`` | A combination of all features supported by this thermostat. See official climate integration documentation for more informations |
### Some results
Convergence of temperature to target configured by preset:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/results-1.png?raw=true)
![image](images/results-1.png?raw=true)
Cycle of on/off calculated by the integration:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/results-2.png?raw=true)
![image](images/results-2.png?raw=true)
Coef_int too high (oscillations around the target)
![image](images/results-3.png?raw=true)
Algorithm calculation evolution
![image](images/results-4.png?raw=true)
Enjoy !
@@ -173,9 +269,9 @@ In this example I set ECO mode during the night and the day when nobody's at hom
I hope this example helps you, don't hesitate to give me your feedbacks !
## Even / even better with custom:simple-thermostat front integration
The custom:simple-thermostat (see https://home.clouderial.fr/hacs/repository/158654878) is a great integration which allow some customisation which fits well with this thermostat.
You can have something like that very easily ![image](https://github.com/jmcollin78/versatile_thermostat/blob/dev/images/simple-thermostat.png?raw=true)
## Even-even better with custom:simple-thermostat front integration
The ``custom:simple-thermostat`` [here](https://github.com/nervetattoo/simple-thermostat) is a great integration which allow some customisation which fits well with this thermostat.
You can have something like that very easily ![image](images/simple-thermostat.png?raw=true)
Example configuration:
```
@@ -202,6 +298,45 @@ Example configuration:
name: Porte sam
```
## Even-Even-Even better with Apex-chart to tune your Thermostat
You can get curve like presented in [some results](#some-results) with kind of Apex-chart configuration only using the custom attributes of the thermostat described [here](#custom-attributes):
```
type: custom:apexcharts-card
header:
show: true
title: Tuning chauffage
show_states: true
colorize_states: true
update_interval: 60sec
graph_span: 4h
yaxis:
- id: left
show: true
decimals: 2
- id: right
decimals: 2
show: true
opposite: true
series:
- entity: climate.thermostat_mythermostat
attribute: temperature
type: line
name: Target temp
curve: smooth
yaxis_id: left
- entity: climate.thermostat_mythermostat
attribute: current_temperature
name: Current temp
curve: smooth
yaxis_id: left
- entity: climate.thermostat_mythermostat
attribute: on_percent
name: Power percent
curve: stepline
yaxis_id: right
```
## Contributions are welcome!
If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md)

View File

@@ -1,9 +1,11 @@
import math
import logging
from datetime import timedelta
from datetime import timedelta, datetime
from typing import Any, Mapping
import voluptuous as vol
from homeassistant.core import (
HomeAssistant,
callback,
@@ -23,7 +25,7 @@ from homeassistant.helpers.event import (
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition
from homeassistant.helpers import condition, entity_platform, config_validation as cv
from homeassistant.components.climate.const import (
ATTR_PRESET_MODE,
@@ -62,6 +64,8 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_HOME,
STATE_NOT_HOME,
)
from .const import (
@@ -79,14 +83,19 @@ from .const import (
CONF_NO_MOTION_PRESET,
CONF_DEVICE_POWER,
CONF_PRESETS,
CONF_PRESETS_AWAY,
CONF_CYCLE_MIN,
CONF_PROP_FUNCTION,
CONF_PROP_BIAS,
CONF_TPI_COEF_C,
CONF_TPI_COEF_T,
CONF_TPI_COEF_INT,
CONF_TPI_COEF_EXT,
CONF_PRESENCE_SENSOR,
CONF_PRESET_POWER,
SUPPORT_FLAGS,
PRESET_POWER,
PROPORTIONAL_FUNCTION_TPI,
SERVICE_SET_PRESENCE,
SERVICE_SET_PRESET_TEMPERATURE,
PRESET_AWAY_SUFFIX,
)
from .prop_algorithm import PropAlgorithm
@@ -109,7 +118,6 @@ async def async_setup_entry(
heater_entity_id = entry.data.get(CONF_HEATER)
cycle_min = entry.data.get(CONF_CYCLE_MIN)
proportional_function = entry.data.get(CONF_PROP_FUNCTION)
proportional_bias = entry.data.get(CONF_PROP_BIAS)
temp_sensor_entity_id = entry.data.get(CONF_TEMP_SENSOR)
ext_temp_sensor_entity_id = entry.data.get(CONF_EXTERNAL_TEMP_SENSOR)
power_sensor_entity_id = entry.data.get(CONF_POWER_SENSOR)
@@ -121,8 +129,10 @@ async def async_setup_entry(
motion_preset = entry.data.get(CONF_MOTION_PRESET)
no_motion_preset = entry.data.get(CONF_NO_MOTION_PRESET)
device_power = entry.data.get(CONF_DEVICE_POWER)
tpi_coefc = entry.data.get(CONF_TPI_COEF_C)
tpi_coeft = entry.data.get(CONF_TPI_COEF_T)
tpi_coef_int = entry.data.get(CONF_TPI_COEF_INT)
tpi_coef_ext = entry.data.get(CONF_TPI_COEF_EXT)
presence_sensor_entity_id = entry.data.get(CONF_PRESENCE_SENSOR)
power_temp = entry.data.get(CONF_PRESET_POWER)
presets = {}
for (key, value) in CONF_PRESETS.items():
@@ -132,6 +142,14 @@ async def async_setup_entry(
else:
_LOGGER.debug("value %s not found in Entry", value)
presets_away = {}
for (key, value) in CONF_PRESETS_AWAY.items():
_LOGGER.debug("looking for key=%s, value=%s", key, value)
if value in entry.data:
presets_away[key] = entry.data.get(value)
else:
_LOGGER.debug("value %s not found in Entry", value)
async_add_entities(
[
VersatileThermostat(
@@ -141,7 +159,6 @@ async def async_setup_entry(
heater_entity_id,
cycle_min,
proportional_function,
proportional_bias,
temp_sensor_entity_id,
ext_temp_sensor_entity_id,
power_sensor_entity_id,
@@ -153,14 +170,39 @@ async def async_setup_entry(
motion_preset,
no_motion_preset,
presets,
presets_away,
device_power,
tpi_coefc,
tpi_coeft,
tpi_coef_int,
tpi_coef_ext,
presence_sensor_entity_id,
power_temp,
)
],
True,
)
# Add services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_PRESENCE,
{
vol.Required("presence"): vol.In(
[STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME]
),
},
"service_set_presence",
)
platform.async_register_entity_service(
SERVICE_SET_PRESET_TEMPERATURE,
{
vol.Required("preset"): vol.In(CONF_PRESETS),
vol.Optional("temperature"): vol.Coerce(float),
vol.Optional("temperature_away"): vol.Coerce(float),
},
"service_set_preset_temperature",
)
class VersatileThermostat(ClimateEntity, RestoreEntity):
"""Representation of a Versatile Thermostat device."""
@@ -169,6 +211,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_heater_entity_id: str
_prop_algorithm: PropAlgorithm
_async_cancel_cycle: CALLBACK_TYPE
_attr_preset_modes: list[str] | None
def __init__(
self,
@@ -178,7 +221,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
heater_entity_id,
cycle_min,
proportional_function,
proportional_bias,
temp_sensor_entity_id,
ext_temp_sensor_entity_id,
power_sensor_entity_id,
@@ -190,9 +232,12 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
motion_preset,
no_motion_preset,
presets,
presets_away,
device_power,
tpi_coefc,
tpi_coeft,
tpi_coef_int,
tpi_coef_ext,
presence_sensor_entity_id,
power_temp,
) -> None:
"""Initialize the thermostat."""
@@ -206,7 +251,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._heater_entity_id = heater_entity_id
self._cycle_min = cycle_min
self._proportional_function = proportional_function
self._proportional_bias = proportional_bias
self._temp_sensor_entity_id = temp_sensor_entity_id
self._ext_temp_sensor_entity_id = ext_temp_sensor_entity_id
self._power_sensor_entity_id = power_sensor_entity_id
@@ -218,8 +262,18 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._motion_delay_sec = motion_delay_sec
self._motion_preset = motion_preset
self._no_motion_preset = no_motion_preset
self._tpi_coefc = tpi_coefc
self._tpi_coeft = tpi_coeft
self._motion_on = (
self._motion_sensor_entity_id is not None
and self._motion_preset is not None
and self._no_motion_preset is not None
)
self._tpi_coef_int = tpi_coef_int
self._tpi_coef_ext = tpi_coef_ext
self._presence_sensor_entity_id = presence_sensor_entity_id
self._power_temp = power_temp
self._presence_on = self._presence_sensor_entity_id != None
# TODO if self.ac_mode:
# self.hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF]
@@ -231,33 +285,35 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._saved_hvac_mode = self._hvac_mode
self._support_flags = SUPPORT_FLAGS
if len(presets):
self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE
self._attr_preset_modes = (
[PRESET_NONE] + list(presets.keys()) + [PRESET_ACTIVITY]
)
_LOGGER.debug("Set preset_modes to %s", self._attr_preset_modes)
else:
_LOGGER.debug("No preset_modes")
self._attr_preset_modes = [PRESET_NONE]
self._presets = presets
_LOGGER.debug("%s - presets are set to: %s", self, self._presets)
self._presets_away = presets_away
_LOGGER.debug(
"%s - presets are set to: %s, away: %s",
self,
self._presets,
self._presets_away,
)
# Will be restored if possible
self._attr_preset_mode = None
self._saved_preset_mode = None
# Power management
self._device_power = device_power
self._pmax_on = False
self._current_power = None
self._current_power_max = None
if (
self._max_power_sensor_entity_id
and self._power_sensor_entity_id
and self._device_power
):
self._pmax_on = True
self._current_power = -1
self._current_power_max = -1
self._current_power = 0
self._current_power_max = 0
else:
self._pmax_on = False
_LOGGER.info("%s - Power management is not fully configured.", self)
# will be restored if possible
self._target_temp = None
@@ -277,14 +333,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.warning(
"Using TPI function but not external temperature sensor is set. Removing the delta temp ext factor. Thermostat will not be fully operationnal" # pylint: disable=line-too-long
)
self._tpi_coeft = 0
self._tpi_coef_ext = 0
# Initiate the ProportionalAlgorithm
self._prop_algorithm = PropAlgorithm(
self._proportional_function,
self._proportional_bias,
self._tpi_coefc,
self._tpi_coeft,
self._tpi_coef_int,
self._tpi_coef_ext,
self._cycle_min,
)
@@ -292,6 +347,35 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._window_call_cancel = None
self._motion_call_cancel = None
self._should_relaunch_control_heating = False
# Memory synthesis state
self._motion_state = None
self._window_state = None
self._overpowering_state = None
self._presence_state = None
# Calculate all possible presets
self._attr_preset_modes = [PRESET_NONE]
if len(presets):
self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE
for k, v in presets.items():
if v != 0.0:
self._attr_preset_modes.append(k)
# self._attr_preset_modes = (
# [PRESET_NONE] + list(presets.keys()) + [PRESET_ACTIVITY]
# )
_LOGGER.debug(
"After adding presets, preset_modes to %s", self._attr_preset_modes
)
else:
_LOGGER.debug("No preset_modes")
if self._motion_on:
self._attr_preset_modes.append(PRESET_ACTIVITY)
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s",
self,
@@ -366,13 +450,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"""Return the sensor temperature."""
return self._cur_temp
# @property
# def extra_state_attributes(self) -> Mapping[str, Any] | None:
# _LOGGER.debug(
# "Calling extra_state_attributes: %s", self._hass.custom_attributes
# )
# return self._hass.custom_attributes
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
_LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode)
@@ -397,15 +474,18 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self._async_set_preset_mode_internal(preset_mode)
await self._async_control_heating()
async def _async_set_preset_mode_internal(self, preset_mode):
async def _async_set_preset_mode_internal(self, preset_mode, force=False):
"""Set new preset mode."""
_LOGGER.info("%s - Set preset_mode: %s", self, preset_mode)
if preset_mode not in (self._attr_preset_modes or []):
_LOGGER.info("%s - Set preset_mode: %s force=%s", self, preset_mode, force)
if (
preset_mode not in (self._attr_preset_modes or [])
and preset_mode != PRESET_POWER
):
raise ValueError(
f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" # pylint: disable=line-too-long
)
if preset_mode == self._attr_preset_mode:
if preset_mode == self._attr_preset_mode and not force:
# I don't think we need to call async_write_ha_state if we didn't change the state
return
if preset_mode == PRESET_NONE:
@@ -414,20 +494,32 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._target_temp = self._saved_target_temp
elif preset_mode == PRESET_ACTIVITY:
self._attr_preset_mode = PRESET_ACTIVITY
self._target_temp = self._presets[self._no_motion_preset]
self._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._presets[preset_mode]
preset_temp = self.find_preset_temp(preset_mode)
self._target_temp = (
preset_temp if preset_mode != PRESET_POWER else self._power_temp
)
# Don't saved preset_mode if we are in POWER mode or in Away mode and presence detection is on
if preset_mode != PRESET_POWER:
self._saved_preset_mode = self._attr_preset_mode
self.async_write_ha_state()
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.recalculate()
def find_preset_temp(self, preset_mode):
"""Find the right temperature of a preset considering the presence if configured"""
if self._presence_on is False or self._presence_state in [STATE_ON, STATE_HOME]:
return self._presets[preset_mode]
else:
return self._presets_away[self.get_preset_away_name(preset_mode)]
def get_preset_away_name(self, preset_mode):
"""Get the preset name in away mode (when presence is off)"""
return preset_mode + PRESET_AWAY_SUFFIX
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
@@ -460,10 +552,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return
self._target_temp = temperature
self._attr_preset_mode = PRESET_NONE
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.async_write_ha_state()
self.recalculate()
@callback
async def entry_update_listener(
@@ -519,15 +608,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
)
if self._cycle_min:
self.async_on_remove(
async_track_time_interval(
self.hass,
self._async_control_heating,
interval=timedelta(minutes=self._cycle_min),
)
)
if self._power_sensor_entity_id:
self.async_on_remove(
async_track_state_change_event(
@@ -546,8 +626,27 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
)
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)
@@ -583,6 +682,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
float(ext_temperature_state.state),
)
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 (
@@ -622,6 +731,53 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
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(
@@ -629,6 +785,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
)
self.hass.create_task(self._async_control_heating())
await self.get_my_previous_state()
if self.hass.state == CoreState.running:
_async_startup_internal()
else:
@@ -636,8 +794,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
await self.get_my_previous_state()
async def get_my_previous_state(self):
"""Try to get my previou state"""
# Check If we have an old state
@@ -664,13 +820,15 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
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._saved_preset_mode = self._attr_preset_mode
if not self._hvac_mode and old_state.state:
self._hvac_mode = old_state.state
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
# 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
@@ -710,10 +868,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return
self._async_update_temp(new_state)
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.async_write_ha_state()
self.recalculate()
async def _async_ext_temperature_changed(self, event):
"""Handle external temperature changes."""
@@ -727,10 +882,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return
self._async_update_ext_temp(new_state)
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.async_write_ha_state()
self.recalculate()
@callback
async def _async_windows_changed(self, event):
@@ -769,14 +921,15 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
if not self._saved_hvac_mode:
self._saved_hvac_mode = self._hvac_mode
if new_state.state == STATE_OFF:
self._window_state = new_state.state
if self._window_state == STATE_OFF:
_LOGGER.info(
"%s - Window is closed. Restoring hvac_mode '%s'",
self,
self._saved_hvac_mode,
)
await self.async_set_hvac_mode(self._saved_hvac_mode)
elif new_state.state == STATE_ON:
elif self._window_state == STATE_ON:
_LOGGER.info(
"%s - Window is open. Set hvac_mode to '%s'", self, HVAC_MODE_OFF
)
@@ -801,8 +954,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._attr_preset_mode,
PRESET_ACTIVITY,
)
if self._attr_preset_mode != PRESET_ACTIVITY:
return
if new_state is None or new_state.state not in (STATE_OFF, STATE_ON):
return
@@ -825,21 +977,21 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
return
_LOGGER.debug("%s - Motion delay condition is satisfied", self)
new_preset = (
self._motion_preset
if new_state.state == STATE_ON
else self._no_motion_preset
)
_LOGGER.info(
"%s - Motion condition have changes. New preset temp will be %s",
self,
new_preset,
)
self._target_temp = self._presets[new_preset]
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.async_write_ha_state()
self._motion_state = new_state.state
if self._attr_preset_mode == PRESET_ACTIVITY:
new_preset = (
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
)
_LOGGER.info(
"%s - Motion condition have changes. New preset temp will be %s",
self,
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]
self.recalculate()
if self._motion_call_cancel:
self._motion_call_cancel()
@@ -899,8 +1051,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(event)
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
if new_state is None or (
old_state is not None and new_state.state == old_state.state
if (
new_state is None
or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
or (old_state is not None and new_state.state == old_state.state)
):
return
@@ -920,8 +1074,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_LOGGER.debug(event)
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
if new_state is None or (
old_state is not None and new_state.state == old_state.state
if (
new_state is None
or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
or (old_state is not None and new_state.state == old_state.state)
):
return
@@ -934,6 +1090,94 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
except ValueError as ex:
_LOGGER.error("Unable to update current_power from sensor: %s", ex)
@callback
async def _async_presence_changed(self, event):
"""Handle presence changes."""
new_state = event.data.get("new_state")
_LOGGER.info(
"%s - Presence changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s",
self,
new_state,
self._attr_preset_mode,
PRESET_ACTIVITY,
)
if new_state is None:
return
self._update_presence(new_state.state)
def _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 == PRESET_POWER or self._presence_on is False:
_LOGGER.info(
"%s - Ignoring presence change cause in Power preset or presence not configured",
self,
)
return
if new_state is None or new_state not in (
STATE_OFF,
STATE_ON,
STATE_HOME,
STATE_NOT_HOME,
):
return
if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]:
return
# Change temperature with preset named _way
new_temp = None
if new_state == STATE_ON or new_state == STATE_HOME:
new_temp = self._presets[self._attr_preset_mode]
_LOGGER.info(
"%s - Someone is back home. Restoring temperature to %.2f",
self,
new_temp,
)
else:
new_temp = self._presets_away[
self.get_preset_away_name(self._attr_preset_mode)
]
_LOGGER.info(
"%s - No one is at home. Apply temperature %.2f",
self,
new_temp,
)
if new_temp is not None:
_LOGGER.debug(
"%s - presence change in temperature mode new_temp will be: %.2f",
self,
new_temp,
)
self._target_temp = new_temp
self.recalculate()
def _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",
self,
self._attr_preset_mode,
self._motion_state,
)
if (
self._motion_sensor_entity_id is None
or self._attr_preset_mode != PRESET_ACTIVITY
):
return
self._target_temp = 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,
self._target_temp,
)
async def _async_heater_turn_on(self):
"""Turn heater toggleable device on."""
data = {ATTR_ENTITY_ID: self._heater_entity_id}
@@ -963,19 +1207,19 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self._current_power_max,
self._device_power,
)
overpowering: bool = (
self._overpowering_state = (
self._current_power + self._device_power >= self._current_power_max
)
if overpowering:
if self._overpowering_state:
_LOGGER.warning(
"%s - overpowering is detected. Heater preset will be set to 'power'",
self,
)
await self._async_set_preset_mode_internal(PRESET_POWER)
return overpowering
return self._overpowering_state
# Check if we need to remove the POWER preset
if self._attr_preset_mode == PRESET_POWER and not overpowering:
if self._attr_preset_mode == PRESET_POWER and not self._overpowering_state:
_LOGGER.warning(
"%s - end of overpowering is detected. Heater preset will be restored to '%s'",
self,
@@ -995,6 +1239,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
self,
self._attr_preset_mode,
)
await self._async_heater_turn_off()
return
on_time_sec: int = self._prop_algorithm.on_time_sec
@@ -1009,10 +1254,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
# Cancel eventual previous cycle if any
if self._async_cancel_cycle is not None:
_LOGGER.debug("Cancelling the previous cycle that was running")
self._async_cancel_cycle()
self._async_cancel_cycle = None
# await self._async_heater_turn_off()
_LOGGER.debug(
"%s - A previous cycle is alredy running -> waits for its end", self
)
self._should_relaunch_control_heating = True
return
# await self._async_cancel_cycle()
# self._async_cancel_cycle = None
# Don't turn off if we will turn on just after
# if on_time_sec <= 0:
# await self._async_heater_turn_off()
if self._hvac_mode == HVAC_MODE_HEAT and on_time_sec > 0:
_LOGGER.info(
@@ -1024,19 +1275,23 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
await self._async_heater_turn_on()
self.update_custom_attributes()
async def _turn_off(_):
_LOGGER.info(
"%s - stop heating for %d min %d sec",
self,
off_time_sec // 60,
off_time_sec % 60,
)
await self._async_heater_turn_off()
self._async_cancel_cycle()
self._async_cancel_cycle = None
self.update_custom_attributes()
if self._should_relaunch_control_heating:
_LOGGER.debug("Don't stop cause a cycle have to be relaunch")
self._should_relaunch_control_heating = False
await self._async_control_heating()
return
else:
_LOGGER.info(
"%s - stop heating for %d min %d sec",
self,
off_time_sec // 60,
off_time_sec % 60,
)
await self._async_heater_turn_off()
self.update_custom_attributes()
# Program turn off
self._async_cancel_cycle = async_call_later(
@@ -1045,15 +1300,46 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
_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_heater_turn_off()
else:
_LOGGER.debug("%s - nothing to do", self)
self.update_custom_attributes()
def recalculate(self):
"""A utility function to force the calculation of a the algo and
update the custom attributes and write the state
"""
_LOGGER.debug("%s - recalculate all", self)
self._prop_algorithm.calculate(
self._target_temp, self._cur_temp, self._cur_ext_temp
)
self.update_custom_attributes()
self.async_write_ha_state()
def update_custom_attributes(self):
"""Update the custom extra attributes for the entity"""
self._attr_extra_state_attributes = {
"away_temp": self._presets[PRESET_AWAY],
"eco_temp": self._presets[PRESET_ECO],
"boost_temp": self._presets[PRESET_BOOST],
"comfort_temp": self._presets[PRESET_BOOST],
"power_temp": self._presets[PRESET_POWER],
"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[
self.get_preset_away_name(PRESET_BOOST)
],
"comfort_away_temp": self._presets_away[
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,
@@ -1061,14 +1347,22 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
"current_power": self._current_power,
"current_power_max": self._current_power_max,
"cycle_min": self._cycle_min,
"bias": self._proportional_bias,
"function": self._proportional_function,
"tpi_coefc": self._tpi_coefc,
"tpi_coeft": self._tpi_coeft,
"is_device_active": self._is_device_active,
"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,
"window_state": self._window_state,
"motion_state": self._motion_state,
"overpowering_state": self._overpowering_state,
"presence_state": self._presence_state,
"last_update_datetime": datetime.now().isoformat(),
}
self.async_write_ha_state()
_LOGGER.debug(
"Calling update_custom_attributes: %s", self._attr_extra_state_attributes
"%s - Calling update_custom_attributes: %s",
self,
self._attr_extra_state_attributes,
)
@callback
@@ -1077,3 +1371,49 @@ class VersatileThermostat(ClimateEntity, RestoreEntity):
Note: this don't work either
"""
_LOGGER.info("%s - The config entry have been updated.")
async def service_set_presence(self, presence):
"""Called by a service call:
service: versatile_thermostat.set_presence
data:
presence: "off"
target:
entity_id: climate.thermostat_1
"""
_LOGGER.info("%s - Calling service_set_presence, presence: %s", self, presence)
self._update_presence(presence)
async def service_set_preset_temperature(
self, preset, temperature=None, temperature_away=None
):
"""Called by a service call:
service: versatile_thermostat.set_preset_temperature
data:
temperature: 17.8
preset: boost
temperature_away: 15
target:
entity_id: climate.thermostat_2
"""
_LOGGER.info(
"%s - Calling service_set_preset_temperature, preset: %s, temperature: %s, temperature_away: %s",
self,
preset,
temperature,
temperature_away,
)
if preset in self._presets:
if temperature is not None:
self._presets[preset] = temperature
if self._presence_on and temperature_away is not None:
self._presets_away[self.get_preset_away_name(preset)] = temperature_away
else:
_LOGGER.warning(
"%s - No preset %s configured for this thermostat. Ignoring set_preset_temperature call",
self,
preset,
)
# If the changed preset is active, change the current temperature
if self._attr_preset_mode == preset:
await self._async_set_preset_mode_internal(preset, force=True)

View File

@@ -2,9 +2,12 @@
from __future__ import annotations
import logging
import copy
import voluptuous as vol
from collections.abc import Mapping
from typing import Any
from homeassistant.core import callback
from homeassistant.config_entries import (
ConfigEntry,
@@ -33,14 +36,14 @@ from .const import (
CONF_NO_MOTION_PRESET,
CONF_DEVICE_POWER,
CONF_CYCLE_MIN,
CONF_PRESET_POWER,
CONF_PRESETS,
CONF_PRESETS_AWAY,
CONF_PRESETS_SELECTIONABLE,
CONF_PROP_FUNCTION,
CONF_PROP_BIAS,
CONF_TPI_COEF_T,
CONF_TPI_COEF_C,
PROPORTIONAL_FUNCTION_ATAN,
PROPORTIONAL_FUNCTION_LINEAR,
CONF_TPI_COEF_EXT,
CONF_TPI_COEF_INT,
CONF_PRESENCE_SENSOR,
PROPORTIONAL_FUNCTION_TPI,
)
@@ -48,112 +51,46 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
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_CYCLE_MIN, default=5): cv.positive_int,
vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_LINEAR): vol.In(
[
PROPORTIONAL_FUNCTION_TPI,
PROPORTIONAL_FUNCTION_LINEAR,
PROPORTIONAL_FUNCTION_ATAN,
]
),
}
)
USER_DATA_CONF = [
CONF_NAME,
CONF_HEATER,
CONF_TEMP_SENSOR,
CONF_EXTERNAL_TEMP_SENSOR,
CONF_CYCLE_MIN,
CONF_PROP_FUNCTION,
]
STEP_P_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PROP_BIAS, default=0.25): vol.Coerce(float),
}
)
P_DATA_CONF = [
CONF_PROP_BIAS,
]
STEP_TPI_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EXTERNAL_TEMP_SENSOR): cv.string,
vol.Required(CONF_TPI_COEF_C, default=0.6): vol.Coerce(float),
vol.Required(CONF_TPI_COEF_T, default=0.01): vol.Coerce(float),
}
)
TPI_DATA_CONF = [
CONF_EXTERNAL_TEMP_SENSOR,
CONF_TPI_COEF_C,
CONF_TPI_COEF_T,
]
STEP_PRESETS_DATA_SCHEMA = vol.Schema(
{vol.Optional(v, default=17): vol.Coerce(float) for (k, v) in CONF_PRESETS.items()}
)
PRESETS_DATA_CONF = [v for (_, v) in CONF_PRESETS.items()]
STEP_WINDOW_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_WINDOW_SENSOR): cv.string,
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
}
)
WINDOW_DATA_CONF = [CONF_WINDOW_SENSOR, CONF_WINDOW_DELAY]
STEP_MOTION_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_MOTION_SENSOR): cv.string,
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
CONF_PRESETS_SELECTIONABLE
),
vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): vol.In(
CONF_PRESETS_SELECTIONABLE
),
}
)
MOTION_DATA_CONF = [
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
CONF_NO_MOTION_PRESET,
]
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),
}
)
POWER_DATA_CONF = [CONF_POWER_SENSOR, CONF_MAX_POWER_SENSOR, CONF_DEVICE_POWER]
# Not used but can be useful in other context
# def schema_defaults(schema, **defaults):
# """Create a new schema with default values filled in."""
# copy = schema.extend({})
# for field, field_type in copy.schema.items():
# if isinstance(field_type, vol.In):
# value = None
#
# if value in field_type.container:
# # field.default = vol.default_factory(value)
# field.description = {"suggested_value": value}
# continue
#
# if field.schema in defaults:
# # field.default = vol.default_factory(defaults[field])
# field.description = {"suggested_value": defaults[field]}
# return copy
#
def schema_defaults(schema, **defaults):
"""Create a new schema with default values filled in."""
copy = schema.extend({})
for field, field_type in copy.schema.items():
if isinstance(field_type, vol.In):
value = None
# for dps in dps_list or []:
# if dps.startswith(f"{defaults.get(field)} "):
# value = dps
# break
def add_suggested_values_to_schema(
data_schema: vol.Schema, suggested_values: Mapping[str, Any]
) -> vol.Schema:
"""Make a copy of the schema, populated with suggested values.
if value in field_type.container:
field.default = vol.default_factory(value)
continue
if field.schema in defaults:
field.default = vol.default_factory(defaults[field])
return copy
For each schema marker matching items in `suggested_values`,
the `suggested_value` will be set. The existing `suggested_value` will
be left untouched if there is no matching item.
"""
schema = {}
for key, val in data_schema.schema.items():
new_key = key
if key in suggested_values and isinstance(key, vol.Marker):
# Copy the marker to not modify the flow schema
new_key = copy.copy(key)
new_key.description = {"suggested_value": suggested_values[key]}
schema[new_key] = val
_LOGGER.debug("add_suggested_values_to_schema: schema=%s", schema)
return vol.Schema(schema)
class VersatileThermostatBaseConfigFlow(FlowHandler):
@@ -166,6 +103,76 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
super().__init__()
_LOGGER.debug("CTOR BaseConfigFlow infos: %s", infos)
self._infos = infos
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_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI
): vol.In(
[
PROPORTIONAL_FUNCTION_TPI,
]
),
}
)
self.STEP_TPI_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_TPI_COEF_INT, default=0.6): vol.Coerce(float),
vol.Required(CONF_TPI_COEF_EXT, default=0.01): vol.Coerce(float),
}
)
self.STEP_PRESETS_DATA_SCHEMA = vol.Schema(
{
vol.Optional(v, default=0.0): vol.Coerce(float)
for (k, v) in CONF_PRESETS.items()
}
)
self.STEP_WINDOW_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_WINDOW_SENSOR): cv.string,
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_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In(
CONF_PRESETS_SELECTIONABLE
),
vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): vol.In(
CONF_PRESETS_SELECTIONABLE
),
}
)
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),
}
)
self.STEP_PRESENCE_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_PRESENCE_SENSOR): cv.string,
}
).extend(
{
vol.Optional(v, default=17): vol.Coerce(float)
for (k, v) in CONF_PRESETS_AWAY.items()
}
)
async def validate_input(self, data: dict) -> dict[str]:
"""Validate the user input allows us to connect.
@@ -182,6 +189,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
CONF_MOTION_SENSOR,
CONF_POWER_SENSOR,
CONF_MAX_POWER_SENSOR,
CONF_PRESENCE_SENSOR,
]:
d = data.get(conf, None) # pylint: disable=invalid-name
if d is not None and self.hass.states.get(d) is None:
@@ -191,6 +199,21 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
)
raise UnknownEntity(conf)
def merge_user_input(self, data_schema: vol.Schema, user_input: dict):
"""For each schema entry not in user_input, set or remove values in infos"""
self._infos.update(user_input)
for key, _ in data_schema.schema.items():
if key not in user_input and isinstance(key, vol.Marker):
_LOGGER.debug(
"add_empty_values_to_user_input: %s is not in user_input", key
)
if key in self._infos:
self._infos.pop(key)
# else: This don't work but I don't know why. _infos seems broken after this (Not serializable exactly)
# self._infos[key] = user_input[key]
_LOGGER.debug("merge_user_input: infos is now %s", self._infos)
async def generic_step(self, step_id, data_schema, user_input, next_step_function):
"""A generic method step"""
_LOGGER.debug(
@@ -210,11 +233,14 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._infos.update(user_input)
self.merge_user_input(data_schema, user_input)
_LOGGER.debug("_info is now: %s", self._infos)
return await next_step_function()
ds = schema_defaults(data_schema, **defaults) # pylint: disable=invalid-name
# ds = schema_defaults(data_schema, **defaults) # pylint: disable=invalid-name
ds = add_suggested_values_to_schema(
data_schema=data_schema, suggested_values=defaults
) # pylint: disable=invalid-name
return self.async_show_form(step_id=step_id, data_schema=ds, errors=errors)
@@ -222,24 +248,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
"""Handle the flow steps"""
_LOGGER.debug("Into ConfigFlow.async_step_user user_input=%s", user_input)
async def choose_next_step():
"""Choose next configuration flow"""
_LOGGER.debug("function is %s", self._infos.get(CONF_PROP_FUNCTION, None))
if self._infos.get(CONF_PROP_FUNCTION, None) == PROPORTIONAL_FUNCTION_TPI:
return await self.async_step_tpi()
else:
return await self.async_step_p()
return await self.generic_step(
"user", STEP_USER_DATA_SCHEMA, user_input, choose_next_step
)
async def async_step_p(self, user_input: dict | None = None) -> FlowResult:
"""Handle the flow steps"""
_LOGGER.debug("Into ConfigFlow.async_step_p user_input=%s", user_input)
return await self.generic_step(
"p", STEP_P_DATA_SCHEMA, user_input, self.async_step_presets
"user", self.STEP_USER_DATA_SCHEMA, user_input, self.async_step_tpi
)
async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult:
@@ -247,7 +257,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
_LOGGER.debug("Into ConfigFlow.async_step_tpi user_input=%s", user_input)
return await self.generic_step(
"tpi", STEP_TPI_DATA_SCHEMA, user_input, self.async_step_presets
"tpi", self.STEP_TPI_DATA_SCHEMA, user_input, self.async_step_presets
)
async def async_step_presets(self, user_input: dict | None = None) -> FlowResult:
@@ -255,7 +265,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
_LOGGER.debug("Into ConfigFlow.async_step_presets user_input=%s", user_input)
return await self.generic_step(
"presets", STEP_PRESETS_DATA_SCHEMA, user_input, self.async_step_window
"presets", self.STEP_PRESETS_DATA_SCHEMA, user_input, self.async_step_window
)
async def async_step_window(self, user_input: dict | None = None) -> FlowResult:
@@ -263,7 +273,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
_LOGGER.debug("Into ConfigFlow.async_step_window user_input=%s", user_input)
return await self.generic_step(
"window", STEP_WINDOW_DATA_SCHEMA, user_input, self.async_step_motion
"window", self.STEP_WINDOW_DATA_SCHEMA, user_input, self.async_step_motion
)
async def async_step_motion(self, user_input: dict | None = None) -> FlowResult:
@@ -271,7 +281,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
_LOGGER.debug("Into ConfigFlow.async_step_motion user_input=%s", user_input)
return await self.generic_step(
"motion", STEP_MOTION_DATA_SCHEMA, user_input, self.async_step_power
"motion", self.STEP_MOTION_DATA_SCHEMA, user_input, self.async_step_power
)
async def async_step_power(self, user_input: dict | None = None) -> FlowResult:
@@ -280,7 +290,18 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
return await self.generic_step(
"power",
STEP_POWER_DATA_SCHEMA,
self.STEP_POWER_DATA_SCHEMA,
user_input,
self.async_step_presence,
)
async def async_step_presence(self, user_input: dict | None = None) -> FlowResult:
"""Handle the presence management flow steps"""
_LOGGER.debug("Into ConfigFlow.async_step_presence user_input=%s", user_input)
return await self.generic_step(
"presence",
self.STEP_PRESENCE_DATA_SCHEMA,
user_input,
self.async_finalize, # pylint: disable=no-member
)
@@ -304,7 +325,7 @@ class VersatileThermostatConfigFlow(
async def async_finalize(self):
"""Finalization of the ConfigEntry creation"""
_LOGGER.debug("CTOR ConfigFlow.async_finalize")
_LOGGER.debug("ConfigFlow.async_finalize")
return self.async_create_entry(title=self._infos[CONF_NAME], data=self._infos)
@@ -342,24 +363,8 @@ class VersatileThermostatOptionsFlowHandler(
"Into OptionsFlowHandler.async_step_user user_input=%s", user_input
)
async def choose_next_step():
"""Choose next configuration flow"""
_LOGGER.debug("function is %s", self._infos.get(CONF_PROP_FUNCTION, None))
if self._infos.get(CONF_PROP_FUNCTION, None) == PROPORTIONAL_FUNCTION_TPI:
return await self.async_step_tpi()
else:
return await self.async_step_p()
return await self.generic_step(
"user", STEP_USER_DATA_SCHEMA, user_input, choose_next_step
)
async def async_step_p(self, user_input: dict | None = None) -> FlowResult:
"""Handle the p flow steps"""
_LOGGER.debug("Into OptionsFlowHandler.async_step_p user_input=%s", user_input)
return await self.generic_step(
"p", STEP_P_DATA_SCHEMA, user_input, self.async_step_presets
"user", self.STEP_USER_DATA_SCHEMA, user_input, self.async_step_tpi
)
async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult:
@@ -369,7 +374,7 @@ class VersatileThermostatOptionsFlowHandler(
)
return await self.generic_step(
"tpi", STEP_TPI_DATA_SCHEMA, user_input, self.async_step_presets
"tpi", self.STEP_TPI_DATA_SCHEMA, user_input, self.async_step_presets
)
async def async_step_presets(self, user_input: dict | None = None) -> FlowResult:
@@ -379,7 +384,7 @@ class VersatileThermostatOptionsFlowHandler(
)
return await self.generic_step(
"presets", STEP_PRESETS_DATA_SCHEMA, user_input, self.async_step_window
"presets", self.STEP_PRESETS_DATA_SCHEMA, user_input, self.async_step_window
)
async def async_step_window(self, user_input: dict | None = None) -> FlowResult:
@@ -389,7 +394,7 @@ class VersatileThermostatOptionsFlowHandler(
)
return await self.generic_step(
"window", STEP_WINDOW_DATA_SCHEMA, user_input, self.async_step_motion
"window", self.STEP_WINDOW_DATA_SCHEMA, user_input, self.async_step_motion
)
async def async_step_motion(self, user_input: dict | None = None) -> FlowResult:
@@ -399,7 +404,7 @@ class VersatileThermostatOptionsFlowHandler(
)
return await self.generic_step(
"motion", STEP_MOTION_DATA_SCHEMA, user_input, self.async_step_power
"motion", self.STEP_MOTION_DATA_SCHEMA, user_input, self.async_step_power
)
async def async_step_power(self, user_input: dict | None = None) -> FlowResult:
@@ -410,7 +415,20 @@ class VersatileThermostatOptionsFlowHandler(
return await self.generic_step(
"power",
STEP_POWER_DATA_SCHEMA,
self.STEP_POWER_DATA_SCHEMA,
user_input,
self.async_step_presence, # pylint: disable=no-member
)
async def async_step_presence(self, user_input: dict | None = None) -> FlowResult:
"""Handle the presence management flow steps"""
_LOGGER.debug(
"Into OptionsFlowHandler.async_step_presence user_input=%s", user_input
)
return await self.generic_step(
"presence",
self.STEP_PRESENCE_DATA_SCHEMA,
user_input,
self.async_finalize, # pylint: disable=no-member
)

View File

@@ -3,7 +3,6 @@
from homeassistant.const import CONF_NAME
from homeassistant.components.climate.const import (
# PRESET_ACTIVITY,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
@@ -11,8 +10,6 @@ from homeassistant.components.climate.const import (
)
from .prop_algorithm import (
PROPORTIONAL_FUNCTION_ATAN,
PROPORTIONAL_FUNCTION_LINEAR,
PROPORTIONAL_FUNCTION_TPI,
)
@@ -30,28 +27,39 @@ CONF_MOTION_SENSOR = "motion_sensor_entity_id"
CONF_DEVICE_POWER = "device_power"
CONF_CYCLE_MIN = "cycle_min"
CONF_PROP_FUNCTION = "proportional_function"
CONF_PROP_BIAS = "proportional_bias"
CONF_WINDOW_DELAY = "window_delay"
CONF_MOTION_DELAY = "motion_delay"
CONF_MOTION_PRESET = "motion_preset"
CONF_NO_MOTION_PRESET = "no_motion_preset"
CONF_TPI_COEF_C = "tpi_coefc"
CONF_TPI_COEF_T = "tpi_coeft"
CONF_TPI_COEF_INT = "tpi_coef_int"
CONF_TPI_COEF_EXT = "tpi_coef_ext"
CONF_PRESENCE_SENSOR = "presence_sensor_entity_id"
CONF_PRESET_POWER = "power_temp"
CONF_PRESETS = {
p: f"{p}_temp"
for p in (
PRESET_ECO,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_POWER,
PRESET_BOOST,
)
}
CONF_PRESETS_SELECTIONABLE = [PRESET_ECO, PRESET_COMFORT, PRESET_AWAY, PRESET_BOOST]
PRESET_AWAY_SUFFIX = "_away"
CONF_PRESETS_AWAY = {
p: f"{p}_temp"
for p in (
PRESET_ECO + PRESET_AWAY_SUFFIX,
PRESET_BOOST + PRESET_AWAY_SUFFIX,
PRESET_COMFORT + PRESET_AWAY_SUFFIX,
)
}
CONF_PRESETS_SELECTIONABLE = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST]
CONF_PRESETS_VALUES = list(CONF_PRESETS.values())
CONF_PRESETS_AWAY_VALUES = list(CONF_PRESETS_AWAY.values())
ALL_CONF = (
[
@@ -70,17 +78,19 @@ ALL_CONF = (
CONF_DEVICE_POWER,
CONF_CYCLE_MIN,
CONF_PROP_FUNCTION,
CONF_PROP_BIAS,
CONF_TPI_COEF_C,
CONF_TPI_COEF_T,
CONF_TPI_COEF_INT,
CONF_TPI_COEF_EXT,
CONF_PRESENCE_SENSOR,
]
+ CONF_PRESETS_VALUES,
+ CONF_PRESETS_VALUES
+ CONF_PRESETS_AWAY_VALUES,
)
CONF_FUNCTIONS = [
PROPORTIONAL_FUNCTION_LINEAR,
PROPORTIONAL_FUNCTION_ATAN,
PROPORTIONAL_FUNCTION_TPI,
]
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
SERVICE_SET_PRESENCE = "set_presence"
SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature"

View File

@@ -16,20 +16,24 @@ class PropAlgorithm:
"""This class aims to do all calculation of the Proportional alogorithm"""
def __init__(
self, function_type: str, bias: float, tpi_coefc, tpi_coeft, cycle_min: int
self,
function_type: str,
tpi_coef_int,
tpi_coef_ext,
cycle_min: int,
):
"""Initialisation of the Proportional Algorithm"""
_LOGGER.debug(
"Creation new PropAlgorithm function_type: %s, bias: %f, cycle_min:%d",
"Creation new PropAlgorithm function_type: %s, tpi_coef_int: %s, tpi_coef_ext: %s, cycle_min:%d",
function_type,
bias,
tpi_coef_int,
tpi_coef_ext,
cycle_min,
)
# TODO test function_type, bias, cycle_min
self._function = function_type
self._bias = bias
self._tpi_coefc = tpi_coefc
self._tpi_coeft = tpi_coeft
self._tpi_coef_int = tpi_coef_int
self._tpi_coef_ext = tpi_coef_ext
self._cycle_min = cycle_min
self._on_percent = 0
self._on_time_sec = 0
@@ -50,13 +54,10 @@ class PropAlgorithm:
target_temp - ext_current_temp if ext_current_temp is not None else 0
)
if self._function == PROPORTIONAL_FUNCTION_LINEAR:
self._on_percent = 0.25 * delta_temp + self._bias
elif self._function == PROPORTIONAL_FUNCTION_ATAN:
self._on_percent = math.atan(delta_temp + self._bias) / 1.4
elif self._function == PROPORTIONAL_FUNCTION_TPI:
if self._function == PROPORTIONAL_FUNCTION_TPI:
self._on_percent = (
self._tpi_coefc * delta_temp + self._tpi_coeft * delta_ext_temp
self._tpi_coef_int * delta_temp
+ self._tpi_coef_ext * delta_ext_temp
)
else:
_LOGGER.warning(

View File

@@ -0,0 +1,71 @@
set_presence:
name: Set presence
description: Force the presence mode in thermostat
target:
entity:
multiple: true
integration: versatile_thermostat
fields:
presence:
name: Presence
description: Presence setting
required: true
advanced: false
example: "on"
default: "on"
selector:
select:
options:
- "on"
- "off"
- "home"
- "not_home"
set_preset_temperature:
name: Set temperature preset
description: Change the target temperature of a preset
target:
entity:
multiple: true
integration: versatile_thermostat
fields:
preset:
name: Preset
description: Preset name
required: true
advanced: false
example: "comfort"
selector:
select:
options:
- "eco"
- "comfort"
- "boost"
temperature:
name: Temperature when present
description: Target temperature for the preset when present
required: false
advanced: false
example: "19.5"
default: "17"
selector:
number:
min: 7
max: 35
step: 0.1
unit_of_measurement: °
mode: slider
temperature_away:
name: Temperature when not present
description: Target temperature for the preset when not present
required: false
advanced: false
example: "17"
default: "15"
selector:
number:
min: 7
max: 35
step: 0.1
unit_of_measurement: °
mode: slider

View File

@@ -10,35 +10,26 @@
"name": "Name",
"heater_entity_id": "Heater entity id",
"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": "Function to use (linear is less aggressive)"
}
},
"p": {
"title": "Proportional",
"description": "Proportional attributes",
"data": {
"proportional_bias": "A bias to use in proportional algorithm"
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
},
"tpi": {
"title": "TPI",
"description": "Time Proportional Integral attributes",
"data": {
"external_temperature_sensor_entity_id": "External temperature sensor entity id",
"tpi_coefc": "Coefficient to use for internal temperature delta",
"tpi_coeft": "Coefficient to use for external temperature delta"
"tpi_coef_int": "Coefficient to use for internal temperature delta",
"tpi_coef_ext": "Coefficient to use for external temperature delta"
}
},
"presets": {
"title": "Presets",
"description": "For each presets, give the target temperature",
"description": "For each presets, give the target temperature (0 to ignore preset)",
"data": {
"eco_temp": "Temperature in Eco preset",
"away_temp": "Temperature in Away preset",
"comfort_temp": "Temperature in Comfort preset",
"boost_temp": "Temperature in Boost preset",
"power_temp": "Temperature in Power (overpowering) preset"
"boost_temp": "Temperature in Boost preset"
}
},
"window": {
@@ -50,7 +41,7 @@
}
},
"motion": {
"title": "Motion sensor management",
"title": "Motion management",
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
@@ -61,11 +52,22 @@
},
"power": {
"title": "Power management",
"description": "Power management attributes.\nGives the power and max power sensor pf your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"description": "Power management attributes.\nGives the power and max power sensor of your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)"
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
"presence": {
"title": "Presence management",
"description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.",
"data": {
"presence_sensor_entity_id": "Presence sensor entity id (true is present)",
"eco_away_temp": "Temperature in Eco preset when no presence",
"comfort_away_temp": "Temperature in Comfort preset when no presence",
"boost_away_temp": "Temperature in Boost preset when no presence"
}
}
},
@@ -87,35 +89,26 @@
"name": "Name",
"heater_entity_id": "Heater entity id",
"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": "Function to use (linear is less aggressive)"
}
},
"p": {
"title": "Proportional",
"description": "Proportional attributes",
"data": {
"proportional_bias": "A bias to use in proportional algorithm"
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
},
"tpi": {
"title": "TPI",
"description": "Time Proportional Integral attributes",
"data": {
"external_temperature_sensor_entity_id": "External temperature sensor entity id",
"tpi_coefc": "Coefficient to use for internal temperature delta",
"tpi_coeft": "Coefficient to use for external temperature delta"
"tpi_coef_int": "Coefficient to use for internal temperature delta",
"tpi_coef_ext": "Coefficient to use for external temperature delta"
}
},
"presets": {
"title": "Presets",
"description": "For each presets, give the target temperature",
"description": "For each presets, give the target temperature (0 to ignore preset)",
"data": {
"eco_temp": "Temperature in Eco preset",
"away_temp": "Temperature in Away preset",
"comfort_temp": "Temperature in Comfort preset",
"boost_temp": "Temperature in Boost preset",
"power_temp": "Temperature in Power (overpowering) preset"
"boost_temp": "Temperature in Boost preset"
}
},
"window": {
@@ -127,7 +120,7 @@
}
},
"motion": {
"title": "Motion sensor management",
"title": "Motion management",
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
@@ -138,11 +131,22 @@
},
"power": {
"title": "Power management",
"description": "Power management attributes.\nGives the power and max power sensor pf your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"description": "Power management attributes.\nGives the power and max power sensor of your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)"
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
"presence": {
"title": "Presence management",
"description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.",
"data": {
"presence_sensor_entity_id": "Presence sensor entity id (true is present)",
"eco_away_temp": "Temperature in Eco preset when no presence",
"comfort_away_temp": "Temperature in Comfort preset when no presence",
"boost_away_temp": "Temperature in Boost preset when no presence"
}
}
},

View File

@@ -10,35 +10,26 @@
"name": "Name",
"heater_entity_id": "Heater entity id",
"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": "Function to use (linear is less aggressive)"
}
},
"p": {
"title": "Proportional",
"description": "Proportional attributes",
"data": {
"proportional_bias": "A bias to use in proportional algorithm"
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
},
"tpi": {
"title": "TPI",
"description": "Time Proportional Integral attributes",
"data": {
"external_temperature_sensor_entity_id": "External temperature sensor entity id",
"tpi_coefc": "Coefficient to use for internal temperature delta",
"tpi_coeft": "Coefficient to use for external temperature delta"
"tpi_coef_int": "Coefficient to use for internal temperature delta",
"tpi_coef_ext": "Coefficient to use for external temperature delta"
}
},
"presets": {
"title": "Presets",
"description": "For each presets, give the target temperature",
"description": "For each presets, give the target temperature (0 to ignore preset)",
"data": {
"eco_temp": "Temperature in Eco preset",
"away_temp": "Temperature in Away preset",
"comfort_temp": "Temperature in Comfort preset",
"boost_temp": "Temperature in Boost preset",
"power_temp": "Temperature in Power (overpowering) preset"
"boost_temp": "Temperature in Boost preset"
}
},
"window": {
@@ -50,7 +41,7 @@
}
},
"motion": {
"title": "Motion sensor management",
"title": "Motion management",
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
@@ -61,11 +52,22 @@
},
"power": {
"title": "Power management",
"description": "Power management attributes.\nGives the power and max power sensor pf your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"description": "Power management attributes.\nGives the power and max power sensor of your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)"
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
"presence": {
"title": "Presence management",
"description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.",
"data": {
"presence_sensor_entity_id": "Presence sensor entity id (true is present)",
"eco_away_temp": "Temperature in Eco preset when no presence",
"comfort_away_temp": "Temperature in Comfort preset when no presence",
"boost_away_temp": "Temperature in Boost preset when no presence"
}
}
},
@@ -87,35 +89,26 @@
"name": "Name",
"heater_entity_id": "Heater entity id",
"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": "Function to use (linear is less aggressive)"
}
},
"p": {
"title": "Proportional",
"description": "Proportional attributes",
"data": {
"proportional_bias": "A bias to use in proportional algorithm"
"proportional_function": "Algorithm to use (TPI is the only one for now)"
}
},
"tpi": {
"title": "TPI",
"description": "Time Proportional Integral attributes",
"data": {
"external_temperature_sensor_entity_id": "External temperature sensor entity id",
"tpi_coefc": "Coefficient to use for internal temperature delta",
"tpi_coeft": "Coefficient to use for external temperature delta"
"tpi_coef_int": "Coefficient to use for internal temperature delta",
"tpi_coef_ext": "Coefficient to use for external temperature delta"
}
},
"presets": {
"title": "Presets",
"description": "For each presets, give the target temperature",
"description": "For each presets, give the target temperature (0 to ignore preset)",
"data": {
"eco_temp": "Temperature in Eco preset",
"away_temp": "Temperature in Away preset",
"comfort_temp": "Temperature in Comfort preset",
"boost_temp": "Temperature in Boost preset",
"power_temp": "Temperature in Power (overpowering) preset"
"boost_temp": "Temperature in Boost preset"
}
},
"window": {
@@ -127,7 +120,7 @@
}
},
"motion": {
"title": "Motion sensor management",
"title": "Motion management",
"description": "Motion sensor management. Preset can switch automatically depending of a motion detection\nLeave corresponding entity_id empty if not used.\nmotion_preset and no_motion_preset should be set to the corresponding preset name",
"data": {
"motion_sensor_entity_id": "Motion sensor entity id",
@@ -138,11 +131,22 @@
},
"power": {
"title": "Power management",
"description": "Power management attributes.\nGives the power and max power sensor pf your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"description": "Power management attributes.\nGives the power and max power sensor of your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).\nLeave corresponding entity_id empty if not used.",
"data": {
"power_sensor_entity_id": "Power sensor entity id",
"max_power_sensor_entity_id": "Max power sensor entity id",
"device_power": "Device power (kW)"
"device_power": "Device power (kW)",
"power_temp": "Temperature for Power shedding"
}
},
"presence": {
"title": "Presence management",
"description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present).\nThen specify either the preset to use when presence sensor is false or the offset in temperature to apply.\nIf preset is given, the offset will not be used.\nLeave corresponding entity_id empty if not used.",
"data": {
"presence_sensor_entity_id": "Presence sensor entity id (true is present)",
"eco_away_temp": "Temperature in Eco preset when no presence",
"comfort_away_temp": "Temperature in Comfort preset when no presence",
"boost_away_temp": "Temperature in Boost preset when no presence"
}
}
},

View File

@@ -10,35 +10,26 @@
"name": "Nom",
"heater_entity_id": "Radiateur entity id",
"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": "Fonction de l'algorithm proportionnel à utiliser (linear est moins aggressive)"
}
},
"p": {
"title": "Proportional",
"description": "Attributs des algos Proportionnel",
"data": {
"proportional_bias": "Un biais à utiliser dans l'algorithm proportionnel"
"proportional_function": "Algorithm à utiliser (Seul TPI est disponible pour l'instant)"
}
},
"tpi": {
"title": "TPI",
"description": "Attributs de l'algo Time Proportional Integral",
"data": {
"external_temperature_sensor_entity_id": "Temperature exterieure sensor entity id",
"tpi_coefc": "coeff_c : Coefficient à utiliser pour le delta de température interne",
"tpi_coeft": "coeff_t : Coefficient à utiliser pour le delta de température externe"
"tpi_coef_int": "coeff_int : Coefficient à utiliser pour le delta de température interne",
"tpi_coef_ext": "coeff_ext : Coefficient à utiliser pour le delta de température externe"
}
},
"presets": {
"title": "Presets",
"description": "Pour chaque preset, donnez la température cible",
"description": "Pour chaque preset, donnez la température cible (0 pour ignorer le preset)",
"data": {
"eco_temp": "Température en preset Eco",
"away_temp": "Température en preset Away",
"comfort_temp": "Température en preset Comfort",
"boost_temp": "Température en preset Boost",
"power_temp": "Température en preset Power (overpowering)"
"boost_temp": "Température en preset Boost"
}
},
"window": {
@@ -65,7 +56,18 @@
"data": {
"power_sensor_entity_id": "Capteur de puissance totale (entity id)",
"max_power_sensor_entity_id": "Capteur de puissance Max (entity id)",
"device_power": "Puissance de l'équipement"
"device_power": "Puissance de l'équipement",
"power_temp": "Température si délestaqe"
}
},
"presence": {
"title": "Gestion de la présence",
"description": "Donnez un capteur de présence (true si quelqu'un est présent).\nEnsuite spécifiez soit un preset à utiliser, soit un offset de température à appliquer lorsque personne n'est présent.\nSi le préset est utilisé, l'offset ne sera pas pris en compte.\nLaissez l'entity id vide si la gestion de la présence est non utilisée.",
"data": {
"presence_sensor_entity_id": "Capteur de présence entity id (true si quelqu'un est présent)",
"eco_away_temp": "Température en preset Eco en cas d'absence",
"comfort_away_temp": "Température en preset Comfort en cas d'absence",
"boost_away_temp": "Température en preset Boost en cas d'absence"
}
}
},
@@ -87,35 +89,26 @@
"name": "Nom",
"heater_entity_id": "Radiateur entity id",
"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": "Fonction de l'algorithm proportionnel à utiliser (linear est moins aggressive)"
}
},
"p": {
"title": "Proportional",
"description": "Attributs des algos Proportionnel",
"data": {
"proportional_bias": "Un biais à utiliser dans l'algorithm proportionnel"
"proportional_function": "Algorithm à utiliser (Seul TPI est disponible pour l'instant)"
}
},
"tpi": {
"title": "TPI",
"description": "Attributs de l'algo Time Proportional Integral",
"data": {
"external_temperature_sensor_entity_id": "Temperature exterieure sensor entity id",
"tpi_coefc": "coeff_c : Coefficient à utiliser pour le delta de température interne",
"tpi_coeft": "coeff_t : Coefficient à utiliser pour le delta de température externe"
"tpi_coef_int": "coeff_int : Coefficient à utiliser pour le delta de température interne",
"tpi_coef_ext": "coeff_ext : Coefficient à utiliser pour le delta de température externe"
}
},
"presets": {
"title": "Presets",
"description": "Pour chaque preset, donnez la température cible",
"description": "Pour chaque preset, donnez la température cible (0 pour ignorer le preset)",
"data": {
"eco_temp": "Température en preset Eco",
"away_temp": "Température en preset Away",
"comfort_temp": "Température en preset Comfort",
"boost_temp": "Température en preset Boost",
"power_temp": "Température en preset Power (overpowering)"
"boost_temp": "Température en preset Boost"
}
},
"window": {
@@ -142,7 +135,18 @@
"data": {
"power_sensor_entity_id": "Capteur de puissance totale (entity id)",
"max_power_sensor_entity_id": "Capteur de puissance Max (entity id)",
"device_power": "Puissance de l'équipement"
"device_power": "Puissance de l'équipement",
"power_temp": "Température si délestaqe"
}
},
"presence": {
"title": "Gestion de la présence",
"description": "Donnez un capteur de présence (true si quelqu'un est présent).\nEnsuite spécifiez soit un preset à utiliser, soit un offset de température à appliquer lorsque personne n'est présent.\nSi le préset est utilisé, l'offset ne sera pas pris en compte.\nLaissez l'entity id vide si la gestion de la présence est non utilisée.",
"data": {
"presence_sensor_entity_id": "Capteur de présence entity id (true si quelqu'un est présent)",
"eco_away_temp": "Température en preset Eco en cas d'absence",
"comfort_away_temp": "Température en preset Comfort en cas d'absence",
"boost_away_temp": "Température en preset Boost en cas d'absence"
}
}
},

BIN
images/config-main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

BIN
images/config-power.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
images/config-presets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
images/config-tpi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
images/results-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
images/results-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
images/tips.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB