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
|
||||
.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