Compare commits

..

10 Commits

Author SHA1 Message Date
Jean-Marc Collin
8961a80086 UPdate README with release 5.0 2023-12-17 10:54:07 +00:00
Jean-Marc Collin
2f6c46ef11 Update strings.json and replace security by safety in README. 2023-12-17 10:03:57 +00:00
Jean-Marc Collin
af817e74b7 Add reload VTherms when central configuration is changed 2023-12-17 09:41:22 +00:00
Jean-Marc Collin
46307286b1 With fixture for init_vtherm_api and init_central_config 2023-12-17 08:57:08 +00:00
Jean-Marc Collin
c8398a48fe All testu ok 2023-12-17 08:16:37 +00:00
Jean-Marc Collin
5063ba3802 With central configuration testu ok 2023-12-17 06:57:06 +00:00
Jean-Marc Collin
fb76a84bde Init data in base_thermostat ok 2023-12-16 15:24:11 +00:00
Jean-Marc Collin
03f6045d34 Ignore central confic in instanciate entities 2023-12-15 22:58:36 +00:00
Jean-Marc Collin
aeb4b2fbbe Test manual of confif_flow ok 2023-12-15 22:37:40 +00:00
Jean-Marc Collin
47f5aa9595 Add central config into ConfigFlow 2023-12-15 18:37:25 +00:00
20 changed files with 130 additions and 447 deletions

View File

@@ -59,8 +59,8 @@ input_number:
unit_of_measurement: kW
fake_valve1:
name: The valve 1
min: 10
max: 90
min: 0
max: 100
icon: mdi:pipe-valve
unit_of_measurement: percentage

View File

@@ -30,8 +30,13 @@
"waderyan.gitblame",
"keesschollaart.vscode-home-assistant",
"vscode.markdown-math",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"ms-python.vscode-pylance"
],
// "mounts": [
// "source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=${localWorkspaceFolder}/config/www/community/,type=bind,consistency=cached",
// "source=${localWorkspaceFolder}/custom_components,target=/home/vscode/core/config/custom_components,type=bind,consistency=cached"
// ],
"settings": {
"files.eol": "\n",
"editor.tabSize": 4,

View File

@@ -14,8 +14,7 @@
"python.testing.pytestEnabled": true,
"python.analysis.extraPaths": [
// "/home/vscode/core",
"/workspaces/versatile_thermostat/custom_components/versatile_thermostat",
"/home/vscode/.local/lib/python3.11/site-packages/homeassistant"
"/workspaces/versatile_thermostat/custom_components/versatile_thermostat"
],
"python.formatting.provider": "none"
}

View File

@@ -114,7 +114,7 @@ En conséquence toute la phase de paramètrage d'un VTherm a été profondemment
**Note :** les copies d'écran de la configuration d'un VTherm n'ont pas été mises à jour.
# Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG @Mexx62, @Someone pour les bières. Ca fait très plaisir et ça m'encourage à continuer !
Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG pour les bières. Ca fait très plaisir et ça m'encourage à continuer !
# Quand l'utiliser et ne pas l'utiliser

View File

@@ -114,7 +114,7 @@ Consequently, the entire configuration phase of a VTherm has been profoundly mod
**Note:** the VTherm configuration screenshots have not been updated.
# Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG, @MattG, @Mexx62, @Someone for the beers. It's very nice and encourages me to continue!
Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG for the beers. It's very nice and encourages me to continue!
# When to use / not use
This thermostat can control 3 types of equipment:

View File

