Full featured but without testu

This commit is contained in:
Jean-Marc Collin
2024-01-14 12:37:09 +00:00
parent 0343d0f0e8
commit 2831257732
13 changed files with 249 additions and 53 deletions

View File

@@ -66,6 +66,13 @@ input_number:
max: 90
icon: mdi:pipe-valve
unit_of_measurement: percentage
fake_boiler_temperature:
name: Boiler temperature
min: 0
max: 30
icon: mdi:water-boiler
unit_of_measurement: °C
mode: box
input_boolean:
# input_boolean to simulate the windows entity. Only for development environment.

View File

@@ -121,6 +121,7 @@ from .const import (
CENTRAL_MODE_HEAT_ONLY,
CENTRAL_MODE_COOL_ONLY,
CENTRAL_MODE_FROST_PROTECTION,
send_vtherm_event,
)
from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -203,6 +204,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"temperature_unit",
"is_device_active",
"target_temperature_step",
"is_used_by_central_boiler",
}
)
)
@@ -883,7 +885,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode})
self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode})
self.send_event(EventType.HVAC_ACTION_EVENT, {"hvac_action": self.hvac_action})
_LOGGER.info(
"%s - restored state is target_temp=%.1f, preset_mode=%s, hvac_mode=%s",
@@ -2419,6 +2420,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"temperature_unit": self.temperature_unit,
"is_device_active": self.is_device_active,
"ema_temp": self._ema_temp,
"is_used_by_central_boiler": self.is_used_by_central_boiler,
}
@callback
@@ -2541,8 +2543,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
def send_event(self, event_type: EventType, data: dict):
"""Send an event"""
_LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data)
data["entity_id"] = self.entity_id
data["name"] = self.name
data["state_attributes"] = self.state_attributes
self._hass.bus.fire(event_type.value, data)
send_vtherm_event(self._hass, event_type=event_type, entity=self, data=data)
# _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data)
# data["entity_id"] = self.entity_id
# data["name"] = self.name
# data["state_attributes"] = self.state_attributes
# self._hass.bus.fire(event_type.value, data)

View File

@@ -3,7 +3,13 @@
import logging
from homeassistant.core import HomeAssistant, callback, Event, CoreState
from homeassistant.core import (
HomeAssistant,
callback,
Event,
CoreState,
HomeAssistantError,
)
from homeassistant.const import STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START
@@ -28,7 +34,10 @@ from homeassistant.components.climate import (
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from .vtherm_api import VersatileThermostatAPI
from .commons import VersatileThermostatBaseEntity
from .commons import (
VersatileThermostatBaseEntity,
check_and_extract_service_configuration,
)
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
@@ -39,7 +48,11 @@ from .const import (
CONF_USE_WINDOW_FEATURE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_CENTRAL_BOILER_ACTIVATION_SRV,
CONF_CENTRAL_BOILER_DEACTIVATION_SRV,
overrides,
EventType,
send_vtherm_event,
)
_LOGGER = logging.getLogger(__name__)
@@ -346,6 +359,12 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
self._device_name = entry_infos.get(CONF_NAME)
self._entities = []
self._hass = hass
self._service_activate = check_and_extract_service_configuration(
entry_infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV)
)
self._service_deactivate = check_and_extract_service_configuration(
entry_infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV)
)
@property
def device_info(self) -> DeviceInfo:
@@ -438,12 +457,43 @@ class CentralBoilerBinarySensor(BinarySensorEntity):
break
if self._attr_is_on != active:
if active:
_LOGGER.info("%s - turning on the central boiler", self)
else:
_LOGGER.info("%s - turning off the central boiler", self)
self._attr_is_on = active
self.async_write_ha_state()
try:
if active:
await self.call_service(self._service_activate)
_LOGGER.info("%s - central boiler have been turned on", self)
else:
await self.call_service(self._service_deactivate)
_LOGGER.info("%s - central boiler have been turned off", self)
self._attr_is_on = active
send_vtherm_event(
hass=self._hass,
event_type=EventType.CENTRAL_BOILER_EVENT,
entity=self,
data={"central_boiler": active},
)
self.async_write_ha_state()
except HomeAssistantError as err:
_LOGGER.error(
"%s - Impossible to activate/deactivat boiler due to error %s."
"Central boiler will not being controled by VTherm."
"Please check your service configuration. Cf. README.",
self,
err,
)
async def call_service(self, service_config: dict):
"""Make a call to a service if correctly configured"""
if not service_config:
return
await self._hass.services.async_call(
service_config["service_domain"],
service_config["service_name"],
service_data=service_config["data"],
target={
"entity_id": service_config["entity_id"],
},
)
def __str__(self):
return f"VersatileThermostat-{self.name}"

