├── .github └── workflows │ ├── ci.yml │ └── validate-hacs.yml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── custom_components └── programmable_thermostat │ ├── __init__.py │ ├── climate.py │ ├── config_flow.py │ ├── config_schema.py │ ├── const.py │ ├── helpers.py │ ├── manifest.json │ ├── services.yaml │ └── translations │ ├── en.json │ └── it.json ├── hacs.json ├── info.md ├── pyproject.toml ├── requirements_test.txt └── resources.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | #push: 6 | #pull_request: ~ 7 | workflow_dispatch: 8 | 9 | env: 10 | CACHE_VERSION: 1 11 | PYTHON_VERSION_DEFAULT: '3.10.8' 12 | PRE_COMMIT_HOME: ~/.cache/pre-commit 13 | 14 | jobs: 15 | # Separate job to pre-populate the base dependency cache 16 | # This prevent upcoming jobs to do the same individually 17 | prepare-base: 18 | name: Prepare base dependencies 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | #python-version: ['3.8.14', '3.9.15', '3.10.8', '3.11.0'] 23 | python-version: ['3.10.8', '3.11.0'] 24 | steps: 25 | - name: Check out code from GitHub 26 | uses: actions/checkout@v3 27 | - name: Set up Python ${{ matrix.python-version }} 28 | id: python 29 | uses: actions/setup-python@v4 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Restore base Python virtual environment 33 | id: cache-venv 34 | uses: actions/cache@v3 35 | with: 36 | path: venv 37 | key: >- 38 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ 39 | steps.python.outputs.python-version }}-${{ 40 | hashFiles('setup.py', 'requirements_test.txt') }} 41 | restore-keys: | 42 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}- 43 | - name: Create Python virtual environment 44 | if: steps.cache-venv.outputs.cache-hit != 'true' 45 | run: | 46 | python -m venv venv 47 | . venv/bin/activate 48 | pip install -U pip setuptools pre-commit 49 | pip install -r requirements_test.txt 50 | pip install -e . 51 | 52 | pre-commit: 53 | name: Prepare pre-commit environment 54 | runs-on: ubuntu-latest 55 | needs: prepare-base 56 | steps: 57 | - name: Check out code from GitHub 58 | uses: actions/checkout@v3 59 | - name: Set up Python ${{ env.PYTHON_VERSION_DEFAULT }} 60 | uses: actions/setup-python@v4 61 | id: python 62 | with: 63 | python-version: ${{ env.PYTHON_VERSION_DEFAULT }} 64 | - name: Restore base Python virtual environment 65 | id: cache-venv 66 | uses: actions/cache@v3 67 | with: 68 | path: venv 69 | key: >- 70 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ 71 | steps.python.outputs.python-version }}-${{ 72 | hashFiles('setup.py', 'requirements_test.txt') }} 73 | - name: Fail job if Python cache restore failed 74 | if: steps.cache-venv.outputs.cache-hit != 'true' 75 | run: | 76 | echo "Failed to restore Python virtual environment from cache" 77 | exit 1 78 | - name: Restore pre-commit environment from cache 79 | id: cache-precommit 80 | uses: actions/cache@v3 81 | with: 82 | path: ${{ env.PRE_COMMIT_HOME }} 83 | key: | 84 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 85 | restore-keys: | 86 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit- 87 | - name: Install pre-commit dependencies 88 | if: steps.cache-precommit.outputs.cache-hit != 'true' 89 | run: | 90 | . venv/bin/activate 91 | pre-commit install-hooks 92 | 93 | pre-commit-run: 94 | name: Run all of pre-commit 95 | runs-on: ubuntu-latest 96 | needs: pre-commit 97 | steps: 98 | - name: Check out code from GitHub 99 | uses: actions/checkout@v3 100 | - name: Set up Python ${{ env.PYTHON_VERSION_DEFAULT }} 101 | uses: actions/setup-python@v4 102 | id: python 103 | with: 104 | python-version: ${{ env.PYTHON_VERSION_DEFAULT }} 105 | - name: Restore base Python virtual environment 106 | id: cache-venv 107 | uses: actions/cache@v3 108 | with: 109 | path: venv 110 | key: >- 111 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ 112 | steps.python.outputs.python-version }}-${{ 113 | hashFiles('setup.py', 'requirements_test.txt') }} 114 | - name: Fail job if Python cache restore failed 115 | if: steps.cache-venv.outputs.cache-hit != 'true' 116 | run: | 117 | echo "Failed to restore Python virtual environment from cache" 118 | exit 1 119 | - name: Restore pre-commit environment from cache 120 | id: cache-precommit 121 | uses: actions/cache@v3 122 | with: 123 | path: ${{ env.PRE_COMMIT_HOME }} 124 | key: | 125 | ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 126 | - name: Fail job if cache restore failed 127 | if: steps.cache-venv.outputs.cache-hit != 'true' 128 | run: | 129 | echo "Failed to restore Python virtual environment from cache" 130 | exit 1 131 | - name: Run pre-commit 132 | run: | 133 | . venv/bin/activate 134 | pre-commit run -a 135 | -------------------------------------------------------------------------------- /.github/workflows/validate-hacs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Validate with hassfest 3 | 4 | on: 5 | push: 6 | pull_request: 7 | schedule: 8 | - cron: 0 0 * * * 9 | 10 | jobs: 11 | validate: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: home-assistant/actions/hassfest@master 16 | hacs: 17 | name: HACS Action 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: HACS Action 22 | uses: hacs/action@main 23 | with: 24 | category: integration 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | files: ^(.*\.(py|json|md|sh|yaml|cfg|txt))$ 3 | exclude: ^(\.[^/]*cache/.*|.*/_user.py)$ 4 | repos: 5 | - repo: https://github.com/verhovsky/pyupgrade-docs 6 | rev: v0.3.0 7 | hooks: 8 | - id: pyupgrade-docs 9 | - repo: https://github.com/executablebooks/mdformat 10 | # Do this before other tools "fixing" the line endings 11 | rev: 0.7.16 12 | hooks: 13 | - id: mdformat 14 | name: Format Markdown 15 | entry: mdformat # Executable to run, with fixed options 16 | language: python 17 | types: [markdown] 18 | args: [--wrap, '75', --number] 19 | additional_dependencies: 20 | - mdformat-toc 21 | - mdformat-beautysh 22 | # -mdformat-shfmt 23 | # -mdformat-tables 24 | - mdformat-config 25 | - mdformat-black 26 | - mdformat-web 27 | - mdformat-gfm 28 | - repo: https://github.com/asottile/blacken-docs 29 | rev: 1.13.0 30 | hooks: 31 | - id: blacken-docs 32 | additional_dependencies: [black==22.6.0] 33 | stages: [manual] # Manual because already done by mdformat-black 34 | - repo: https://github.com/pre-commit/pre-commit-hooks 35 | rev: v4.4.0 36 | hooks: 37 | - id: no-commit-to-branch 38 | args: [--branch, main] 39 | - id: check-yaml 40 | args: [--unsafe] 41 | - id: debug-statements 42 | - id: end-of-file-fixer 43 | - id: trailing-whitespace 44 | - id: check-json 45 | - id: mixed-line-ending 46 | - id: check-builtin-literals 47 | - id: check-ast 48 | - id: check-merge-conflict 49 | - id: check-executables-have-shebangs 50 | - id: check-shebang-scripts-are-executable 51 | - id: check-docstring-first 52 | - id: fix-byte-order-marker 53 | - id: check-case-conflict 54 | # - id: check-toml 55 | - repo: https://github.com/adrienverge/yamllint.git 56 | rev: v1.29.0 57 | hooks: 58 | - id: yamllint 59 | args: 60 | - --no-warnings 61 | - -d 62 | - '{extends: relaxed, rules: {line-length: {max: 90}}}' 63 | - repo: https://github.com/lovesegfault/beautysh.git 64 | rev: v6.2.1 65 | hooks: 66 | - id: beautysh 67 | - repo: https://github.com/asottile/pyupgrade 68 | rev: v3.3.1 69 | hooks: 70 | - id: pyupgrade 71 | args: 72 | - --py310-plus 73 | - repo: https://github.com/psf/black 74 | rev: 22.12.0 75 | hooks: 76 | - id: black 77 | args: 78 | - --safe 79 | - --quiet 80 | - -l 79 81 | - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit 82 | rev: v1.0.6 83 | hooks: 84 | - id: python-bandit-vulnerability-check 85 | - repo: https://github.com/fsouza/autoflake8 86 | rev: v0.4.0 87 | hooks: 88 | - id: autoflake8 89 | args: 90 | - -i 91 | - -r 92 | - --expand-star-imports 93 | - custom_components 94 | - repo: https://github.com/PyCQA/flake8 95 | rev: 6.0.0 96 | hooks: 97 | - id: flake8 98 | additional_dependencies: 99 | # - pyproject-flake8>=0.0.1a5 100 | - flake8-bugbear>=22.7.1 101 | - flake8-comprehensions>=3.10.1 102 | - flake8-2020>=1.7.0 103 | - mccabe>=0.7.0 104 | - pycodestyle>=2.9.1 105 | - pyflakes>=2.5.0 106 | - repo: https://github.com/PyCQA/isort 107 | rev: 5.12.0 108 | hooks: 109 | - id: isort 110 | - repo: https://github.com/codespell-project/codespell 111 | rev: v2.2.2 112 | hooks: 113 | - id: codespell 114 | args: [--toml, pyproject.toml] 115 | additional_dependencies: 116 | - tomli 117 | - repo: https://github.com/pre-commit/mirrors-pylint 118 | rev: v3.0.0a5 119 | hooks: 120 | - id: pylint 121 | additional_dependencies: 122 | #- voluptuous==0.13.1 123 | - homeassistant-stubs==2023.3.1 124 | #- sqlalchemy 125 | #- pyyaml 126 | - repo: https://github.com/pre-commit/mirrors-mypy 127 | rev: v0.991 128 | hooks: 129 | - id: mypy 130 | additional_dependencies: 131 | #- voluptuous==0.13.1 132 | - pydantic>=1.10.5 133 | - homeassistant-stubs==2023.3.1 134 | #- sqlalchemy 135 | #- pyyaml 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PROGRAMMABLE THERMOSTAT 2 | This component is a revision of the official Home Assistant component 'Generic Thermostat' to have the possibility to have the target temperature vary according to a sensor state value. 3 | 4 | ## HOW TO INSTALL 5 | Use HACS to install the custom component and configure it through the user interface (settings/integration) to have easy and smooth usage. 6 | 7 | If you are for the manual method: 8 | Just copy paste the content of the `climate.programmable_thermostat/custom_components` folder in your `config/custom_components` directory. 9 | 10 | For example, you will get the '.py' file in the following path: `/config/custom_components/programmable_thermostat/climate.py`. 11 | 12 | ## EXAMPLE OF SETUP 13 | Config flow is available, so just configure all the entities you want through the user interface. 14 | 15 | Here below the example of manual setup of sensor and parameters to configure. 16 | ```yaml 17 | climate: 18 | - platform: programmable_thermostat 19 | name: room 20 | heater: 21 | - switch.riscaldamento_1 22 | - switch.riscaldamento_2 23 | cooler: switch.condizionamento 24 | actual_temp_sensor: sensor.target_temperature 25 | min_temp: 10 26 | max_temp: 30 27 | target_temp_sensor: sensor.program_temperature 28 | tolerance: 0.3 29 | related_climate: climate.room_2 30 | hvac_options: 7 31 | auto_mode: all 32 | min_cycle_duration: 33 | seconds: 20 34 | ``` 35 | 36 | Field | Value | Necessity | Comments 37 | --- | --- | --- | --- 38 | platform | `programmable_thermostat` | *Required* | 39 | name| Programmable Thermostat | Optional | 40 | heater | | *Conditional* | Switch that will activate/deactivate the heating system. This can be a single entity or a list of entities. At least one between `heater` and `cooler` has to be defined. 41 | cooler | | *Conditional* | Switch that will activate/deactivate the cooling system. This can be a single entity or a list of entities. At least one between `heater` and `cooler` has to be defined. 42 | actual_temp_sensor | | *Required* | Sensor of actual room temperature. 43 | min_temp | 5 | Optional | Minimum temperature manually selectable. 44 | max_temp | 40 | Optional | Maximum temperature manually selectable. 45 | target_temp_sensor | | *Required* | Sensor that represent the desired temperature for the room. Suggestion: use my [`file_restore`][1] component or something similar. 46 | tolerance | 0.5 | Optional | Tolerance for turn on and off the switches mode. 47 | initial_hvac_mode | `heat_cool`, `heat`, `cool`, `off` | Optional | If not set, components will restore old state after restart. I suggest not to use it. 48 | related_climate | | Optional | To be used if the climate object is a slave of another one. below 'Related climate' chapter a description. 49 | hvac_options | 7 | Optional | This defines which combination of manual-auto-off options you want to activate. Refer to the chapter below for the value. 50 | auto_mode | `all`, `heating`, `cooling` | Optional | This allows limiting the heating/cooling function with HVAC mode HEAT_COOL. 51 | min_cycle_duration | | Optional | TIMEDELTA type. This will allow protecting devices that request a minimum type of work/rest before changing status. On this, you have to define hours, minutes and/or seconds as son elements. 52 | 53 | ## SPECIFICITIES 54 | ### TARGET TEMPERATURE SENSOR 55 | `target_temp_sensor` is the Home Assistant `entity_id` of a sensor which' state change accordingly a specified temperature profile. This temperature profile should describe the desired temperature for the room each day/hour. 56 | `target_temp_sensor` must have a temperature value (a number with or without decimal) as a state. 57 | 58 | Suggestion: use my [`file_restore`][1] custom components. 59 | 60 | ### ADDITIONAL INFO 61 | The programmed temperature will change accordingly to the one set by the `target_temp_sensor` when in `heat_cool` mode. You can still change it temporarily with the slider. Target temperature will be set, again, to the one of `target_temp_sensor` at its first change. 62 | `heat` and `cool` modes are the manual mode; in this mode, the planning will not be followed. 63 | 64 | After a restart of Home Assistant, room temperature and planned room temperature will match till `actual_temp_sensor` will return a temperature value. 65 | This is done to avoid possible issues with Homekit support with a temperature sensor that needs some time to sync with Home Assistant. 66 | 67 | ### RELATED CLIMATE 68 | This field is used if the climate-to-climate objects are related to each other, for example, if they use the same heater. 69 | Set this field with the `entity_id` with a different climate object and this will prevent the heater/cooler to be turned off by the slavery climate if the master one is active. 70 | 71 | For example, I have two climate objects, one for the room and one for the boiler. 72 | The boiler's climate is used to prevent freezing and, if the temperature is lower than the programmed one, the room heater is turned on. 73 | This means that, if the room's heater is on and the boiler's heater is off, the boiler will turn off the heater despite the room climate demands heat. 74 | With this `master_climate` field this unwanted turn-off will not happen. 75 | 76 | Note: my suggestion is to set it to both climates that are related to each other. 77 | 78 | ### HVAC OPTIONS 79 | This parameter allows you to define which mode you want to activate for that climate object. This is a number with a meaning of every single bit. Here below the table. 80 | 81 | bit3 - AUTOMATIC | bit2 - MANUAL | bit1 - OFF | RESULT | Meaning 82 | --- | --- | --- | --- | --- 83 | 0 | 0 | 0 | 0 | Noting active - USELESS 84 | 0 | 0 | 1 | 1 | OFF only 85 | 0 | 1 | 0 | 2 | MANUAL only, you will have only `heat` and/or `cool` modes 86 | 0 | 1 | 1 | 3 | MANUAL and OFF 87 | 1 | 0 | 0 | 4 | AUTOMATIC only, you will have only `heat_cool` modes 88 | 1 | 0 | 1 | 5 | AUTOMATIC and OFF 89 | 1 | 1 | 0 | 6 | AUTOMATIC and MANUAL 90 | 1 | 1 | 1 | 7 | DEAFAULT - Full mode, you will have active all the options. 91 | 92 | ### HEATERS AND COOLER SPECIFICS 93 | From version 7.6 you will be able to set `heaters` and `coolers` to the same list and you'll get the correct way of work in manual mode. 94 | This means that `heat` and `cool` mode will work correctly with the same list, but `heat_cool` mode will not (otherwise you will not be able to switch the real device between the 2 modes). 95 | My suggestion is to set `hvac_options: 3` to remove the auto mode. 96 | 97 | ## NOTE 98 | This component has been developed for the bigger project of building a smart thermostat using Home Assistant and way cheaper than the commercial ones. 99 | You can find more info on that [here][3] 100 | 101 | *** 102 | Everything is available through HACS. 103 | 104 | ##### CREDITS 105 | Icons made by Freepik from www.flaticon.com 106 | 107 | *** 108 | ![logo][2] 109 | 110 | 111 | [1]: https://github.com/custom-components/sensor.file_restore 112 | [2]: https://github.com/MapoDan/home-assistant/blob/master/mapodanlogo.png 113 | [3]: https://github.com/MapoDan/home-assistant 114 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an upgraded version of Generic Thermostat. 3 | This, compared to the old one allow to have a variable target temperature according to a sensor. 4 | Best use is with 'file_restore' that allow to program a temperature profile and the heating system will make your home confortable. 5 | """ 6 | import os 7 | import logging 8 | import asyncio 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.config_entries import ( 12 | SOURCE_IMPORT, 13 | ConfigEntry 14 | ) 15 | from homeassistant.helpers import discovery 16 | from homeassistant.util import Throttle 17 | from .climate import ProgrammableThermostat 18 | from .const import ( 19 | VERSION, 20 | CONFIGFLOW_VERSION, 21 | DOMAIN, 22 | PLATFORM, 23 | ISSUE_URL 24 | ) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | async def async_setup(hass, config): 29 | _LOGGER.info("Set up of integration %s, version %s, in case of issue open ticket at %s", DOMAIN, VERSION, ISSUE_URL) 30 | return True 31 | 32 | async def async_setup_entry(hass, config_entry: ConfigEntry): 33 | """Set up this integration using UI.""" 34 | if config_entry.source == config_entries.SOURCE_IMPORT: 35 | # We get here if the integration is set up using YAML 36 | hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id)) 37 | return True 38 | undo_listener = config_entry.add_update_listener(update_listener) 39 | _LOGGER.info("Added new ProgrammableThermostat entity, entry_id: %s", config_entry.entry_id) 40 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORM) 41 | 42 | return True 43 | 44 | async def async_unload_entry(hass, config_entry): 45 | """Unload a config entry.""" 46 | _LOGGER.debug("async_unload_entry: %s", config_entry) 47 | await asyncio.gather(hass.config_entries.async_forward_entry_unload(config_entry, PLATFORM)) 48 | return True 49 | 50 | async def update_listener(hass, config_entry): 51 | """Handle options update.""" 52 | _LOGGER.debug("update_listener: %s", config_entry) 53 | await hass.config_entries.async_reload(config_entry.entry_id) 54 | 55 | async def async_migrate_entry(hass, config_entry: ConfigEntry): 56 | """Migrate old entry.""" 57 | _LOGGER.debug("Migrating from version %s to version %s", config_entry.version, CONFIGFLOW_VERSION) 58 | 59 | new_data = {**config_entry.data} 60 | new_options = {**config_entry.options} 61 | 62 | if config_entry.version <= 2: 63 | _LOGGER.warning("Impossible to migrate config version from version %s to version %s.\r\nPlease consider to delete and recreate the entry.", config_entry.version, CONFIGFLOW_VERSION) 64 | return False 65 | elif config_entry.version == 3: 66 | config_entry.unique_id = config_entry.data["unique_id"] 67 | del new_data["unique_id"] 68 | config_entry.version = CONFIGFLOW_VERSION 69 | config_entry.data = {**new_data} 70 | _LOGGER.info("Migration of entry %s done to version %s", config_entry.title, config_entry.version) 71 | return True 72 | 73 | _LOGGER.info("Migration not required") 74 | return True 75 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/climate.py: -------------------------------------------------------------------------------- 1 | """Adds support for generic thermostat units.""" 2 | import asyncio 3 | import logging 4 | import json 5 | from datetime import timedelta 6 | from homeassistant.helpers.reload import async_setup_reload_service 7 | from homeassistant.config_entries import SOURCE_IMPORT 8 | from homeassistant.components.climate import ( 9 | PLATFORM_SCHEMA, 10 | ClimateEntity, 11 | HVACAction, 12 | HVACMode 13 | ) 14 | from homeassistant.const import ( 15 | ATTR_ENTITY_ID, 16 | ATTR_TEMPERATURE, 17 | CONF_NAME, 18 | EVENT_HOMEASSISTANT_START, 19 | SERVICE_TURN_OFF, 20 | SERVICE_TURN_ON, 21 | STATE_ON, 22 | STATE_OFF, 23 | STATE_UNKNOWN, 24 | STATE_UNAVAILABLE 25 | ) 26 | from homeassistant.core import DOMAIN as HA_DOMAIN, callback 27 | from homeassistant.helpers import condition 28 | import homeassistant.helpers.config_validation as cv 29 | from homeassistant.helpers.event import ( 30 | async_track_state_change_event, 31 | async_track_time_interval 32 | ) 33 | from homeassistant.helpers.restore_state import RestoreEntity 34 | from homeassistant.util import slugify 35 | from .const import ( 36 | VERSION, 37 | DOMAIN, 38 | PLATFORM, 39 | ATTR_HEATER_IDS, 40 | ATTR_COOLER_IDS, 41 | ATTR_SENSOR_ID 42 | ) 43 | from .config_schema import( 44 | CLIMATE_SCHEMA, 45 | CONF_HEATER, 46 | CONF_COOLER, 47 | CONF_SENSOR, 48 | CONF_MIN_TEMP, 49 | CONF_MAX_TEMP, 50 | CONF_TARGET, 51 | CONF_TOLERANCE, 52 | CONF_INITIAL_HVAC_MODE, 53 | CONF_RELATED_CLIMATE, 54 | CONF_HVAC_OPTIONS, 55 | CONF_AUTO_MODE, 56 | CONF_MIN_CYCLE_DURATION, 57 | SUPPORT_FLAGS 58 | ) 59 | from .helpers import dict_to_timedelta 60 | 61 | _LOGGER = logging.getLogger(__name__) 62 | 63 | __version__ = VERSION 64 | 65 | DEPENDENCIES = ['switch', 'sensor'] 66 | 67 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(CLIMATE_SCHEMA) 68 | 69 | async def async_setup_platform(hass, config, async_add_entities, 70 | discovery_info=None): 71 | """Add ProgrammableThermostat entities from configuration.yaml.""" 72 | _LOGGER.info("Setup entity coming from configuration.yaml named: %s", config.get(CONF_NAME)) 73 | await async_setup_reload_service(hass, DOMAIN, PLATFORM) 74 | async_add_entities([ProgrammableThermostat(hass, config)]) 75 | 76 | async def async_setup_entry(hass, config_entry, async_add_devices): 77 | """Add ProgrammableThermostat entities from configuration flow.""" 78 | result = {} 79 | if config_entry.options != {}: 80 | result = config_entry.options 81 | else: 82 | result = config_entry.data 83 | _LOGGER.info("setup entity-config_entry_data=%s",result) 84 | await async_setup_reload_service(hass, DOMAIN, PLATFORM) 85 | async_add_devices([ProgrammableThermostat(hass, result)]) 86 | 87 | 88 | class ProgrammableThermostat(ClimateEntity, RestoreEntity): 89 | """ProgrammableThermostat.""" 90 | 91 | def __init__(self, hass, config): 92 | 93 | """Initialize the thermostat.""" 94 | self.hass = hass 95 | self._name = config.get(CONF_NAME) 96 | self._attr_unique_id = f"programmable_thermostat_{slugify(self._name)}" 97 | self.heaters_entity_ids = self._getEntityList(config.get(CONF_HEATER)) 98 | self.coolers_entity_ids = self._getEntityList(config.get(CONF_COOLER)) 99 | self.sensor_entity_id = config.get(CONF_SENSOR) 100 | self._tolerance = config.get(CONF_TOLERANCE) 101 | self._min_temp = config.get(CONF_MIN_TEMP) 102 | self._max_temp = config.get(CONF_MAX_TEMP) 103 | self._initial_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE) 104 | self.target_entity_id = config.get(CONF_TARGET) 105 | self._unit = hass.config.units.temperature_unit 106 | self._related_climate = self._getEntityList(config.get(CONF_RELATED_CLIMATE)) 107 | self._hvac_options = config.get(CONF_HVAC_OPTIONS) 108 | self._auto_mode = config.get(CONF_AUTO_MODE) 109 | self._hvac_list = [] 110 | self.min_cycle_duration = config.get(CONF_MIN_CYCLE_DURATION) 111 | if type(self.min_cycle_duration) == type({}): 112 | self.min_cycle_duration = dict_to_timedelta(self.min_cycle_duration) 113 | self._target_temp = self._getFloat(self._getStateSafe(self.target_entity_id), None) 114 | self._restore_temp = self._target_temp 115 | self._cur_temp = self._getFloat(self._getStateSafe(self.sensor_entity_id), self._target_temp) 116 | self._active = False 117 | self._temp_lock = asyncio.Lock() 118 | self._hvac_action = HVACAction.OFF 119 | 120 | """Setting up of HVAC list according to the option parameter""" 121 | options = "{0:b}".format(self._hvac_options).zfill(3)[::-1] 122 | if options[0] == "1": 123 | self._hvac_list.append(HVACMode.OFF) 124 | if self.heaters_entity_ids is not None and options[1] == "1": 125 | self._hvac_list.append(HVACMode.HEAT) 126 | if self.coolers_entity_ids is not None and options[1] == "1": 127 | self._hvac_list.append(HVACMode.COOL) 128 | if (self.heaters_entity_ids != None or self.coolers_entity_ids != None) and options[2] == "1": 129 | self._hvac_list.append(HVACMode.HEAT_COOL) 130 | if self.heaters_entity_ids == None and self.coolers_entity_ids == None: 131 | _LOGGER.error("ERROR on climate.%s, you have to define at least one between heater and cooler", self._name) 132 | if not self._hvac_list: 133 | self._hvac_list.append(HVACMode.OFF) 134 | _LOGGER.error("ERROR on climate.%s, you have choosen a wrong value of hvac_options, please check documentation", self._name) 135 | 136 | if self._initial_hvac_mode == HVACMode.HEAT: 137 | self._hvac_mode = HVACMode.HEAT 138 | elif self._initial_hvac_mode == HVACMode.HEAT_COOL: 139 | self._hvac_mode = HVACMode.HEAT_COOL 140 | elif self._initial_hvac_mode == HVACMode.COOL: 141 | self._hvac_mode = HVACMode.COOL 142 | else: 143 | self._hvac_mode = HVACMode.OFF 144 | self._support_flags = SUPPORT_FLAGS 145 | 146 | """ Check if heaters and coolers are the same """ 147 | if self.heaters_entity_ids == self.coolers_entity_ids: 148 | self._are_entities_same = True 149 | else: 150 | self._are_entities_same = False 151 | 152 | async def async_added_to_hass(self): 153 | """Run when entity about to be added.""" 154 | await super().async_added_to_hass() 155 | 156 | # Add listener 157 | self.async_on_remove( 158 | async_track_state_change_event( 159 | self.hass, self.sensor_entity_id, self._async_sensor_changed)) 160 | if self._hvac_mode == HVACMode.HEAT: 161 | self.async_on_remove( 162 | async_track_state_change_event( 163 | self.hass, self.heaters_entity_ids, self._async_switch_changed)) 164 | elif self._hvac_mode == HVACMode.COOL: 165 | self.async_on_remove( 166 | async_track_state_change_event( 167 | self.hass, self.coolers_entity_ids, self._async_switch_changed)) 168 | self.async_on_remove( 169 | async_track_state_change_event( 170 | self.hass, self.target_entity_id, self._async_target_changed)) 171 | if self._related_climate is not None: 172 | for _related_entity in self._related_climate: 173 | self.async_on_remove( 174 | async_track_state_change_event( 175 | self.hass, _related_entity, self._async_switch_changed)) 176 | 177 | @callback 178 | def _async_startup(event): 179 | """Init on startup.""" 180 | sensor_state = self._getStateSafe(self.sensor_entity_id) 181 | if sensor_state and sensor_state != STATE_UNKNOWN: 182 | self._async_update_temp(sensor_state) 183 | target_state = self._getStateSafe(self.target_entity_id) 184 | if target_state and \ 185 | target_state != STATE_UNKNOWN and \ 186 | self._hvac_mode != HVACMode.HEAT_COOL: 187 | self._async_update_program_temp(target_state) 188 | 189 | self.hass.bus.async_listen_once( 190 | EVENT_HOMEASSISTANT_START, _async_startup) 191 | 192 | # Check If we have an old state 193 | old_state = await self.async_get_last_state() 194 | _LOGGER.info("climate.%s old state: %s", self._name, old_state) 195 | if old_state is not None: 196 | # If we have no initial temperature, restore 197 | if self._target_temp is None: 198 | # If we have a previously saved temperature 199 | if old_state.attributes.get(ATTR_TEMPERATURE) is None: 200 | target_entity_state = self._getStateSafe(self.target_entity_id) 201 | if target_entity_state is not None: 202 | self._target_temp = float(target_entity_state) 203 | else: 204 | self._target_temp = float((self._min_temp + self._max_temp)/2) 205 | _LOGGER.warning("climate.%s - Undefined target temperature," 206 | "falling back to %s", self._name , self._target_temp) 207 | else: 208 | self._target_temp = float( 209 | old_state.attributes[ATTR_TEMPERATURE]) 210 | if (self._initial_hvac_mode is None and 211 | old_state.state is not None): 212 | self._hvac_mode = \ 213 | old_state.state 214 | self._enabled = self._hvac_mode != HVACMode.OFF 215 | 216 | else: 217 | # No previous state, try and restore defaults 218 | if self._target_temp is None: 219 | self._target_temp = float((self._min_temp + self._max_temp)/2) 220 | _LOGGER.warning("climate.%s - No previously saved temperature, setting to %s", self._name, 221 | self._target_temp) 222 | 223 | # Set default state to off 224 | if not self._hvac_mode: 225 | self._hvac_mode = HVACMode.OFF 226 | 227 | async def control_system_mode(self): 228 | """this is used to decide what to do, so this function turn off switches and run the function 229 | that control the temperature.""" 230 | if self._hvac_mode == HVACMode.OFF: 231 | _LOGGER.debug("set to off") 232 | for opmod in self._hvac_list: 233 | if opmod is HVACMode.HEAT: 234 | await self._async_turn_off(mode="heat", forced=True) 235 | if opmod is HVACMode.COOL: 236 | await self._async_turn_off(mode="cool", forced=True) 237 | self._hvac_action = HVACAction.OFF 238 | elif self._hvac_mode == HVACMode.HEAT: 239 | _LOGGER.debug("set to heat") 240 | await self._async_control_thermo(mode="heat") 241 | for opmod in self._hvac_list: 242 | if opmod is HVACMode.COOL and not self._are_entities_same: 243 | await self._async_turn_off(mode="cool", forced=True) 244 | return 245 | elif self._hvac_mode == HVACMode.COOL: 246 | _LOGGER.debug("set to cool") 247 | await self._async_control_thermo(mode="cool") 248 | for opmod in self._hvac_list: 249 | if opmod is HVACMode.HEAT and not self._are_entities_same: 250 | await self._async_turn_off(mode="heat", forced=True) 251 | return 252 | else: 253 | _LOGGER.debug("set to auto") 254 | for opmod in self._hvac_list: 255 | # Check of self._auto_mode has been added to avoid cooling a room that has just been heated and vice versa 256 | # LET'S PRESERVE ENERGY! 257 | # If you don't want to check that you have just to set auto_mode=all 258 | if opmod is HVACMode.HEAT and self._auto_mode != 'cooling': 259 | _LOGGER.debug("climate.%s - Entered here in heating mode", self._name) 260 | await self._async_control_thermo(mode="heat") 261 | if opmod is HVACMode.COOL and self._auto_mode != 'heating': 262 | _LOGGER.debug("climate.%s - Entered here in cooling mode", self._name) 263 | await self._async_control_thermo(mode="cool") 264 | return 265 | 266 | async def _async_turn_on(self, mode=None): 267 | """Turn heater toggleable device on.""" 268 | if mode == "heat": 269 | data = {ATTR_ENTITY_ID: self.heaters_entity_ids} 270 | elif mode == "cool": 271 | data = {ATTR_ENTITY_ID: self.coolers_entity_ids} 272 | else: 273 | _LOGGER.error("climate.%s - No type has been passed to turn_on function", self._name) 274 | 275 | if not self._is_device_active_function(forced=False) and self.is_active_long_enough(mode=mode): 276 | self._set_hvac_action_on(mode=mode) 277 | await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) 278 | self.async_write_ha_state() 279 | 280 | async def _async_turn_off(self, mode=None, forced=False): 281 | """Turn heater toggleable device off.""" 282 | if self._related_climate is not None: 283 | for _climate in self._related_climate: 284 | related_climate_hvac_action = self.hass.states.get(_climate).attributes['hvac_action'] 285 | if related_climate_hvac_action == HVACAction.HEATING or related_climate_hvac_action == HVACAction.COOLING: 286 | _LOGGER.info("climate.%s - Master climate object action is %s, so no action taken.", self._name, related_climate_hvac_action) 287 | return 288 | if mode == "heat": 289 | data = {ATTR_ENTITY_ID: self.heaters_entity_ids} 290 | elif mode == "cool": 291 | data = {ATTR_ENTITY_ID: self.coolers_entity_ids} 292 | else: 293 | _LOGGER.error("climate.%s - No type has been passed to turn_off function", self._name) 294 | self._check_mode_type = mode 295 | if self._is_device_active_function(forced=forced) and self.is_active_long_enough(mode=mode): 296 | self._set_hvac_action_off(mode=mode) 297 | await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) 298 | self.async_write_ha_state() 299 | 300 | async def async_set_hvac_mode(self, hvac_mode): 301 | """Set hvac mode.""" 302 | if hvac_mode == HVACMode.HEAT: 303 | self._hvac_mode = HVACMode.HEAT 304 | elif hvac_mode == HVACMode.COOL: 305 | self._hvac_mode = HVACMode.COOL 306 | elif hvac_mode == HVACMode.OFF: 307 | self._hvac_mode = HVACMode.OFF 308 | elif hvac_mode == HVACMode.HEAT_COOL: 309 | self._hvac_mode = HVACMode.HEAT_COOL 310 | self._async_restore_program_temp() 311 | else: 312 | _LOGGER.error("climate.%s - Unrecognized hvac mode: %s", self._name, hvac_mode) 313 | return 314 | await self.control_system_mode() 315 | # Ensure we update the current operation after changing the mode 316 | self.async_write_ha_state() 317 | 318 | async def async_set_temperature(self, **kwargs): 319 | """Set new target temperature.""" 320 | temperature = kwargs.get(ATTR_TEMPERATURE) 321 | if temperature is None: 322 | return 323 | self._target_temp = float(temperature) 324 | await self.control_system_mode() 325 | self.async_write_ha_state() 326 | 327 | async def _async_sensor_changed(self, event): 328 | """Handle temperature changes.""" 329 | new_state = event.data.get("new_state") 330 | if new_state is None: 331 | return 332 | self._async_update_temp(new_state.state) 333 | await self.control_system_mode() 334 | self.async_write_ha_state() 335 | 336 | async def _async_target_changed(self, event): 337 | """Handle temperature changes in the program.""" 338 | new_state = event.data.get("new_state") 339 | if new_state is None: 340 | return 341 | self._restore_temp = float(new_state.state) 342 | if self._hvac_mode == HVACMode.HEAT_COOL: 343 | self._async_restore_program_temp() 344 | await self.control_system_mode() 345 | self.async_write_ha_state() 346 | 347 | async def _async_control_thermo(self, mode=None): 348 | """Check if we need to turn heating on or off.""" 349 | if self._cur_temp is None: 350 | _LOGGER.info("climate.%s - Abort _async_control_thermo as _cur_temp is None", self._name) 351 | return 352 | if self._target_temp is None: 353 | _LOGGER.info("climate.%s - Abort _async_control_thermo as _target_temp is None", self._name) 354 | return 355 | 356 | if mode == "heat": 357 | hvac_mode = HVACMode.COOL 358 | delta = self._target_temp - self._cur_temp 359 | entities = self.heaters_entity_ids 360 | elif mode == "cool": 361 | hvac_mode = HVACMode.HEAT 362 | delta = self._cur_temp - self._target_temp 363 | entities = self.coolers_entity_ids 364 | else: 365 | _LOGGER.error("climate.%s - No type has been passed to control_thermo function", self._name) 366 | self._check_mode_type = mode 367 | async with self._temp_lock: 368 | if not self._active and None not in (self._cur_temp, 369 | self._target_temp): 370 | self._active = True 371 | _LOGGER.debug("climate.%s - Obtained current and target temperature. " 372 | "Generic thermostat active. %s, %s", self._name, 373 | self._cur_temp, self._target_temp) 374 | 375 | if not self._active or self._hvac_mode == HVACMode.OFF or self._hvac_mode == hvac_mode: 376 | return 377 | 378 | if delta <= 0: 379 | if not self._areAllInState(entities, STATE_OFF): 380 | _LOGGER.debug("Turning off %s", entities) 381 | await self._async_turn_off(mode=mode) 382 | self._set_hvac_action_off(mode=mode) 383 | elif delta >= self._tolerance: 384 | self._set_hvac_action_on(mode=mode) 385 | if not self._areAllInState(entities, STATE_ON): 386 | _LOGGER.debug("Turning on %s", entities) 387 | await self._async_turn_on(mode=mode) 388 | 389 | def _set_hvac_action_off(self, mode=None): 390 | """This is used to set HVACAction.OFF on the climate integration. 391 | This has been split form turn_off function since this will allow to make dedicated calls. 392 | For the other CURRENT_HVAC_*, this is not needed becasue they work perfectly at the turn_on.""" 393 | # This if condition is necessary to correctly manage the action for the different modes. 394 | _LOGGER.debug("climate.%s - mode=%s \r\ntarget=%s \r\n current=%s", self._name, mode, self._target_temp, self._cur_temp) 395 | if mode == "heat": 396 | delta = self._target_temp - self._cur_temp 397 | entities = self.coolers_entity_ids 398 | mode_2 = "cool" 399 | elif mode == "cool": 400 | delta = self._cur_temp - self._target_temp 401 | entities = self.heaters_entity_ids 402 | mode_2 = "heat" 403 | else: 404 | _LOGGER.error("climate.%s - No type has been passed to control_thermo function", self._name) 405 | mode_2 = None 406 | _LOGGER.debug("climate.%s - delta=%s", self._name, delta) 407 | if (((mode == "cool" and not self._hvac_mode == HVACMode.HEAT) or \ 408 | (mode == "heat" and not self._hvac_mode == HVACMode.COOL)) and \ 409 | not self._hvac_mode == HVACMode.HEAT_COOL): 410 | self._hvac_action = HVACAction.OFF 411 | _LOGGER.debug("climate.%s - new action %s", self._name, self._hvac_action) 412 | elif self._hvac_mode == HVACMode.HEAT_COOL and delta <= 0: 413 | self._hvac_action = HVACAction.OFF 414 | _LOGGER.debug("climate.%s - new action %s", self._name, self._hvac_action) 415 | if abs(delta) >= self._tolerance and entities != None: 416 | self._set_hvac_action_on(mode=mode_2) 417 | else: 418 | if self._are_entities_same and not self._is_device_active_function(forced=False): 419 | self._hvac_action = HVACAction.OFF 420 | else: 421 | _LOGGER.error("climate.%s - Error during set of HVAC_ACTION", self._name) 422 | 423 | def _set_hvac_action_on(self, mode=None): 424 | """This is used to set CURRENT_HVAC_* according to the mode that is running.""" 425 | if mode == "heat": 426 | self._hvac_action = HVACAction.HEATING 427 | elif mode == "cool": 428 | self._hvac_action = HVACAction.COOLING 429 | else: 430 | _LOGGER.error("climate.%s - No type has been passed to turn_on function", self._name) 431 | _LOGGER.debug("climate.%s - new action %s", self._name, self._hvac_action) 432 | 433 | def _getEntityList(self, entity_ids): 434 | if entity_ids is not None: 435 | if not isinstance(entity_ids, list): 436 | return [ entity_ids ] 437 | elif len(entity_ids)<=0: 438 | return None 439 | return entity_ids 440 | 441 | def _getStateSafe(self, entity_id): 442 | full_state = self.hass.states.get(entity_id) 443 | if full_state is not None: 444 | return full_state.state 445 | return None 446 | 447 | def _getFloat(self, valStr, defaultVal): 448 | if valStr!=STATE_UNKNOWN and valStr!=STATE_UNAVAILABLE and valStr is not None: 449 | return float(valStr) 450 | return defaultVal 451 | 452 | def _areAllInState(self, entity_ids, state): 453 | for entity_id in entity_ids: 454 | if not self.hass.states.is_state(entity_id, state): 455 | return False 456 | return True 457 | 458 | def _is_device_active_function(self, forced): 459 | """If the toggleable device is currently active.""" 460 | _LOGGER.debug("climate.%s - \r\nheaters: %s \r\ncoolers: %s \r\n_check_mode_type: %s \r\n_hvac_mode: %s \r\nforced: %s", self._name, self.heaters_entity_ids, self.coolers_entity_ids, self._check_mode_type, self._hvac_mode, forced) 461 | if not forced: 462 | _LOGGER.debug("climate.%s - 410- enter in classic mode: %s", self._name, forced) 463 | if self._hvac_mode == HVACMode.HEAT_COOL: 464 | if self._check_mode_type == "cool": 465 | return self._areAllInState(self.coolers_entity_ids, STATE_ON) 466 | elif self._check_mode_type == "heat": 467 | return self._areAllInState(self.heaters_entity_ids, STATE_ON) 468 | else: 469 | return False 470 | elif self._hvac_mode == HVACMode.HEAT: 471 | _LOGGER.debug("climate.%s - 419 - heaters: %s", self._name, self.heaters_entity_ids) 472 | return self._areAllInState(self.heaters_entity_ids, STATE_ON) 473 | elif self._hvac_mode == HVACMode.COOL: 474 | _LOGGER.debug("climate.%s - 422 - coolers: %s", self._name, self.coolers_entity_ids) 475 | return self._areAllInState(self.coolers_entity_ids, STATE_ON) 476 | else: 477 | return False 478 | """if self._check_mode_type == "cool": 479 | return self._areAllInState(self.coolers_entity_ids, STATE_ON) 480 | elif self._check_mode_type == "heat": 481 | return self._areAllInState(self.heaters_entity_ids, STATE_ON) 482 | else: 483 | return False""" 484 | else: 485 | _LOGGER.debug("climate.%s - 433- enter in forced mode: %s", self._name, forced) 486 | if self._check_mode_type == "heat": 487 | _LOGGER.debug("climate.%s - 435 - heaters: %s", self._name, self.heaters_entity_ids) 488 | return self._areAllInState(self.heaters_entity_ids, STATE_ON) 489 | elif self._check_mode_type == "cool": 490 | _LOGGER.debug("climate.%s - 438 - coolers: %s", self._name, self.coolers_entity_ids) 491 | return self._areAllInState(self.coolers_entity_ids, STATE_ON) 492 | else: 493 | return False 494 | 495 | def is_active_long_enough(self, mode=None): 496 | """ This function is to check if the heater/cooler has been active long enough """ 497 | if not self.min_cycle_duration: 498 | return True 499 | if self._is_device_active: 500 | current_state = STATE_ON 501 | else: 502 | current_state = STATE_OFF 503 | if mode == "heat": 504 | for entity in self.heaters_entity_ids: 505 | return condition.state(self.hass, entity, current_state, self.min_cycle_duration) 506 | elif mode == "cool": 507 | for entity in self.coolers_entity_ids: 508 | return condition.state(self.hass, entity, current_state, self.min_cycle_duration) 509 | else: 510 | _LOGGER.error("Wrong mode have been passed to function is_active_long_enough") 511 | return True 512 | 513 | @callback 514 | def _async_switch_changed(self, event): 515 | """Handle heater switch state changes.""" 516 | new_state = event.data.get("new_state") 517 | if new_state is None: 518 | return 519 | self.async_write_ha_state() 520 | 521 | @callback 522 | def _async_update_temp(self, state): 523 | """Update thermostat with latest state from sensor.""" 524 | try: 525 | self._cur_temp = float(state) 526 | except ValueError as ex: 527 | _LOGGER.warning("climate.%s - Unable to update current temperature from sensor: %s", self._name, ex) 528 | 529 | @callback 530 | def _async_restore_program_temp(self): 531 | """Update thermostat with latest state from sensor to have back automatic value.""" 532 | try: 533 | if self._restore_temp is not None: 534 | self._target_temp = self._restore_temp 535 | else: 536 | self._target_temp = self._getFloat(self._getStateSafe(self.target_entity_id), None) 537 | except ValueError as ex: 538 | _LOGGER.warning("climate.%s - Unable to restore program temperature from sensor: %s", self._name, ex) 539 | 540 | @callback 541 | def _async_update_program_temp(self, state): 542 | """Update thermostat with latest state from sensor.""" 543 | try: 544 | self._target_temp = float(state) 545 | except ValueError as ex: 546 | _LOGGER.warning("climate.%s - Unable to update target temperature from sensor: %s", self._name, ex) 547 | 548 | @property 549 | def should_poll(self): 550 | """Return the polling state.""" 551 | return False 552 | 553 | @property 554 | def name(self): 555 | """Return the name of the thermostat.""" 556 | return self._name 557 | 558 | @property 559 | def temperature_unit(self): 560 | """Return the unit of measurement.""" 561 | return self._unit 562 | 563 | @property 564 | def current_temperature(self): 565 | """Return the sensor temperature.""" 566 | return self._cur_temp 567 | 568 | @property 569 | def hvac_mode(self): 570 | """Return current operation.""" 571 | return self._hvac_mode 572 | 573 | @property 574 | def target_temperature(self): 575 | """Return the temperature we try to reach.""" 576 | return self._target_temp 577 | 578 | @property 579 | def hvac_modes(self): 580 | """List of available operation modes.""" 581 | return self._hvac_list 582 | 583 | @property 584 | def min_temp(self): 585 | """Return the minimum temperature.""" 586 | if self._min_temp: 587 | return self._min_temp 588 | 589 | # get default temp from super class 590 | return super().min_temp 591 | 592 | @property 593 | def max_temp(self): 594 | """Return the maximum temperature.""" 595 | if self._max_temp: 596 | return self._max_temp 597 | 598 | # Get default temp from super class 599 | return super().max_temp 600 | 601 | @property 602 | def _is_device_active(self): 603 | """If the toggleable device is currently active.""" 604 | return self._is_device_active_function(forced=False) 605 | 606 | @property 607 | def supported_features(self): 608 | """Return the list of supported features.""" 609 | return self._support_flags 610 | 611 | @property 612 | def hvac_action(self): 613 | """Return the current running hvac operation if supported. 614 | 615 | Need to be one of CURRENT_HVAC_*. 616 | """ 617 | return self._hvac_action 618 | 619 | @property 620 | def extra_state_attributes(self): 621 | """Return entity specific state attributes to be saved. """ 622 | attributes = {} 623 | 624 | attributes[ATTR_HEATER_IDS] = self.heaters_entity_ids 625 | attributes[ATTR_COOLER_IDS] = self.coolers_entity_ids 626 | attributes[ATTR_SENSOR_ID] = self.sensor_entity_id 627 | 628 | return attributes 629 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/config_flow.py: -------------------------------------------------------------------------------- 1 | """ Configuration flow for the programmable_thermostat integration to allow user 2 | to define all programmable_thermostat entities from Lovelace UI.""" 3 | import logging 4 | from homeassistant.core import callback 5 | import voluptuous as vol 6 | import homeassistant.helpers.config_validation as cv 7 | from homeassistant import config_entries 8 | import uuid 9 | import re 10 | 11 | from datetime import timedelta, datetime 12 | from homeassistant.const import ( 13 | CONF_NAME, 14 | EVENT_HOMEASSISTANT_START 15 | ) 16 | from .const import ( 17 | DOMAIN, 18 | CONFIGFLOW_VERSION, 19 | REGEX_STRING 20 | ) 21 | from .config_schema import ( 22 | get_config_flow_schema, 23 | CONF_HEATER, 24 | CONF_COOLER, 25 | CONF_SENSOR, 26 | CONF_MIN_TEMP, 27 | CONF_MAX_TEMP, 28 | CONF_TARGET, 29 | CONF_TOLERANCE, 30 | CONF_RELATED_CLIMATE, 31 | CONF_MIN_CYCLE_DURATION 32 | ) 33 | from .helpers import ( 34 | are_entities_valid, 35 | string_to_list, 36 | string_to_timedelta, 37 | null_data_cleaner 38 | ) 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | ##################################################### 43 | #################### CONFIG FLOW #################### 44 | ##################################################### 45 | @config_entries.HANDLERS.register(DOMAIN) 46 | class ProgrammableThermostatConfigFlow(config_entries.ConfigFlow): 47 | """Programmable Thermostat config flow.""" 48 | 49 | VERSION = CONFIGFLOW_VERSION 50 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 51 | 52 | def __init__(self): 53 | """Initialize.""" 54 | self._errors = {} 55 | self._data = {} 56 | self._unique_id = str(uuid.uuid4()) 57 | 58 | """ INITIATE CONFIG FLOW """ 59 | async def async_step_user(self, user_input={}): 60 | """User initiated config flow.""" 61 | self._errors = {} 62 | if user_input is not None: 63 | if are_first_step_data_valid(self, user_input): 64 | self._data.update(user_input) 65 | self._data[CONF_HEATER] = string_to_list(self._data[CONF_HEATER]) 66 | self._data[CONF_COOLER] = string_to_list(self._data[CONF_COOLER]) 67 | _LOGGER.info("First input data are valid. Proceed with second step. %s", self._data) 68 | return await self.async_step_second() 69 | _LOGGER.warning("Wrong data have been input in the first form") 70 | return await self._show_config_form_first(user_input) 71 | return await self._show_config_form_first(user_input) 72 | 73 | """ SHOW FIRST FORM """ 74 | async def _show_config_form_first(self, user_input): 75 | """ Show form for config flow """ 76 | _LOGGER.info("Show first form") 77 | return self.async_show_form( 78 | step_id="user", 79 | data_schema=vol.Schema(get_config_flow_schema(user_input, 1)), 80 | errors=self._errors 81 | ) 82 | 83 | """ SECOND CONFIG FLOW STEP """ 84 | async def async_step_second(self, user_input={}): 85 | """User proceed on the second step config flow.""" 86 | self._errors = {} 87 | if user_input is not None and user_input != {}: 88 | if are_second_step_data_valid(self, user_input): 89 | self._data.update(user_input) 90 | _LOGGER.info("Second input data are valid. Proceed with final step.") 91 | return await self.async_step_final() 92 | _LOGGER.warning("Wrong data have been input in the second form") 93 | return await self._show_config_form_second(user_input) 94 | return await self._show_config_form_second(user_input) 95 | 96 | """ SHOW SECOND FORM """ 97 | async def _show_config_form_second(self, user_input): 98 | """ Show form for config flow """ 99 | _LOGGER.info("Show second form") 100 | return self.async_show_form( 101 | step_id="second", 102 | data_schema=vol.Schema(get_config_flow_schema(user_input, 2)), 103 | errors=self._errors 104 | ) 105 | 106 | """ LAST CONFIG FLOW STEP """ 107 | async def async_step_final(self, user_input={}): 108 | """User initiated config flow.""" 109 | self._errors = {} 110 | if user_input is not None and user_input != {}: 111 | if are_third_step_data_valid(self, user_input): 112 | self._data.update(user_input) 113 | self._data[CONF_RELATED_CLIMATE] = string_to_list(self._data[CONF_RELATED_CLIMATE]) 114 | self._data[CONF_MIN_CYCLE_DURATION] = string_to_timedelta(self._data[CONF_MIN_CYCLE_DURATION]) 115 | final_data = {} 116 | for key in self._data.keys(): 117 | if self._data[key] != "" and self._data[key] != []: 118 | final_data.update({key: self._data[key]}) 119 | _LOGGER.info("Data are valid. Proceed with entity creation. - %s", final_data) 120 | await self.async_set_unique_id(self._unique_id) 121 | self._abort_if_unique_id_configured() 122 | return self.async_create_entry(title=final_data["name"], data=final_data) 123 | _LOGGER.warning("Wrong data have been input in the last form") 124 | return await self._show_config_form_final(user_input) 125 | return await self._show_config_form_final(user_input) 126 | 127 | """ SHOW LAST FORM """ 128 | async def _show_config_form_final(self, user_input): 129 | """ Show form for config flow """ 130 | _LOGGER.info("Show final form") 131 | return self.async_show_form( 132 | step_id="final", 133 | data_schema=vol.Schema(get_config_flow_schema(user_input, 3)), 134 | errors=self._errors 135 | ) 136 | 137 | """ SHOW CONFIGURATION.YAML ENTITIES """ 138 | async def async_step_import(self, user_input): 139 | """Import a config entry. 140 | Special type of import, we're not actually going to store any data. 141 | Instead, we're going to rely on the values that are in config file.""" 142 | 143 | if self._async_current_entries(): 144 | return self.async_abort(reason="single_instance_allowed") 145 | 146 | return self.async_create_entry(title="configuration.yaml", data={}) 147 | 148 | @staticmethod 149 | @callback 150 | def async_get_options_flow(config_entry): 151 | if config_entry.unique_id is not None: 152 | return OptionsFlowHandler(config_entry) 153 | else: 154 | return EmptyOptions(config_entry) 155 | 156 | ##################################################### 157 | #################### OPTION FLOW #################### 158 | ##################################################### 159 | class OptionsFlowHandler(config_entries.OptionsFlow): 160 | """Programmable Thermostat option flow.""" 161 | 162 | def __init__(self, config_entry): 163 | """Initialize.""" 164 | self._errors = {} 165 | self._data = {} 166 | self.config_entry = config_entry 167 | if self.config_entry.options == {}: 168 | self._data.update(self.config_entry.data) 169 | else: 170 | self._data.update(self.config_entry.options) 171 | _LOGGER.debug("_data to start options flow: %s", self._data) 172 | 173 | """ INITIATE CONFIG FLOW """ 174 | async def async_step_init(self, user_input={}): 175 | """User initiated config flow.""" 176 | self._errors = {} 177 | _LOGGER.debug("user_input= %s", user_input) 178 | if user_input is not None: 179 | if are_first_step_data_valid(self, user_input): 180 | self._data = null_data_cleaner(self._data, user_input) 181 | self._data[CONF_HEATER] = string_to_list(self._data[CONF_HEATER]) 182 | self._data[CONF_COOLER] = string_to_list(self._data[CONF_COOLER]) 183 | _LOGGER.info("First input data are valid. Proceed with second step. %s", self._data) 184 | return await self.async_step_second() 185 | _LOGGER.warning("Wrong data have been input in the first form") 186 | return await self._show_config_form_first(user_input) 187 | return await self._show_config_form_first(user_input) 188 | 189 | """ SHOW FIRST FORM """ 190 | async def _show_config_form_first(self, user_input): 191 | """ Show form for config flow """ 192 | _LOGGER.info("Show first form") 193 | if user_input is None or user_input == {}: 194 | user_input = self._data 195 | #4 is necessary for options. Check config_schema.py for explanations. 196 | return self.async_show_form( 197 | step_id="init", 198 | data_schema=vol.Schema(get_config_flow_schema(user_input, 4)), 199 | errors=self._errors 200 | ) 201 | 202 | """ SECOND CONFIG FLOW STEP """ 203 | async def async_step_second(self, user_input={}): 204 | """User proceed on the second step config flow.""" 205 | self._errors = {} 206 | if user_input is not None and user_input != {}: 207 | if are_second_step_data_valid(self, user_input): 208 | self._data = null_data_cleaner(self._data, user_input) 209 | _LOGGER.info("Second input data are valid. Proceed with final step.") 210 | return await self.async_step_final() 211 | _LOGGER.warning("Wrong data have been input in the second form") 212 | return await self._show_config_form_second(user_input) 213 | return await self._show_config_form_second(user_input) 214 | 215 | """ SHOW SECOND FORM """ 216 | async def _show_config_form_second(self, user_input): 217 | """ Show form for config flow """ 218 | _LOGGER.info("Show second form") 219 | if user_input is None or user_input == {}: 220 | user_input = self._data 221 | return self.async_show_form( 222 | step_id="second", 223 | data_schema=vol.Schema(get_config_flow_schema(user_input, 2)), 224 | errors=self._errors 225 | ) 226 | 227 | """ LAST CONFIG FLOW STEP """ 228 | async def async_step_final(self, user_input={}): 229 | """User initiated config flow.""" 230 | self._errors = {} 231 | if user_input is not None and user_input != {}: 232 | if are_third_step_data_valid(self, user_input): 233 | self._data = null_data_cleaner(self._data, user_input) 234 | self._data[CONF_RELATED_CLIMATE] = string_to_list(self._data[CONF_RELATED_CLIMATE]) 235 | self._data[CONF_MIN_CYCLE_DURATION] = string_to_timedelta(self._data[CONF_MIN_CYCLE_DURATION]) 236 | final_data = {} 237 | for key in self._data.keys(): 238 | if self._data[key] != "" and self._data[key] != []: 239 | final_data.update({key: self._data[key]}) 240 | _LOGGER.debug("Data are valid. Proceed with entity creation. - %s", final_data) 241 | return self.async_create_entry(title="", data=final_data) 242 | #return self.hass.config_entries.async_update_entry(self, entry=self.config_entry, data=final_data, unique_id=self.config_entry.unique_id) 243 | _LOGGER.warning("Wrong data have been input in the last form") 244 | return await self._show_config_form_final(user_input) 245 | return await self._show_config_form_final(user_input) 246 | 247 | """ SHOW LAST FORM """ 248 | async def _show_config_form_final(self, user_input): 249 | """ Show form for config flow """ 250 | _LOGGER.info("Show final form") 251 | if user_input is None or user_input == {}: 252 | user_input = self._data 253 | #5 is necessary for options. Check config_schema.py for explanations. 254 | return self.async_show_form( 255 | step_id="final", 256 | data_schema=vol.Schema(get_config_flow_schema(user_input, 5)), 257 | errors=self._errors 258 | ) 259 | 260 | ##################################################### 261 | #################### EMPTY FLOW #################### 262 | ##################################################### 263 | class EmptyOptions(config_entries.OptionsFlow): 264 | """A class for default options. Not sure why this is required.""" 265 | 266 | def __init__(self, config_entry): 267 | """Just set the config_entry parameter.""" 268 | self.config_entry = config_entry 269 | 270 | ##################################################### 271 | ############## DATA VALIDATION FUCTION ############## 272 | ##################################################### 273 | def are_first_step_data_valid(self, user_input) -> bool: 274 | _LOGGER.debug("entered in data validation first") 275 | if (user_input[CONF_HEATER] == "" and user_input[CONF_COOLER] == "") or (user_input[CONF_HEATER] == "null" and user_input[CONF_COOLER] == "null"): 276 | self._errors["base"]="heater and cooler" 277 | return False 278 | else: 279 | if user_input[CONF_HEATER] != "" and user_input[CONF_HEATER] != "null": 280 | if not are_entities_valid(self, user_input[CONF_HEATER]): 281 | self._errors["base"]="heater wrong" 282 | return False 283 | if user_input[CONF_COOLER] != "" and user_input[CONF_COOLER] != "null": 284 | if not are_entities_valid(self, user_input[CONF_COOLER]): 285 | self._errors["base"]="cooler wrong" 286 | return False 287 | if not are_entities_valid(self, user_input[CONF_SENSOR]): 288 | self._errors["base"]="sensor wrong" 289 | return False 290 | if not are_entities_valid(self, user_input[CONF_TARGET]): 291 | self._errors["base"]="target wrong" 292 | return False 293 | return True 294 | 295 | def are_second_step_data_valid(self, user_input) -> bool: 296 | if user_input[CONF_MIN_TEMP] == "" or user_input[CONF_MAX_TEMP] == "" or user_input[CONF_TOLERANCE] == "": 297 | self._errors["base"]="missing_data" 298 | return False 299 | if not user_input[CONF_MIN_TEMP] 0) or (not user_input[CONF_TOLERANCE] < abs(user_input[CONF_MIN_TEMP])): 303 | self._errors["base"]="tolerance" 304 | return False 305 | return True 306 | 307 | def are_third_step_data_valid(self, user_input) -> bool: 308 | if user_input[CONF_RELATED_CLIMATE] != "" and user_input[CONF_RELATED_CLIMATE] != "null": 309 | if not are_entities_valid(self, user_input[CONF_RELATED_CLIMATE]) or not user_input[CONF_RELATED_CLIMATE][:8:] == "climate." : 310 | self._errors["base"] = "related climate" 311 | return False 312 | if user_input[CONF_MIN_CYCLE_DURATION] != "" and user_input[CONF_MIN_CYCLE_DURATION] != "null": 313 | check = re.match(REGEX_STRING, user_input[CONF_MIN_CYCLE_DURATION]) 314 | _LOGGER.debug("check: %s", check) 315 | if check == None: 316 | _LOGGER.debug("enter in regex") 317 | self._errors["base"] = "duration error" 318 | return False 319 | return True 320 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/config_schema.py: -------------------------------------------------------------------------------- 1 | import voluptuous as vol 2 | import logging 3 | import homeassistant.helpers.config_validation as cv 4 | from homeassistant.components.climate import ( 5 | ClimateEntityFeature 6 | ) 7 | from homeassistant.const import CONF_NAME, CONF_ENTITIES 8 | from .const import ( 9 | DOMAIN, 10 | DEFAULT_TOLERANCE, 11 | DEFAULT_NAME, 12 | DEFAULT_MAX_TEMP, 13 | DEFAULT_MIN_TEMP, 14 | DEFAULT_HVAC_OPTIONS, 15 | DEFAULT_AUTO_MODE, 16 | DEFAULT_MIN_CYCLE_DURATION, 17 | MAX_HVAC_OPTIONS, 18 | AUTO_MODE_OPTIONS, 19 | INITIAL_HVAC_MODE_OPTIONS, 20 | INITIAL_HVAC_MODE_OPTIONS_OPTFLOW 21 | ) 22 | from .helpers import dict_to_string 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | CONF_HEATER = 'heater' 27 | CONF_COOLER = 'cooler' 28 | CONF_SENSOR = 'actual_temp_sensor' 29 | CONF_MIN_TEMP = 'min_temp' 30 | CONF_MAX_TEMP = 'max_temp' 31 | CONF_TARGET = 'target_temp_sensor' 32 | CONF_TOLERANCE = 'tolerance' 33 | CONF_INITIAL_HVAC_MODE = 'initial_hvac_mode' 34 | CONF_RELATED_CLIMATE = 'related_climate' 35 | CONF_HVAC_OPTIONS = 'hvac_options' 36 | CONF_AUTO_MODE = 'auto_mode' 37 | CONF_MIN_CYCLE_DURATION = 'min_cycle_duration' 38 | SUPPORT_FLAGS = (ClimateEntityFeature.TARGET_TEMPERATURE|ClimateEntityFeature.TURN_OFF|ClimateEntityFeature.TURN_ON) 39 | 40 | CLIMATE_SCHEMA = { 41 | vol.Optional(CONF_HEATER): cv.entity_ids, 42 | vol.Optional(CONF_COOLER): cv.entity_ids, 43 | vol.Required(CONF_SENSOR): cv.entity_id, 44 | vol.Required(CONF_TARGET): cv.entity_id, 45 | vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), 46 | vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), 47 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 48 | vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), 49 | vol.Optional(CONF_RELATED_CLIMATE): cv.entity_ids, 50 | vol.Optional(CONF_HVAC_OPTIONS, default=DEFAULT_HVAC_OPTIONS): vol.In(range(MAX_HVAC_OPTIONS)), 51 | vol.Optional(CONF_AUTO_MODE, default=DEFAULT_AUTO_MODE): vol.In(AUTO_MODE_OPTIONS), 52 | vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In(INITIAL_HVAC_MODE_OPTIONS), 53 | vol.Optional(CONF_MIN_CYCLE_DURATION): cv.positive_time_period 54 | } 55 | 56 | def get_config_flow_schema(config: dict = {}, config_flow_step: int = 0) -> dict: 57 | if not config: 58 | config = { 59 | CONF_NAME: DEFAULT_NAME, 60 | CONF_HEATER: "", 61 | CONF_COOLER: "", 62 | CONF_SENSOR: "", 63 | CONF_TARGET: "", 64 | CONF_MAX_TEMP: DEFAULT_MAX_TEMP, 65 | CONF_MIN_TEMP: DEFAULT_MIN_TEMP, 66 | CONF_TOLERANCE: DEFAULT_TOLERANCE, 67 | CONF_RELATED_CLIMATE: "", 68 | CONF_HVAC_OPTIONS: DEFAULT_HVAC_OPTIONS, 69 | CONF_AUTO_MODE: DEFAULT_AUTO_MODE, 70 | CONF_INITIAL_HVAC_MODE: "", 71 | CONF_MIN_CYCLE_DURATION: DEFAULT_MIN_CYCLE_DURATION 72 | } 73 | if config_flow_step==1: 74 | return { 75 | vol.Optional(CONF_NAME, default=config.get(CONF_NAME)): str, 76 | vol.Optional(CONF_HEATER, default=config.get(CONF_HEATER)): str, 77 | vol.Optional(CONF_COOLER, default=config.get(CONF_COOLER)): str, 78 | vol.Required(CONF_SENSOR, default=config.get(CONF_SENSOR)): str, 79 | vol.Required(CONF_TARGET, default=config.get(CONF_TARGET)): str 80 | } 81 | elif config_flow_step==4: 82 | #identical to step 1 but without NAME (better to not change it since it will break configuration) 83 | #this is used for options flow only 84 | return { 85 | vol.Optional(CONF_HEATER, default=config.get(CONF_HEATER)): str, 86 | vol.Optional(CONF_COOLER, default=config.get(CONF_COOLER)): str, 87 | vol.Required(CONF_SENSOR, default=config.get(CONF_SENSOR)): str, 88 | vol.Required(CONF_TARGET, default=config.get(CONF_TARGET)): str 89 | } 90 | elif config_flow_step==2: 91 | return { 92 | vol.Required(CONF_MAX_TEMP, default=config.get(CONF_MAX_TEMP)): int, 93 | vol.Required(CONF_MIN_TEMP, default=config.get(CONF_MIN_TEMP)): int, 94 | vol.Required(CONF_TOLERANCE, default=config.get(CONF_TOLERANCE)): float 95 | } 96 | elif config_flow_step==3: 97 | return { 98 | vol.Optional(CONF_RELATED_CLIMATE, default=config.get(CONF_RELATED_CLIMATE)): str, 99 | vol.Required(CONF_HVAC_OPTIONS, default=config.get(CONF_HVAC_OPTIONS)): vol.In(range(MAX_HVAC_OPTIONS)), 100 | vol.Required(CONF_AUTO_MODE, default=config.get(CONF_AUTO_MODE)): vol.In(AUTO_MODE_OPTIONS), 101 | vol.Optional(CONF_INITIAL_HVAC_MODE, default=config.get(CONF_INITIAL_HVAC_MODE)): vol.In(INITIAL_HVAC_MODE_OPTIONS), 102 | vol.Optional(CONF_MIN_CYCLE_DURATION, default=config.get(CONF_MIN_CYCLE_DURATION)): str 103 | } 104 | elif config_flow_step==5: 105 | #identical to 3 but with CONF_MIN_CYCLE_DURATION converted in string from dict (necessary since it is always set as null if not used) 106 | #this is used for options flow only 107 | return { 108 | vol.Optional(CONF_RELATED_CLIMATE, default=config.get(CONF_RELATED_CLIMATE)): str, 109 | vol.Required(CONF_HVAC_OPTIONS, default=config.get(CONF_HVAC_OPTIONS)): vol.In(range(MAX_HVAC_OPTIONS)), 110 | vol.Required(CONF_AUTO_MODE, default=config.get(CONF_AUTO_MODE)): vol.In(AUTO_MODE_OPTIONS), 111 | vol.Optional(CONF_INITIAL_HVAC_MODE, default=config.get(CONF_INITIAL_HVAC_MODE)): vol.In(INITIAL_HVAC_MODE_OPTIONS_OPTFLOW), 112 | vol.Optional(CONF_MIN_CYCLE_DURATION, default=dict_to_string(config.get(CONF_MIN_CYCLE_DURATION))): str 113 | } 114 | 115 | return {} 116 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/const.py: -------------------------------------------------------------------------------- 1 | """Programmable thermostat's constant """ 2 | from homeassistant.components.climate import HVACMode 3 | from homeassistant.const import Platform 4 | 5 | #Generic 6 | VERSION = '8.6' 7 | DOMAIN = 'programmable_thermostat' 8 | PLATFORM = [Platform.CLIMATE] 9 | ISSUE_URL = 'https://github.com/custom-components/climate.programmable_thermostat/issues' 10 | CONFIGFLOW_VERSION = 4 11 | 12 | 13 | #Defaults 14 | DEFAULT_TOLERANCE = 0.5 15 | DEFAULT_NAME = 'Programmable Thermostat' 16 | DEFAULT_MAX_TEMP = 40 17 | DEFAULT_MIN_TEMP = 5 18 | DEFAULT_HVAC_OPTIONS = 7 19 | DEFAULT_AUTO_MODE = 'all' 20 | DEFAULT_MIN_CYCLE_DURATION = '' 21 | 22 | #Others 23 | MAX_HVAC_OPTIONS = 8 24 | AUTO_MODE_OPTIONS = ['all', 'heating', 'cooling'] 25 | INITIAL_HVAC_MODE_OPTIONS = ['', HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF, HVACMode.HEAT_COOL] 26 | INITIAL_HVAC_MODE_OPTIONS_OPTFLOW = ['null', HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF, HVACMode.HEAT_COOL] 27 | REGEX_STRING = r'((?P\d+?):(?=(\d+?:\d+?)))?((?P\d+?):)?((?P\d+?))?$' 28 | 29 | #Attributes 30 | ATTR_HEATER_IDS = "heater_ids" 31 | ATTR_COOLER_IDS = "cooler_ids" 32 | ATTR_SENSOR_ID = "sensor_id" 33 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from .const import REGEX_STRING 4 | from datetime import timedelta, datetime 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | 8 | def are_entities_valid(self, entities_list) -> bool: 9 | """ To validate the existence of the entities list """ 10 | entities = string_to_list(entities_list) 11 | for entity in entities: 12 | try: 13 | self.hass.states.get(entity).state 14 | except: 15 | return False 16 | return True 17 | 18 | def string_to_list(string): 19 | """ To convert a string of entities diveded by commas into a list. """ 20 | if string is None or string == "": 21 | return [] 22 | return list(map(lambda x: x.strip(), string.split(","))) 23 | 24 | def string_to_timedelta(string): 25 | """ to convert a string with format hh:mm:ss or mm:ss into a timedelta data. """ 26 | string = re.match(REGEX_STRING, string) 27 | if string is None or string == "": 28 | return [] 29 | string = string.groupdict() 30 | return string 31 | 32 | def dict_to_string(time_delta: dict = {}) -> str: 33 | """ to convert a dict like {'hours': hh, 'minutes': mm, 'seconds': ss} into a string hh:mm:ss. 34 | dict is expected sorted as hours-minutes-seconds """ 35 | result = '' 36 | for key in time_delta.keys(): 37 | if time_delta[key] == None: 38 | result = result + '00:' 39 | else: 40 | result = result + str(time_delta[key]) + ':' 41 | return result[0:len(result)-1:] 42 | 43 | def dict_to_timedelta(string): 44 | """ to convert dict (mappingproxy) like {'hours': hh, 'minutes': mm, 'seconds': ss} into a timedelta's class element. """ 45 | time_params = {} 46 | for name in string.keys(): 47 | if string[name]: 48 | time_params[name] = int(string[name]) 49 | return timedelta(**time_params) 50 | 51 | def null_data_cleaner(original_data: dict, data: dict) -> dict: 52 | """ this is to remove all null parameters from data that are added during option flow """ 53 | for key in data.keys(): 54 | if data[key] == "null": 55 | original_data[key] = "" 56 | else: 57 | original_data[key]=data[key] 58 | return original_data 59 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "programmable_thermostat", 3 | "name": "Programmable Thermostat", 4 | "documentation": "https://github.com/custom-components/climate.programmable_thermostat", 5 | "issue_tracker": "https://github.com/custom-components/climate.programmable_thermostat/issues", 6 | "dependencies": [ 7 | "sensor", 8 | "switch" 9 | ], 10 | "codeowners": [], 11 | "requirements": [], 12 | "config_flow": true, 13 | "iot_class": "local_polling", 14 | "version": "8.3" 15 | } 16 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | description: Reload all programmable_thermostat entities. 3 | name: reload programmable thermostat 4 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Programmable thermostat - entities definition", 6 | "description": "Define entities connected to the thermostat. Documentation at https://github.com/custom-components/climate.programmable_thermostat", 7 | "data": { 8 | "name": "Name", 9 | "heater": "Heaters (comma separated name of entities)", 10 | "cooler": "Coolers (comma separated name of entities)", 11 | "actual_temp_sensor": "Room temperature sensor (entity name)", 12 | "target_temp_sensor": "Planned temperature sensor (entity name)" 13 | } 14 | }, 15 | "second": { 16 | "title": "Programmable thermostat - temperature definition", 17 | "description": "Define min and max temperature and the delta activation. Documentation at https://github.com/custom-components/climate.programmable_thermostat", 18 | "data": { 19 | "min_temp": "Min temperature allowed", 20 | "max_temp": "Max temperature allowed", 21 | "tolerance": "Activation temperature delta (tenth has to different from 0)" 22 | } 23 | }, 24 | "final": { 25 | "title": "Programmable thermostat - options definition", 26 | "description": "Define additional options. Documentation at https://github.com/custom-components/climate.programmable_thermostat", 27 | "data": { 28 | "initial_hvac_mode": "Startup mode", 29 | "related_climate": "Climate entity connect to this one (entity name)", 30 | "hvac_options": "Allowed modes", 31 | "auto_mode": "Automatic mode type", 32 | "min_cycle_duration": "Minimum cycle duration (hh:mm:ss or mm:ss)" 33 | } 34 | } 35 | }, 36 | "error": { 37 | "name": "Choose a name", 38 | "heater and cooler": "Valorize at least one between heaters and coolers", 39 | "heater wrong": "One or more heaters are not existing into Home Assistant", 40 | "cooler wrong": "One or more coolers are not existing into Home Assistant", 41 | "sensor wrong": "Room temperature sensor is not existing into Home Assistant", 42 | "target wrong": "Plannned temperature sensor is not existing into Home Assistant", 43 | "min_temp": "Min temperature has to be lower then max one", 44 | "tolerance": "Activation delta has to be positive, lower then min temperature and with tenth different from 0", 45 | "related climate": "Climate entity that you want to connect is not valid or not existing", 46 | "duration error": "Time delta format is wrong. accepted format are hh:mm:ss or mm:ss", 47 | "missing_data": "Missing data. All data of this form are mandatry" 48 | }, 49 | "abort": { 50 | "single_instance_allowed": "Issue during configuration" 51 | } 52 | }, 53 | "options": { 54 | "step": { 55 | "init": { 56 | "title": "Programmable thermostat - entities definition", 57 | "description": "Define entities connected to the thermostat. Documentation at https://github.com/custom-components/climate.programmable_thermostat", 58 | "data": { 59 | "name": "Name", 60 | "heater": "Heaters (comma separated name of entities) - null to keep it empty", 61 | "cooler": "Coolers (comma separated name of entities) - null to keep it empty", 62 | "actual_temp_sensor": "Room temperature sensor (entity name)", 63 | "target_temp_sensor": "Planned temperature sensor (entity name)" 64 | } 65 | }, 66 | "second": { 67 | "title": "Programmable thermostat - temperature definition", 68 | "description": "Define min and max temperature and the delta activation. Documentation at https://github.com/custom-components/climate.programmable_thermostat", 69 | "data": { 70 | "min_temp": "Min temperature allowed", 71 | "max_temp": "Max temperature allowed", 72 | "tolerance": "Activation temperature delta (tenth has to different from 0)" 73 | } 74 | }, 75 | "final": { 76 | "title": "Programmable thermostat - options definition", 77 | "description": "Define additional options. Documentation at https://github.com/custom-components/climate.programmable_thermostat", 78 | "data": { 79 | "initial_hvac_mode": "Startup mode - null to keep it empty", 80 | "related_climate": "Climate entity connect to this one (comma separated name of entities) - null to keep it empty", 81 | "hvac_options": "Allowed modes", 82 | "auto_mode": "Automatic mode type", 83 | "min_cycle_duration": "Minimum cycle duration (hh:mm:ss or mm:ss) - null to keep it empty" 84 | } 85 | } 86 | }, 87 | "error": { 88 | "name": "Choose a name", 89 | "heater and cooler": "Valorize at least one between heaters and coolers", 90 | "heater wrong": "One or more heaters are not existing into Home Assistant", 91 | "cooler wrong": "One or more coolers are not existing into Home Assistant", 92 | "sensor wrong": "Room temperature sensor is not existing into Home Assistant", 93 | "target wrong": "Plannned temperature sensor is not existing into Home Assistant", 94 | "min_temp": "Min temperature has to be lower then max one", 95 | "tolerance": "Activation delta has to be positive, lower then min temperature and with tenth different from 0", 96 | "related climate": "Climate entity that you want to connect is not valid or not existing", 97 | "duration error": "Time delta format is wrong. accepted format are hh:mm:ss or mm:ss", 98 | "missing_data": "Missing data. All data of this form are mandatry" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /custom_components/programmable_thermostat/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Termostato programmabile - definizione entità", 6 | "description": "Definisci le entità collegate al termostato. Documentazione su https://github.com/custom-components/climate.programmable_thermostat", 7 | "data": { 8 | "name": "Nome", 9 | "heater": "Riscaldamento (nomi entità separate da virgole)", 10 | "cooler": "Raffrescamento (nomi entità separate da virgole)", 11 | "actual_temp_sensor": "Sensore di temperatura della stanza (nome entità)", 12 | "target_temp_sensor": "Sensore della temperatura pianificata (nome entità)" 13 | } 14 | }, 15 | "second": { 16 | "title": "Termostato programmabile - definizione temperature", 17 | "description": "Definisci i le temperature massime e minime utilizzabili e il delta di attivazione. Documentazione su https://github.com/custom-components/climate.programmable_thermostat", 18 | "data": { 19 | "min_temp": "Temperatura minima di esercizio", 20 | "max_temp": "Temperatura massima di esercizio", 21 | "tolerance": "Delta di temperatura per attivazione termostato (decimale obbligatorio diverso da 0)" 22 | } 23 | }, 24 | "final": { 25 | "title": "Termostato programmabile - definizione opzioni", 26 | "description": "Definisci le opzioni aggiuntive. Documentazione su https://github.com/custom-components/climate.programmable_thermostat", 27 | "data": { 28 | "initial_hvac_mode": "Modalità di lavoro all'avvio", 29 | "related_climate": "Altra entità climate legata a questa (nome entità)", 30 | "hvac_options": "Modalità di lavoro attive", 31 | "auto_mode": "Modalità di lavoro in automatico", 32 | "min_cycle_duration": "Durata minima del ciclo (hh:mm:ss o mm:ss)" 33 | } 34 | } 35 | }, 36 | "error": { 37 | "name": "Inserisci un nome", 38 | "heater and cooler": "Inserisci almeno una tra riscaldamento e condizionamento", 39 | "heater wrong": "Una o più entità di riscaldamento non esistono in Home Assistant", 40 | "cooler wrong": "Una o più entità di condizionamento non esistono in Home Assistant", 41 | "sensor wrong": "L'entità del sensore di temperatura non esiste in Home Assistant", 42 | "target wrong": "L'entità del programma di temperatura non esiste in Home Assistant", 43 | "min_temp": "La temperatura minima deve essere minore della massima", 44 | "tolerance": "Il delta di attivazione deve essere positivo, minore della temperatura minima e con decimale diverso da 0", 45 | "related climate": "L'entità climate che vuoi collegare non è valida o inesistente", 46 | "duration error": "Il delta di tempo definito non è valido. I formati accettati sono hh:mm:ss or mm:ss", 47 | "missing_data": "Dati mancanti. Tutti i dati di questo form sono obbligatori" 48 | }, 49 | "abort": { 50 | "single_instance_allowed": "Problema durante la configurazione" 51 | } 52 | }, 53 | "options": { 54 | "step": { 55 | "init": { 56 | "title": "Termostato programmabile - definizione entità", 57 | "description": "Definisci le entità collegate al termostato. Documentazione su https://github.com/custom-components/climate.programmable_thermostat", 58 | "data": { 59 | "name": "Nome", 60 | "heater": "Riscaldamento (nomi entità separate da virgole) - null per lasciare vuota la cella", 61 | "cooler": "Raffrescamento (nomi entità separate da virgole) - null per lasciare vuota la cella", 62 | "actual_temp_sensor": "Sensore di temperatura della stanza (nome entità)", 63 | "target_temp_sensor": "Sensore della temperatura pianificata (nome entità)" 64 | } 65 | }, 66 | "second": { 67 | "title": "Termostato programmabile - definizione temperature", 68 | "description": "Definisci i le temperature massime e minime utilizzabili e il delta di attivazione. Documentazione su https://github.com/custom-components/climate.programmable_thermostat", 69 | "data": { 70 | "min_temp": "Temperatura minima di esercizio", 71 | "max_temp": "Temperatura massima di esercizio", 72 | "tolerance": "Delta di temperatura per attivazione termostato (decimale obbligatorio diverso da 0)" 73 | } 74 | }, 75 | "final": { 76 | "title": "Termostato programmabile - definizione opzioni", 77 | "description": "Definisci le opzioni aggiuntive. Documentazione su https://github.com/custom-components/climate.programmable_thermostat", 78 | "data": { 79 | "initial_hvac_mode": "Modalità di lavoro all'avvio - null per lasciare vuota la cella", 80 | "related_climate": "Altra entità climate legata a questa (nomi entità separate da virgole) - null per lasciare vuota la cella", 81 | "hvac_options": "Modalità di lavoro attive", 82 | "auto_mode": "Modalità di lavoro in automatico", 83 | "min_cycle_duration": "Durata minima del ciclo (hh:mm:ss o mm:ss) - null per lasciare vuota la cella" 84 | } 85 | } 86 | }, 87 | "error": { 88 | "name": "Inserisci un nome", 89 | "heater and cooler": "Inserisci almeno una tra riscaldamento e condizionamento", 90 | "heater wrong": "Una o più entità di riscaldamento non esistono in Home Assistant", 91 | "cooler wrong": "Una o più entità di condizionamento non esistono in Home Assistant", 92 | "sensor wrong": "L'entità del sensore di temperatura non esiste in Home Assistant", 93 | "target wrong": "L'entità del programma di temperatura non esiste in Home Assistant", 94 | "min_temp": "La temperatura minima deve essere minore della massima", 95 | "tolerance": "Il delta di attivazione deve essere positivo, minore della temperatura minima e con decimale diverso da 0", 96 | "related climate": "L'entità climate che vuoi collegare non è valida o inesistente", 97 | "duration error": "Il delta di tempo definito non è valido. I formati accettati sono hh:mm:ss or mm:ss", 98 | "missing_data": "Dati mancanti. Tutti i dati di questo form sono obbligatori" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Programmable Thermostat" 3 | } 4 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # PROGRAMMABLE THERMOSTAT 2 | This component is a revision of the official Home Assistant component 'Generic Thermostat' in order to have possibility to have target temperature variable according to a sensor state value. 3 | 4 | ## EXAMPLE OF SETUP 5 | Config flow is available, so just configure all the entities you want through the user interface. 6 | 7 | Here below the example of manual setup of sensor and parameters to configure. 8 | ```yaml 9 | climate: 10 | - platform: programmable_thermostat 11 | name: room 12 | heater: 13 | - switch.riscaldamento_1 14 | - switch.riscaldamento_2 15 | cooler: switch.condizionamento 16 | actual_temp_sensor: sensor.target_temperature 17 | min_temp: 10 18 | max_temp: 30 19 | target_temp_sensor: sensor.program_temperature 20 | tolerance: 0.3 21 | related_climate: climate.room_2 22 | hvac_options: 7 23 | auto_mode: all 24 | min_cycle_duration: 25 | seconds: 20 26 | ``` 27 | 28 | Field | Value | Necessity | Comments 29 | --- | --- | --- | --- 30 | platform | `programmable_thermostat` | *Required* | 31 | name| Programmable Thermostat | Optional | 32 | heater | | *Conditional* | Switch that will activate/deactivate the heating system. This can be a single entity or a list of entities. At least one between `heater` and `cooler` has to be defined. 33 | cooler | | *Conditional* | Switch that will activate/deactivate the cooling system. This can be a single entity or a list of entities. At least one between `heater` and `cooler` has to be defined. 34 | actual_temp_sensor | | *Required* | Sensor of actual room temperature. 35 | min_temp | 5 | Optional | Minimum temperature manually selectable. 36 | max_temp | 40 | Optional | Maximum temperature manually selectable. 37 | target_temp_sensor | | *Required* | Sensor that rapresent the desired temperature for the room. Suggestion: use my [`file_restore`][1] compontent or something similar. 38 | tolerance | 0.5 | Optional | Tolerance for turn on and off the switches mode. 39 | initial_hvac_mode | `heat_cool`, `heat`, `cool`, `off` | Optional | If not set, components will restore old state after restart. I suggest to not use it. 40 | related_climate | | Optional | To be used if the climate object is a slave of an other one. below 'Related climate' chapter a description. 41 | hvac_options | 7 | Optional | This define which combination of manual-auto-off options you want to active. Refer to chapter below for the value. 42 | auto_mode | `all`, `heating`, `cooling` | Optional | This allows to limit the the heating/cooling function with HVAC mode HEAT_COOL. 43 | min_cycle_duration | | Optional | TIMEDELTA type. This will allow to protect devices that request a minimum type of work/rest before changing status. On this you have to define hours, minutes, seconds as son elements. 44 | 45 | ## SPECIFICITIES 46 | ### TARGET TEMPERATURE SENSOR 47 | `target_temp_sensor` is the Home Assistant `entity_id` of a sensor which state change accrodingly a specified temperature profile. This temperature profile should described the desired temperature for the room each day/hour. 48 | `target_temp_sensor` must have a temperature value (number with or without decimal) as state. 49 | 50 | Suggestion: use my [`file_restore`][1] custom components. 51 | 52 | ### ADDITIONAL INFO 53 | Programmed temperature will change accordingly to the one set by the `target_temp_sensor` when in `heat_cool` mode. You can still change it temporarly with the slider. Target temperature will be set, again, to the one of `target_temp_sensor` at its first change. 54 | `heat` and `cool` modes are the manual mode; in this mode the planning will not be followed. 55 | 56 | After a restart of Home Assistant, room temperature and planned room temperature will match till `actual_temp_sensor` will return a temperature value. 57 | This is done to avoid possible issues with Homekit support with temperature sensor that need some time to sync with Home Assistant. 58 | 59 | ### RELATED CLIMATE 60 | This field is used if the climate 2 climate object are related each other, for example if they used the same heater. 61 | Set this field with the `entity_id` with a different climate object and this will prevent the heater/cooler to be turned off by the slavery climate if the master one is active. 62 | 63 | For example I have 2 climate objects, one for the room and one for the boiler. 64 | Boiler's climate is used to prevent freezing and, if the temperature is lower the the programmed one, room heater is turned on. 65 | This means that, if the room's heater is on and boiler's heater is off, boiler will turn off the heater despite the room one. 66 | With this `master_climate` field this unwanted turn off will not happen. 67 | 68 | Note: my suggestion is to set it to both climates that are related each other. 69 | 70 | ### HVAC OPTIONS 71 | This parameter allows you to define which mode you want to activate for that climate object. This is a number with a meaning of each single bit. Here below the table. 72 | 73 | bit3 - AUTOMATIC | bit2 - MANUAL | bit1 - OFF | RESULT | Meaning 74 | --- | --- | --- | --- | --- 75 | 0 | 0 | 0 | 0 | Noting active - USELESS 76 | 0 | 0 | 1 | 1 | OFF only 77 | 0 | 1 | 0 | 2 | MANUAL only, you will have only `heat` and/or `cool` modes 78 | 0 | 1 | 1 | 3 | MANUAL and OFF 79 | 1 | 0 | 0 | 4 | AUTOMATIC only, you will have only `heat_cool` modes 80 | 1 | 0 | 1 | 5 | AUTOMATIC and OFF 81 | 1 | 1 | 0 | 6 | AUTOMATIC and MANUAL 82 | 1 | 1 | 1 | 7 | DEAFAULT - Full mode, you will have active all the options. 83 | 84 | ### HEATERS AND COOLER SPECIFITIES 85 | From version 7.6 you will be able to set `heaters` and `coolers` to the same list and you'll get the correct way of work in manual mode. 86 | This means that `heat` and `cool` mode will work correctly with the same list, but `heat_cool` mode will not (otherwise you will not be able to switch the real device between the 2 modes). 87 | My suggestion is to set `hvac_options: 3` to remove the auto mode. 88 | 89 | ## NOTE 90 | This component has been developed for the bigger project of building a smart thermostat using Home Assistant and way cheeper then the commercial ones. 91 | You can find more info on that [here][3] 92 | 93 | 94 | [1]: https://github.com/custom-components/sensor.file_restore 95 | [2]: https://github.com/MapoDan/home-assistant/blob/master/mapodanlogo.png 96 | [3]: https://github.com/MapoDan/home-assistant 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | # PEP 621 project metadata 3 | # See https://www.python.org/dev/peps/pep-0621/ 4 | dynamic = ["version"] 5 | #authors = [ {name = "TBD", email = "TBD"}, ] 6 | #license = {text = "TBD"} 7 | requires-python = ">=3.9.0" 8 | dependencies = [ 9 | ] 10 | name = "ha_programmable_thermostat" 11 | description = "TBD" 12 | readme = "README.md" 13 | classifiers=[ 14 | "Development Status :: 5 - Production/Stable", 15 | "Intended Audience :: Developers", 16 | #"License :: OSI Approved :: TBD", 17 | "Operating System :: Unix", 18 | "Operating System :: POSIX", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Topic :: Utilities", 24 | "Natural Language :: English", 25 | ] 26 | 27 | 28 | [tool.codespell] 29 | ignore-words-list = """ 30 | master, 31 | slave, 32 | hass""" 33 | skip = """ 34 | ./.*,./assets/*,./data/*,*.svg,*.css,*.json,*.js 35 | """ 36 | quiet-level=2 37 | ignore-regex = '\\[fnrstv]' 38 | builtin = "clear,rare,informal,usage,code,names" 39 | 40 | # --------- Pylint ------------- 41 | [tool.pylint.'TYPECHECK'] 42 | generated-members = "sh" 43 | 44 | [tool.pylint.'MESSAGES CONTROL'] 45 | extension-pkg-whitelist = "pydantic" 46 | disable = [ 47 | "broad-except", 48 | "invalid-name", 49 | "line-too-long", 50 | "missing-function-docstring", 51 | "missing-module-docstring", 52 | "too-few-public-methods", 53 | "too-many-arguments", 54 | "too-many-branches", 55 | "too-many-instance-attributes", 56 | "too-many-statements", 57 | "unused-import", 58 | "wrong-import-order", 59 | ] 60 | 61 | [tool.pylint.FORMAT] 62 | expected-line-ending-format = "LF" 63 | 64 | 65 | 66 | # --------- Mypy ------------- 67 | 68 | [tool.mypy] 69 | show_error_codes = true 70 | follow_imports = "silent" 71 | ignore_missing_imports = false 72 | strict_optional = true 73 | warn_redundant_casts = true 74 | warn_unused_ignores = true 75 | disallow_any_generics = true 76 | check_untyped_defs = true 77 | no_implicit_reexport = true 78 | warn_unused_configs = true 79 | disallow_subclassing_any = true 80 | disallow_incomplete_defs = true 81 | disallow_untyped_decorators = true 82 | disallow_untyped_calls = true 83 | disallow_untyped_defs = true 84 | plugins = [ 85 | "pydantic.mypy" 86 | ] 87 | 88 | [tool.pydantic-mypy] 89 | init_forbid_extra = true 90 | init_typed = true 91 | warn_required_dynamic_aliases = true 92 | warn_untyped_fields = true 93 | 94 | [[tool.mypy.overrides]] 95 | module = "tests.*" 96 | # Required to not have error: Untyped decorator makes function on fixtures and 97 | # parametrize decorators 98 | disallow_untyped_decorators = false 99 | 100 | [[tool.mypy.overrides]] 101 | #module = [ ] 102 | ignore_missing_imports = true 103 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/climate.programmable_thermostat/4f7d02a4a24f2235e3ccbf5b858e7e325dab8237/requirements_test.txt -------------------------------------------------------------------------------- /resources.json: -------------------------------------------------------------------------------- 1 | [ 2 | "https://raw.githubusercontent.com/custom-components/climate.programmable_thermostat/master/custom_components/programmable_thermostat/__init__.py", 3 | "https://raw.githubusercontent.com/custom-components/climate.programmable_thermostat/master/custom_components/programmable_thermostat/const.py", 4 | "https://raw.githubusercontent.com/custom-components/climate.programmable_thermostat/master/custom_components/programmable_thermostat/reproduce_state" 5 | ] 6 | --------------------------------------------------------------------------------