@@ -86,10 +86,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
# VTherm API should have been initialized before arriving here
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
if vtherm_api is not None:
self._central_config = vtherm_api.find_central_configuration()
else:
self._central_config = None
self._central_config = vtherm_api.find_central_configuration()
self._init_feature_flags(infos)
self._init_central_config_flags(infos)
@@ -122,7 +119,6 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
CONF_USE_WINDOW_CENTRAL_CONFIG,
CONF_USE_MOTION_CENTRAL_CONFIG,
CONF_USE_POWER_CENTRAL_CONFIG,
CONF_USE_PRESETS_CENTRAL_CONFIG,
CONF_USE_PRESENCE_CENTRAL_CONFIG,
CONF_USE_ADVANCED_CENTRAL_CONFIG,
):
@@ -171,7 +167,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
_LOGGER.error(
"Only one window detection method should be used. Use window_sensor or auto window open detection but not both"
)
raise WindowOpenDetectionMethod(CONF_WINDOW_AUTO_OPEN_THRESHOLD)
raise WindowOpenDetectionMethod(CONF_WINDOW_SENSOR)
# Check that is USE_CENTRAL config is used, that a central config exists
if self._central_config is None:
@@ -364,7 +360,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
schema = schema_ac_or_not
elif user_input and user_input.get(CONF_USE_PRESETS_CENTRAL_CONFIG) is False:
next_step = self.async_step_spec_presets
schema = STEP_PRESETS_DATA_SCHEMA
schema = schema_ac_or_not
return await self.generic_step("presets", schema, user_input, next_step)
@@ -408,11 +404,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
next_step = self.async_step_motion
# If comes from async_step_spec_window
elif self._infos.get(COMES_FROM) == "async_step_spec_window":
# If we have a window sensor don't display the auto window parameters
if self._infos.get(CONF_WINDOW_SENSOR) is not None:
schema = STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA
else:
schema = STEP_CENTRAL_WINDOW_DATA_SCHEMA
schema = STEP_CENTRAL_WINDOW_DATA_SCHEMA
elif user_input and user_input.get(CONF_USE_WINDOW_CENTRAL_CONFIG) is False:
next_step = self.async_step_spec_window
@@ -427,8 +419,6 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
)
schema = STEP_CENTRAL_WINDOW_DATA_SCHEMA
if self._infos.get(CONF_WINDOW_SENSOR) is not None:
schema = STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA
self._infos[COMES_FROM] = "async_step_spec_window"

View File

@@ -195,12 +195,6 @@ STEP_CENTRAL_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
}
)
STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
}
)
STEP_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_MOTION_SENSOR): selector.EntitySelector(

View File

@@ -413,7 +413,6 @@
"eco_away_temp": "Eco away preset",
"comfort_away_temp": "Comfort away preset",
"boost_away_temp": "Boost away preset",
"frost_away_temp": "Frost protection preset",
"eco_ac_away_temp": "Eco away preset in AC mode",
"comfort_ac_away_temp": "Comfort away preset in AC mode",
"boost_ac_away_temp": "Boost away preset in AC mode",

View File

@@ -136,11 +136,6 @@ class ThermostatOverClimate(BaseThermostat):
async def _send_regulated_temperature(self, force=False):
"""Sends the regulated temperature to all underlying"""
if self.hvac_mode == HVACMode.OFF:
_LOGGER.debug("%s - don't send regulated temperature cause VTherm is off ")
return
_LOGGER.info(
"%s - Calling ThermostatClimate._send_regulated_temperature force=%s",
self,
@@ -239,45 +234,45 @@ class ThermostatOverClimate(BaseThermostat):
await self.async_set_fan_mode(self._auto_deactivated_fan_mode)
@overrides
def post_init(self, config_entry):
def post_init(self, entry_infos):
"""Initialize the Thermostat"""
super().post_init(config_entry)
super().post_init(entry_infos)
for climate in [
CONF_CLIMATE,
CONF_CLIMATE_2,
CONF_CLIMATE_3,
CONF_CLIMATE_4,
]:
if config_entry.get(climate):
if entry_infos.get(climate):
self._underlyings.append(
UnderlyingClimate(
hass=self._hass,
thermostat=self,
climate_entity_id=config_entry.get(climate),
climate_entity_id=entry_infos.get(climate),
)
)
self.choose_auto_regulation_mode(
config_entry.get(CONF_AUTO_REGULATION_MODE)
if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None
entry_infos.get(CONF_AUTO_REGULATION_MODE)
if entry_infos.get(CONF_AUTO_REGULATION_MODE) is not None
else CONF_AUTO_REGULATION_NONE
)
self._auto_regulation_dtemp = (
config_entry.get(CONF_AUTO_REGULATION_DTEMP)
if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None
entry_infos.get(CONF_AUTO_REGULATION_DTEMP)
if entry_infos.get(CONF_AUTO_REGULATION_DTEMP) is not None
else 0.5
)
self._auto_regulation_period_min = (
config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN)
if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None
entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN)
if entry_infos.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None
else 5
)
self._auto_fan_mode = (
config_entry.get(CONF_AUTO_FAN_MODE)
if config_entry.get(CONF_AUTO_FAN_MODE) is not None
entry_infos.get(CONF_AUTO_FAN_MODE)
if entry_infos.get(CONF_AUTO_FAN_MODE) is not None
else CONF_AUTO_FAN_NONE
)

