diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 4978db3..fbb23b2 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -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. diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 247c539..6a65c40 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -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) diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 1e58a28..a6c2405 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -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}" diff --git a/custom_components/versatile_thermostat/commons.py b/custom_components/versatile_thermostat/commons.py index 4c99eff..13b1477 100644 --- a/custom_components/versatile_thermostat/commons.py +++ b/custom_components/versatile_thermostat/commons.py @@ -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 """ diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index 1a7cdb1..2da46f6 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -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" diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 66c99b0..26c1d5b 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -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""" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index a855d76..051f319 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -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" diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 69fd04d..b0f09bb 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -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() diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index cd48d4d..761c244 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -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() diff --git a/custom_components/versatile_thermostat/thermostat_valve.py b/custom_components/versatile_thermostat/thermostat_valve.py index 33513bd..bd98081 100644 --- a/custom_components/versatile_thermostat/thermostat_valve.py +++ b/custom_components/versatile_thermostat/thermostat_valve.py @@ -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): diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 9bd25ee..051f319 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -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" diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 9590915..14bc041 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -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é" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 509d868..b27c088 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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"]