Initialisation of component ok

This commit is contained in:
Jean-Marc Collin
2022-12-28 17:25:25 +01:00
parent b7018c29b9
commit 3a0a475810
19 changed files with 1480 additions and 0 deletions

60
.devcontainer/README.md Normal file
View 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/).

View 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

View 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
}
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -102,3 +102,5 @@ dist
# TernJS port file
.tern-port
__pycache__

35
.vscode/launch.json vendored Normal file
View 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
View 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
View 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": []
}
]
}

View 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

View 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)

View 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,
)

View 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

View 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"
}

View 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%]"
}
}
}

View 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)"
}
}
}
}
}