View File

@@ -48,9 +48,9 @@ class ThermostatOverSwitch(BaseThermostat):
)
# useless for now
# def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None:
# def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
# """Initialize the thermostat over switch."""
# super().__init__(hass, unique_id, name, config_entry)
# super().__init__(hass, unique_id, name, entry_infos)
_is_inversed: bool = None
@property
@@ -72,10 +72,10 @@ class ThermostatOverSwitch(BaseThermostat):
return None
@overrides
def post_init(self, config_entry):
def post_init(self, entry_infos):
"""Initialize the Thermostat"""
super().post_init(config_entry)
super().post_init(entry_infos)
self._prop_algorithm = PropAlgorithm(
self._proportional_function,
@@ -85,13 +85,13 @@ class ThermostatOverSwitch(BaseThermostat):
self._minimal_activation_delay,
)
lst_switches = [config_entry.get(CONF_HEATER)]
if config_entry.get(CONF_HEATER_2):
lst_switches.append(config_entry.get(CONF_HEATER_2))
if config_entry.get(CONF_HEATER_3):
lst_switches.append(config_entry.get(CONF_HEATER_3))
if config_entry.get(CONF_HEATER_4):
lst_switches.append(config_entry.get(CONF_HEATER_4))
lst_switches = [entry_infos.get(CONF_HEATER)]
if entry_infos.get(CONF_HEATER_2):
lst_switches.append(entry_infos.get(CONF_HEATER_2))
if entry_infos.get(CONF_HEATER_3):
lst_switches.append(entry_infos.get(CONF_HEATER_3))
if entry_infos.get(CONF_HEATER_4):
lst_switches.append(entry_infos.get(CONF_HEATER_4))
delta_cycle = self._cycle_min * 60 / len(lst_switches)
for idx, switch in enumerate(lst_switches):
@@ -104,7 +104,7 @@ class ThermostatOverSwitch(BaseThermostat):
)
)
self._is_inversed = config_entry.get(CONF_INVERSE_SWITCH) is True
self._is_inversed = entry_infos.get(CONF_INVERSE_SWITCH) is True
self._should_relaunch_control_heating = False
@overrides

View File