View File

@@ -1,4 +1,6 @@
""" Some usefull commons class """
# pylint: disable=line-too-long
import logging
from datetime import timedelta, datetime
from homeassistant.core import HomeAssistant, callback, Event
@@ -10,41 +12,148 @@ from homeassistant.helpers.event import async_track_state_change_event, async_ca
from homeassistant.util import dt as dt_util
from .base_thermostat import BaseThermostat
from .const import DOMAIN, DEVICE_MANUFACTURER
from .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError
_LOGGER = logging.getLogger(__name__)
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
class NowClass:
""" For testing purpose only"""
"""For testing purpose only"""
@staticmethod
def get_now(hass: HomeAssistant) -> datetime:
""" A test function to get the now.
For testing purpose this method can be overriden to get a specific
timestamp.
"""A test function to get the now.
For testing purpose this method can be overriden to get a specific
timestamp.
"""
return datetime.now( get_tz(hass))
return datetime.now(get_tz(hass))
def round_to_nearest(n:float, x: float)->float:
""" Round a number to the nearest x (which should be decimal but not null)
Example:
nombre1 = 3.2
nombre2 = 4.7
x = 0.3
nombre_arrondi1 = round_to_nearest(nombre1, x)
nombre_arrondi2 = round_to_nearest(nombre2, x)
def round_to_nearest(n: float, x: float) -> float:
"""Round a number to the nearest x (which should be decimal but not null)
Example:
nombre1 = 3.2
nombre2 = 4.7
x = 0.3
print(nombre_arrondi1) # Output: 3.3
print(nombre_arrondi2) # Output: 4.6
nombre_arrondi1 = round_to_nearest(nombre1, x)
nombre_arrondi2 = round_to_nearest(nombre2, x)
print(nombre_arrondi1) # Output: 3.3
print(nombre_arrondi2) # Output: 4.6
"""
assert x > 0
return round(n * (1/x)) / (1/x)
return round(n * (1 / x)) / (1 / x)
def check_and_extract_service_configuration(service_config) -> dict:
"""Raise a ServiceConfigurationError. In return you have a dict formatted like follows.
Example if you call with 'climate.central_boiler/climate.set_temperature/temperature:10':
{
"service_domain": "climate",
"service_name": "set_temperature",
"entity_id": "climate.central_boiler",
"entity_domain": "climate",
"entity_name": "central_boiler",
"data": {
"temperature": "10"
},
"attribute_name": "temperature",
"attribute_value: "10"
}
For this example 'switch.central_boiler/switch.turn_off' you will have this:
{
"service_domain": "switch",
"service_name": "turn_off",
"entity_id": "switch.central_boiler",
"entity_domain": "switch",
"entity_name": "central_boiler",
"data": { },
}
All values are striped (white space are removed) and are string
"""
ret = {}
if service_config is None:
return ret
parties = service_config.split("/")
if len(parties) < 2:
raise ServiceConfigurationError(
f"Incorrect service configuration. Service {service_config} should be formatted with: 'entity_name/service_name[/data]'. See README for more information."
)
entity_id = parties[0]
service_name = parties[1]
service_infos = service_name.split(".")
if len(service_infos) != 2:
raise ServiceConfigurationError(
f"Incorrect service configuration. The service {service_config} should be formatted like: 'domain.service_name' (ex: 'switch.turn_on'). See README for more information."
)
ret.update(
{
"service_domain": service_infos[0].strip(),
"service_name": service_infos[1].strip(),
}
)
entity_infos = entity_id.split(".")
if len(entity_infos) != 2:
raise ServiceConfigurationError(
f"Incorrect service configuration. The entity_id {entity_id} should be formatted like: 'domain.entity_name' (ex: 'switch.central_boiler_switch'). See README for more information."
)
ret.update(
{
"entity_domain": entity_infos[0].strip(),
"entity_name": entity_infos[1].strip(),
"entity_id": entity_id.strip(),
}
)
if len(parties) == 3:
data = parties[2]
if len(data) > 0:
data_infos = None
data_infos = data.split(":")
if (
len(data_infos) != 2
or len(data_infos[0]) <= 0
or len(data_infos[1]) <= 0
):
raise ServiceConfigurationError(
f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information."
)
ret.update(
{
"attribute_name": data_infos[0].strip(),
"attribute_value": data_infos[1].strip(),
"data": {data_infos[0].strip(): data_infos[1].strip()},
}
)
else:
raise ServiceConfigurationError(
f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information."
)
else:
ret.update({"data": {}})
_LOGGER.debug(
"check_and_extract_service_configuration(%s) gives '%s'", service_config, ret
)
return ret
class VersatileThermostatBaseEntity(Entity):
"""A base class for all entities"""
@@ -130,7 +239,9 @@ class VersatileThermostatBaseEntity(Entity):
await try_find_climate(None)
@callback
async def async_my_climate_changed(self, event: Event): # pylint: disable=unused-argument
async def async_my_climate_changed(
self, event: Event
): # pylint: disable=unused-argument
"""Called when my climate have change
This method aims to be overriden to take the status change
"""

