Initialisation of component ok
This commit is contained in:
60
.devcontainer/README.md
Normal file
60
.devcontainer/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
## Developing with Visual Studio Code + devcontainer
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Prerequisites**
|
||||||
|
|
||||||
|
- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||||
|
- Docker
|
||||||
|
- For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/)
|
||||||
|
- 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.
|
||||||
|
- [Visual Studio code](https://code.visualstudio.com/)
|
||||||
|
- [Remote - Containers (VSC Extension)][extension-link]
|
||||||
|
|
||||||
|
[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started)
|
||||||
|
|
||||||
|
[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers
|
||||||
|
|
||||||
|
**Getting started:**
|
||||||
|
|
||||||
|
1. Fork the repository.
|
||||||
|
2. Clone the repository to your computer.
|
||||||
|
3. Open the repository using Visual Studio code.
|
||||||
|
|
||||||
|
When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container.
|
||||||
|
|
||||||
|
_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The available tasks are:
|
||||||
|
|
||||||
|
Task | Description
|
||||||
|
-- | --
|
||||||
|
Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`.
|
||||||
|
Run Home Assistant configuration against /config | Check the configuration.
|
||||||
|
Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch.
|
||||||
|
Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container.
|
||||||
|
|
||||||
|
### Step by Step debugging
|
||||||
|
|
||||||
|
With the development container,
|
||||||
|
you can test your custom component in Home Assistant with step by step debugging.
|
||||||
|
|
||||||
|
You need to modify the `configuration.yaml` file in `.devcontainer` folder
|
||||||
|
by uncommenting the line:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# debugpy:
|
||||||
|
```
|
||||||
|
|
||||||
|
Then launch the task `Run Home Assistant on port 9123`, and launch the debbuger
|
||||||
|
with the existing debugging configuration `Python: Attach Local`.
|
||||||
|
|
||||||
|
For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/).
|
||||||
44
.devcontainer/configuration.yaml
Normal file
44
.devcontainer/configuration.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
default_config:
|
||||||
|
|
||||||
|
logger:
|
||||||
|
default: info
|
||||||
|
logs:
|
||||||
|
custom_components.versatile_thermostat: debug
|
||||||
|
|
||||||
|
# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/)
|
||||||
|
debugpy:
|
||||||
|
|
||||||
|
input_number:
|
||||||
|
fake_temperature_sensor1:
|
||||||
|
name: Temperature
|
||||||
|
min: 0
|
||||||
|
max: 35
|
||||||
|
step: 1
|
||||||
|
icon: mdi:thermometer
|
||||||
|
fake_current_power:
|
||||||
|
name: Current power
|
||||||
|
min: 0
|
||||||
|
max: 1000
|
||||||
|
step: 10
|
||||||
|
icon: mdi:flash
|
||||||
|
fake_current_power_max:
|
||||||
|
name: Current power max threshold
|
||||||
|
min: 0
|
||||||
|
max: 1000
|
||||||
|
step: 10
|
||||||
|
icon: mdi:flash
|
||||||
|
|
||||||
|
|
||||||
|
input_boolean:
|
||||||
|
# input_boolean to simulate the windows entity. Only for development environment.
|
||||||
|
fake_window_sensor1:
|
||||||
|
name: Window 1
|
||||||
|
icon: mdi:window-closed-variant
|
||||||
|
# input_boolean to simulate the heater entity switch. Only for development environment.
|
||||||
|
fake_heater_switch1:
|
||||||
|
name: Heater 1
|
||||||
|
icon: mdi:radiator
|
||||||
|
# input_boolean to simulate the motion sensor entity. Only for development environment.
|
||||||
|
fake_motion_sensor1:
|
||||||
|
name: Motion Sensor 1
|
||||||
|
icon: mdi:run
|
||||||
37
.devcontainer/devcontainer.json
Normal file
37
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
||||||
|
{
|
||||||
|
"image": "ghcr.io/ludeeus/devcontainer/integration:latest",
|
||||||
|
"name": "Blueprint integration development",
|
||||||
|
"context": "..",
|
||||||
|
"appPort": [
|
||||||
|
"9123:8123"
|
||||||
|
],
|
||||||
|
"postCreateCommand": "container install",
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"github.vscode-pull-request-github",
|
||||||
|
"ryanluker.vscode-coverage-gutters",
|
||||||
|
"ms-python.vscode-pylance"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"files.eol": "\n",
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"terminal.integrated.profiles.linux": {
|
||||||
|
"Bash Profile": {
|
||||||
|
"path": "bash",
|
||||||
|
"args": [ ]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terminal.integrated.defaultProfile.linux": "Bash Profile",
|
||||||
|
// "terminal.integrated.shell.linux": "/bin/bash",
|
||||||
|
"python.pythonPath": "/usr/bin/python3",
|
||||||
|
"python.analysis.autoSearchPaths": false,
|
||||||
|
"python.linting.pylintEnabled": true,
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.formatting.provider": "black",
|
||||||
|
"editor.formatOnPaste": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.formatOnType": true,
|
||||||
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
|
}
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
42
.github/ISSUE_TEMPLATE/issue.md
vendored
Normal file
42
.github/ISSUE_TEMPLATE/issue.md
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: Issue
|
||||||
|
about: Create a report to help us improve
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Before you open a new issue, search through the existing issues to see if others have had the same problem.
|
||||||
|
|
||||||
|
Issues not containing the minimum requirements will be closed:
|
||||||
|
|
||||||
|
- Issues without a description (using the header is not good enough) will be closed.
|
||||||
|
- Issues without debug logging will be closed.
|
||||||
|
- Issues without configuration will be closed
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Version of the custom_component
|
||||||
|
<!-- If you are not using the newest version, download and try that before opening an issue
|
||||||
|
If you are unsure about the version check the const.py file.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
Add your logs here.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Describe the bug
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
|
||||||
|
## Debug log
|
||||||
|
|
||||||
|
<!-- To enable debug logs check this https://www.home-assistant.io/components/logger/ -->
|
||||||
|
|
||||||
|
```text
|
||||||
|
|
||||||
|
Add your logs here.
|
||||||
|
|
||||||
|
```
|
||||||
22
.github/workflows/cron.yaml
vendored
Normal file
22
.github/workflows/cron.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Cron actions
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
name: Validate
|
||||||
|
steps:
|
||||||
|
- uses: "actions/checkout@v2"
|
||||||
|
|
||||||
|
- name: HACS validation
|
||||||
|
uses: "hacs/action@main"
|
||||||
|
with:
|
||||||
|
category: "integration"
|
||||||
|
ignore: brands
|
||||||
|
|
||||||
|
- name: Hassfest validation
|
||||||
|
uses: "home-assistant/actions/hassfest@master"
|
||||||
55
.github/workflows/pull.yml
vendored
Normal file
55
.github/workflows/pull.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Pull actions
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
name: Validate
|
||||||
|
steps:
|
||||||
|
- uses: "actions/checkout@v2"
|
||||||
|
|
||||||
|
- name: HACS validation
|
||||||
|
uses: "hacs/action@main"
|
||||||
|
with:
|
||||||
|
category: "integration"
|
||||||
|
ignore: brands
|
||||||
|
|
||||||
|
- name: Hassfest validation
|
||||||
|
uses: "home-assistant/actions/hassfest@master"
|
||||||
|
|
||||||
|
style:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
name: Check style formatting
|
||||||
|
steps:
|
||||||
|
- uses: "actions/checkout@v2"
|
||||||
|
- uses: "actions/setup-python@v1"
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- run: python3 -m pip install black
|
||||||
|
- run: black .
|
||||||
|
|
||||||
|
tests:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
name: Run tests
|
||||||
|
steps:
|
||||||
|
- name: Check out code from GitHub
|
||||||
|
uses: "actions/checkout@v2"
|
||||||
|
- name: Setup Python
|
||||||
|
uses: "actions/setup-python@v1"
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
- name: Install requirements
|
||||||
|
run: python3 -m pip install -r requirements_test.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
pytest \
|
||||||
|
-qq \
|
||||||
|
--timeout=9 \
|
||||||
|
--durations=10 \
|
||||||
|
-n auto \
|
||||||
|
--cov custom_components.integration_blueprint \
|
||||||
|
-o console_output_style=count \
|
||||||
|
-p no:sugar \
|
||||||
|
tests
|
||||||
34
.github/workflows/push.yml
vendored
Normal file
34
.github/workflows/push.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Push actions
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
name: Validate
|
||||||
|
steps:
|
||||||
|
- uses: "actions/checkout@v2"
|
||||||
|
|
||||||
|
- name: HACS validation
|
||||||
|
uses: "hacs/action@main"
|
||||||
|
with:
|
||||||
|
category: "integration"
|
||||||
|
ignore: brands
|
||||||
|
|
||||||
|
- name: Hassfest validation
|
||||||
|
uses: "home-assistant/actions/hassfest@master"
|
||||||
|
|
||||||
|
style:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
name: Check style formatting
|
||||||
|
steps:
|
||||||
|
- uses: "actions/checkout@v2"
|
||||||
|
- uses: "actions/setup-python@v1"
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- run: python3 -m pip install black
|
||||||
|
- run: black .
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -102,3 +102,5 @@ dist
|
|||||||
|
|
||||||
# TernJS port file
|
# TernJS port file
|
||||||
.tern-port
|
.tern-port
|
||||||
|
|
||||||
|
__pycache__
|
||||||
35
.vscode/launch.json
vendored
Normal file
35
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
// Example of attaching to local debug server
|
||||||
|
"name": "Python: Attach Local",
|
||||||
|
"type": "python",
|
||||||
|
"request": "attach",
|
||||||
|
"port": 5678,
|
||||||
|
"host": "localhost",
|
||||||
|
"justMyCode": false,
|
||||||
|
"pathMappings": [
|
||||||
|
{
|
||||||
|
"localRoot": "${workspaceFolder}",
|
||||||
|
"remoteRoot": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Example of attaching to my production server
|
||||||
|
"name": "Python: Attach Remote",
|
||||||
|
"type": "python",
|
||||||
|
"request": "attach",
|
||||||
|
"port": 5678,
|
||||||
|
"host": "homeassistant.local",
|
||||||
|
"pathMappings": [
|
||||||
|
{
|
||||||
|
"localRoot": "${workspaceFolder}",
|
||||||
|
"remoteRoot": "/usr/src/homeassistant"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
.vscode/settings.json
vendored
Normal file
8
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"python.linting.pylintEnabled": true,
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.pythonPath": "/usr/local/bin/python",
|
||||||
|
"files.associations": {
|
||||||
|
"*.yaml": "home-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
.vscode/tasks.json
vendored
Normal file
29
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Run Home Assistant on port 9123",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "container start",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Run Home Assistant configuration against /config",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "container check",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Upgrade Home Assistant to latest dev",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "container install",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Install a specific version of Home Assistant",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "container set-version",
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
91
custom_components/versatile_thermostat/__init__.py
Normal file
91
custom_components/versatile_thermostat/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""The Versatile Thermostat integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .climate import VersatileThermostat
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Versatile Thermostat from a config entry."""
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Calling async_setup_entry entry: entry_id='%s', value='%s'",
|
||||||
|
entry.entry_id,
|
||||||
|
entry.data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
|
# TODO 1. Create API instance
|
||||||
|
api: VersatileThermostatAPI = hass.data.get(DOMAIN)
|
||||||
|
if api is None:
|
||||||
|
api = VersatileThermostatAPI(hass)
|
||||||
|
|
||||||
|
# TODO 2. Validate the API connection (and authentication)
|
||||||
|
# TODO 3. Store an API object for your platforms to access
|
||||||
|
api.add_entry(entry)
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
api: VersatileThermostatAPI = hass.data.get(DOMAIN)
|
||||||
|
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
if api:
|
||||||
|
api.remove_entry(entry)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
class VersatileThermostatAPI(Dict):
|
||||||
|
"""The VersatileThermostatAPI"""
|
||||||
|
|
||||||
|
_hass: HomeAssistant
|
||||||
|
# _entries: Dict(str, ConfigEntry)
|
||||||
|
|
||||||
|
def __init__(self, hass):
|
||||||
|
_LOGGER.debug("building a VersatileThermostatAPI")
|
||||||
|
super().__init__()
|
||||||
|
self._hass = hass
|
||||||
|
# self._entries = dict()
|
||||||
|
# Add the API in hass.data
|
||||||
|
self._hass.data[DOMAIN] = self
|
||||||
|
|
||||||
|
def add_entry(self, entry: ConfigEntry):
|
||||||
|
"""Add a new entry"""
|
||||||
|
_LOGGER.debug("Add the entry %s", entry.entry_id)
|
||||||
|
# self._entries[entry.entry_id] = entry
|
||||||
|
# Add the entry in hass.data
|
||||||
|
self._hass.data[DOMAIN][entry.entry_id] = entry
|
||||||
|
|
||||||
|
def remove_entry(self, entry: ConfigEntry):
|
||||||
|
"""Remove an entry"""
|
||||||
|
_LOGGER.debug("Remove the entry %s", entry.entry_id)
|
||||||
|
# self._entries.pop(entry.entry_id)
|
||||||
|
self._hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
# If not more entries are preset, remove the API
|
||||||
|
if len(self) == 0:
|
||||||
|
_LOGGER.debug("No more entries-> Remove the API from DOMAIN")
|
||||||
|
self._hass.data.pop(DOMAIN)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hass(self):
|
||||||
|
"""Get the HomeAssistant object"""
|
||||||
|
return self._hass
|
||||||
691
custom_components/versatile_thermostat/climate.py
Normal file
691
custom_components/versatile_thermostat/climate.py
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, callback, CoreState
|
||||||
|
from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity
|
||||||
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from homeassistant.helpers.event import (
|
||||||
|
async_track_state_change_event,
|
||||||
|
async_track_time_interval,
|
||||||
|
async_call_later,
|
||||||
|
)
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from homeassistant.components.climate.const import (
|
||||||
|
ATTR_PRESET_MODE,
|
||||||
|
ATTR_FAN_MODE,
|
||||||
|
CURRENT_HVAC_COOL,
|
||||||
|
CURRENT_HVAC_HEAT,
|
||||||
|
CURRENT_HVAC_IDLE,
|
||||||
|
CURRENT_HVAC_OFF,
|
||||||
|
HVAC_MODE_COOL,
|
||||||
|
HVAC_MODE_HEAT,
|
||||||
|
HVAC_MODE_OFF,
|
||||||
|
PRESET_ACTIVITY,
|
||||||
|
PRESET_AWAY,
|
||||||
|
PRESET_BOOST,
|
||||||
|
PRESET_COMFORT,
|
||||||
|
PRESET_ECO,
|
||||||
|
PRESET_HOME,
|
||||||
|
PRESET_NONE,
|
||||||
|
PRESET_SLEEP,
|
||||||
|
SUPPORT_PRESET_MODE,
|
||||||
|
SUPPORT_TARGET_TEMPERATURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
UnitOfTemperature,
|
||||||
|
ATTR_TEMPERATURE,
|
||||||
|
TEMP_CELSIUS,
|
||||||
|
TEMP_FAHRENHEIT,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_UNIQUE_ID,
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
STATE_OFF,
|
||||||
|
STATE_ON,
|
||||||
|
EVENT_HOMEASSISTANT_START,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
CONF_HEATER,
|
||||||
|
CONF_POWER_SENSOR,
|
||||||
|
CONF_TEMP_SENSOR,
|
||||||
|
CONF_MAX_POWER_SENSOR,
|
||||||
|
CONF_MOTION_SENSOR,
|
||||||
|
CONF_WINDOW_SENSOR,
|
||||||
|
CONF_DEVICE_POWER,
|
||||||
|
CONF_PRESETS,
|
||||||
|
SUPPORT_FLAGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the VersatileThermostat thermostat with config flow."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_id = entry.entry_id
|
||||||
|
name = entry.data.get(CONF_NAME)
|
||||||
|
heater_entity_id = entry.data.get(CONF_HEATER)
|
||||||
|
temp_sensor_entity_id = entry.data.get(CONF_TEMP_SENSOR)
|
||||||
|
power_sensor_entity_id = entry.data.get(CONF_POWER_SENSOR)
|
||||||
|
max_power_sensor_entity_id = entry.data.get(CONF_MAX_POWER_SENSOR)
|
||||||
|
window_sensor_entity_id = entry.data.get(CONF_WINDOW_SENSOR)
|
||||||
|
motion_sensor_entity_id = entry.data.get(CONF_MOTION_SENSOR)
|
||||||
|
device_power = entry.data.get(CONF_DEVICE_POWER)
|
||||||
|
|
||||||
|
presets = {}
|
||||||
|
for (key, value) in CONF_PRESETS.items():
|
||||||
|
_LOGGER.debug("looking for key=%s, value=%s", key, value)
|
||||||
|
if value in entry.data:
|
||||||
|
presets[key] = entry.data.get(value)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("value %s not found in Entry", value)
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
VersatileThermostat(
|
||||||
|
unique_id,
|
||||||
|
name,
|
||||||
|
heater_entity_id,
|
||||||
|
temp_sensor_entity_id,
|
||||||
|
power_sensor_entity_id,
|
||||||
|
max_power_sensor_entity_id,
|
||||||
|
window_sensor_entity_id,
|
||||||
|
motion_sensor_entity_id,
|
||||||
|
presets,
|
||||||
|
device_power,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VersatileThermostat(ClimateEntity, RestoreEntity):
|
||||||
|
"""Representation of a Versatile Thermostat device."""
|
||||||
|
|
||||||
|
_name: str
|
||||||
|
_heater_entity_id: str
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
unique_id,
|
||||||
|
name,
|
||||||
|
heater_entity_id,
|
||||||
|
temp_sensor_entity_id,
|
||||||
|
power_sensor_entity_id,
|
||||||
|
max_power_sensor_entity_id,
|
||||||
|
window_sensor_entity_id,
|
||||||
|
motion_sensor_entity_id,
|
||||||
|
presets,
|
||||||
|
device_power,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the thermostat."""
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._unique_id = unique_id
|
||||||
|
self._name = name
|
||||||
|
self._heater_entity_id = heater_entity_id
|
||||||
|
self._temp_sensor_entity_id = temp_sensor_entity_id
|
||||||
|
self._power_sensor_entity_id = power_sensor_entity_id
|
||||||
|
self._max_power_sensor_entity_id = max_power_sensor_entity_id
|
||||||
|
self._window_sensor_entity_id = window_sensor_entity_id
|
||||||
|
self._motion_sensor_entity_id = motion_sensor_entity_id
|
||||||
|
|
||||||
|
# if self.ac_mode:
|
||||||
|
# self.hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF]
|
||||||
|
# else:
|
||||||
|
self._hvac_list = [HVAC_MODE_HEAT, HVAC_MODE_OFF]
|
||||||
|
self._unit = TEMP_CELSIUS
|
||||||
|
# Will be restored if possible
|
||||||
|
self._hvac_mode = None # HVAC_MODE_OFF
|
||||||
|
self._saved_hvac_mode = self._hvac_mode
|
||||||
|
|
||||||
|
self._support_flags = SUPPORT_FLAGS
|
||||||
|
if len(presets):
|
||||||
|
self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE
|
||||||
|
self._attr_preset_modes = [PRESET_NONE] + list(presets.keys())
|
||||||
|
_LOGGER.debug("Set preset_modes to %s", self._attr_preset_modes)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("No preset_modes")
|
||||||
|
self._attr_preset_modes = [PRESET_NONE]
|
||||||
|
self._presets = presets
|
||||||
|
_LOGGER.debug("%s - presets are set to: %s", self, self._presets)
|
||||||
|
# Will be restored if possible
|
||||||
|
self._attr_preset_mode = None # PRESET_NONE
|
||||||
|
|
||||||
|
# Power management
|
||||||
|
self._device_power = device_power
|
||||||
|
if (
|
||||||
|
self._max_power_sensor_entity_id
|
||||||
|
and self._power_sensor_entity_id
|
||||||
|
and self._device_power
|
||||||
|
):
|
||||||
|
self._pmax_on = True
|
||||||
|
self._current_power = -1
|
||||||
|
self._current_power_max = -1
|
||||||
|
else:
|
||||||
|
self._pmax_on = False
|
||||||
|
|
||||||
|
# will be restored if possible
|
||||||
|
self._target_temp = None
|
||||||
|
self._saved_target_temp = self._target_temp
|
||||||
|
self._humidity = None
|
||||||
|
self._ac_mode = False
|
||||||
|
self._fan_mode = None
|
||||||
|
self._swing_mode = None
|
||||||
|
self._cur_temp = None
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s",
|
||||||
|
self,
|
||||||
|
self.unique_id,
|
||||||
|
heater_entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"VersatileThermostat-{self.name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
return self._unique_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_modes(self):
|
||||||
|
"""List of available operation modes."""
|
||||||
|
return self._hvac_list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def temperature_unit(self):
|
||||||
|
"""Return the unit of measurement."""
|
||||||
|
return self._unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_mode(self):
|
||||||
|
"""Return current operation."""
|
||||||
|
return self._hvac_mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_action(self):
|
||||||
|
"""Return the current running hvac operation if supported.
|
||||||
|
|
||||||
|
Need to be one of CURRENT_HVAC_*.
|
||||||
|
"""
|
||||||
|
if self._hvac_mode == HVAC_MODE_OFF:
|
||||||
|
return CURRENT_HVAC_OFF
|
||||||
|
if not self._is_device_active:
|
||||||
|
return CURRENT_HVAC_IDLE
|
||||||
|
if self._ac_mode:
|
||||||
|
return CURRENT_HVAC_COOL
|
||||||
|
return CURRENT_HVAC_HEAT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature(self):
|
||||||
|
"""Return the temperature we try to reach."""
|
||||||
|
return self._target_temp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self):
|
||||||
|
"""Return the list of supported features."""
|
||||||
|
return self._support_flags
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _is_device_active(self):
|
||||||
|
"""If the toggleable device is currently active."""
|
||||||
|
if not self.hass.states.get(self._heater_entity_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.hass.states.is_state(self._heater_entity_id, STATE_ON)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_temperature(self):
|
||||||
|
"""Return the sensor temperature."""
|
||||||
|
return self._cur_temp
|
||||||
|
|
||||||
|
async def async_set_hvac_mode(self, hvac_mode):
|
||||||
|
"""Set new target hvac mode."""
|
||||||
|
_LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode)
|
||||||
|
if hvac_mode == HVAC_MODE_HEAT:
|
||||||
|
self._hvac_mode = HVAC_MODE_HEAT
|
||||||
|
# TODO await self._async_control_heating(force=True)
|
||||||
|
elif hvac_mode == HVAC_MODE_COOL:
|
||||||
|
self._hvac_mode = HVAC_MODE_COOL
|
||||||
|
# TODO await self._async_control_heating(force=True)
|
||||||
|
elif hvac_mode == HVAC_MODE_OFF:
|
||||||
|
self._hvac_mode = HVAC_MODE_OFF
|
||||||
|
# TODO self.prop_current_phase = PROP_PHASE_NONE
|
||||||
|
# if self._is_device_active:
|
||||||
|
# await self._async_heater_turn_off()
|
||||||
|
else:
|
||||||
|
_LOGGER.error("Unrecognized hvac mode: %s", hvac_mode)
|
||||||
|
return
|
||||||
|
# Ensure we update the current operation after changing the mode
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_set_preset_mode(self, preset_mode):
|
||||||
|
"""Set new preset mode."""
|
||||||
|
_LOGGER.info("%s - Set preset_mode: %s", self, preset_mode)
|
||||||
|
if preset_mode not in (self._attr_preset_modes or []):
|
||||||
|
raise ValueError(
|
||||||
|
f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}"
|
||||||
|
)
|
||||||
|
if preset_mode == self._attr_preset_mode:
|
||||||
|
# I don't think we need to call async_write_ha_state if we didn't change the state
|
||||||
|
return
|
||||||
|
if preset_mode == PRESET_NONE:
|
||||||
|
self._attr_preset_mode = PRESET_NONE
|
||||||
|
self._target_temp = self._saved_target_temp
|
||||||
|
# TODO await self._async_control_heating(force=True)
|
||||||
|
elif preset_mode == PRESET_ACTIVITY:
|
||||||
|
self._attr_preset_mode = PRESET_ACTIVITY
|
||||||
|
# TODO self._target_temp = self._presets[self.no_motion_mode]
|
||||||
|
# await self._async_control_heating(force=True)
|
||||||
|
else:
|
||||||
|
if self._attr_preset_mode == PRESET_NONE:
|
||||||
|
self._saved_target_temp = self._target_temp
|
||||||
|
self._attr_preset_mode = preset_mode
|
||||||
|
self._target_temp = self._presets[preset_mode]
|
||||||
|
# TODO await self._async_control_heating(force=True)
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_set_fan_mode(self, fan_mode):
|
||||||
|
"""Set new target fan mode."""
|
||||||
|
_LOGGER.info("%s - Set fan mode: %s", self, fan_mode)
|
||||||
|
if fan_mode is None:
|
||||||
|
return
|
||||||
|
self._fan_mode = fan_mode
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_set_humidity(self, humidity: int):
|
||||||
|
"""Set new target humidity."""
|
||||||
|
_LOGGER.info("%s - Set fan mode: %s", self, humidity)
|
||||||
|
if humidity is None:
|
||||||
|
return
|
||||||
|
self._humidity = humidity
|
||||||
|
|
||||||
|
async def async_set_swing_mode(self, swing_mode):
|
||||||
|
"""Set new target swing operation."""
|
||||||
|
_LOGGER.info("%s - Set fan mode: %s", self, swing_mode)
|
||||||
|
if swing_mode is None:
|
||||||
|
return
|
||||||
|
self._swing_mode = swing_mode
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_set_temperature(self, **kwargs):
|
||||||
|
"""Set new target temperature."""
|
||||||
|
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||||
|
_LOGGER.info("%s - Set target temp: %s", self, temperature)
|
||||||
|
if temperature is None:
|
||||||
|
return
|
||||||
|
self._target_temp = temperature
|
||||||
|
self._attr_preset_mode = PRESET_NONE
|
||||||
|
# TODO await self._async_control_heating(force=True)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def entry_update_listener(
|
||||||
|
self, hass: HomeAssistant, config_entry: ConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Called when the entry have changed in ConfigFlow"""
|
||||||
|
_LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""Run when entity about to be added."""
|
||||||
|
_LOGGER.debug("Calling async_added_to_hass")
|
||||||
|
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
|
# Add listener
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass, [self._heater_entity_id], self._async_switch_changed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._temp_sensor_entity_id],
|
||||||
|
self._async_temperature_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self._window_sensor_entity_id:
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._window_sensor_entity_id],
|
||||||
|
self._async_windows_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self._motion_sensor_entity_id:
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._motion_sensor_entity_id],
|
||||||
|
self._async_motion_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# if self._keep_alive:
|
||||||
|
# self.async_on_remove(
|
||||||
|
# async_track_time_interval(
|
||||||
|
# self.hass, self._async_control_heating, self._keep_alive
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
if self._power_sensor_entity_id:
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._power_sensor_entity_id],
|
||||||
|
self._async_power_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._max_power_sensor_entity_id:
|
||||||
|
self.async_on_remove(
|
||||||
|
async_track_state_change_event(
|
||||||
|
self.hass,
|
||||||
|
[self._max_power_sensor_entity_id],
|
||||||
|
self._async_max_power_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.async_startup()
|
||||||
|
|
||||||
|
async def async_startup(self):
|
||||||
|
"""Triggered on startup, used to get old state and set internal states accordingly"""
|
||||||
|
_LOGGER.debug("%s - Calling async_startup", self)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_startup_internal(*_):
|
||||||
|
_LOGGER.debug("%s - Calling async_startup_internal", self)
|
||||||
|
need_write_state = False
|
||||||
|
temperature_state = self.hass.states.get(self._temp_sensor_entity_id)
|
||||||
|
if temperature_state and temperature_state.state not in (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - temperature sensor have been retrieved: %f",
|
||||||
|
self,
|
||||||
|
float(temperature_state.state),
|
||||||
|
)
|
||||||
|
# TODO self._async_update_temp(temperature_state)
|
||||||
|
need_write_state = True
|
||||||
|
|
||||||
|
switch_state = self.hass.states.get(self._heater_entity_id)
|
||||||
|
if switch_state and switch_state.state not in (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
):
|
||||||
|
self.hass.create_task(self._check_switch_initial_state())
|
||||||
|
|
||||||
|
if self._pmax_on:
|
||||||
|
# try to acquire current power and power max
|
||||||
|
current_power_state = self.hass.states.get(self._power_sensor_entity_id)
|
||||||
|
if current_power_state and current_power_state.state not in (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
):
|
||||||
|
self._current_power = float(current_power_state.state)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Current power have been retrieved: %f",
|
||||||
|
self,
|
||||||
|
self._current_power,
|
||||||
|
)
|
||||||
|
need_write_state = True
|
||||||
|
|
||||||
|
# Try to acquire power max
|
||||||
|
current_power_max_state = self.hass.states.get(
|
||||||
|
self._max_power_sensor_entity_id
|
||||||
|
)
|
||||||
|
if current_power_max_state and current_power_max_state.state not in (
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
):
|
||||||
|
self._current_power_max = float(current_power_max_state.state)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Current power max have been retrieved: %f",
|
||||||
|
self,
|
||||||
|
self._current_power_max,
|
||||||
|
)
|
||||||
|
need_write_state = True
|
||||||
|
|
||||||
|
if need_write_state:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
# TODO self.hass.create_task(self._async_control_heating())
|
||||||
|
|
||||||
|
if self.hass.state == CoreState.running:
|
||||||
|
_async_startup_internal()
|
||||||
|
else:
|
||||||
|
self.hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_START, _async_startup_internal
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.get_my_previous_state()
|
||||||
|
|
||||||
|
async def get_my_previous_state(self):
|
||||||
|
"""Try to get my previou state"""
|
||||||
|
# Check If we have an old state
|
||||||
|
old_state = await self.async_get_last_state()
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - Calling get_my_previous_state old_state is %s", self, old_state
|
||||||
|
)
|
||||||
|
if old_state is not None:
|
||||||
|
# If we have no initial temperature, restore
|
||||||
|
if self._target_temp is None:
|
||||||
|
# If we have a previously saved temperature
|
||||||
|
if old_state.attributes.get(ATTR_TEMPERATURE) is None:
|
||||||
|
if self._ac_mode:
|
||||||
|
self._target_temp = self.max_temp
|
||||||
|
else:
|
||||||
|
self._target_temp = self.min_temp
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s - Undefined target temperature, falling back to %s",
|
||||||
|
self,
|
||||||
|
self._target_temp,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE])
|
||||||
|
|
||||||
|
if old_state.attributes.get(ATTR_PRESET_MODE) in self._attr_preset_modes:
|
||||||
|
self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE)
|
||||||
|
|
||||||
|
if not self._hvac_mode and old_state.state:
|
||||||
|
self._hvac_mode = old_state.state
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No previous state, try and restore defaults
|
||||||
|
if self._target_temp is None:
|
||||||
|
if self._ac_mode:
|
||||||
|
self._target_temp = self.max_temp
|
||||||
|
else:
|
||||||
|
self._target_temp = self.min_temp
|
||||||
|
_LOGGER.warning(
|
||||||
|
"No previously saved temperature, setting to %s", self._target_temp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set default state to off
|
||||||
|
if not self._hvac_mode:
|
||||||
|
self._hvac_mode = HVAC_MODE_OFF
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s - restored state is target_temp=%f, preset_mode=%s, hvac_mode=%s",
|
||||||
|
self,
|
||||||
|
self._target_temp,
|
||||||
|
self._attr_preset_mode,
|
||||||
|
self._hvac_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_temperature_changed(self, event):
|
||||||
|
"""Handle temperature changes."""
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s - Temperature changed. Event.new_state is %s",
|
||||||
|
self,
|
||||||
|
new_state,
|
||||||
|
)
|
||||||
|
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._async_update_temp(new_state)
|
||||||
|
# TODO await self._async_control_heating()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_windows_changed(self, event):
|
||||||
|
"""Handle window changes."""
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
old_state = event.data.get("old_state")
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s - Window changed. Event.new_state is %s, _hvac_mode=%s, _saved_hvac_mode=%s",
|
||||||
|
self,
|
||||||
|
new_state,
|
||||||
|
self._hvac_mode,
|
||||||
|
self._saved_hvac_mode,
|
||||||
|
)
|
||||||
|
if new_state is None or old_state is None or new_state.state == old_state.state:
|
||||||
|
return
|
||||||
|
if not self._saved_hvac_mode:
|
||||||
|
self._saved_hvac_mode = self._hvac_mode
|
||||||
|
if new_state.state == STATE_OFF:
|
||||||
|
await self.async_set_hvac_mode(self._saved_hvac_mode)
|
||||||
|
elif new_state.state == STATE_ON:
|
||||||
|
self._saved_hvac_mode = self._hvac_mode
|
||||||
|
await self.async_set_hvac_mode(HVAC_MODE_OFF)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_motion_changed(self, event):
|
||||||
|
"""Handle motion changes."""
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
_LOGGER.info(
|
||||||
|
"%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s",
|
||||||
|
self,
|
||||||
|
new_state,
|
||||||
|
self._attr_preset_mode,
|
||||||
|
PRESET_ACTIVITY,
|
||||||
|
)
|
||||||
|
if self._attr_preset_mode != PRESET_ACTIVITY:
|
||||||
|
return
|
||||||
|
if new_state is None or new_state.state not in (STATE_OFF, STATE_ON):
|
||||||
|
return
|
||||||
|
|
||||||
|
# if self.motion_delay:
|
||||||
|
# if new_state.state == STATE_ON:
|
||||||
|
# self._target_temp = self._presets[self.motion_mode]
|
||||||
|
# await self._async_control_heating()
|
||||||
|
# self.async_write_ha_state()
|
||||||
|
# else:
|
||||||
|
# async def try_no_motion_condition(_):
|
||||||
|
# if self._attr_preset_mode != PRESET_ACTIVITY:
|
||||||
|
# return
|
||||||
|
# try:
|
||||||
|
# long_enough = condition.state(
|
||||||
|
# self.hass,
|
||||||
|
# self.motion_entity_id,
|
||||||
|
# STATE_OFF,
|
||||||
|
# self.motion_delay,
|
||||||
|
# )
|
||||||
|
# except ConditionError:
|
||||||
|
# long_enough = False
|
||||||
|
# if long_enough:
|
||||||
|
# self._target_temp = self._presets[self.no_motion_mode]
|
||||||
|
# await self._async_control_heating()
|
||||||
|
# self.async_write_ha_state()
|
||||||
|
#
|
||||||
|
# async_call_later(self.hass, self.motion_delay, try_no_motion_condition)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _check_switch_initial_state(self):
|
||||||
|
"""Prevent the device from keep running if HVAC_MODE_OFF."""
|
||||||
|
_LOGGER.debug("%s - Calling _check_switch_initial_state", self)
|
||||||
|
if self._hvac_mode == HVAC_MODE_OFF and self._is_device_active:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"The climate mode is OFF, but the switch device is ON. Turning off device %s",
|
||||||
|
self._heater_entity_id,
|
||||||
|
)
|
||||||
|
# TODO await self._async_heater_turn_off()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_switch_changed(self, event):
|
||||||
|
"""Handle heater switch state changes."""
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
old_state = event.data.get("old_state")
|
||||||
|
if new_state is None:
|
||||||
|
return
|
||||||
|
if old_state is None:
|
||||||
|
self.hass.create_task(self._check_switch_initial_state())
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_update_temp(self, state):
|
||||||
|
"""Update thermostat with latest state from sensor."""
|
||||||
|
try:
|
||||||
|
cur_temp = float(state.state)
|
||||||
|
if math.isnan(cur_temp) or math.isinf(cur_temp):
|
||||||
|
raise ValueError(f"Sensor has illegal state {state.state}")
|
||||||
|
self._cur_temp = cur_temp
|
||||||
|
except ValueError as ex:
|
||||||
|
_LOGGER.error("Unable to update temperature from sensor: %s", ex)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_power_changed(self, event):
|
||||||
|
"""Handle power changes."""
|
||||||
|
_LOGGER.debug("Thermostat %s - Receive new Power event", self.name)
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
old_state = event.data.get("old_state")
|
||||||
|
if new_state is None or new_state.state == old_state.state:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_power = float(new_state.state)
|
||||||
|
if math.isnan(current_power) or math.isinf(current_power):
|
||||||
|
raise ValueError(f"Sensor has illegal state {new_state.state}")
|
||||||
|
self._current_power = current_power
|
||||||
|
|
||||||
|
except ValueError as ex:
|
||||||
|
_LOGGER.error("Unable to update current_power from sensor: %s", ex)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def _async_max_power_changed(self, event):
|
||||||
|
"""Handle power max changes."""
|
||||||
|
_LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name)
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
new_state = event.data.get("new_state")
|
||||||
|
old_state = event.data.get("old_state")
|
||||||
|
if new_state is None or new_state.state == old_state.state:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_power_max = float(new_state.state)
|
||||||
|
if math.isnan(current_power_max) or math.isinf(current_power_max):
|
||||||
|
raise ValueError(f"Sensor has illegal state {new_state.state}")
|
||||||
|
self._current_power_max = current_power_max
|
||||||
|
|
||||||
|
except ValueError as ex:
|
||||||
|
_LOGGER.error("Unable to update current_power from sensor: %s", ex)
|
||||||
195
custom_components/versatile_thermostat/config_flow.py
Normal file
195
custom_components/versatile_thermostat/config_flow.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Config flow for Versatile Thermostat integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import (
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigFlow as HAConfigFlow,
|
||||||
|
OptionsFlow,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_HEATER,
|
||||||
|
CONF_TEMP_SENSOR,
|
||||||
|
CONF_POWER_SENSOR,
|
||||||
|
CONF_MAX_POWER_SENSOR,
|
||||||
|
CONF_WINDOW_SENSOR,
|
||||||
|
CONF_MOTION_SENSOR,
|
||||||
|
CONF_DEVICE_POWER,
|
||||||
|
ALL_CONF,
|
||||||
|
CONF_PRESETS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# from .climate import VersatileThermostat
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_NAME): cv.string,
|
||||||
|
vol.Required(CONF_HEATER): cv.string,
|
||||||
|
vol.Required(CONF_TEMP_SENSOR): cv.string,
|
||||||
|
vol.Optional(CONF_POWER_SENSOR): cv.string,
|
||||||
|
vol.Optional(CONF_MAX_POWER_SENSOR): cv.string,
|
||||||
|
vol.Optional(CONF_WINDOW_SENSOR): cv.string,
|
||||||
|
vol.Optional(CONF_MOTION_SENSOR): cv.string,
|
||||||
|
vol.Optional(CONF_DEVICE_POWER): vol.Coerce(float),
|
||||||
|
}
|
||||||
|
).extend(
|
||||||
|
{vol.Optional(v, default=17): vol.Coerce(float) for (k, v) in CONF_PRESETS.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_defaults(schema, **defaults):
|
||||||
|
"""Create a new schema with default values filled in."""
|
||||||
|
copy = schema.extend({})
|
||||||
|
for field, field_type in copy.schema.items():
|
||||||
|
if isinstance(field_type, vol.In):
|
||||||
|
value = None
|
||||||
|
# for dps in dps_list or []:
|
||||||
|
# if dps.startswith(f"{defaults.get(field)} "):
|
||||||
|
# value = dps
|
||||||
|
# break
|
||||||
|
|
||||||
|
if value in field_type.container:
|
||||||
|
field.default = vol.default_factory(value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if field.schema in defaults:
|
||||||
|
field.default = vol.default_factory(defaults[field])
|
||||||
|
return copy
|
||||||
|
|
||||||
|
|
||||||
|
# class PlaceholderHub:
|
||||||
|
# """Placeholder class to make tests pass.
|
||||||
|
#
|
||||||
|
# TODO Remove this placeholder class and replace with things from your PyPI package.
|
||||||
|
# """
|
||||||
|
#
|
||||||
|
# def __init__(self, name: str, heater_entity_id: str) -> None:
|
||||||
|
# """Initialize."""
|
||||||
|
# self.name = name
|
||||||
|
# self.heater_entity_id = heater_entity_id
|
||||||
|
#
|
||||||
|
# # async def authenticate(self, username: str, password: str) -> bool:
|
||||||
|
# # """Test if we can authenticate with the host."""
|
||||||
|
# # return True
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(
|
||||||
|
hass: HomeAssistant, data: dict[str, str, str, Any]
|
||||||
|
) -> dict[str]:
|
||||||
|
"""Validate the user input allows us to connect.
|
||||||
|
|
||||||
|
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If your PyPI package is not built with async, pass your methods
|
||||||
|
# to the executor:
|
||||||
|
# await hass.async_add_executor_job(
|
||||||
|
# your_validate_func, data["username"], data["password"]
|
||||||
|
# )
|
||||||
|
|
||||||
|
# hub = PlaceholderHub(data["name"], data["heater_id"])
|
||||||
|
|
||||||
|
# if not await hub.authenticate(data["username"], data["password"]):
|
||||||
|
# raise InvalidAuth
|
||||||
|
|
||||||
|
# Return info that you want to store in the config entry.
|
||||||
|
return {"title": data["name"]}
|
||||||
|
|
||||||
|
|
||||||
|
class VersatileThermostatConfigFlow(HAConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Versatile Thermostat."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry: ConfigEntry):
|
||||||
|
"""Get options flow for this handler."""
|
||||||
|
return VersatileThermostatOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
return self.async_create_entry(title=info["title"], data=user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(HomeAssistantError):
|
||||||
|
"""Error to indicate we cannot connect."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAuth(HomeAssistantError):
|
||||||
|
"""Error to indicate there is invalid auth."""
|
||||||
|
|
||||||
|
|
||||||
|
class VersatileThermostatOptionsFlowHandler(OptionsFlow):
|
||||||
|
"""Handle options flow for Versatile Thermostat integration."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: ConfigEntry):
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.config_entry = config_entry
|
||||||
|
_LOGGER.debug(
|
||||||
|
"CTOR VersatileThermostatOptionsFlowHandler config_entry.data: %s, entry_id: %s",
|
||||||
|
config_entry.data,
|
||||||
|
config_entry.entry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input=None):
|
||||||
|
"""Manage basic options."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Into VersatileThermostatOptionsFlowHandler.async_step_init user_input =%s, config_entry.data=%s",
|
||||||
|
user_input,
|
||||||
|
self.config_entry.data,
|
||||||
|
)
|
||||||
|
if user_input is not None:
|
||||||
|
_LOGGER.debug("We receive the new values: %s", user_input)
|
||||||
|
data = dict(self.config_entry.data)
|
||||||
|
for conf in ALL_CONF:
|
||||||
|
data[conf] = user_input.get(conf)
|
||||||
|
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
|
||||||
|
return self.async_create_entry(title=None, data=None)
|
||||||
|
else:
|
||||||
|
defaults = self.config_entry.data.copy()
|
||||||
|
defaults.update(user_input or {})
|
||||||
|
user_data_schema = schema_defaults(STEP_USER_DATA_SCHEMA, **defaults)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=user_data_schema,
|
||||||
|
)
|
||||||
47
custom_components/versatile_thermostat/const.py
Normal file
47
custom_components/versatile_thermostat/const.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Constants for the Versatile Thermostat integration."""
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_NAME
|
||||||
|
from homeassistant.components.climate.const import (
|
||||||
|
PRESET_ACTIVITY,
|
||||||
|
PRESET_AWAY,
|
||||||
|
PRESET_BOOST,
|
||||||
|
PRESET_COMFORT,
|
||||||
|
PRESET_ECO,
|
||||||
|
SUPPORT_TARGET_TEMPERATURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
PRESET_POWER = "power"
|
||||||
|
|
||||||
|
DOMAIN = "versatile_thermostat"
|
||||||
|
|
||||||
|
CONF_HEATER = "heater_entity_id"
|
||||||
|
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
|
||||||
|
CONF_POWER_SENSOR = "power_sensor_entity_id"
|
||||||
|
CONF_MAX_POWER_SENSOR = "max_power_sensor_entity_id"
|
||||||
|
CONF_WINDOW_SENSOR = "window_sensor_entity_id"
|
||||||
|
CONF_MOTION_SENSOR = "motion_sensor_entity_id"
|
||||||
|
CONF_DEVICE_POWER = "device_power"
|
||||||
|
|
||||||
|
ALL_CONF = [
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_HEATER,
|
||||||
|
CONF_TEMP_SENSOR,
|
||||||
|
CONF_POWER_SENSOR,
|
||||||
|
CONF_MAX_POWER_SENSOR,
|
||||||
|
CONF_WINDOW_SENSOR,
|
||||||
|
CONF_MOTION_SENSOR,
|
||||||
|
CONF_DEVICE_POWER,
|
||||||
|
]
|
||||||
|
|
||||||
|
CONF_PRESETS = {
|
||||||
|
p: f"{p}_temp"
|
||||||
|
for p in (
|
||||||
|
PRESET_ECO,
|
||||||
|
PRESET_AWAY,
|
||||||
|
PRESET_BOOST,
|
||||||
|
PRESET_COMFORT,
|
||||||
|
PRESET_POWER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
|
||||||
17
custom_components/versatile_thermostat/manifest.json
Normal file
17
custom_components/versatile_thermostat/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"domain": "versatile_thermostat",
|
||||||
|
"name": "Versatile Thermostat",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://github.com/jmcollin78/versatile_thermostat",
|
||||||
|
"requirements": [],
|
||||||
|
"ssdp": [],
|
||||||
|
"zeroconf": [],
|
||||||
|
"homekit": {},
|
||||||
|
"dependencies": [],
|
||||||
|
"codeowners": [
|
||||||
|
"@jmcollin78"
|
||||||
|
],
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"integration_type": "device"
|
||||||
|
}
|
||||||
28
custom_components/versatile_thermostat/strings.json
Normal file
28
custom_components/versatile_thermostat/strings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"title": "Versatile Thermostat configuration",
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Add new Versatile Thermostat",
|
||||||
|
"data": {
|
||||||
|
"name": "[%key:config::data::name%]",
|
||||||
|
"heater_entity_id": "[%key:config::data::heater_entity_id%]",
|
||||||
|
"temperature_sensor_entity_id": "[%key:config::data::temperature_sensor_entity_id%]",
|
||||||
|
"power_sensor_entity_id": "[%key:config::data::power_sensor_entity_id%]",
|
||||||
|
"max_power_sensor_entity_id": "[%key:config::data::max_power_sensor_entity_id%]",
|
||||||
|
"window_sensor_entity_id": "[%key:config::data::window_sensor_entity_id%]",
|
||||||
|
"motion_sensor_entity_id": "[%key:config::data::motion_sensor_entity_id%]",
|
||||||
|
"device_power": "[%key:config::data::device_power%]",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
custom_components/versatile_thermostat/translations/en.json
Normal file
26
custom_components/versatile_thermostat/translations/en.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"name": "Name",
|
||||||
|
"heater_entity_id": "Heater entity id",
|
||||||
|
"temperature_sensor_entity_id": "Temperature sensor entity id",
|
||||||
|
"power_sensor_entity_id": "Power sensor entity id",
|
||||||
|
"max_power_sensor_entity_id": "Max power sensor entity id",
|
||||||
|
"window_sensor_entity_id": "Window sensor entity id",
|
||||||
|
"motion_sensor_entity_id": "Motion sensor entity id",
|
||||||
|
"device_power": "Device power (kW)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user