@@ -3,10 +3,7 @@
import logging
from datetime import timedelta
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval
from homeassistant.core import callback
from homeassistant.components.climate import HVACMode
@@ -19,53 +16,39 @@ from .underlyings import UnderlyingValve
_LOGGER = logging.getLogger(__name__)
class ThermostatOverValve(BaseThermostat):
"""Representation of a class for a Versatile Thermostat over a Valve"""
_entity_component_unrecorded_attributes = (
BaseThermostat._entity_component_unrecorded_attributes.union(
frozenset(
{
"is_over_valve",
"underlying_valve_0",
"underlying_valve_1",
"underlying_valve_2",
"underlying_valve_3",
"on_time_sec",
"off_time_sec",
"cycle_min",
"function",
"tpi_coef_int",
"tpi_coef_ext",
}
)
)
)
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset(
{
"is_over_valve", "underlying_valve_0", "underlying_valve_1",
"underlying_valve_2", "underlying_valve_3", "on_time_sec", "off_time_sec",
"cycle_min", "function", "tpi_coef_int", "tpi_coef_ext"
}))
# Useless for now
# def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None:
# def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
# """Initialize the thermostat over switch."""
# super().__init__(hass, unique_id, name, config_entry)
# super().__init__(hass, unique_id, name, entry_infos)
@property
def is_over_valve(self) -> bool:
"""True if the Thermostat is over_valve"""
""" True if the Thermostat is over_valve"""
return True
@property
def valve_open_percent(self) -> int:
"""Gives the percentage of valve needed"""
""" Gives the percentage of valve needed"""
if self._hvac_mode == HVACMode.OFF:
return 0
else:
return round(max(0, min(self.proportional_algorithm.on_percent, 1)) * 100)
@overrides
def post_init(self, config_entry):
"""Initialize the Thermostat"""
def post_init(self, entry_infos):
""" Initialize the Thermostat"""
super().post_init(config_entry)
super().post_init(entry_infos)
self._prop_algorithm = PropAlgorithm(
self._proportional_function,
self._tpi_coef_int,
@@ -74,17 +57,21 @@ class ThermostatOverValve(BaseThermostat):
self._minimal_activation_delay,
)
lst_valves = [config_entry.get(CONF_VALVE)]
if config_entry.get(CONF_VALVE_2):
lst_valves.append(config_entry.get(CONF_VALVE_2))
if config_entry.get(CONF_VALVE_3):
lst_valves.append(config_entry.get(CONF_VALVE_3))
if config_entry.get(CONF_VALVE_4):
lst_valves.append(config_entry.get(CONF_VALVE_4))
lst_valves = [entry_infos.get(CONF_VALVE)]
if entry_infos.get(CONF_VALVE_2):
lst_valves.append(entry_infos.get(CONF_VALVE_2))
if entry_infos.get(CONF_VALVE_3):
lst_valves.append(entry_infos.get(CONF_VALVE_3))
if entry_infos.get(CONF_VALVE_4):
lst_valves.append(entry_infos.get(CONF_VALVE_4))
for _, valve in enumerate(lst_valves):
self._underlyings.append(
UnderlyingValve(hass=self._hass, thermostat=self, valve_entity_id=valve)
UnderlyingValve(
hass=self._hass,
thermostat=self,
valve_entity_id=valve
)
)
self._should_relaunch_control_heating = False
@@ -102,7 +89,7 @@ class ThermostatOverValve(BaseThermostat):
async_track_state_change_event(
self.hass, [valve.entity_id], self._async_valve_changed
)
)
)
# Start the control_heating
# starts a cycle
@@ -120,34 +107,29 @@ class ThermostatOverValve(BaseThermostat):
This method just log the change. It changes nothing to avoid loops.
"""
new_state = event.data.get("new_state")
_LOGGER.debug(
"%s - _async_valve_changed new_state is %s", self, new_state.state
)
_LOGGER.debug("%s - _async_valve_changed new_state is %s", self, new_state.state)
@overrides
def update_custom_attributes(self):
"""Custom attributes"""
""" Custom attributes """
super().update_custom_attributes()
self._attr_extra_state_attributes[
"valve_open_percent"
] = self.valve_open_percent
self._attr_extra_state_attributes["valve_open_percent"] = self.valve_open_percent
self._attr_extra_state_attributes["is_over_valve"] = self.is_over_valve
self._attr_extra_state_attributes["underlying_valve_0"] = self._underlyings[
0
].entity_id
self._attr_extra_state_attributes["underlying_valve_0"] = (
self._underlyings[0].entity_id)
self._attr_extra_state_attributes["underlying_valve_1"] = (
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
)
self._underlyings[1].entity_id if len(self._underlyings) > 1 else None
)
self._attr_extra_state_attributes["underlying_valve_2"] = (
self._underlyings[2].entity_id if len(self._underlyings) > 2 else None
)
self._underlyings[2].entity_id if len(self._underlyings) > 2 else None
)
self._attr_extra_state_attributes["underlying_valve_3"] = (
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
)
self._underlyings[3].entity_id if len(self._underlyings) > 3 else None
)
self._attr_extra_state_attributes[
"on_percent"
] = self._prop_algorithm.on_percent
"on_percent"
] = self._prop_algorithm.on_percent
self._attr_extra_state_attributes[
"on_time_sec"
] = self._prop_algorithm.on_time_sec
@@ -180,7 +162,9 @@ class ThermostatOverValve(BaseThermostat):
)
for under in self._underlyings:
under.set_valve_open_percent()
under.set_valve_open_percent(
self._prop_algorithm.on_percent
)
self.update_custom_attributes()
self.async_write_ha_state()
@@ -201,4 +185,4 @@ class ThermostatOverValve(BaseThermostat):
self,
added_energy,
self._total_energy,
)
)

View File

@@ -413,7 +413,6 @@
"eco_away_temp": "Eco away preset",
"comfort_away_temp": "Comfort away preset",
"boost_away_temp": "Boost away preset",
"frost_away_temp": "Frost protection preset",
"eco_ac_away_temp": "Eco away preset in AC mode",
"comfort_ac_away_temp": "Comfort away preset in AC mode",
"boost_ac_away_temp": "Boost away preset in AC mode",

View File

@@ -6,7 +6,6 @@ from typing import Any
from enum import StrEnum
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature
from homeassistant.core import State
from homeassistant.exceptions import ServiceNotFound
@@ -112,18 +111,18 @@ class UnderlyingEntity:
# This should be the correct way to handle turn_off and turn_on but this breaks the unit test
# will an not understandable error: TypeError: object MagicMock can't be used in 'await' expression
async def turn_off(self):
"""Turn off the underlying equipement.
Need to be overriden"""
""" Turn off the underlying equipement.
Need to be overriden"""
return NotImplementedError
async def turn_on(self):
"""Turn off the underlying equipement.
Need to be overriden"""
""" Turn off the underlying equipement.
Need to be overriden"""
return NotImplementedError
@property
def is_inversed(self):
"""Tells if the switch command should be inversed"""
""" Tells if the switch command should be inversed"""
return False
def remove_entity(self):
@@ -165,11 +164,7 @@ class UnderlyingEntity:
"""Starting cycle for switch"""
def _cancel_cycle(self):
"""Stops an eventual cycle"""
def cap_sent_value(self, value) -> float:
"""capping of the value send to the underlying eqt"""
return value
""" Stops an eventual cycle """
class UnderlyingSwitch(UnderlyingEntity):
@@ -210,7 +205,7 @@ class UnderlyingSwitch(UnderlyingEntity):
@overrides
@property
def is_inversed(self):
"""Tells if the switch command should be inversed"""
""" Tells if the switch command should be inversed"""
return self._thermostat.is_inversed
# @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
@@ -232,16 +227,14 @@ class UnderlyingSwitch(UnderlyingEntity):
def is_device_active(self):
"""If the toggleable device is currently active."""
real_state = self._hass.states.is_state(self._entity_id, STATE_ON)
return (self.is_inversed and not real_state) or (
not self.is_inversed and real_state
)
return (self.is_inversed and not real_state) or (not self.is_inversed and real_state)
# @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
async def turn_off(self):
"""Turn heater toggleable device off."""
_LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id)
command = SERVICE_TURN_OFF if not self.is_inversed else SERVICE_TURN_ON
domain = self._entity_id.split(".")[0]
domain = self._entity_id.split('.')[0]
# This may fails if called after shutdown
try:
data = {ATTR_ENTITY_ID: self._entity_id}
@@ -257,7 +250,7 @@ class UnderlyingSwitch(UnderlyingEntity):
"""Turn heater toggleable device on."""
_LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id)
command = SERVICE_TURN_ON if not self.is_inversed else SERVICE_TURN_OFF
domain = self._entity_id.split(".")[0]
domain = self._entity_id.split('.')[0]
try:
data = {ATTR_ENTITY_ID: self._entity_id}
await self._hass.services.async_call(
@@ -268,6 +261,7 @@ class UnderlyingSwitch(UnderlyingEntity):
except ServiceNotFound as err:
_LOGGER.error(err)
@overrides
async def start_cycle(
self,
@@ -496,14 +490,10 @@ class UnderlyingClimate(UnderlyingEntity):
def is_device_active(self):
"""If the toggleable device is currently active."""
if self.is_initialized:
return (
self._underlying_climate.hvac_mode != HVACMode.OFF
and self._underlying_climate.hvac_action
not in [
HVACAction.IDLE,
HVACAction.OFF,
]
)
return self._underlying_climate.hvac_mode != HVACMode.OFF and self._underlying_climate.hvac_action not in [
HVACAction.IDLE,
HVACAction.OFF,
]
else:
return None
@@ -560,7 +550,7 @@ class UnderlyingClimate(UnderlyingEntity):
return
data = {
ATTR_ENTITY_ID: self._entity_id,
"temperature": self.cap_sent_value(temperature),
"temperature": temperature,
"target_temp_high": max_temp,
"target_temp_low": min_temp,
}
@@ -674,40 +664,6 @@ class UnderlyingClimate(UnderlyingEntity):
return None
return self._underlying_climate.turn_aux_heat_off()
@overrides
def cap_sent_value(self, value) -> float:
"""Try to adapt the target temp value to the min_temp / max_temp found
in the underlying entity (if any)"""
if not self.is_initialized:
return value
# Gets the min_temp and max_temp
if (
self._underlying_climate.min_temp is not None
and self._underlying_climate is not None
):
min_val = self._underlying_climate.min_temp
max_val = self._underlying_climate.max_temp
new_value = round(max(min_val, min(value, max_val)))
else:
_LOGGER.debug("%s - no min and max attributes on underlying", self)
new_value = value
if new_value != value:
_LOGGER.info(
"%s - Target temp have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f",
self,
new_value,
value,
min_val,
max_val,
)
return new_value
class UnderlyingValve(UnderlyingEntity):
"""Represent a underlying switch"""
@@ -716,7 +672,10 @@ class UnderlyingValve(UnderlyingEntity):
_percent_open: int
def __init__(
self, hass: HomeAssistant, thermostat: Any, valve_entity_id: str
self,
hass: HomeAssistant,
thermostat: Any,
valve_entity_id: str
) -> None:
"""Initialize the underlying switch"""
@@ -730,14 +689,13 @@ class UnderlyingValve(UnderlyingEntity):
self._should_relaunch_control_heating = False
self._hvac_mode = None
self._percent_open = self._thermostat.valve_open_percent
self._valve_entity_id = valve_entity_id
async def send_percent_open(self):
"""Send the percent open to the underlying valve"""
""" Send the percent open to the underlying valve """
# This may fails if called after shutdown
try:
data = {ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open}
domain = self._entity_id.split(".")[0]
data = { ATTR_ENTITY_ID: self._entity_id, "value": self._percent_open }
domain = self._entity_id.split('.')[0]
await self._hass.services.async_call(
domain,
SERVICE_SET_VALUE,
@@ -776,7 +734,7 @@ class UnderlyingValve(UnderlyingEntity):
# To test if real device is open but this is causing some side effect
# because the activation can be deferred -
# or float(self._hass.states.get(self._entity_id).state) > 0
except Exception: # pylint: disable=broad-exception-caught
except Exception: # pylint: disable=broad-exception-caught
return False
@overrides
@@ -792,40 +750,9 @@ class UnderlyingValve(UnderlyingEntity):
if force:
await self.send_percent_open()
@overrides
def cap_sent_value(self, value) -> float:
"""Try to adapt the open_percent value to the min / max found
in the underlying entity (if any)"""
# Gets the last number state
valve_state: State = self._hass.states.get(self._valve_entity_id)
if valve_state is None:
return value
if "min" in valve_state.attributes and "max" in valve_state.attributes:
min_val = valve_state.attributes["min"]
max_val = valve_state.attributes["max"]
new_value = round(max(min_val, min(value, max_val)))
else:
_LOGGER.debug("%s - no min and max attributes on underlying", self)
new_value = value
if new_value != value:
_LOGGER.info(
"%s - Valve open percent have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f",
self,
new_value,
value,
min_val,
max_val,
)
return new_value
def set_valve_open_percent(self):
"""Update the valve open percent"""
caped_val = self.cap_sent_value(self._thermostat.valve_open_percent)
def set_valve_open_percent(self, percent):
""" Update the valve open percent """
caped_val = self._thermostat.valve_open_percent
if self._percent_open == caped_val:
# No changes
return
@@ -833,9 +760,7 @@ class UnderlyingValve(UnderlyingEntity):
self._percent_open = caped_val
# Send the new command to valve via a service call
_LOGGER.info(
"%s - Setting valve ouverture percent to %s", self, self._percent_open
)
_LOGGER.info("%s - Setting valve ouverture percent to %s", self, self._percent_open)
# Send the change to the valve, in background
self._hass.create_task(self.send_percent_open())

View File

@@ -19,27 +19,26 @@ _LOGGER = logging.getLogger(__name__)
class VersatileThermostatAPI(dict):
"""The VersatileThermostatAPI"""
_hass: HomeAssistant = None
_hass: HomeAssistant
# _entries: Dict(str, ConfigEntry)
@classmethod
def get_vtherm_api(cls, hass=None):
"""Get the eventual VTherm API class instance or
instantiate it if it doesn't exists"""
"""Get the eventual VTherm API class instance"""
if hass is not None:
VersatileThermostatAPI._hass = hass
if VersatileThermostatAPI._hass is None:
return None
else:
if VersatileThermostatAPI._hass is None:
return None
domain = VersatileThermostatAPI._hass.data.get(DOMAIN)
if not domain:
VersatileThermostatAPI._hass.data.setdefault(DOMAIN, {})
hass.data.setdefault(DOMAIN, {})
ret = VersatileThermostatAPI._hass.data.get(DOMAIN).get(VTHERM_API_NAME)
if ret is None:
ret = VersatileThermostatAPI()
VersatileThermostatAPI._hass.data[DOMAIN][VTHERM_API_NAME] = ret
hass.data[DOMAIN][VTHERM_API_NAME] = ret
return ret
def __init__(self) -> None:
@@ -47,6 +46,7 @@ class VersatileThermostatAPI(dict):
super().__init__()
self._expert_params = None
self._short_ema_params = None
self._central_config = None
def find_central_configuration(self):
"""Search for a central configuration"""
@@ -57,8 +57,8 @@ class VersatileThermostatAPI(dict):
config_entry.data.get(CONF_THERMOSTAT_TYPE)
== CONF_THERMOSTAT_CENTRAL_CONFIG
):
central_config = config_entry
return central_config
self._central_config = config_entry
return self._central_config
return None
def add_entry(self, entry: ConfigEntry):