View File

@@ -23,6 +23,7 @@ from homeassistant.data_entry_flow import FlowHandler, FlowResult
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import
from .vtherm_api import VersatileThermostatAPI
from .commons import check_and_extract_service_configuration
COMES_FROM = "comes_from"
@@ -191,6 +192,17 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
)
raise NoCentralConfig(conf)
# Check the service for central boiler format
if self._infos.get(CONF_ADD_CENTRAL_BOILER_CONTROL):
for conf in [
CONF_CENTRAL_BOILER_ACTIVATION_SRV,
CONF_CENTRAL_BOILER_DEACTIVATION_SRV,
]:
try:
check_and_extract_service_configuration(data.get(conf))
except ServiceConfigurationError as err:
raise ServiceConfigurationError(conf) from err
def merge_user_input(self, data_schema: vol.Schema, user_input: dict):
"""For each schema entry not in user_input, set or remove values in infos"""
self._infos.update(user_input)
@@ -225,6 +237,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
errors[str(err)] = "window_open_detection_method"
except NoCentralConfig as err:
errors[str(err)] = "no_central_config"
except ServiceConfigurationError as err:
errors[str(err)] = "service_configuration_format"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

View File

@@ -1,6 +1,8 @@
# pylint: disable=line-too-long
"""Constants for the Versatile Thermostat integration."""
import logging
from enum import Enum
from homeassistant.const import CONF_NAME, Platform
@@ -18,6 +20,8 @@ from .prop_algorithm import (
PROPORTIONAL_FUNCTION_TPI,
)
_LOGGER = logging.getLogger(__name__)
PRESET_AC_SUFFIX = "_ac"
PRESET_ECO_AC = PRESET_ECO + PRESET_AC_SUFFIX
PRESET_COMFORT_AC = PRESET_COMFORT + PRESET_AC_SUFFIX
@@ -403,11 +407,20 @@ class EventType(Enum):
POWER_EVENT: str = "versatile_thermostat_power_event"
TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event"
HVAC_MODE_EVENT: str = "versatile_thermostat_hvac_mode_event"
HVAC_ACTION_EVENT: str = "versatile_thermostat_hvac_action_event"
CENTRAL_BOILER_EVENT: str = "versatile_thermostat_central_boiler_event"
PRESET_EVENT: str = "versatile_thermostat_preset_event"
WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event"
def send_vtherm_event(hass, event_type: EventType, entity, data: dict):
"""Send an event"""
_LOGGER.info("%s - Sending event %s with data: %s", entity, event_type, data)
data["entity_id"] = entity.entity_id
data["name"] = entity.name
data["state_attributes"] = entity.state_attributes
hass.bus.fire(event_type.value, data)
class UnknownEntity(HomeAssistantError):
"""Error to indicate there is an unknown entity_id given."""
@@ -420,6 +433,10 @@ class NoCentralConfig(HomeAssistantError):
"""Error to indicate that we try to use a central configuration but no VTherm of type CENTRAL CONFIGURATION has been found"""
class ServiceConfigurationError(HomeAssistantError):
"""Error in the service configuration to control the central boiler"""
class overrides: # pylint: disable=invalid-name
"""An annotation to inform overrides"""

View File

