├── .gitattributes ├── requirements_dev.txt ├── example.png ├── custom_components └── multizone_generic_thermostat │ ├── services.yaml │ ├── __init__.py │ ├── manifest.json │ ├── binary_sensor.py │ └── climate.py ├── hacs.json ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .devcontainer ├── configuration.yaml ├── devcontainer.json └── README.md ├── .github ├── workflows │ ├── cron.yaml │ ├── pull.yml │ └── push.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── LICENSE ├── updates.txt ├── setup.cfg ├── CONTRIBUTING.md ├── info.md ├── pyscript └── thermostatautomation.py └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant 2 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tpacri/multizone_generic_thermostat/HEAD/example.png -------------------------------------------------------------------------------- /custom_components/multizone_generic_thermostat/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | description: Reload all multizone_generic_thermostat entities. 3 | -------------------------------------------------------------------------------- /custom_components/multizone_generic_thermostat/__init__.py: -------------------------------------------------------------------------------- 1 | """The generic_thermostat component.""" 2 | 3 | DOMAIN = "multizone_generic_thermostat" 4 | PLATFORMS = ["climate"] 5 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multizone_generic_thermostat", 3 | "domains": [ 4 | "climate", 5 | "switch" 6 | ], 7 | "iot_class": "Local Polling", 8 | "homeassistant": "2021.2.3" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "/usr/local/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.multizone_generic_thermostat: debug 7 | 8 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 9 | # debugpy: 10 | -------------------------------------------------------------------------------- /custom_components/multizone_generic_thermostat/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "multizone_generic_thermostat", 3 | "name": "Multizone Generic Thermostat", 4 | "documentation": "https://github.com/tpacri/multizone_generic_thermostat/blob/main/README.md", 5 | "issue_tracker": "https://github.com/tpacri/multizone_generic_thermostat/issues", 6 | "dependencies": [ "sensor", "switch", "binary_sensor" ], 7 | "codeowners": [ "@tpacri" ], 8 | "version": "1.0" 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | name: Cron actions 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | validate: 9 | runs-on: "ubuntu-latest" 10 | name: Validate 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | ignore: brands 19 | 20 | - name: Hassfest validation 21 | uses: "home-assistant/actions/hassfest@master" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ludeeus/container:integration-debian", 4 | "name": "multizone_generic_thermostat integration development", 5 | "context": "..", 6 | "appPort": [ 7 | "9123:8123" 8 | ], 9 | "postCreateCommand": "container install", 10 | "extensions": [ 11 | "ms-python.python", 12 | "github.vscode-pull-request-github", 13 | "ryanluker.vscode-coverage-gutters", 14 | "ms-python.vscode-pylance" 15 | ], 16 | "settings": { 17 | "files.eol": "\n", 18 | "editor.tabSize": 4, 19 | "terminal.integrated.shell.linux": "/bin/bash", 20 | "python.pythonPath": "/usr/bin/python3", 21 | "python.analysis.autoSearchPaths": false, 22 | "python.linting.pylintEnabled": true, 23 | "python.linting.enabled": true, 24 | "python.formatting.provider": "black", 25 | "editor.formatOnPaste": false, 26 | "editor.formatOnSave": true, 27 | "editor.formatOnType": true, 28 | "files.trimTrailingWhitespace": true 29 | } 30 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | 26 | Add your logs here. 27 | 28 | ``` 29 | 30 | ## Describe the bug 31 | A clear and concise description of what the bug is. 32 | 33 | 34 | ## Debug log 35 | 36 | 37 | 38 | ```text 39 | 40 | Add your logs here. 41 | 42 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tipa Cristian @tpacri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /updates.txt: -------------------------------------------------------------------------------- 1 | 1. Based on Generic thermostat 2 | This componenet is compatibe with Generic Thermostat config. 3 | 4 | 1. Added support for zones that can be bound to fixed temperature or to a sensor/input_number that can be used to adjust temperature in a specific zone. 5 | If we set 3 zones and each one is boud to different temperature sensor and different input_number sensor then thermostat will trigger heating only when any of the 3 zones is too cold. 6 | 7 | 2. Added support for presets 8 | User can define presets, and in each preset can define a list of zones. Thermostat will use only the selected Preset for controlling heating/cooling. Presets can be changed manually 9 | 10 | 3. Added a hack in presets to allow display on default termostat card the coldest zone based on the set limits (check pictures). Thic can be achieved only by setting report_zone_name_instead_preset_name: true in a preset. With this hack on, you will see the coldest zone name on the thermostat card like in the picture and know what zone triggered heating, or what zone is about to trigger heating 11 | 12 | 4. Added option to ignore specific sensors for being used in "open window" detection -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=88 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.multizone_generic_thermostat 35 | combine_as_imports = true 36 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull actions 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | validate: 8 | runs-on: "ubuntu-latest" 9 | name: Validate 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | 13 | - name: HACS validation 14 | uses: "hacs/action@main" 15 | with: 16 | category: "integration" 17 | ignore: brands 18 | 19 | - name: Hassfest validation 20 | uses: "home-assistant/actions/hassfest@master" 21 | 22 | style: 23 | runs-on: "ubuntu-latest" 24 | name: Check style formatting 25 | steps: 26 | - uses: "actions/checkout@v2" 27 | - uses: "actions/setup-python@v1" 28 | with: 29 | python-version: "3.x" 30 | - run: python3 -m pip install black 31 | - run: black . 32 | 33 | # tests: 34 | # runs-on: "ubuntu-latest" 35 | # name: Run tests 36 | # steps: 37 | # - name: Check out code from GitHub 38 | # uses: "actions/checkout@v2" 39 | # - name: Setup Python 40 | # uses: "actions/setup-python@v1" 41 | # with: 42 | # python-version: "3.8" 43 | # - name: Install requirements 44 | # run: python3 -m pip install -r requirements_test.txt 45 | # - name: Run tests 46 | # run: | 47 | # pytest \ 48 | # -qq \ 49 | # --timeout=9 \ 50 | # --durations=10 \ 51 | # -n auto \ 52 | # --cov custom_components.integration_blueprint \ 53 | # -o console_output_style=count \ 54 | # -p no:sugar \ 55 | # tests 56 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push actions 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | - dev 9 | 10 | jobs: 11 | validate: 12 | runs-on: "ubuntu-latest" 13 | name: Validate 14 | steps: 15 | - uses: "actions/checkout@v2" 16 | 17 | - name: HACS validation 18 | uses: "hacs/action@main" 19 | with: 20 | category: "integration" 21 | ignore: brands 22 | 23 | - name: Hassfest validation 24 | uses: "home-assistant/actions/hassfest@master" 25 | 26 | style: 27 | runs-on: "ubuntu-latest" 28 | name: Check style formatting 29 | steps: 30 | - uses: "actions/checkout@v2" 31 | - uses: "actions/setup-python@v1" 32 | with: 33 | python-version: "3.x" 34 | - run: python3 -m pip install black 35 | - run: black . 36 | 37 | # tests: 38 | # runs-on: "ubuntu-latest" 39 | # name: Run tests 40 | # steps: 41 | # - name: Check out code from GitHub 42 | # uses: "actions/checkout@v2" 43 | # - name: Setup Python 44 | # uses: "actions/setup-python@v1" 45 | # with: 46 | # python-version: "3.8" 47 | # - name: Install requirements 48 | # run: python3 -m pip install -r requirements_test.txt 49 | # - name: Run tests 50 | # run: | 51 | # pytest \ 52 | # -qq \ 53 | # --timeout=9 \ 54 | # --durations=10 \ 55 | # -n auto \ 56 | # --cov custom_components.integration_blueprint \ 57 | # -o console_output_style=count \ 58 | # -p no:sugar \ 59 | # tests -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release][releases-shield]][releases] 2 | [![GitHub Activity][commits-shield]][commits] 3 | [![License][license-shield]][license] 4 | 5 | [![hacs][hacsbadge]][hacs] 6 | [![Project Maintenance][maintenance-shield]][user_profile] 7 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 8 | 9 | [![Discord][discord-shield]][discord] 10 | [![Community Forum][forum-shield]][forum] 11 | 12 | _Component to integrate with [multizone_generic_thermostat][multizone_generic_thermostat]._ 13 | 14 | **This component will set up the following platforms.** 15 | 16 | Platform | Description 17 | -- | -- 18 | `climate` | Control heater/cooler to maintain temperatures in a zone or different zones between required ranges. 19 | 20 | ![example][exampleimg] 21 | 22 | {% if not installed %} 23 | ## Installation 24 | 25 | 1. Click install. 26 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "multizone_generic_thermostat". 27 | 28 | {% endif %} 29 | 30 | 31 | ## Configuration is done in the UI 32 | 33 | 34 | 35 | *** 36 | 37 | [multizone_generic_thermostat]: https://github.com/custom-components/multizone_generic_thermostat 38 | [buymecoffee]: https://www.buymeacoffee.com/ludeeus 39 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 40 | [commits-shield]: https://img.shields.io/github/commit-activity/y/custom-components/multizone_generic_thermostat.svg?style=for-the-badge 41 | [commits]: https://github.com/custom-components/multizone_generic_thermostat/commits/master 42 | [hacs]: https://hacs.xyz 43 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 44 | [discord]: https://discord.gg/Qa5fW2R 45 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 46 | [exampleimg]: example.png 47 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 48 | [forum]: https://community.home-assistant.io/ 49 | [license]: https://github.com/custom-components/multizone_generic_thermostat/blob/main/LICENSE 50 | [license-shield]: https://img.shields.io/github/license/custom-components/multizone_generic_thermostat.svg?style=for-the-badge 51 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge 52 | [releases-shield]: https://img.shields.io/github/release/custom-components/multizone_generic_thermostat.svg?style=for-the-badge 53 | [releases]: https://github.com/custom-components/multizone_generic_thermostat/releases 54 | [user_profile]: https://github.com/tpacri 55 | -------------------------------------------------------------------------------- /custom_components/multizone_generic_thermostat/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import voluptuous as vol 5 | 6 | from datetime import timedelta 7 | from datetime import datetime 8 | 9 | from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT, BinarySensorDeviceClass 10 | from homeassistant.helpers.entity import async_generate_entity_id 11 | 12 | from homeassistant.const import ( 13 | ATTR_ENTITY_ID, 14 | ATTR_FRIENDLY_NAME, 15 | ATTR_TEMPERATURE, 16 | CONF_NAME, 17 | CONF_UNIQUE_ID, 18 | EVENT_HOMEASSISTANT_START, 19 | PRECISION_HALVES, 20 | PRECISION_TENTHS, 21 | PRECISION_WHOLE, 22 | SERVICE_TURN_OFF, 23 | SERVICE_TURN_ON, 24 | STATE_ON, 25 | STATE_OFF, 26 | STATE_UNAVAILABLE, 27 | STATE_UNKNOWN 28 | ) 29 | 30 | 31 | try: 32 | from homeassistant.components.binary_sensor import BinarySensorEntity 33 | except ImportError: 34 | from homeassistant.components.binary_sensor import ( 35 | BinarySensorDevice as BinarySensorEntity, 36 | ) 37 | from . import DOMAIN, PLATFORMS 38 | from typing import Any, Dict 39 | 40 | _LOGGER = logging.getLogger(__name__) 41 | 42 | 43 | class IsWindowOpenSensor(BinarySensorEntity): 44 | def __init__(self, hass, name): 45 | uid = f"{name}_is_window_open" 46 | self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass) 47 | self._name = uid 48 | self.zone_with_window_open = None 49 | self.custom_attributes = {} 50 | 51 | @property 52 | def is_on(self): 53 | return self.zone_with_window_open != None 54 | 55 | @property 56 | def zone_name(self): 57 | return self.zone_with_window_open 58 | 59 | @property 60 | def extra_state_attributes(self): 61 | """Return the state attributes of the sensor.""" 62 | return {'zone': self.zone_with_window_open} 63 | 64 | @property 65 | def device_class(self): 66 | """Return device_class.""" 67 | return BinarySensorDeviceClass.OPENING 68 | 69 | @property 70 | def icon(self): 71 | """Return icon.""" 72 | return "mdi:window-open" if self.is_on else "mdi:window-closed" 73 | 74 | @property 75 | def name(self): 76 | """Return name.""" 77 | return self._name 78 | 79 | def set_zone_with_window_open(self, zone): 80 | if zone != self.zone_with_window_open: 81 | self.zone_with_window_open = zone 82 | 83 | self.async_write_ha_state() 84 | # @asyncio.coroutine 85 | # async def async_update(self): 86 | # pass 87 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | ## Developing with Visual Studio Code + devcontainer 2 | 3 | The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. 4 | 5 | In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. 6 | 7 | **Prerequisites** 8 | 9 | - [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 10 | - Docker 11 | - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) 12 | - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. 13 | - [Visual Studio code](https://code.visualstudio.com/) 14 | - [Remote - Containers (VSC Extension)][extension-link] 15 | 16 | [More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) 17 | 18 | [extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers 19 | 20 | **Getting started:** 21 | 22 | 1. Fork the repository. 23 | 2. Clone the repository to your computer. 24 | 3. Open the repository using Visual Studio code. 25 | 26 | When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. 27 | 28 | _If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ 29 | 30 | ### Tasks 31 | 32 | The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. 33 | 34 | When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. 35 | 36 | The available tasks are: 37 | 38 | Task | Description 39 | -- | -- 40 | Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. 41 | Run Home Assistant configuration against /config | Check the configuration. 42 | Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. 43 | Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. 44 | 45 | ### Step by Step debugging 46 | 47 | With the development container, 48 | you can test your custom component in Home Assistant with step by step debugging. 49 | 50 | You need to modify the `configuration.yaml` file in `.devcontainer` folder 51 | by uncommenting the line: 52 | 53 | ```yaml 54 | # debugpy: 55 | ``` 56 | 57 | Then launch the task `Run Home Assistant on port 9123`, and launch the debbuger 58 | with the existing debugging configuration `Python: Attach Local`. 59 | 60 | For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). 61 | -------------------------------------------------------------------------------- /pyscript/thermostatautomation.py: -------------------------------------------------------------------------------- 1 | import string 2 | import datetime 3 | 4 | # required fields: 5 | def dataget(name: string): 6 | print("Get param:" + name) 7 | #return data.get(name) 8 | 9 | 10 | def callService(serviceName: string, action: string, service_data): 11 | loginfo("Calling " + serviceName + "." + action + ":" + str(service_data)) 12 | try: 13 | service.call(serviceName, action, **service_data) 14 | except Exception as e: 15 | log.error(e) 16 | 17 | def stateget(name: string): 18 | try: 19 | result=str(state.get(name)) 20 | 21 | #loginfo("stateget: " + name+"="+result) 22 | return result 23 | except Exception as e: 24 | log.error("Error in state.get(" + str(name) + ") " + str(e)) 25 | 26 | return None 27 | 28 | def loginfo(values: string): 29 | log.info(values) 30 | print(values) 31 | 32 | """ 33 | ioanas_smart_heater_entity_id: climate.zhimi_heater_mc2_54_48_e6_89_5f_4f 34 | input_number.min_ioanas_bedroom_temperature 35 | fabian_min_temp_entity_id: input_number.min_fabians_bedroom_temperature 36 | dining_min_temp_entity_id: input_number.min_dining_temperature 37 | smallbedroom_min_temp_entity_id: input_number.min_small_bedroom_temperature 38 | input_boolean.ioana_sleeps_in_her_room 39 | """ 40 | 41 | m_ioanas_smart_heater_entity_id = dataget("ioanas_smart_heater_entity_id")#climate.zhimi_heater_mc2_54_48_e6_89_5f_4f 42 | m_ioana_min_temp_entity_id = dataget("ioana_min_temp_entity_id") #input_number.min_ioanas_bedroom_temperature 43 | m_fabian_min_temp_entity_id = dataget("fabian_min_temp_entity_id")#input_number.min_fabians_bedroom_temperature 44 | m_dining_min_temp_entity_id = dataget("dining_min_temp_entity_id")#input_number.min_dining_temperature 45 | m_smallbedroom_min_temp_entity_id = dataget("smallbedroom_min_temp_entity_id")#input_number.min_small_bedroom_temperature 46 | m_ioana_sleeps_in_her_room = dataget("input_boolean.ioana_sleeps_in_her_room") 47 | m_thermostat_away_mode = dataget("input_boolean.thermostat_away_mode") 48 | 49 | m_ioanas_smart_heater_entity_id = m_ioanas_smart_heater_entity_id if m_ioanas_smart_heater_entity_id is not None else "climate.zhimi_heater_mc2_54_48_e6_89_5f_4f" 50 | m_ioana_min_temp_entity_id = m_ioana_min_temp_entity_id if m_ioana_min_temp_entity_id is not None else "input_number.min_ioanas_bedroom_temperature" 51 | m_fabian_min_temp_entity_id = m_fabian_min_temp_entity_id if m_fabian_min_temp_entity_id is not None else "input_number.min_fabians_bedroom_temperature" 52 | m_dining_min_temp_entity_id = m_dining_min_temp_entity_id if m_dining_min_temp_entity_id is not None else "input_number.min_dining_temperature" 53 | m_smallbedroom_min_temp_entity_id = m_smallbedroom_min_temp_entity_id if m_smallbedroom_min_temp_entity_id is not None else "input_number.min_small_bedroom_temperature" 54 | m_ioana_sleeps_in_her_room = m_ioana_sleeps_in_her_room if m_ioana_sleeps_in_her_room is not None else "input_boolean.ioana_sleeps_in_her_room" 55 | m_thermostat_away_mode = m_thermostat_away_mode if m_thermostat_away_mode is not None else "input_boolean.thermostat_away_mode" 56 | 57 | 58 | alldays=[0, 1, 2, 3, 4, 5, 6, 7] 59 | weekdays=[0, 1, 2, 3, 4, 5] 60 | weekend=[6, 7] 61 | 62 | def IsWeekDay(timestamp: datetime): 63 | day = timestamp.weekday() 64 | return day >=0 and day <=5 65 | 66 | class TurnOnSmartHeater: 67 | def __init__(self, entity_id: string, temp: float) -> None: 68 | self.entity_id = entity_id 69 | self.temp = temp 70 | 71 | def Execute(self): 72 | 73 | if (stateget(self.entity_id) != "heat"): 74 | service_data = {"entity_id": self.entity_id} 75 | callService("climate", "turn_on", service_data) 76 | 77 | if (float(stateget(self.entity_id+".temperature")) != self.temp): 78 | service_data = {"entity_id": self.entity_id, "temperature": self.temp} 79 | callService("climate", "set_temperature", service_data) 80 | 81 | class TurnOffSmartHeater: 82 | def __init__(self, entity_id: string) -> None: 83 | self.entity_id = entity_id 84 | 85 | def Execute(self): 86 | if (stateget(self.entity_id) != "off"): 87 | service_data = {"entity_id": self.entity_id} 88 | callService("climate", "turn_off", service_data) 89 | 90 | class SetTemperature: 91 | def __init__(self, entity_id: string, temp: float) -> None: 92 | self.entity_id = entity_id 93 | self.temp = temp 94 | 95 | def Execute(self): 96 | if (float(stateget(self.entity_id)) != self.temp): 97 | service_data = {"entity_id": self.entity_id, "value": float(self.temp)} 98 | callService("input_number", "set_value", service_data) 99 | 100 | class TimeFrame: 101 | def __init__(self, name: string, day: int, hour: int, minute: int, actions): 102 | self.actions=actions 103 | self.name=name 104 | self.day=day 105 | self.time = datetime.time(hour, minute, 0, 0) 106 | self.endTime = self.time 107 | #loginfo(self.time) 108 | 109 | def Contains(self, tm: datetime): 110 | if self.time == self.endTime: 111 | return True 112 | 113 | if self.time <= self.endTime: 114 | return self.time<=tm.time() and tm.time() "Integrations" click "+" and search for "multizone_generic_thermostat" 84 | 85 | Using your HA configuration directory (folder) as a starting point you should now also have this: 86 | 87 | ```text 88 | custom_components/multizone_generic_thermostat/__init__.py 89 | custom_components/multizone_generic_thermostat/climate.py 90 | custom_components/multizone_generic_thermostat/manifest.json 91 | custom_components/multizone_generic_thermostat/services.yaml 92 | ``` 93 | 94 | ## Configuration is done in configuration.yaml 95 | ```text 96 | input_number: 97 | min_ambiental_temperature: 98 | name: Min ambiental temperature 99 | initial: 21 100 | min: 14 101 | max: 25 102 | step: 0.1 103 | 104 | min_dining_temperature: 105 | name: Min dining temperature 106 | initial: 20 107 | min: 14 108 | max: 25 109 | step: 0.1 110 | 111 | min_kitchen_temperature: 112 | name: Min kitchen temperature 113 | initial: 20 114 | min: 14 115 | max: 25 116 | step: 0.1 117 | 118 | min_fabians_bedroom_temperature: 119 | name: Min Fabian's bedroom temperature 120 | initial: 20 121 | min: 14 122 | max: 25 123 | step: 0.1 124 | 125 | min_small_bedroom_temperature: 126 | name: Min small bedroom temperature 127 | initial: 18 128 | min: 14 129 | max: 25 130 | step: 0.1 131 | 132 | min_ioanas_bedroom_temperature: 133 | name: Min Ioana's bedroom temperature 134 | initial: 21 135 | min: 14 136 | max: 25 137 | step: 0.1 138 | 139 | climate: 140 | 141 | - platform: multizone_generic_thermostat 142 | name: Thermostat 143 | heater: switch.heating 144 | presets: 145 | none: 146 | report_zone_name_instead_preset_name: true 147 | zones: 148 | dining: 149 | target_sensors: 150 | - sensor.dining_room_temperature2 151 | - sensor.mi_dining_temperature 152 | target_temp_sensor: input_number.min_dining_temperature 153 | fabians_bedroom: 154 | friendly_name: Fabian's bedroom 155 | target_sensors: 156 | - sensor.fabians_bedroom_temperature 157 | - sensor.mi_fabian_bedroom_temperature 158 | target_temp_sensor: input_number.min_fabians_bedroom_temperature 159 | small_bedroom: 160 | friendly_name: small bedroom 161 | target_sensor: sensor.small_bedroom_temperature 162 | target_temp_sensor: input_number.min_small_bedroom_temperature 163 | ioanas_bedroom: 164 | friendly_name: Ioana's bedroom 165 | target_sensor: sensor.ioanas_bedroom_temperature 166 | target_temp_sensor: input_number.min_ioanas_bedroom_temperature 167 | #open_window: 168 | # delta_temp: 4 169 | # delta_time: 100 170 | # zone_react_delay: 00:10:00 171 | open_window: 172 | delta_temp: 0.25 173 | min_delta_time: 60 174 | delta_time: 180 175 | zone_react_delay: 00:10:00 176 | ignored_target_sensors: 177 | - sensor.thermostat_ambiental_temperature 178 | min_temp: 17 179 | max_temp: 25 180 | ac_mode: false 181 | #target_temp: 22.5 182 | cold_tolerance: 0.5 183 | hot_tolerance: 0.5 184 | min_cycle_duration: 185 | seconds: 10 186 | keep_alive: 187 | minutes: 3 188 | initial_hvac_mode: "heat" 189 | away_temp: 22 190 | precision: 0.1 191 | ``` 192 | 193 | 194 | Automation suggestion: 195 | These step is optional. Only install pyscript and use my script for automating it if u find it usefull. I find it very handy, that's why I decided to put it here. 196 | 197 | Additionally I added inside pyscript folder a script to be used with pyscript addon. This script can be used to automate much easier target temperatures per each room per each day of the week/per whatever time intervals you want. 198 | 199 | For example, I want every weekday at 7 AM to heat up little bit my son's room to be warmer when he dresses for school. 200 | At 7:40 when he should be gone to school already, I change it down back to lower temperature. Etc... 201 | 202 | Install pyscript integration. Copy the pyscript/thermostatautomation.py onto your homeassistant pyscript folder (if folder is missing, create pyscript folder on the same level with your configuration.yaml smd place the thermostatautomation.py file inside this folder) 203 | 204 | ## Contributions are welcome! 205 | 206 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 207 | 208 | *** 209 | 210 | [multizone_generic_thermostat]: https://github.com/tpacri/multizone_generic_thermostat 211 | [buymecoffee]: https://www.buymeacoffee.com/tpacri 212 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 213 | [commits-shield]: https://img.shields.io/github/commit-activity/y/tpacri/multizone_generic_thermostat.svg?style=for-the-badge 214 | [commits]: https://github.com/tpacri/multizone_generic_thermostat/commits/main 215 | [hacs]: https://github.com/custom-components/hacs 216 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 217 | [discord]: https://discord.gg/Qa5fW2R 218 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 219 | [exampleimg]: example.png 220 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 221 | [forum]: https://community.home-assistant.io/ 222 | [license-shield]: https://img.shields.io/github/license/tpacri/multizone_generic_thermostat.svg?style=for-the-badge 223 | [maintenance-shield]: https://img.shields.io/badge/maintainer-tpacri-blue.svg?style=for-the-badge 224 | [releases-shield]: https://img.shields.io/github/release/tpacri/multizone_generic_thermostat.svg?style=for-the-badge 225 | [releases]: https://github.com/tpacri/multizone_generic_thermostat/releases 226 | -------------------------------------------------------------------------------- /custom_components/multizone_generic_thermostat/climate.py: -------------------------------------------------------------------------------- 1 | """Adds support for multizone generic thermostat units.""" 2 | import asyncio 3 | import logging 4 | from copy import deepcopy 5 | 6 | from .binary_sensor import IsWindowOpenSensor 7 | 8 | import voluptuous as vol 9 | 10 | from datetime import timedelta 11 | from datetime import datetime 12 | 13 | from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity 14 | from homeassistant.components.climate.const import ( 15 | ATTR_PRESET_MODE, 16 | HVACMode, 17 | HVACAction, 18 | ClimateEntityFeature, 19 | PRESET_AWAY, 20 | PRESET_NONE 21 | ) 22 | 23 | from homeassistant.const import ( 24 | ATTR_ENTITY_ID, 25 | ATTR_FRIENDLY_NAME, 26 | ATTR_TEMPERATURE, 27 | CONF_NAME, 28 | CONF_UNIQUE_ID, 29 | EVENT_HOMEASSISTANT_START, 30 | PRECISION_HALVES, 31 | PRECISION_TENTHS, 32 | PRECISION_WHOLE, 33 | SERVICE_TURN_OFF, 34 | SERVICE_TURN_ON, 35 | STATE_ON, 36 | STATE_OFF, 37 | STATE_UNAVAILABLE, 38 | STATE_UNKNOWN 39 | ) 40 | 41 | from homeassistant.core import DOMAIN as HA_DOMAIN, CoreState, callback 42 | from homeassistant.helpers import condition 43 | import homeassistant.helpers.config_validation as cv 44 | from homeassistant.helpers.entity import async_generate_entity_id 45 | from homeassistant.helpers.event import ( 46 | async_track_state_change_event, 47 | async_track_time_interval, 48 | ) 49 | from homeassistant.helpers.reload import async_setup_reload_service 50 | from homeassistant.helpers.restore_state import RestoreEntity 51 | 52 | from . import DOMAIN, PLATFORMS 53 | from typing import Any, Dict 54 | 55 | _LOGGER = logging.getLogger(__name__) 56 | 57 | DEFAULT_TOLERANCE = 0.3 58 | DEFAULT_NAME = "Multizone Generic Thermostat" 59 | 60 | CONF_HEATER = "heater" 61 | ZONES = "zones" 62 | PRESETS = "presets" 63 | CONF_SENSOR = "target_sensor" 64 | CONF_TEMP_SENSORS = "target_sensors" 65 | CONF_MIN_TEMP = "min_temp" 66 | CONF_MAX_TEMP = "max_temp" 67 | CONF_TARGET_TEMP = "target_temp" 68 | CONF_TARGET_TEMP_SENSOR = "target_temp_sensor" 69 | CONF_AC_MODE = "ac_mode" 70 | CONF_MIN_DUR = "min_cycle_duration" 71 | CONF_COLD_TOLERANCE = "cold_tolerance" 72 | CONF_HOT_TOLERANCE = "hot_tolerance" 73 | CONF_KEEP_ALIVE = "keep_alive" 74 | CONF_INITIAL_HVAC_MODE = "initial_hvac_mode" 75 | CONF_AWAY_TEMP = "away_temp" 76 | CONF_PRECISION = "precision" 77 | ATTR_ONGOING_ZONE = "ongoing_zone" 78 | ATTR_SELECTED_ZONE="selected_zone" 79 | ATTR_SELECTED_PRESET="selected_preset" 80 | CONF_REPORT_ZONE_NAME_INSTEAD_OF_PRESET_NAME="report_zone_name_instead_preset_name" 81 | CONF_DELTA_TEMP="delta_temp" 82 | CONF_MIN_DELTA_TIME="min_delta_time" 83 | CONF_DELTA_TIME="delta_time" 84 | CONF_OPEN_WINDOW="open_window" 85 | CONF_ZONE_REACT_DELAY="zone_react_delay" 86 | CONF_IGNORED_TEMP_SENSORS = "ignored_target_sensors" 87 | 88 | CONF_RULES = "rules" 89 | CONF_RULES_ENABLE_SENSOR = "enable_sensor" 90 | 91 | CONF_MAX_HEATER_TEMP_RULE = "max_heater_temp_rule" 92 | CONF_MAX_HEATER_TEMP_RULE_TEMP_SENSORS = "heater_temp_sensors" 93 | CONF_MAX_HEATER_TEMP_RULE_MAX_TEMP = "max_temp" 94 | CONF_MAX_HEATER_TEMP_RULE_MAX_TEMP_TOLERANCE = "tolerance" 95 | 96 | CONF_ON_DURATION_RULE = "on_duration_rule" 97 | CONF_MAX_ON_DURATION = "max_on_duration" 98 | CONF_MIN_OFF_DURATION = "min_off_duration" 99 | 100 | SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE 101 | 102 | OPEN_WINDIW_SCHEMA = vol.Schema( 103 | { 104 | vol.Required(CONF_DELTA_TEMP): vol.Coerce(float), 105 | vol.Required(CONF_DELTA_TIME): cv.time_period, 106 | vol.Optional(CONF_MIN_DELTA_TIME): cv.time_period, 107 | vol.Optional(CONF_ZONE_REACT_DELAY): cv.time_period, 108 | vol.Optional(CONF_IGNORED_TEMP_SENSORS, default=[]): vol.All(cv.ensure_list, [cv.entity_id]) 109 | } 110 | ) 111 | 112 | MAX_HEATER_TEMP_RULE_SCHEMA = vol.Schema( 113 | { 114 | vol.Required(CONF_MAX_HEATER_TEMP_RULE_TEMP_SENSORS, default=[]): vol.All(cv.ensure_list, [cv.entity_id]), 115 | vol.Required(CONF_MAX_HEATER_TEMP_RULE_MAX_TEMP): vol.Coerce(float), 116 | vol.Required(CONF_MAX_HEATER_TEMP_RULE_MAX_TEMP_TOLERANCE): vol.Coerce(float) 117 | } 118 | ) 119 | 120 | ON_DURATION_RULE_SCHEMA = vol.Schema( 121 | { 122 | vol.Required(CONF_MAX_ON_DURATION): cv.time_period, 123 | vol.Required(CONF_MIN_OFF_DURATION): cv.time_period 124 | } 125 | ) 126 | 127 | ZONE_SCHEMA = vol.All( 128 | vol.Schema( 129 | { 130 | vol.Optional(ATTR_FRIENDLY_NAME): cv.string, 131 | vol.Optional(CONF_SENSOR): cv.entity_id, 132 | vol.Optional(CONF_TEMP_SENSORS, default=[]): vol.All(cv.ensure_list, [cv.entity_id]), 133 | vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), 134 | vol.Optional(CONF_TARGET_TEMP_SENSOR): cv.entity_id, 135 | vol.Optional(CONF_OPEN_WINDOW): vol.Schema(OPEN_WINDIW_SCHEMA) 136 | } 137 | ), 138 | # cv.has_at_least_one_key(CONF_SENSOR, CONF_TEMP_SENSORS), 139 | # cv.has_at_most_one_key(CONF_SENSOR, CONF_TEMP_SENSORS), 140 | cv.has_at_least_one_key(CONF_TARGET_TEMP, CONF_TARGET_TEMP_SENSOR), 141 | cv.has_at_most_one_key(CONF_TARGET_TEMP, CONF_TARGET_TEMP_SENSOR)) 142 | 143 | RULES_SCHEMA = vol.All( 144 | vol.Schema( 145 | { 146 | vol.Optional(CONF_RULES_ENABLE_SENSOR): cv.entity_id, 147 | vol.Optional(CONF_MAX_HEATER_TEMP_RULE): vol.Schema(MAX_HEATER_TEMP_RULE_SCHEMA), 148 | vol.Optional(CONF_ON_DURATION_RULE): vol.Schema(ON_DURATION_RULE_SCHEMA) 149 | } 150 | )) 151 | 152 | PRESET_SCHEMA = vol.Schema({ 153 | vol.Optional(ZONES): cv.schema_with_slug_keys(ZONE_SCHEMA), 154 | }) 155 | 156 | PRESET_SCHEMA = vol.All( 157 | vol.Schema( 158 | { 159 | vol.Optional(ATTR_FRIENDLY_NAME): cv.string, 160 | vol.Required(ZONES): vol.All(cv.schema_with_slug_keys(ZONE_SCHEMA)), 161 | vol.Optional(CONF_REPORT_ZONE_NAME_INSTEAD_OF_PRESET_NAME): cv.boolean, 162 | vol.Optional(CONF_RULES): vol.Schema(RULES_SCHEMA) 163 | } 164 | )) 165 | 166 | PLATFORM_SCHEMA = vol.All( 167 | cv.deprecated(CONF_AWAY_TEMP), 168 | PLATFORM_SCHEMA.extend( 169 | { 170 | vol.Required(CONF_HEATER): cv.entity_id, 171 | vol.Optional(ZONES): vol.All(cv.schema_with_slug_keys(ZONE_SCHEMA)), 172 | vol.Optional(PRESETS): vol.All(cv.schema_with_slug_keys(PRESET_SCHEMA)), 173 | vol.Optional(CONF_SENSOR): cv.entity_id, 174 | vol.Optional(CONF_TEMP_SENSORS, default=[]): vol.All(cv.ensure_list, [cv.entity_id]), 175 | vol.Optional(CONF_AC_MODE): cv.boolean, 176 | vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), 177 | vol.Optional(CONF_MIN_DUR): cv.positive_time_period, 178 | vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), 179 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 180 | vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), 181 | vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), 182 | vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), 183 | vol.Optional(CONF_KEEP_ALIVE): cv.positive_time_period, 184 | vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In( 185 | [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] 186 | ), 187 | vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), 188 | vol.Optional(CONF_PRECISION): vol.In( 189 | [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] 190 | ), 191 | vol.Optional(CONF_UNIQUE_ID): cv.string, 192 | vol.Optional(CONF_OPEN_WINDOW): vol.Schema(OPEN_WINDIW_SCHEMA), 193 | vol.Optional(CONF_RULES): vol.Schema(RULES_SCHEMA) 194 | }, 195 | # cv.has_at_most_one_key(CONF_SENSOR, CONF_TEMP_SENSORS) 196 | )) 197 | 198 | 199 | def is_temp_valid(temp)->bool: 200 | return temp is not None and temp not in ( 201 | STATE_UNAVAILABLE, 202 | STATE_UNKNOWN, 203 | 'unavailable', 204 | '' 205 | ) 206 | 207 | class TempWithTime(): 208 | def __init__(self, temp, temp_timestamp): 209 | self._temp = temp 210 | self._temp_timestamp = temp_timestamp 211 | 212 | class Rules(): 213 | def __init__(self, enable_sensor, max_heater_temp_rule, max_on_duration_rule): 214 | self._enable_sensor = enable_sensor 215 | self._enable_sensor_state = True if enable_sensor is None else False 216 | self._max_heater_temp_rule = max_heater_temp_rule 217 | self._max_on_duration_rule = max_on_duration_rule 218 | _LOGGER.info("Rules %s", self._enable_sensor) 219 | 220 | 221 | class MaxHeaterTempRuleDef(): 222 | def __init__(self, sensors, max_temp, tolerance): 223 | self._sensors_entity_id = sensors 224 | self._max_temp = max_temp 225 | self._tolerance = tolerance 226 | self._current_temperatures = {} 227 | self._sensors_exceeding_limit = {} 228 | 229 | def _async_on_heater_temp_changed(self, event): 230 | """Handle heater temperature changes.""" 231 | sender = event.data.get("entity_id") 232 | new_state = event.data.get("new_state") 233 | 234 | if new_state is None or not is_temp_valid(new_state.state) or sender not in self._sensors_entity_id: 235 | _LOGGER.info("INNER _async_on_heater_temp_changed SKIPPED %s %s", sender, new_state) 236 | return 237 | 238 | _LOGGER.info("MaxHeaterTempRuleDef: heater_temp changed. %s %s %s", sender, new_state.state, sender in self._sensors_exceeding_limit) 239 | 240 | temp = float(new_state.state) 241 | self._current_temperatures[sender] = TempWithTime(temp, datetime.now()) 242 | 243 | if temp > self._max_temp: 244 | isNewlyExceeded = sender not in self._sensors_exceeding_limit 245 | self._sensors_exceeding_limit[sender] = datetime.now() 246 | 247 | #if isNewlyExceeded: 248 | # _LOGGER.warning("MaxHeaterTempRuleDef: sensor exceeding limit %s>%s=%s %s", temp, self._max_temp, self.is_heating_allowed(), self._sensors_exceeding_limit) 249 | 250 | if sender in self._sensors_exceeding_limit and temp <= self._max_temp-self._tolerance: 251 | self._sensors_exceeding_limit.pop(sender) 252 | #_LOGGER.warning("MaxHeaterTempRuleDef: sensor NOT exceeding limit anymore %s>%s=%s %s", temp, self._max_temp, self.is_heating_allowed(), self._sensors_exceeding_limit) 253 | 254 | _LOGGER.info("MaxHeaterTempRuleDef: is_heating_allowed %s>%s=%s tol:%s minTmp:%s %s", temp, self._max_temp, self.is_heating_allowed(),self._tolerance, self._max_temp-self._tolerance, self._sensors_exceeding_limit) 255 | 256 | def is_heating_allowed(self): 257 | if not self._sensors_exceeding_limit: 258 | return True 259 | 260 | refTime = datetime.now() - timedelta(minutes=5) 261 | any_temp_blocking = any((t[0] in self._current_temperatures and self._current_temperatures[t[0]]._temp_timestamp >= refTime) for t in self._sensors_exceeding_limit.items()) 262 | 263 | _LOGGER.info("MaxHeaterTempRuleDef check any valid temp blocking = %s, ref: %s now: %s tt:%s", any_temp_blocking, refTime, datetime.now(), self._current_temperatures['sensor.temperature_mysensor_7_0']._temp_timestamp) 264 | 265 | return not any_temp_blocking 266 | 267 | class MaxOnDurationRuleDef(): 268 | def __init__(self, max_on_duration, min_off_duration): 269 | self._max_on_duration = max_on_duration 270 | self._min_off_duration = min_off_duration 271 | self._on_time = None 272 | self._off_time = None 273 | self._on_time_exceeded = False 274 | 275 | def on_turned_on(self): 276 | _LOGGER.info("calculate_if_time_exceeded - on_turned_on") 277 | 278 | if self._on_time: 279 | return 280 | 281 | self._on_time = datetime.now() 282 | self._off_time = None 283 | 284 | def on_turned_off(self): 285 | _LOGGER.info("calculate_if_time_exceeded - on_turned_off") 286 | 287 | if self._off_time: 288 | return 289 | 290 | self._on_time = None 291 | self._off_time = datetime.now() 292 | 293 | def calculate_if_time_exceeded(self): 294 | onTime = (datetime.now() - self._on_time) if self._on_time else None 295 | offTime = (datetime.now() - self._off_time) if self._off_time else None 296 | 297 | if (onTime and onTime >=self._max_on_duration): 298 | #if not self._on_time_exceeded: 299 | # _LOGGER.warning("MaxOnDurationRuleDef exceeded %s", onTime) 300 | 301 | self._on_time_exceeded = True 302 | 303 | if (self._on_time_exceeded and offTime and offTime >= self._min_off_duration): 304 | #_LOGGER.warning("MaxOnDurationRuleDef CANCEL exceeded %s", offTime) 305 | self._on_time_exceeded = False 306 | 307 | _LOGGER.info("calculate_if_time_exceeded %s %s>=%s %s>=%s", self._on_time_exceeded, onTime, self._max_on_duration, offTime, self._min_off_duration) 308 | 309 | def is_heating_allowed(self): 310 | self.calculate_if_time_exceeded() 311 | return not self._on_time_exceeded 312 | 313 | class OpenWindowDef(): 314 | def __init__(self, delta, timediff, mintimediff, zoneReactDelay, ignored_sensors_entity_id): 315 | self._delta = abs(delta) 316 | self._timedelta = timediff 317 | self._mintimedelta = mintimediff 318 | self._zoneReactDelay = zoneReactDelay 319 | self._steep = delta/timediff.total_seconds() 320 | self._temperature_history = [] 321 | self._is_open_window = False 322 | self._is_open_window_timestamp = None 323 | self._zone_react_timestamp = None 324 | self._zoneName = "not set yet" 325 | self._ignored_sensors_entity_id = ignored_sensors_entity_id 326 | 327 | def is_sensor_ignored(self, sensor_id): 328 | return self._ignored_sensors_entity_id is not None and sensor_id in self._ignored_sensors_entity_id 329 | 330 | def add_temp(self, sender_sensor, temp): 331 | if self.is_sensor_ignored(sender_sensor): 332 | return 333 | 334 | if is_temp_valid(temp): 335 | self._temperature_history.append(TempWithTime(float(temp), datetime.now())) 336 | time_limit = datetime.now()-max(self._timedelta, self._mintimedelta if self._mintimedelta is not None else self._timedelta) 337 | 338 | is_open_window = self.calculate_is_openwindow() 339 | 340 | while len(self._temperature_history) > 0 and self._temperature_history[0]._temp_timestamp < time_limit: 341 | self._temperature_history.pop(0) 342 | 343 | if is_open_window != self._is_open_window: 344 | self._is_open_window = is_open_window 345 | self._is_open_window_timestamp = datetime.now() if is_open_window else None 346 | self._zone_react_timestamp = (self._is_open_window_timestamp + self._zoneReactDelay) if (self._is_open_window_timestamp is not None and self._zoneReactDelay is not None) else None 347 | _LOGGER.warning("Is window open changed for %s to %s", self._zoneName, is_open_window) 348 | 349 | def calculate_is_openwindow(self)->bool: 350 | if len(self._temperature_history)<=1: 351 | return False 352 | temp_diff= self._temperature_history[0]._temp - self._temperature_history[len(self._temperature_history)-1]._temp 353 | time_diff=self._temperature_history[len(self._temperature_history)-1]._temp_timestamp - self._temperature_history[0]._temp_timestamp 354 | #_LOGGER.info("calculating calculate_is_openwindow len:%s Dtemp:%s DTime:%s DSeconds:%s Steep:%s TargetSteep:%s", len(self._temperature_history), temp_diff, time_diff, time_diff.total_seconds(), temp_diff/time_diff.total_seconds(), self._steep) 355 | time_limit = max(self._timedelta, self._mintimedelta if self._mintimedelta is not None else self._timedelta) 356 | 357 | result = True 358 | if temp_diff < 0 or time_diff.total_seconds() == 0 or temp_diff/time_diff.total_seconds() <= self._steep or (time_diff =time_limit and timeTemp['failures']<=failuresLimit) or timeTemp['failures']==0): 390 | return True 391 | return False 392 | 393 | def getValidTimeTemp(self, sensor_name): 394 | if sensor_name is None: 395 | return None 396 | 397 | if sensor_name not in self.last_valid_temp_per_sensor: 398 | return None 399 | 400 | timeTemp = self.last_valid_temp_per_sensor[sensor_name] 401 | if self.is_timetemp_valid(timeTemp, self.default_failuresLimit): 402 | return timeTemp 403 | 404 | return None 405 | 406 | def get_cur_temp(self): 407 | if self._cur_temp_per_sensor and self.last_valid_temp_per_sensor and all(self.is_timetemp_valid(x, 0) for x in self.last_valid_temp_per_sensor.values()): 408 | maxTimeTemp = max(self.last_valid_temp_per_sensor.values(), key = lambda k: k['value']) 409 | maxSensorName = maxTimeTemp['sensor'] 410 | if maxSensorName != self.active_sensor: 411 | _LOGGER.info("TempAggregator: all valid. Active sensor temp changed %s: from %s to %s(%s)", self._name, self.active_sensor, maxSensorName, self.last_valid_temp_per_sensor.values()) 412 | self.active_sensor = maxSensorName 413 | mxval = maxTimeTemp['value'] 414 | #mxval = max(self._cur_temp_per_sensor.values()) 415 | _LOGGER.debug("TempAggregator: get_cur_temp all valid %s %s: %s", self._name, self.active_sensor, mxval) 416 | return mxval 417 | 418 | current_sensor_timetemp = self.getValidTimeTemp(self.active_sensor) 419 | if current_sensor_timetemp is not None: 420 | _LOGGER.debug("TempAggregator: get_cur_temp current_sensor_timetemp %s: %s", self._name, current_sensor_timetemp['value']) 421 | return current_sensor_timetemp['value'] 422 | 423 | validSensors = list(filter(lambda mappedTimeTemp: self.is_timetemp_valid(mappedTimeTemp, self.default_failuresLimit), self.last_valid_temp_per_sensor.values())) 424 | 425 | validSensors.sort(key=lambda x: x['time'], reverse=True) 426 | if validSensors: 427 | oldActiveSensor = self.active_sensor 428 | self.active_sensor = validSensors[0]['sensor'] 429 | _LOGGER.info("TempAggregator: Active sensor temp changed %s: from %s to %s(%s)", self._name, oldActiveSensor, self.active_sensor, self.last_valid_temp_per_sensor.values()) 430 | return validSensors[0]['value'] 431 | 432 | _LOGGER.info("TempAggregator: get_cur_temp None %s %s", self._name, self.last_valid_temp_per_sensor.values()) 433 | return None 434 | 435 | class ZoneDef(): 436 | def __init__(self, friendly_name, sensor_entity_id, sensors_entity_id, target_entity_id, target_temp, name, openWindow): 437 | self._friendly_name = friendly_name or name 438 | self._sensors_entity_id = [sensor_entity_id] if sensor_entity_id is not None else sensors_entity_id 439 | self._target_entity_id = target_entity_id 440 | self._name = name 441 | self._target_temp = target_temp 442 | self._openWindow = openWindow 443 | if (self._openWindow): 444 | self._openWindow._zoneName = name 445 | self._saved_target_temp = None 446 | #if self._target_entity_id and (isinstance(self._target_entity_id, float) or isinstance(self._target_entity_id, int)): 447 | # self._target_temp = float(self._target_entity_id); 448 | self._cur_temp_per_sensor = TempAggregator(name) 449 | 450 | def has_temp_sensor_defined(self): 451 | return self._sensors_entity_id is not None 452 | 453 | def get_sensors_entity_id_names(self): 454 | return (' '.join(self._sensors_entity_id)) if self._sensors_entity_id is not None else 'None' 455 | 456 | def is_cur_temp_valid(self): 457 | return is_temp_valid(self.get_cur_temp()) 458 | 459 | def is_target_temp_valid(self): 460 | return is_temp_valid(self._target_temp) 461 | 462 | def get_cur_temp(self): 463 | return self._cur_temp_per_sensor.get_cur_temp() 464 | 465 | def set_cur_temp(self, sender_sensor, cur_temp): 466 | self._cur_temp_per_sensor.set_cur_temp(sender_sensor, cur_temp) 467 | if self._openWindow is not None: 468 | self._openWindow.add_temp(sender_sensor, self.get_cur_temp()) 469 | 470 | def is_zone_with_open_window(self) -> bool: 471 | if self._openWindow is None: 472 | return False 473 | return self._openWindow._is_open_window 474 | 475 | class PresetDef(): 476 | def __init__(self, friendly_name, zones, name, report_zone_name_instead_preset_name, rules): 477 | self._friendly_name = friendly_name or name 478 | self._name = name 479 | self._zones = zones 480 | self._report_zone_name_instead_preset_name = report_zone_name_instead_preset_name 481 | self._rules = rules 482 | 483 | def get_zone_with_open_window(self): 484 | if (self._zones == None or len(self._zones)==0): 485 | return None 486 | return next((z._friendly_name for z in self._zones if z.is_zone_with_open_window()), None) 487 | 488 | def parse_openwindow(config): 489 | if config is None: 490 | return None 491 | delta = config[CONF_DELTA_TEMP] 492 | deltaTime = config[CONF_DELTA_TIME] 493 | minDeltaTime = config[CONF_MIN_DELTA_TIME] if CONF_MIN_DELTA_TIME in config else None 494 | zoneReactDelay = config[CONF_ZONE_REACT_DELAY] if CONF_ZONE_REACT_DELAY in config else None 495 | ignoredTempSensors = config[CONF_IGNORED_TEMP_SENSORS] if (CONF_IGNORED_TEMP_SENSORS in config) else None 496 | 497 | return OpenWindowDef(delta, deltaTime, minDeltaTime, zoneReactDelay, ignoredTempSensors) 498 | 499 | def parse_rules(config): 500 | if config is None: 501 | return Rules(None, None, None) 502 | 503 | enabled_sensor = config[CONF_RULES_ENABLE_SENSOR] if (CONF_RULES_ENABLE_SENSOR in config) else None 504 | max_heater_temp_rule= parse_max_heater_temp_rule(config[CONF_MAX_HEATER_TEMP_RULE]) if (CONF_MAX_HEATER_TEMP_RULE in config) else None 505 | max_on_duration_rule= parse_max_on_duration_rule(config[CONF_ON_DURATION_RULE]) if (CONF_ON_DURATION_RULE in config) else None 506 | 507 | return Rules(enabled_sensor, max_heater_temp_rule, max_on_duration_rule) 508 | 509 | def parse_max_heater_temp_rule(config): 510 | if config is None: 511 | return None 512 | sensors = config[CONF_MAX_HEATER_TEMP_RULE_TEMP_SENSORS] if (CONF_MAX_HEATER_TEMP_RULE_TEMP_SENSORS in config) else None 513 | max_temp = config.get(CONF_MAX_HEATER_TEMP_RULE_MAX_TEMP) 514 | tolerance = config[CONF_MAX_HEATER_TEMP_RULE_MAX_TEMP_TOLERANCE] 515 | 516 | return MaxHeaterTempRuleDef(sensors, max_temp, tolerance) 517 | 518 | 519 | def parse_max_on_duration_rule(config): 520 | if config is None: 521 | return None 522 | max_on_duration = config.get(CONF_MAX_ON_DURATION) 523 | min_off_duration = config[CONF_MIN_OFF_DURATION] 524 | 525 | return MaxOnDurationRuleDef(max_on_duration, min_off_duration) 526 | 527 | def parse_zones_dict(explicit_zones, default_openwindow): 528 | zones = [] 529 | try: 530 | if explicit_zones: 531 | for key, z in explicit_zones.items(): 532 | zones.append(ZoneDef(z[ATTR_FRIENDLY_NAME] if (ATTR_FRIENDLY_NAME in z) else None, 533 | z[CONF_SENSOR] if (CONF_SENSOR in z) else None, 534 | z[CONF_TEMP_SENSORS] if (CONF_TEMP_SENSORS in z) else None, 535 | z[CONF_TARGET_TEMP_SENSOR] if (CONF_TARGET_TEMP_SENSOR in z) else None, 536 | z[CONF_TARGET_TEMP] if (CONF_TARGET_TEMP in z) else None, 537 | key, 538 | parse_openwindow(z[CONF_OPEN_WINDOW]) if (CONF_OPEN_WINDOW in z) else deepcopy(default_openwindow))) 539 | except ValueError as ex: 540 | _LOGGER.error("Unable to parse zones %s %s", explicit_zones, ex) 541 | except TypeError as ex: 542 | _LOGGER.error("Unable to parse zones %s %s", explicit_zones, ex) 543 | return zones 544 | 545 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 546 | """Set up the multizone generic thermostat platform.""" 547 | _LOGGER.info("async_setup_platform. ") 548 | 549 | await async_setup_reload_service(hass, DOMAIN, PLATFORMS) 550 | 551 | name = config.get(CONF_NAME) 552 | heater_entity_id = config.get(CONF_HEATER) 553 | explicit_zones = config.get(ZONES) 554 | explicit_presets = config.get(PRESETS) 555 | sensor_entity_id0 = config.get(CONF_SENSOR) 556 | sensors_entity_id0 = config.get(CONF_TEMP_SENSORS) 557 | min_temp = config.get(CONF_MIN_TEMP) 558 | max_temp = config.get(CONF_MAX_TEMP) 559 | target_temp0 = config.get(CONF_TARGET_TEMP) 560 | target_temp_sensor0 = config.get(CONF_TARGET_TEMP_SENSOR) 561 | ac_mode = config.get(CONF_AC_MODE) 562 | min_cycle_duration = config.get(CONF_MIN_DUR) 563 | cold_tolerance = config.get(CONF_COLD_TOLERANCE) 564 | hot_tolerance = config.get(CONF_HOT_TOLERANCE) 565 | keep_alive = config.get(CONF_KEEP_ALIVE) 566 | initial_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE) 567 | away_temp = config.get(CONF_AWAY_TEMP) 568 | precision = config.get(CONF_PRECISION) 569 | unit = hass.config.units.temperature_unit 570 | unique_id = config.get(CONF_UNIQUE_ID) 571 | 572 | default_openwindow= parse_openwindow(config[CONF_OPEN_WINDOW]) if (CONF_OPEN_WINDOW in config) else None 573 | default_rules=parse_rules(config[CONF_RULES]) if (CONF_RULES in config) else None 574 | global_zone = list(filter(lambda z: (z.has_temp_sensor_defined()) and (not z._target_entity_id is None), [ZoneDef("Global", sensor_entity_id0, sensors_entity_id0, target_temp_sensor0, target_temp0, "GlobalZone", default_openwindow)])) 575 | 576 | zones = global_zone + parse_zones_dict(explicit_zones, default_openwindow) 577 | 578 | presets = list(filter(lambda p: len(p._zones) > 0, [PresetDef(PRESET_NONE, zones, PRESET_NONE, False, default_rules)])) 579 | 580 | try: 581 | if explicit_presets: 582 | for key, p in explicit_presets.items(): 583 | presets.append(PresetDef(p[ATTR_FRIENDLY_NAME] if (ATTR_FRIENDLY_NAME in p) else None, \ 584 | parse_zones_dict(p[ZONES], default_openwindow), \ 585 | key, \ 586 | p[CONF_REPORT_ZONE_NAME_INSTEAD_OF_PRESET_NAME] if (CONF_REPORT_ZONE_NAME_INSTEAD_OF_PRESET_NAME in p) else None, \ 587 | parse_rules(p[CONF_RULES]) if (CONF_RULES in p) else default_rules)) 588 | except ValueError as ex: 589 | _LOGGER.error("Unable to parse presets %s %s", explicit_presets, ex) 590 | except TypeError as ex: 591 | _LOGGER.error("Unable to parse presets %s %s", explicit_presets, ex) 592 | 593 | if len(presets) == 0: 594 | _LOGGER.error("No zones nor presets defined") 595 | 596 | for p in presets: 597 | _LOGGER.info("Preset: %s %s %s", p._name, p._friendly_name, p._report_zone_name_instead_preset_name) 598 | for z in p._zones: 599 | _LOGGER.debug("Zone: %s %s SensorId:%s Target:%s", z._name, z._friendly_name, z.get_sensors_entity_id_names(), z._target_entity_id) 600 | 601 | thermostat = MultizoneGenericThermostat( 602 | hass, 603 | name, 604 | heater_entity_id, 605 | presets, 606 | min_temp, 607 | max_temp, 608 | ac_mode, 609 | min_cycle_duration, 610 | cold_tolerance, 611 | hot_tolerance, 612 | keep_alive, 613 | initial_hvac_mode, 614 | away_temp, 615 | precision, 616 | unit, 617 | unique_id, 618 | ) 619 | 620 | async_add_entities( 621 | [ 622 | thermostat, 623 | thermostat._isWindowOpenBinarySensor 624 | ] 625 | ) 626 | 627 | class MultizoneGenericThermostat(ClimateEntity, RestoreEntity): 628 | """Representation of a Multizone Generic Thermostat device.""" 629 | 630 | def __init__( 631 | self, 632 | hass, 633 | name, 634 | heater_entity_id, 635 | presets, 636 | min_temp, 637 | max_temp, 638 | ac_mode, 639 | min_cycle_duration, 640 | cold_tolerance, 641 | hot_tolerance, 642 | keep_alive, 643 | initial_hvac_mode, 644 | away_temp, 645 | precision, 646 | unit, 647 | unique_id, 648 | ): 649 | """Initialize the thermostat.""" 650 | _LOGGER.info("__init__. ") 651 | 652 | self._name = name 653 | self.heater_entity_id = heater_entity_id 654 | self._presets = presets 655 | self._selected_preset = self._presets[0] 656 | self._selected_zone = self._selected_preset._zones[0] 657 | self._ongoing_zone = None 658 | self._ongoing_zone_temporarely_turned_off_by_rules = None 659 | self.ac_mode = ac_mode 660 | self.min_cycle_duration = min_cycle_duration 661 | self._cold_tolerance = cold_tolerance 662 | self._hot_tolerance = hot_tolerance 663 | self._keep_alive = keep_alive 664 | self._hvac_mode = initial_hvac_mode 665 | for z in self._selected_preset._zones: 666 | z._saved_target_temp = z._target_temp or away_temp 667 | self._temp_precision = precision 668 | if self.ac_mode: 669 | self._hvac_list = [HVACMode.COOL, HVACMode.OFF] 670 | else: 671 | self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] 672 | self._active = False 673 | self._cur_temp = None 674 | self._temp_lock = asyncio.Lock() 675 | self._min_temp = min_temp 676 | self._max_temp = max_temp 677 | self._unit = unit 678 | self._unique_id = unique_id 679 | self._support_flags = SUPPORT_FLAGS 680 | if away_temp: 681 | self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE 682 | self._away_temp = away_temp 683 | self._is_away = False 684 | self._isWindowOpenBinarySensor = IsWindowOpenSensor(hass, name) 685 | 686 | 687 | async def async_added_to_hass(self): 688 | """Run when entity about to be added.""" 689 | await super().async_added_to_hass() 690 | 691 | _LOGGER.info("async_added_to_hass. ") 692 | 693 | # Add listener 694 | for z in self._selected_preset._zones: 695 | if z._sensors_entity_id: 696 | for ts in z._sensors_entity_id: 697 | self.async_on_remove( 698 | async_track_state_change_event( 699 | self.hass, [ts], self._async_sensor_changed 700 | ) 701 | ) 702 | 703 | if z._target_entity_id: 704 | self.async_on_remove( 705 | async_track_state_change_event( 706 | self.hass, [z._target_entity_id], self._async_target_changed 707 | ) 708 | ) 709 | 710 | if self._selected_preset._rules and self._selected_preset._rules._enable_sensor: 711 | _LOGGER.info("subscribe to rules enable sensor %s", self._selected_preset._rules._enable_sensor) 712 | rules_enable_sensor_state = self.hass.states.get(self._selected_preset._rules._enable_sensor) 713 | await self._handle_rules_enable_sensor_changed(rules_enable_sensor_state) 714 | self.async_on_remove( 715 | async_track_state_change_event( 716 | self.hass, self._selected_preset._rules._enable_sensor, self._async_on_rules_enable_sensor_changed 717 | ) 718 | ) 719 | 720 | if self._selected_preset._rules and self._selected_preset._rules._max_heater_temp_rule: 721 | _LOGGER.info("subscribe to heat temp sensors %s", self._selected_preset._rules._max_heater_temp_rule._sensors_entity_id) 722 | self.async_on_remove( 723 | async_track_state_change_event( 724 | self.hass, self._selected_preset._rules._max_heater_temp_rule._sensors_entity_id, self._async_on_heater_temp_changed 725 | ) 726 | ) 727 | 728 | self.async_on_remove( 729 | async_track_state_change_event( 730 | self.hass, [self.heater_entity_id], self._async_switch_changed 731 | ) 732 | ) 733 | 734 | if self._keep_alive: 735 | self.async_on_remove( 736 | async_track_time_interval( 737 | self.hass, self._async_control_heating, self._keep_alive 738 | ) 739 | ) 740 | 741 | @callback 742 | def _async_startup(*_): 743 | """Init on startup.""" 744 | _LOGGER.info("_async_startup. ") 745 | 746 | obj = type('', (), {})() 747 | 748 | #for z in self._selected_preset._zones: 749 | # sensor_state = self.hass.states.get(z._sensor_entity_id) 750 | # obj.state = sensor_state 751 | 752 | # #self._async_update_temp(z, obj) 753 | # self.async_write_ha_state() 754 | 755 | # if z._target_entity_id: 756 | # sensor_state = self.hass.states.get(z._target_entity_id) 757 | # obj.state = sensor_state 758 | #self._async_update_target_temp(z, obj) 759 | # self.async_write_ha_state() 760 | #self.async_write_ha_state() 761 | 762 | _LOGGER.debug("Initial set of target temps") 763 | for p in self._presets: 764 | _LOGGER.debug("Initial set of target temps for preset %s", p._friendly_name) 765 | for z in p._zones: 766 | _LOGGER.debug("Initial set of target temps for preset %s Zone: %s | Target sensor id:%s", p._friendly_name, z._friendly_name, z._target_entity_id) 767 | target_value = self.hass.states.get(z._target_entity_id) 768 | _LOGGER.debug("Initial set of target temp: %s Zone: %s Target:%s Target Value:%s", p._friendly_name, z._friendly_name, z._target_entity_id, target_value) 769 | self._async_update_target_temp(z, target_value) 770 | 771 | self.async_write_ha_state() 772 | 773 | if self.hass.state == CoreState.running: 774 | _async_startup() 775 | else: 776 | self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) 777 | 778 | # Check If we have an old state 779 | old_state = await self.async_get_last_state() 780 | if old_state is not None: 781 | # If we have no initial temperature, restore 782 | #for z in self._selected_preset._zones: 783 | # if z._target_temp is None: 784 | # # If we have a previously saved temperature 785 | # if old_state.attributes.get(ATTR_TEMPERATURE+z._name) is None: 786 | # if self.ac_mode: 787 | # z._target_temp = self.max_temp 788 | # else: 789 | # z._target_temp = self.min_temp 790 | # _LOGGER.warning( 791 | # "Undefined target temperature, falling back %s to %s", 792 | # z._name, z._target_temp, 793 | # ) 794 | # else: 795 | # z._target_temp = float(old_state.attributes[ATTR_TEMPERATURE+z._name]) 796 | 797 | if old_state.attributes.get(ATTR_PRESET_MODE) == PRESET_AWAY: 798 | self._is_away = True 799 | if not self._hvac_mode and old_state.state: 800 | self._hvac_mode = old_state.state 801 | 802 | else: 803 | # No previous state, try and restore defaults 804 | for z in self._selected_preset._zones: 805 | if z._target_temp is None: 806 | if self.ac_mode: 807 | z._target_temp = self.max_temp 808 | else: 809 | z._target_temp = self.min_temp 810 | _LOGGER.warning( 811 | "No previously saved temperature, setting %s to %s", z._name, z._target_temp 812 | ) 813 | 814 | # Set default state to off 815 | if not self._hvac_mode: 816 | self._hvac_mode = HVACMode.OFF 817 | 818 | @property 819 | def should_poll(self): 820 | """Return the polling state.""" 821 | return False 822 | 823 | @property 824 | def name(self): 825 | """Return the name of the thermostat.""" 826 | return self._name 827 | 828 | @property 829 | def selected_zone_name(self): 830 | """Return the name of the selected zone (with worst temperature).""" 831 | return self._selected_zone._name 832 | 833 | @property 834 | def ongoing_zone_name(self): 835 | """Return the name of the zone that is curently being heated or cooled.""" 836 | if self._ongoing_zone is None: 837 | return None 838 | return self._ongoing_zone._name 839 | 840 | @property 841 | def unique_id(self): 842 | """Return the unique id of this thermostat.""" 843 | return self._unique_id 844 | 845 | @property 846 | def precision(self): 847 | """Return the precision of the system.""" 848 | if self._temp_precision is not None: 849 | return self._temp_precision 850 | return super().precision 851 | 852 | @property 853 | def target_temperature_step(self): 854 | """Return the supported step of target temperature.""" 855 | # Since this integration does not yet have a step size parameter 856 | # we have to re-use the precision as the step size for now. 857 | return self.precision 858 | 859 | @property 860 | def temperature_unit(self): 861 | """Return the unit of measurement.""" 862 | return self._unit 863 | 864 | @property 865 | def current_temperature(self): 866 | """Return the sensor temperature.""" 867 | _LOGGER.debug("current_temperature +++ %s - %s", self._ongoing_zone._name if self._ongoing_zone else 'None', self._selected_zone._name if self._selected_zone else 'None') 868 | cur_temp = (self._ongoing_zone or self._selected_zone).get_cur_temp() 869 | _LOGGER.debug("current_temperature is %s - %s - %s", self._ongoing_zone._name if self._ongoing_zone else 'None', self._selected_zone._name if self._selected_zone else 'None', cur_temp) 870 | return float(cur_temp) if is_temp_valid(cur_temp) else None 871 | 872 | @property 873 | def hvac_mode(self): 874 | """Return current operation.""" 875 | #_LOGGER.warning("hvac_mode %s", self._hvac_mode) 876 | return self._hvac_mode 877 | 878 | @property 879 | def hvac_action(self): 880 | """Return the current running hvac operation if supported. 881 | 882 | Need to be one of CURRENT_HVAC_*. 883 | """ 884 | if self._hvac_mode == HVACMode.OFF: 885 | #_LOGGER.warning("hvac_action HVACAction.OFF") 886 | return HVACAction.OFF 887 | if not self._is_device_active: 888 | #_LOGGER.warning("hvac_action HVACAction.IDLE") 889 | return HVACAction.IDLE 890 | if self.ac_mode: 891 | #_LOGGER.warning("hvac_action HVACAction.COOL") 892 | return HVACAction.COOLING 893 | #_LOGGER.warning("hvac_action HVACAction.HEATING") 894 | return HVACAction.HEATING 895 | 896 | @property 897 | def target_temperature(self): 898 | """Return the temperature we try to reach.""" 899 | target_temp = (self._ongoing_zone or self._selected_zone)._target_temp 900 | return float(target_temp) if is_temp_valid(target_temp) else None 901 | 902 | @property 903 | def hvac_modes(self): 904 | """List of available operation modes.""" 905 | #_LOGGER.warning("hvac_modes %s", ", ".join(self._hvac_list)) 906 | return self._hvac_list 907 | 908 | @property 909 | def preset_mode(self): 910 | """Return the current preset mode, e.g., home, away, temp.""" 911 | if self._selected_preset._report_zone_name_instead_preset_name == True: 912 | return self._selected_zone._friendly_name + (" [P]" if self._ongoing_zone_temporarely_turned_off_by_rules else "") 913 | return PRESET_AWAY if self._is_away else self._selected_preset._name # PRESET_AWAY if self._is_away else PRESET_NONE 914 | 915 | @property 916 | def preset_modes(self): 917 | """Return a list of available preset modes or PRESET_NONE if _away_temp is undefined.""" 918 | #return [PRESET_NONE, PRESET_AWAY] if self._away_temp else PRESET_NONE 919 | return list(p._name for p in self._presets) + list([PRESET_AWAY] if self._away_temp else []) 920 | 921 | async def async_set_hvac_mode(self, hvac_mode): 922 | """Set hvac mode.""" 923 | #_LOGGER.warning("async_set_hvac_mode %s", hvac_mode) 924 | if hvac_mode == HVACMode.HEAT: 925 | self._hvac_mode = HVACMode.HEAT 926 | await self._async_control_heating(force=True) 927 | elif hvac_mode == HVACMode.COOL: 928 | self._hvac_mode = HVACMode.COOL 929 | await self._async_control_heating(force=True) 930 | elif hvac_mode == HVACMode.OFF: 931 | self._hvac_mode = HVACMode.OFF 932 | if self._is_device_active: 933 | await self._async_heater_turn_off() 934 | else: 935 | _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) 936 | return 937 | # Ensure we update the current operation after changing the mode 938 | self.async_write_ha_state() 939 | 940 | async def async_set_temperature(self, **kwargs): 941 | """Set new target temperature.""" 942 | 943 | temperature = kwargs.get(ATTR_TEMPERATURE) 944 | _LOGGER.debug("async_set_temperature. %s", temperature) 945 | 946 | if temperature is None: 947 | return 948 | (self._ongoing_zone or self._selected_zone)._target_temp = temperature 949 | await self._async_control_heating(force=True) 950 | self.async_write_ha_state() 951 | 952 | @property 953 | def min_temp(self): 954 | """Return the minimum temperature.""" 955 | if self._min_temp is not None: 956 | return self._min_temp 957 | 958 | # get default temp from super class 959 | return super().min_temp 960 | 961 | @property 962 | def max_temp(self): 963 | """Return the maximum temperature.""" 964 | if self._max_temp is not None: 965 | return self._max_temp 966 | 967 | # Get default temp from super class 968 | return super().max_temp 969 | 970 | async def _async_on_rules_enable_sensor_changed(self, event): 971 | _LOGGER.info("_async_on_rules_enable_sensor_changed") 972 | new_state = event.data.get("new_state") 973 | await self._handle_rules_enable_sensor_changed(new_state) 974 | 975 | async def _handle_rules_enable_sensor_changed(self, new_state): 976 | self._selected_preset._rules._enable_sensor_state = new_state.state == STATE_ON 977 | _LOGGER.info("on_rules_enable_sensor_changed %s %s", new_state.state, self._selected_preset._rules._enable_sensor_state) 978 | await self._async_control_heating() 979 | 980 | async def _async_on_heater_temp_changed(self, event): 981 | _LOGGER.info("_async_on_heater_temp_changed") 982 | 983 | if self._selected_preset._rules and self._selected_preset._rules._max_heater_temp_rule: 984 | self._selected_preset._rules._max_heater_temp_rule._async_on_heater_temp_changed(event) 985 | await self._async_control_heating() 986 | 987 | async def _async_target_changed(self, event): 988 | """Handle target temperature changes.""" 989 | sender = event.data.get("entity_id") 990 | new_state = event.data.get("new_state") 991 | if new_state is None: 992 | return 993 | 994 | #_LOGGER.info("_async_target_changed. %s", new_state.state) 995 | 996 | for p in self._presets: 997 | for z in p._zones: 998 | if z._target_entity_id == sender: 999 | _LOGGER.debug("zone target value updated. %s: %s<%s", z._name, z._target_temp, new_state.state) 1000 | self._async_update_target_temp(z, new_state) 1001 | await self._async_control_heating() 1002 | self.async_write_ha_state() 1003 | 1004 | async def _async_sensor_changed(self, event): 1005 | """Handle temperature changes.""" 1006 | sender = event.data.get("entity_id") 1007 | new_state = event.data.get("new_state") 1008 | if new_state is None: 1009 | return 1010 | 1011 | # _LOGGER.info("_async_sensor_changed. %s", new_state.state) 1012 | 1013 | for p in self._presets: 1014 | for z in p._zones: 1015 | for ts in z._sensors_entity_id: 1016 | if ts == sender: 1017 | _LOGGER.debug("zone sensor value updated. %s-%s: %s<%s", z._name, sender, z._target_temp, new_state.state) 1018 | self._async_update_temp(z, sender, new_state) 1019 | self._isWindowOpenBinarySensor.set_zone_with_window_open(self._selected_preset.get_zone_with_open_window()) 1020 | await self._async_control_heating() 1021 | self.async_write_ha_state() 1022 | 1023 | @callback 1024 | def _async_switch_changed(self, event): 1025 | """Handle heater switch state changes.""" 1026 | new_state = event.data.get("new_state") 1027 | if new_state is None: 1028 | return 1029 | 1030 | _LOGGER.info("_async_switch_changed. %s", new_state.state) 1031 | 1032 | self.async_write_ha_state() 1033 | 1034 | if new_state.state == STATE_OFF: 1035 | self._ongoing_zone = None 1036 | 1037 | @callback 1038 | def _async_update_target_temp(self, zone, state): 1039 | """Update thermostat with latest state from target sensor.""" 1040 | try: 1041 | zone._target_temp = state.state 1042 | _LOGGER.debug("zone target value updated. %s: - set %s<%s", zone._name, zone._target_temp, zone.get_cur_temp()) 1043 | except ValueError as ex: 1044 | _LOGGER.error("Unable to update target temp %s from sensor: %s", z._name, ex) 1045 | 1046 | @callback 1047 | def _async_update_temp(self, zone, sender_sensor, state): 1048 | """Update thermostat with latest state from sensor.""" 1049 | try: 1050 | _LOGGER.debug("zone sensor value updated.. %s-%s: - set %s<%s", zone._name, sender_sensor, zone._target_temp, state.state) 1051 | zone.set_cur_temp(sender_sensor, state.state) 1052 | except ValueError as ex: 1053 | _LOGGER.error("Unable to update %s from sensor: %s", zone._name, ex) 1054 | 1055 | def select_worst_zone(self): 1056 | if (self._ongoing_zone is not None) and (self._ongoing_zone.is_cur_temp_valid()): 1057 | self._selected_zone = self._ongoing_zone 1058 | return 1059 | 1060 | if (self._ongoing_zone_temporarely_turned_off_by_rules is not None) and (self._ongoing_zone_temporarely_turned_off_by_rules.is_cur_temp_valid()): 1061 | self._selected_zone = self._ongoing_zone_temporarely_turned_off_by_rules 1062 | return 1063 | 1064 | sortedZones =list(sorted(filter(lambda z: (z.is_cur_temp_valid()) and (z.is_target_temp_valid()) and (z._openWindow is None or (z._openWindow._zone_react_timestamp is None or z._openWindow._zone_react_timestamp<=datetime.now())), self._selected_preset._zones), key=lambda z: float(z.get_cur_temp()) - float(z._target_temp))) 1065 | if len(sortedZones) > 0: 1066 | selected_zone = sortedZones[0] 1067 | else: 1068 | selected_zone = self._selected_preset._zones[0] 1069 | 1070 | if self._selected_zone != selected_zone: 1071 | _LOGGER.info("Selected zone changed from %s -> %s (%s<%s)", self._selected_zone._name, selected_zone._name, selected_zone._target_temp, selected_zone.get_cur_temp()) 1072 | self._selected_zone = selected_zone 1073 | 1074 | async def _async_control_heating(self, time=None, force=False): 1075 | _LOGGER.info("_async_control_heating +++") 1076 | 1077 | """Check if we need to turn heating on or off.""" 1078 | async with self._temp_lock: 1079 | _LOGGER.info("_async_control_heating inside _temp_lock") 1080 | self.select_worst_zone() 1081 | if not self._active and (self._selected_zone.is_cur_temp_valid()) and (self._selected_zone.is_target_temp_valid()): 1082 | self._active = True 1083 | _LOGGER.debug( 1084 | "Obtained current and target temperature. " 1085 | "Multizone generic thermostat active. %s < %s", 1086 | self._selected_zone._target_temp, 1087 | self._selected_zone.get_cur_temp(), 1088 | ) 1089 | 1090 | if not self._active or self._hvac_mode == HVACMode.OFF: 1091 | _LOGGER.info("_async_control_heating exit - nota active") 1092 | return 1093 | 1094 | if not force and time is None: 1095 | # If the `force` argument is True, we 1096 | # ignore `min_cycle_duration`. 1097 | # If the `time` argument is not none, we were invoked for 1098 | # keep-alive purposes, and `min_cycle_duration` is irrelevant. 1099 | _LOGGER.info("_async_control_heating if not force and time is None") 1100 | 1101 | if self.min_cycle_duration and self._is_heater_valid(): 1102 | if self._is_device_active: 1103 | current_state = STATE_ON 1104 | else: 1105 | current_state = HVACMode.OFF 1106 | long_enough = condition.state( 1107 | self.hass, 1108 | self.heater_entity_id, 1109 | current_state, 1110 | self.min_cycle_duration, 1111 | ) 1112 | if not long_enough: 1113 | _LOGGER.info("_async_control_heating exit not long_enough %s vs %s: %s", current_state, self.min_cycle_duration, long_enough) 1114 | return 1115 | 1116 | isValidTemp = (self._selected_zone.is_cur_temp_valid()) and (self._selected_zone.is_target_temp_valid()) 1117 | 1118 | if not isValidTemp: 1119 | self._ongoing_zone = None 1120 | self.select_worst_zone() 1121 | _LOGGER.debug("_async_control_heating skipped because temps are invalid Zone:%s CurTemp:%s TargetTemp:%s", self._selected_zone._name, self._selected_zone.get_cur_temp(), self._selected_zone._target_temp) 1122 | return 1123 | 1124 | target_temp_value = float(self._selected_zone._target_temp) 1125 | cur_temp_value = float(self._selected_zone.get_cur_temp()) 1126 | 1127 | too_cold = isValidTemp and (target_temp_value >= cur_temp_value + self._cold_tolerance) 1128 | too_hot = isValidTemp and (cur_temp_value >= target_temp_value + self._hot_tolerance) 1129 | 1130 | _LOGGER.info("_async_control_heating - CHECK rules (%s) - (%s) - (%s)", self._selected_preset._rules._enable_sensor_state if self._selected_preset._rules else "No rules set", self._ongoing_zone._name if self._ongoing_zone else "No Ongoing Zone", self._ongoing_zone_temporarely_turned_off_by_rules._name if self._ongoing_zone_temporarely_turned_off_by_rules else "No paused zone") 1131 | 1132 | if self._selected_preset._rules and self._selected_preset._rules._enable_sensor_state: 1133 | if self._selected_preset._rules._max_heater_temp_rule and not self._selected_preset._rules._max_heater_temp_rule.is_heating_allowed(): 1134 | _LOGGER.info("_async_control_heating stopped because of max_heater_temp_rule") 1135 | self._ongoing_zone_temporarely_turned_off_by_rules = self._ongoing_zone_temporarely_turned_off_by_rules or self._ongoing_zone 1136 | await self._async_heater_turn_off() 1137 | return 1138 | 1139 | if self._selected_preset._rules._max_on_duration_rule and not self._selected_preset._rules._max_on_duration_rule.is_heating_allowed(): 1140 | _LOGGER.info("_async_control_heating stopped because of max_on_time_rule") 1141 | self._ongoing_zone_temporarely_turned_off_by_rules = self._ongoing_zone_temporarely_turned_off_by_rules or self._ongoing_zone 1142 | await self._async_heater_turn_off() 1143 | return 1144 | 1145 | if self._ongoing_zone_temporarely_turned_off_by_rules: 1146 | _LOGGER.info("_async_control_heating - detected _ongoing_zone_temporarely_turned_off_by_rules %s", self._ongoing_zone_temporarely_turned_off_by_rules._name) 1147 | self._ongoing_zone = self._selected_zone 1148 | self._ongoing_zone_temporarely_turned_off_by_rules = None 1149 | await self._async_heater_turn_on() 1150 | else: 1151 | _LOGGER.info("_async_control_heating - clear _ongoing_zone_temporarely_turned_off_by_rules") 1152 | self._ongoing_zone_temporarely_turned_off_by_rules = None 1153 | 1154 | _LOGGER.info("_async_control_heating - rules PASSED") 1155 | 1156 | if self._is_device_active: 1157 | if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): 1158 | _LOGGER.info("Turning off heater %s %s (%s<%s)", self._selected_zone._name, self.heater_entity_id, target_temp_value, cur_temp_value) 1159 | self._ongoing_zone = None 1160 | self.select_worst_zone() 1161 | new_too_cold = target_temp_value >= cur_temp_value + self._cold_tolerance 1162 | new_too_hot = cur_temp_value >= target_temp_value + self._hot_tolerance 1163 | if (self.ac_mode and not new_too_hot) or (not self.ac_mode and not new_too_cold): 1164 | await self._async_heater_turn_off() 1165 | else: 1166 | _LOGGER.info("Not turning off heater because there is another zone too cold") 1167 | elif time is not None: 1168 | # The time argument is passed only in keep-alive case 1169 | _LOGGER.debug( 1170 | "Keep-alive - Turning on heater heater %s", 1171 | self.heater_entity_id, 1172 | ) 1173 | 1174 | self._ongoing_zone = self._selected_zone 1175 | await self._async_heater_turn_on() 1176 | else: 1177 | if (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): 1178 | _LOGGER.info("Turning on heater %s %s (%s<%s)", self._selected_zone._name, self.heater_entity_id, target_temp_value, cur_temp_value) 1179 | 1180 | self._ongoing_zone = self._selected_zone 1181 | await self._async_heater_turn_on() 1182 | elif time is not None: 1183 | # The time argument is passed only in keep-alive case 1184 | _LOGGER.debug( 1185 | "Keep-alive - Turning off heater %s", self.heater_entity_id 1186 | ) 1187 | self._ongoing_zone = None 1188 | await self._async_heater_turn_off() 1189 | 1190 | @property 1191 | def _is_device_active(self): 1192 | """If the toggleable device is currently active.""" 1193 | return self.hass.states.is_state(self.heater_entity_id, STATE_ON) 1194 | 1195 | def _is_heater_valid(self): 1196 | return (not self.hass.states.is_state(self.heater_entity_id, STATE_UNAVAILABLE)) and (not self.hass.states.is_state(self.heater_entity_id, STATE_UNKNOWN)) 1197 | 1198 | @property 1199 | def supported_features(self): 1200 | """Return the list of supported features.""" 1201 | return self._support_flags 1202 | 1203 | async def _async_heater_turn_on(self): 1204 | """Turn heater toggleable device on.""" 1205 | if self._selected_preset._rules and self._selected_preset._rules._max_on_duration_rule: 1206 | self._selected_preset._rules._max_on_duration_rule.on_turned_on() 1207 | 1208 | data = {ATTR_ENTITY_ID: self.heater_entity_id} 1209 | await self.hass.services.async_call( 1210 | HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context 1211 | ) 1212 | 1213 | async def _async_heater_turn_off(self): 1214 | """Turn heater toggleable device off.""" 1215 | if self._selected_preset._rules and self._selected_preset._rules._max_on_duration_rule: 1216 | self._selected_preset._rules._max_on_duration_rule.on_turned_off() 1217 | 1218 | data = {ATTR_ENTITY_ID: self.heater_entity_id} 1219 | await self.hass.services.async_call( 1220 | HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context 1221 | ) 1222 | 1223 | async def async_set_preset_mode(self, preset_mode: str): 1224 | """Set new preset mode.""" 1225 | 1226 | _LOGGER.info("async_set_preset_mode %s %s", self.heater_entity_id, preset_mode) 1227 | 1228 | if preset_mode in self._presets: 1229 | self._selected_preset = self._presets[preset_mode] 1230 | self._ongoing_zone = None 1231 | await self._async_control_heating(force=True) 1232 | else: 1233 | if preset_mode == PRESET_AWAY and not self._is_away: 1234 | self._is_away = True 1235 | for z in self._selected_preset._zones: 1236 | z._saved_target_temp = z._target_temp 1237 | z._target_temp = self._away_temp 1238 | self._ongoing_zone = None 1239 | await self._async_control_heating(force=True) 1240 | elif preset_mode == PRESET_NONE and self._is_away: 1241 | self._is_away = False 1242 | for z in self._selected_preset._zones: 1243 | z._target_temp = z._saved_target_temp 1244 | self._ongoing_zone = None 1245 | await self._async_control_heating(force=True) 1246 | 1247 | self.async_write_ha_state() 1248 | 1249 | @ClimateEntity.state_attributes.getter 1250 | def state_attributes(self) -> Dict[str, Any]: 1251 | data = super().state_attributes 1252 | data[ATTR_ONGOING_ZONE] = self._ongoing_zone._name if self._ongoing_zone else None 1253 | data[ATTR_SELECTED_ZONE] = self._selected_zone._name 1254 | data[ATTR_SELECTED_PRESET] = self._selected_preset._name 1255 | return data 1256 | --------------------------------------------------------------------------------