View File

@@ -336,14 +336,6 @@ class MagicMockClimate(MagicMock):
def supported_features(self): # pylint: disable=missing-function-docstring
return ClimateEntityFeature.TARGET_TEMPERATURE
@property
def min_temp(self): # pylint: disable=missing-function-docstring
return 15
@property
def max_temp(self): # pylint: disable=missing-function-docstring
return 19
async def create_thermostat(
hass: HomeAssistant, entry: MockConfigEntry, entity_id: str

View File

@@ -139,11 +139,6 @@ MOCK_PRESETS_AC_CONFIG = {
MOCK_WINDOW_CONFIG = {
CONF_WINDOW_SENSOR: "binary_sensor.window_sensor",
# Not used normally only for tests to avoid rewrite all tests
CONF_WINDOW_DELAY: 10,
}
MOCK_WINDOW_DELAY_CONFIG = {
CONF_WINDOW_DELAY: 10,
}

View File

@@ -6,10 +6,6 @@ from datetime import datetime, timedelta
import logging
from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE,
)
from .commons import *
logging.getLogger().setLevel(logging.DEBUG)
@@ -572,141 +568,3 @@ async def test_bug_101(
)
assert entity.target_temperature == 12.75
assert entity.preset_mode is PRESET_NONE
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_bug_272(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that it not possible to set the target temperature under the min_temp setting"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
)
# Min_temp is 15 and max_temp is 19
fake_underlying_climate = MagicMockClimate()
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
), patch(
"homeassistant.core.ServiceRegistry.async_call"
) as mock_service_call:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity = find_my_entity("climate.theoverclimatemockname")
assert entity
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.is_regulated is True
assert mock_service_call.call_count == 0
# Set the hvac_mode to HEAT
await entity.async_set_hvac_mode(HVACMode.HEAT)
# In the accepted interval
await entity.async_set_temperature(temperature=17)
assert mock_service_call.call_count == 2
mock_service_call.assert_has_calls(
[
call.async_call(
"climate",
SERVICE_SET_HVAC_MODE,
{"entity_id": "climate.mock_climate", "hvac_mode": HVACMode.HEAT},
),
call.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.mock_climate",
"temperature": 17,
"target_temp_high": 30,
"target_temp_low": 15,
},
),
]
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Set room temperature to something very cold
event_timestamp = now + timedelta(minutes=1)
await send_temperature_change_event(entity, 13, event_timestamp)
await send_ext_temperature_change_event(entity, 9, event_timestamp)
# In the accepted interval
await entity.async_set_temperature(temperature=10)
assert mock_service_call.call_count == 1
mock_service_call.assert_has_calls(
[
call.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.mock_climate",
"temperature": 15, # the minimum acceptable
"target_temp_high": 30,
"target_temp_low": 15,
},
),
]
)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Set room temperature to something very cold
event_timestamp = now + timedelta(minutes=1)
await send_temperature_change_event(entity, 13, event_timestamp)
await send_ext_temperature_change_event(entity, 9, event_timestamp)
# In the accepted interval
await entity.async_set_temperature(temperature=20)
assert mock_service_call.call_count == 1
mock_service_call.assert_has_calls(
[
call.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.mock_climate",
"temperature": 19, # the maximum acceptable
"target_temp_high": 30,
"target_temp_low": 15,
},
),
]
)