@@ -458,7 +458,8 @@
"unknown": "Unexpected error",
"unknown_entity": "Unknown entity id",
"window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both",
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it."
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.",
"service_configuration_format": "The format of the service configuration is wrong"
},
"abort": {
"already_configured": "Device is already configured"

View File

@@ -48,7 +48,6 @@ from .const import (
AUTO_FAN_DTEMP_THRESHOLD,
AUTO_FAN_DEACTIVATED_MODES,
UnknownEntity,
EventType,
)
from .vtherm_api import VersatileThermostatAPI
@@ -552,9 +551,6 @@ class ThermostatOverClimate(BaseThermostat):
async def end_climate_changed(changes):
"""To end the event management"""
if changes:
self.send_event(
EventType.HVAC_ACTION_EVENT, {"hvac_action": self.hvac_action}
)
self.async_write_ha_state()
self.update_custom_attributes()
await self.async_control_heating()

View File

@@ -13,7 +13,6 @@ from .const import (
CONF_HEATER_4,
CONF_INVERSE_SWITCH,
overrides,
EventType,
)
from .base_thermostat import BaseThermostat
@@ -210,6 +209,5 @@ class ThermostatOverSwitch(BaseThermostat):
if old_state is None:
self.hass.create_task(self._check_initial_state())
self.send_event(EventType.HVAC_ACTION_EVENT, {"hvac_action": self.hvac_action})
self.async_write_ha_state()
self.update_custom_attributes()

View File

@@ -19,7 +19,6 @@ from .const import (
CONF_VALVE_3,
CONF_VALVE_4,
overrides,
EventType,
)
from .underlyings import UnderlyingValve
@@ -130,7 +129,6 @@ class ThermostatOverValve(BaseThermostat):
_LOGGER.debug(
"%s - _async_valve_changed new_state is %s", self, new_state.state
)
self.send_event(EventType.HVAC_ACTION_EVENT, {"hvac_action": self.hvac_action})
@overrides
def update_custom_attributes(self):

View File

@@ -24,16 +24,16 @@
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power",
"use_central_mode": "Enable the control by central entity (need central config)",
"use_central_mode": "Enable the control by central entity (need central config). Check to enable the control of the VTherm with the select central_mode entities.",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection",
"use_main_central_config": "Use central main configuration"
"use_main_central_config": "Use central main configuration. Check to use the central main configuration. Uncheck to use a specific main configuration for this VTherm",
"add_central_boiler_control": "Add a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm need heating, the boiler will be turned on. If no VTherm needs heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the next configuration page",
"used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler"
},
"data_description": {
"use_central_mode": "Check to enable the control of the VTherm with the select central_mode entities",
"use_main_central_config": "Check to use the central main configuration. Uncheck to use a specific main configuration for this VTherm",
"external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected"
}
},
@@ -256,16 +256,16 @@
"temp_min": "Minimal temperature allowed",
"temp_max": "Maximal temperature allowed",
"device_power": "Device power",
"use_central_mode": "Enable the control by central entity (need central config)",
"use_central_mode": "Enable the control by central entity (need central config). Check to enable the control of the VTherm with the select central_mode entities.",
"use_window_feature": "Use window detection",
"use_motion_feature": "Use motion detection",
"use_power_feature": "Use power management",
"use_presence_feature": "Use presence detection",
"use_main_central_config": "Use central main configuration"
"use_main_central_config": "Use central main configuration. Check to use the central main configuration. Uncheck to use a specific main configuration for this VTherm",
"add_central_boiler_control": "Add a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm need heating, the boiler will be turned on. If no VTherm needs heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the next configuration page",
"used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler"
},
"data_description": {
"use_central_mode": "Check to enable the control of the VTherm with the select central_mode entities",
"use_main_central_config": "Check to use the central main configuration. Uncheck to use a specific configuration for this VTherm",
"external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected"
}
},
@@ -458,7 +458,8 @@
"unknown": "Unexpected error",
"unknown_entity": "Unknown entity id",
"window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both",
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it."
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.",
"service_configuration_format": "The format of the service configuration is wrong"
},
"abort": {
"already_configured": "Device is already configured"

View File

@@ -476,7 +476,8 @@
"unknown": "Erreur inattendue",
"unknown_entity": "entity id inconnu",
"window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux.",
"no_central_config": "Vous ne pouvez pas cocher 'Utiliser la configuration centrale' car aucune configuration centrale n'a été trouvée. Vous devez créer un Versatile Thermostat de type 'Central Configuration' pour pouvoir l'utiliser."
"no_central_config": "Vous ne pouvez pas cocher 'Utiliser la configuration centrale' car aucune configuration centrale n'a été trouvée. Vous devez créer un Versatile Thermostat de type 'Central Configuration' pour pouvoir l'utiliser.",
"service_configuration_format": "Mauvais format de la configuration du service"
},
"abort": {
"already_configured": "Le device est déjà configuré"

View File

@@ -241,7 +241,6 @@ async def test_user_config_flow_over_climate(
CONF_USE_POWER_CENTRAL_CONFIG: False,
CONF_USE_PRESENCE_CENTRAL_CONFIG: False,
CONF_USE_ADVANCED_CENTRAL_CONFIG: False,
CONF_ADD_CENTRAL_BOILER_CONTROL: False,
CONF_USED_BY_CENTRAL_BOILER: False,
}
assert result["result"]