Initialisation of component ok
This commit is contained in:
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