View File

@@ -31,8 +31,6 @@ from .commons import * # pylint: disable=wildcard-import, unused-wildcard-impor
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_add_a_central_config(hass: HomeAssistant, skip_hass_states_is_state):
"""Tests the clean_central_config_doubon of base_thermostat"""
central_config_entry = MockConfigEntry(
@@ -97,8 +95,6 @@ async def test_add_a_central_config(hass: HomeAssistant, skip_hass_states_is_sta
assert central_configuration is not None
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_minimal_over_switch_wo_central_config(
hass: HomeAssistant, skip_hass_states_is_state, init_vtherm_api
):
@@ -173,8 +169,6 @@ async def test_minimal_over_switch_wo_central_config(
assert entity.is_inversed
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_full_over_switch_wo_central_config(
hass: HomeAssistant, skip_hass_states_is_state, init_vtherm_api
):
@@ -287,8 +281,6 @@ async def test_full_over_switch_wo_central_config(
assert entity._presence_sensor_entity_id == "binary_sensor.mock_presence_sensor"
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_full_over_switch_with_central_config(
hass: HomeAssistant, skip_hass_states_is_state, init_central_config
):
@@ -396,8 +388,6 @@ async def test_full_over_switch_with_central_config(
assert entity._presence_sensor_entity_id == "binary_sensor.mock_presence_sensor"
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_switch_with_central_config_but_no_central_config(
hass: HomeAssistant, skip_hass_states_get, init_vtherm_api
):

View File

@@ -472,17 +472,15 @@ async def test_user_config_flow_window_auto_ko(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_WINDOW_DELAY_CONFIG,
user_input=MOCK_WINDOW_AUTO_CONFIG,
)
# Since issue #280 we cannot have the error because we only display the
# MOCK_WINDOW_DELAY_CONFIG form if we have a sensor configured
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# We should stay on window with an error
assert result["errors"] == {}
# "window_sensor_entity_id": "window_open_detection_method"
# }
assert result["step_id"] == "advanced"
assert result["errors"] == {
"window_sensor_entity_id": "window_open_detection_method"
}
assert result["step_id"] == "window"
@pytest.mark.parametrize("expected_lingering_tasks", [True])

View File

@@ -4,7 +4,7 @@
from unittest.mock import patch, call
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.config_entries import ConfigEntryState
@@ -214,60 +214,20 @@ async def test_over_valve_full_start(
assert entity.hvac_action == HVACAction.HEATING
# Change internal temperature
expected_state = State(
entity_id="number.mock_valve", state="0", attributes={"min": 10, "max": 50}
)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"homeassistant.core.ServiceRegistry.async_call"
) as mock_service_call, patch(
"homeassistant.core.StateMachine.get", return_value=expected_state
"homeassistant.core.StateMachine.get", return_value=0
):
event_timestamp = now - timedelta(minutes=3)
await send_temperature_change_event(entity, 20, datetime.now())
assert entity.valve_open_percent == 0
assert entity.is_device_active is True # Should be 0 but in fact 10 is send
assert (
entity.hvac_action == HVACAction.HEATING
) # Should be IDLE but heating due to 10
assert mock_service_call.call_count == 1
# The VTherm valve is 0, but the underlying have received 10 which is the min
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{"entity_id": "number.mock_valve", "value": 10},
)
]
)
assert entity.is_device_active is False
assert entity.hvac_action == HVACAction.IDLE
await send_temperature_change_event(entity, 17, datetime.now())
assert mock_service_call.call_count == 2
# The VTherm valve is 0, but the underlying have received 10 which is the min
mock_service_call.assert_has_calls(
[
call.async_call(
"number",
"set_value",
{
"entity_id": "number.mock_valve",
"value": 10,
}, # the min allowed value
),
call.async_call(
"number",
"set_value",
{
"entity_id": "number.mock_valve",
"value": 50,
}, # the max allowed value
),
]
)
# switch to Eco
await entity.async_set_preset_mode(PRESET_ECO)
assert entity.preset_mode is PRESET_ECO