diff --git a/esphome/components/opentherm/.gitignore b/esphome/components/opentherm/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/esphome/components/opentherm/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/esphome/components/opentherm/LICENSE b/esphome/components/opentherm/LICENSE new file mode 100644 index 0000000000..fc774e33d4 --- /dev/null +++ b/esphome/components/opentherm/LICENSE @@ -0,0 +1,5 @@ +Parts of the code (namely opentherm.h and opentherm.cpp) are adapted from arduino-opentherm project by +jparus (https://github.com/jpraus/arduino-opentherm). That project is published under Creative Commons +Attribution-NonCommercial-ShareAlike 4.0 International Public License. That license is compatible with +GPLv3 license, which covers C++ part of ESPHome project (see the top-level license file). License compatibility +is described here: https://creativecommons.org/share-your-work/licensing-considerations/compatible-licenses. \ No newline at end of file diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py new file mode 100644 index 0000000000..bb6a2cf155 --- /dev/null +++ b/esphome/components/opentherm/__init__.py @@ -0,0 +1,69 @@ +from typing import Any, Dict + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor +from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome import pins + +from . import const, schema, validate, generate + +CODEOWNERS = ["@olegtarasov"] +MULTI_CONF = True + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(generate.OpenthermHub), + cv.Required("in_pin"): pins.internal_gpio_input_pin_schema, + cv.Required("out_pin"): pins.internal_gpio_output_pin_schema, + cv.Optional("ch_enable", True): cv.boolean, + cv.Optional("dhw_enable", True): cv.boolean, + cv.Optional("cooling_enable", False): cv.boolean, + cv.Optional("otc_active", False): cv.boolean, + cv.Optional("ch2_active", False): cv.boolean, + } + ) + .extend( + validate.create_entities_schema( + schema.INPUTS, (lambda _: cv.use_id(sensor.Sensor)) + ) + ) + .extend(cv.COMPONENT_SCHEMA), + cv.only_with_arduino, + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]) +) + + +async def to_code(config: Dict[str, Any]) -> None: + id = str(config[CONF_ID]) + # Create the hub, passing the two callbacks defined below + # Since the hub is used in the callbacks, we need to define it first + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # Set pins + in_pin = await cg.gpio_pin_expression(config["in_pin"]) + cg.add(var.set_in_pin(in_pin)) + + out_pin = await cg.gpio_pin_expression(config["out_pin"]) + cg.add(var.set_out_pin(out_pin)) + + input_sensors = [] + non_sensors = {CONF_ID, "in_pin", "out_pin"} + for key, value in config.items(): + if key not in non_sensors: + if key in schema.INPUTS: + sensor = await cg.get_variable(value) + cg.add(getattr(var, f"set_{key}_{const.INPUT_SENSOR.lower()}")(sensor)) + input_sensors.append(key) + else: + cg.add(getattr(var, f"set_{key}")(value)) + + if len(input_sensors) > 0: + generate.define_has_component(const.INPUT_SENSOR, input_sensors) + generate.define_message_handler( + const.INPUT_SENSOR, input_sensors, schema.INPUTS + ) + generate.define_readers(const.INPUT_SENSOR, input_sensors) + generate.add_messages(var, input_sensors, schema.INPUTS) diff --git a/esphome/components/opentherm/binary_sensor/__init__.py b/esphome/components/opentherm/binary_sensor/__init__.py new file mode 100644 index 0000000000..a23e5c2b23 --- /dev/null +++ b/esphome/components/opentherm/binary_sensor/__init__.py @@ -0,0 +1,29 @@ +from typing import Any, Dict + +import esphome.config_validation as cv +from esphome.components import binary_sensor + +from .. import const, schema, validate, generate + +DEPENDENCIES = [const.OPENTHERM] +COMPONENT_TYPE = const.BINARY_SENSOR + + +def get_entity_validation_schema(entity: schema.BinarySensorSchema) -> cv.Schema: + return binary_sensor.binary_sensor_schema( + device_class=entity["device_class"] if "device_class" in entity else binary_sensor._UNDEF, + icon=entity["icon"] if "icon" in entity else binary_sensor._UNDEF + ) + + +CONFIG_SCHEMA = validate.create_component_schema(schema.BINARY_SENSORS, get_entity_validation_schema) + + +async def to_code(config: Dict[str, Any]) -> None: + await generate.component_to_code( + COMPONENT_TYPE, + schema.BINARY_SENSORS, + binary_sensor.BinarySensor, + generate.create_only_conf(binary_sensor.new_binary_sensor), + config + ) diff --git a/esphome/components/opentherm/const.py b/esphome/components/opentherm/const.py new file mode 100644 index 0000000000..6a46c81340 --- /dev/null +++ b/esphome/components/opentherm/const.py @@ -0,0 +1,10 @@ +OPENTHERM = "opentherm" + +CONF_OPENTHERM_ID = "opentherm_id" + +SENSOR = "sensor" +BINARY_SENSOR = "binary_sensor" +SWITCH = "switch" +NUMBER = "number" +OUTPUT = "output" +INPUT_SENSOR = "input_sensor" diff --git a/esphome/components/opentherm/generate.py b/esphome/components/opentherm/generate.py new file mode 100644 index 0000000000..e972aad3c1 --- /dev/null +++ b/esphome/components/opentherm/generate.py @@ -0,0 +1,145 @@ +from typing import Any, Awaitable, Callable, Dict, List, Set, Tuple, TypeVar + +import esphome.codegen as cg +from esphome.const import CONF_ID + +from . import const, schema + +opentherm_ns = cg.esphome_ns.namespace("esphome::opentherm") +OpenthermHub = opentherm_ns.class_("OpenthermHub", cg.Component) + + +def define_has_component(component_type: str, keys: List[str]) -> None: + cg.add_define( + f"OPENTHERM_{component_type.upper()}_LIST(F, sep)", + cg.RawExpression( + " sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys)) + ), + ) + for key in keys: + cg.add_define(f"OPENTHERM_HAS_{component_type.upper()}_{key}") + + +TSchema = TypeVar("TSchema", bound=schema.EntitySchema) + + +def define_message_handler( + component_type: str, keys: List[str], schema_: schema.Schema[TSchema] +) -> None: + # The macros defined here should be able to generate things like this: + # // Parsing a message and publishing to sensors + # case MessageId::Message: + # // Can have multiple sensors here, for example for a Status message with multiple flags + # this->thing_binary_sensor->publish_state(parse_flag8_lb_0(response)); + # this->other_binary_sensor->publish_state(parse_flag8_lb_1(response)); + # break; + # // Building a message for a write request + # case MessageId::Message: { + # unsigned int data = 0; + # data = write_flag8_lb_0(some_input_switch->state, data); // Where input_sensor can also be a number/output/switch + # data = write_u8_hb(some_number->state, data); + # return opentherm_->build_request_(MessageType::WriteData, MessageId::Message, data); + # } + + # There doesn't seem to be a way to combine the handlers for different components, so we'll + # have to call them seperately in C++. + + messages: Dict[str, List[Tuple[str, str]]] = {} + for key in keys: + msg = schema_[key]["message"] + if msg not in messages: + messages[msg] = [] + messages[msg].append((key, schema_[key]["message_data"])) + + cg.add_define( + f"OPENTHERM_{component_type.upper()}_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep)", + cg.RawExpression( + " msg_sep ".join( + [ + f"MESSAGE({msg}) " + + " entity_sep ".join( + [ + f"ENTITY({key}_{component_type.lower()}, {msg_data})" + for key, msg_data in keys + ] + ) + + " postscript" + for msg, keys in messages.items() + ] + ) + ), + ) + + +def define_readers(component_type: str, keys: List[str]) -> None: + for key in keys: + cg.add_define( + f"OPENTHERM_READ_{key}", + cg.RawExpression(f"this->{key}_{component_type.lower()}->state"), + ) + + +def add_messages(hub: cg.MockObj, keys: List[str], schema_: schema.Schema[TSchema]): + messages: Set[Tuple[str, bool]] = set() + for key in keys: + messages.add((schema_[key]["message"], schema_[key]["keep_updated"])) + for msg, keep_updated in messages: + msg_expr = cg.RawExpression(f"esphome::opentherm::MessageId::{msg}") + if keep_updated: + cg.add(hub.add_repeating_message(msg_expr)) + else: + cg.add(hub.add_initial_message(msg_expr)) + + +def add_property_set(var: cg.MockObj, config_key: str, config: Dict[str, Any]) -> None: + if config_key in config: + cg.add(getattr(var, f"set_{config_key}")(config[config_key])) + + +Create = Callable[[Dict[str, Any], str, cg.MockObj], Awaitable[cg.Pvariable]] + + +def create_only_conf( + create: Callable[[Dict[str, Any]], Awaitable[cg.Pvariable]] +) -> Create: + return lambda conf, _key, _hub: create(conf) + + +async def component_to_code( + component_type: str, + schema_: schema.Schema[TSchema], + type: cg.MockObjClass, + create: Create, + config: Dict[str, Any], +) -> List[str]: + """Generate the code for each configured component in the schema of a component type. + + Parameters: + - component_type: The type of component, e.g. "sensor" or "binary_sensor" + - schema_: The schema for that component type, a list of available components + - type: The type of the component, e.g. sensor.Sensor or OpenthermOutput + - create: A constructor function for the component, which receives the config, + the key and the hub and should asynchronously return the new component + - config: The configuration for this component type + + Returns: The list of keys for the created components + """ + cg.add_define(f"OPENTHERM_USE_{component_type.upper()}") + + hub = await cg.get_variable(config[const.CONF_OPENTHERM_ID]) + + keys: List[str] = [] + for key, conf in config.items(): + if not isinstance(conf, dict): + continue + id = conf[CONF_ID] + if id and id.type == type: + entity = await create(conf, key, hub) + cg.add(getattr(hub, f"set_{key}_{component_type.lower()}")(entity)) + keys.append(key) + + define_has_component(component_type, keys) + define_message_handler(component_type, keys, schema_) + add_messages(hub, keys, schema_) + + return keys diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp new file mode 100644 index 0000000000..730a3b0780 --- /dev/null +++ b/esphome/components/opentherm/hub.cpp @@ -0,0 +1,355 @@ +#include "hub.h" + +#include + +// Disable incomplete switch statement warnings, because the cases in each +// switch are generated based on the configured sensors and inputs. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wswitch" + +namespace esphome { +namespace opentherm { + +namespace message_data { +bool parse_flag8_lb_0(OpenthermData &data) { return bitRead(data.valueLB, 0); } +bool parse_flag8_lb_1(OpenthermData &data) { return bitRead(data.valueLB, 1); } +bool parse_flag8_lb_2(OpenthermData &data) { return bitRead(data.valueLB, 2); } +bool parse_flag8_lb_3(OpenthermData &data) { return bitRead(data.valueLB, 3); } +bool parse_flag8_lb_4(OpenthermData &data) { return bitRead(data.valueLB, 4); } +bool parse_flag8_lb_5(OpenthermData &data) { return bitRead(data.valueLB, 5); } +bool parse_flag8_lb_6(OpenthermData &data) { return bitRead(data.valueLB, 6); } +bool parse_flag8_lb_7(OpenthermData &data) { return bitRead(data.valueLB, 7); } +bool parse_flag8_hb_0(OpenthermData &data) { return bitRead(data.valueHB, 0); } +bool parse_flag8_hb_1(OpenthermData &data) { return bitRead(data.valueHB, 1); } +bool parse_flag8_hb_2(OpenthermData &data) { return bitRead(data.valueHB, 2); } +bool parse_flag8_hb_3(OpenthermData &data) { return bitRead(data.valueHB, 3); } +bool parse_flag8_hb_4(OpenthermData &data) { return bitRead(data.valueHB, 4); } +bool parse_flag8_hb_5(OpenthermData &data) { return bitRead(data.valueHB, 5); } +bool parse_flag8_hb_6(OpenthermData &data) { return bitRead(data.valueHB, 6); } +bool parse_flag8_hb_7(OpenthermData &data) { return bitRead(data.valueHB, 7); } +uint8_t parse_u8_lb(OpenthermData &data) { return data.valueLB; } +uint8_t parse_u8_hb(OpenthermData &data) { return data.valueHB; } +int8_t parse_s8_lb(OpenthermData &data) { return (int8_t) data.valueLB; } +int8_t parse_s8_hb(OpenthermData &data) { return (int8_t) data.valueHB; } +uint16_t parse_u16(OpenthermData &data) { return data.u16(); } +int16_t parse_s16(OpenthermData &data) { return data.s16(); } +float parse_f88(OpenthermData &data) { return data.f88(); } + +void write_flag8_lb_0(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 0, value); } +void write_flag8_lb_1(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 1, value); } +void write_flag8_lb_2(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 2, value); } +void write_flag8_lb_3(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 3, value); } +void write_flag8_lb_4(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 4, value); } +void write_flag8_lb_5(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 5, value); } +void write_flag8_lb_6(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 6, value); } +void write_flag8_lb_7(const bool value, OpenthermData &data) { bitWrite(data.valueLB, 7, value); } +void write_flag8_hb_0(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 0, value); } +void write_flag8_hb_1(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 1, value); } +void write_flag8_hb_2(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 2, value); } +void write_flag8_hb_3(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 3, value); } +void write_flag8_hb_4(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 4, value); } +void write_flag8_hb_5(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 5, value); } +void write_flag8_hb_6(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 6, value); } +void write_flag8_hb_7(const bool value, OpenthermData &data) { bitWrite(data.valueHB, 7, value); } +void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = value; } +void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; } +void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; } +void write_s8_hb(const int8_t value, OpenthermData &data) { data.valueHB = (uint8_t) value; } +void write_u16(const uint16_t value, OpenthermData &data) { data.u16(value); } +void write_s16(const int16_t value, OpenthermData &data) { data.s16(value); } +void write_f88(const float value, OpenthermData &data) { data.f88(value); } + +} // namespace message_data + +#define OPENTHERM_IGNORE_1(x) +#define OPENTHERM_IGNORE_2(x, y) + +OpenthermData OpenthermHub::build_request_(MessageId request_id) { + OpenthermData data; + data.type = 0; + data.id = 0; + data.valueHB = 0; + data.valueLB = 0; + + // First, handle the status request. This requires special logic, because we + // wouldn't want to inadvertently disable domestic hot water, for example. + // It is also included in the macro-generated code below, but that will + // never be executed, because we short-circuit it here. + if (request_id == MessageId::STATUS) { + // NOLINTBEGIN + bool const ch_enabled = this->ch_enable && +#ifdef OPENTHERM_READ_ch_enable + OPENTHERM_READ_ch_enable +#else + true +#endif + && +#ifdef OPENTHERM_READ_t_set + OPENTHERM_READ_t_set > 0.0 +#else + true +#endif + ; + + bool dhw_enabled = this->dhw_enable && +#ifdef OPENTHERM_READ_dhw_enable + OPENTHERM_READ_dhw_enable +#else + true +#endif + ; + bool cooling_enabled = this->cooling_enable && +#ifdef OPENTHERM_READ_cooling_enable + OPENTHERM_READ_cooling_enable +#else + true +#endif + && +#ifdef OPENTHERM_READ_cooling_control + OPENTHERM_READ_cooling_control > 0.0 +#else + true +#endif + ; + bool otc_enabled = this->otc_active && +#ifdef OPENTHERM_READ_otc_active + OPENTHERM_READ_otc_active +#else + true +#endif + ; + bool ch2_enabled = this->ch2_active && +#ifdef OPENTHERM_READ_ch2_active + OPENTHERM_READ_ch2_active +#else + true +#endif + && +#ifdef OPENTHERM_READ_t_set_ch2 + OPENTHERM_READ_t_set_ch2 > 0.0 +#else + true +#endif + ; + // NOLINTEND + + data.type = MessageType::READ_DATA; + data.id = MessageId::STATUS; + data.valueHB = ch_enabled | (dhw_enabled << 1) | (cooling_enabled << 2) | (otc_enabled << 3) | (ch2_enabled << 4); + + return data; + } + +// Next, we start with the write requests from switches and other inputs, +// because we would want to write that data if it is available, rather than +// request a read for that type (in the case that both read and write are +// supported). +#define OPENTHERM_MESSAGE_WRITE_MESSAGE(msg) \ + case MessageId::msg: { \ + data.type = MessageType::WRITE_DATA; \ + data.id = request_id; +#define OPENTHERM_MESSAGE_WRITE_ENTITY(key, msg_data) message_data::write_##msg_data(this->key->state, data); +#define OPENTHERM_MESSAGE_WRITE_POSTSCRIPT \ + return data; \ + } + switch (request_id) { + OPENTHERM_SWITCH_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , + OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) + OPENTHERM_NUMBER_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , + OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) + OPENTHERM_OUTPUT_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , + OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) + OPENTHERM_INPUT_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , + OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) + } + +// Finally, handle the simple read requests, which only change with the message id. +#define OPENTHERM_MESSAGE_READ_MESSAGE(msg) \ + case MessageId::msg: \ + data.type = MessageType::READ_DATA; \ + data.id = request_id; \ + return data; + switch (request_id) { OPENTHERM_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_READ_MESSAGE, OPENTHERM_IGNORE_2, , , ) } + switch (request_id) { + OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_READ_MESSAGE, OPENTHERM_IGNORE_2, , , ) + } + + // And if we get here, a message was requested which somehow wasn't handled. + // This shouldn't happen due to the way the defines are configured, so we + // log an error and just return a 0 message. + ESP_LOGE(OT_TAG, "Tried to create a request with unknown id %d. This should never happen, so please open an issue.", + request_id); + return OpenthermData(); +} + +OpenthermHub::OpenthermHub() : Component() {} + +void OpenthermHub::process_response(OpenthermData &data) { + ESP_LOGD(OT_TAG, "Received OpenTherm response with id %d (%s)", data.id, + opentherm_->message_id_to_str((MessageId) data.id)); + ESP_LOGD(OT_TAG, "%s", opentherm_->debug_data(data).c_str()); + +// Define the handler helpers to publish the results to all sensors +#define OPENTHERM_MESSAGE_RESPONSE_MESSAGE(msg) case MessageId::msg: +#define OPENTHERM_MESSAGE_RESPONSE_ENTITY(key, msg_data) this->key->publish_state(message_data::parse_##msg_data(data)); +#define OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT break; + + // Then use those to create a switch statement for each thing we would want + // to report. We use a separate switch statement for each type, because some + // messages include results for multiple types, like flags and a number. + switch (data.id) { + OPENTHERM_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_RESPONSE_MESSAGE, OPENTHERM_MESSAGE_RESPONSE_ENTITY, , + OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT, ) + } + switch (data.id) { + OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_RESPONSE_MESSAGE, OPENTHERM_MESSAGE_RESPONSE_ENTITY, , + OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT, ) + } +} + +void OpenthermHub::setup() { + ESP_LOGD(OT_TAG, "Setting up OpenTherm component"); + this->opentherm_ = new OpenTherm(this->in_pin_, this->out_pin_); // NOLINT because hub is never deleted + this->opentherm_->begin(); + + // Ensure that there is at least one request, as we are required to + // communicate at least once every second. Sending the status request is + // good practice anyway. + this->add_repeating_message(MessageId::STATUS); + + this->current_message_iterator_ = this->initial_messages_.begin(); +} + +void OpenthermHub::on_shutdown() { this->opentherm_->stop(); } + +void OpenthermHub::loop() { + if (!this->opentherm_->is_idle()) { + ESP_LOGE(OT_TAG, "OpenTherm is not idle at the start of the loop"); + return; + } + + if (this->initializing_ && this->current_message_iterator_ == this->initial_messages_.end()) { + this->initializing_ = false; + this->current_message_iterator_ = this->repeating_messages_.begin(); + } else if (this->current_message_iterator_ == this->repeating_messages_.end()) { + this->current_message_iterator_ = this->repeating_messages_.begin(); + } + + auto cur_time = millis(); + if (last_conversation_start_ > 0 && (cur_time - last_conversation_start_) > 1150) { + ESP_LOGW(OT_TAG, + "%d ms elapsed since the start of the last convo, but 1150 ms are allowed at maximum. Look at other " + "components that might slow the loop down.", + cur_time - last_conversation_start_); + } + + if (last_conversation_end_ > 0 && (cur_time - last_conversation_end_) < 100) { + ESP_LOGD(OT_TAG, "Less than 100 ms elapsed since last convo, skipping this iteration"); + return; + } + + auto request = this->build_request_(*this->current_message_iterator_); + + ESP_LOGD(OT_TAG, "Sending request with id %d (%s)", request.id, + opentherm_->message_id_to_str((MessageId) request.id)); + ESP_LOGD(OT_TAG, "%s", opentherm_->debug_data(request).c_str()); + + // Send the request + last_conversation_start_ = millis(); + opentherm_->send(request); + if (!spin_wait_(1150, [&] { return opentherm_->is_active(); })) { + ESP_LOGE(OT_TAG, "Hub timeout triggered during send"); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } + + if (opentherm_->is_error()) { + ESP_LOGW(OT_TAG, "Error while sending request: %s", opentherm_->operation_mode_to_str(opentherm_->get_mode())); + ESP_LOGW(OT_TAG, "%s", opentherm_->debug_data(request).c_str()); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } else if (!opentherm_->is_sent()) { + ESP_LOGW(OT_TAG, "Unexpected state after sending request: %s", + opentherm_->operation_mode_to_str(opentherm_->get_mode())); + ESP_LOGW(OT_TAG, "%s", opentherm_->debug_data(request).c_str()); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } + + // Listen for the response + opentherm_->listen(); + if (!spin_wait_(1150, [&] { return opentherm_->is_active(); })) { + ESP_LOGE(OT_TAG, "Hub timeout triggered during receive"); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } + + if (opentherm_->is_timeout()) { + ESP_LOGW(OT_TAG, "Receive response timed out at a protocol level"); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } else if (opentherm_->is_protocol_error()) { + OpenThermError error; + opentherm_->get_protocol_error(error); + ESP_LOGW(OT_TAG, "Protocol error occured while receiving response: %s", opentherm_->debug_error(error).c_str()); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } else if (!opentherm_->has_message()) { + ESP_LOGW(OT_TAG, "Unexpected state after receiving response: %s", + opentherm_->operation_mode_to_str(opentherm_->get_mode())); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } + + // Process the response + OpenthermData response; + if (!opentherm_->get_message(response)) { + ESP_LOGW(OT_TAG, "Couldn't get the response, but flags indicated success. This is a bug."); + opentherm_->stop(); + last_conversation_end_ = millis(); + return; + } + + opentherm_->stop(); + last_conversation_end_ = millis(); + + process_response(response); + + this->current_message_iterator_++; +} + +#define ID(x) x +#define SHOW2(x) #x +#define SHOW(x) SHOW2(x) + +void OpenthermHub::dump_config() { + ESP_LOGCONFIG(OT_TAG, "OpenTherm:"); + ESP_LOGCONFIG(OT_TAG, " In: GPIO%d", this->in_pin_->get_pin()); + ESP_LOGCONFIG(OT_TAG, " Out: GPIO%d", this->out_pin_->get_pin()); + ESP_LOGCONFIG(OT_TAG, " Sensors: %s", SHOW(OPENTHERM_SENSOR_LIST(ID, ))); + ESP_LOGCONFIG(OT_TAG, " Binary sensors: %s", SHOW(OPENTHERM_BINARY_SENSOR_LIST(ID, ))); + ESP_LOGCONFIG(OT_TAG, " Switches: %s", SHOW(OPENTHERM_SWITCH_LIST(ID, ))); + ESP_LOGCONFIG(OT_TAG, " Input sensors: %s", SHOW(OPENTHERM_INPUT_SENSOR_LIST(ID, ))); + ESP_LOGCONFIG(OT_TAG, " Outputs: %s", SHOW(OPENTHERM_OUTPUT_LIST(ID, ))); + ESP_LOGCONFIG(OT_TAG, " Numbers: %s", SHOW(OPENTHERM_NUMBER_LIST(ID, ))); + ESP_LOGCONFIG(OT_TAG, " Initial requests:"); + for (auto type : this->initial_messages_) { + ESP_LOGCONFIG(OT_TAG, " - %d", type); + } + ESP_LOGCONFIG(OT_TAG, " Repeating requests:"); + for (auto type : this->repeating_messages_) { + ESP_LOGCONFIG(OT_TAG, " - %d", type); + } +} + +} // namespace opentherm +} // namespace esphome + +#pragma GCC diagnostic pop diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h new file mode 100644 index 0000000000..1ba985e3d4 --- /dev/null +++ b/esphome/components/opentherm/hub.h @@ -0,0 +1,195 @@ +#pragma once + +#include "esphome/core/hal.h" +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +#include "opentherm.h" + +#ifdef OPENTHERM_SENSOR_LIST +#include "esphome/components/sensor/sensor.h" +#endif + +#ifdef OPENTHERM_BINARY_SENSOR_LIST +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +#ifdef OPENTHERM_SWITCH_LIST +#include "esphome/components/opentherm/switch/switch.h" +#endif + +#ifdef OPENTHERM_OUTPUT_LIST +#include "esphome/components/opentherm/output/output.h" +#endif + +#ifdef OPENTHERM_NUMBER_LIST +#include "esphome/components/opentherm/number/number.h" +#endif + +#include +#include +#include + +#define OT_TAG "opentherm" + +// Ensure that all component macros are defined, even if the component is not used +#ifndef OPENTHERM_SENSOR_LIST +#define OPENTHERM_SENSOR_LIST(F, sep) +#endif +#ifndef OPENTHERM_BINARY_SENSOR_LIST +#define OPENTHERM_BINARY_SENSOR_LIST(F, sep) +#endif +#ifndef OPENTHERM_SWITCH_LIST +#define OPENTHERM_SWITCH_LIST(F, sep) +#endif +#ifndef OPENTHERM_NUMBER_LIST +#define OPENTHERM_NUMBER_LIST(F, sep) +#endif +#ifndef OPENTHERM_OUTPUT_LIST +#define OPENTHERM_OUTPUT_LIST(F, sep) +#endif +#ifndef OPENTHERM_INPUT_SENSOR_LIST +#define OPENTHERM_INPUT_SENSOR_LIST(F, sep) +#endif + +#ifndef OPENTHERM_SENSOR_MESSAGE_HANDLERS +#define OPENTHERM_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif +#ifndef OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS +#define OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif +#ifndef OPENTHERM_SWITCH_MESSAGE_HANDLERS +#define OPENTHERM_SWITCH_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif +#ifndef OPENTHERM_NUMBER_MESSAGE_HANDLERS +#define OPENTHERM_NUMBER_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif +#ifndef OPENTHERM_OUTPUT_MESSAGE_HANDLERS +#define OPENTHERM_OUTPUT_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif +#ifndef OPENTHERM_INPUT_SENSOR_MESSAGE_HANDLERS +#define OPENTHERM_INPUT_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif + +namespace esphome { +namespace opentherm { + +// OpenTherm component for ESPHome +class OpenthermHub : public Component { + protected: + // Communication pins for the OpenTherm interface + InternalGPIOPin *in_pin_, *out_pin_; + // The OpenTherm interface + OpenTherm *opentherm_; + +// Use macros to create fields for every entity specified in the ESPHome configuration +#define OPENTHERM_DECLARE_SENSOR(entity) sensor::Sensor *entity; + OPENTHERM_SENSOR_LIST(OPENTHERM_DECLARE_SENSOR, ) + +#define OPENTHERM_DECLARE_BINARY_SENSOR(entity) binary_sensor::BinarySensor *entity; + OPENTHERM_BINARY_SENSOR_LIST(OPENTHERM_DECLARE_BINARY_SENSOR, ) + +#define OPENTHERM_DECLARE_SWITCH(entity) OpenthermSwitch *entity; + OPENTHERM_SWITCH_LIST(OPENTHERM_DECLARE_SWITCH, ) + +#define OPENTHERM_DECLARE_NUMBER(entity) OpenthermNumber *entity; + OPENTHERM_NUMBER_LIST(OPENTHERM_DECLARE_NUMBER, ) + +#define OPENTHERM_DECLARE_OUTPUT(entity) OpenthermOutput *entity; + OPENTHERM_OUTPUT_LIST(OPENTHERM_DECLARE_OUTPUT, ) + +#define OPENTHERM_DECLARE_INPUT_SENSOR(entity) sensor::Sensor *entity; + OPENTHERM_INPUT_SENSOR_LIST(OPENTHERM_DECLARE_INPUT_SENSOR, ) + + // The set of initial messages to send on starting communication with the boiler + std::unordered_set initial_messages_; + // and the repeating messages which are sent repeatedly to update various sensors + // and boiler parameters (like the setpoint). + std::unordered_set repeating_messages_; + // Indicates if we are still working on the initial requests or not + bool initializing_ = true; + // Index for the current request in one of the _requests sets. + std::unordered_set::const_iterator current_message_iterator_; + + uint32_t last_conversation_start_ = 0; + uint32_t last_conversation_end_ = 0; + + // Create OpenTherm messages based on the message id + OpenthermData build_request_(MessageId request_id); + + template bool spin_wait_(uint32_t timeout, F func) { + auto start_time = millis(); + while (func()) { + yield(); + auto cur_time = millis(); + if (cur_time - start_time >= timeout) { + return false; + } + } + return true; + } + + public: + // Constructor with references to the global interrupt handlers + OpenthermHub(); + + // Handle responses from the OpenTherm interface + void process_response(OpenthermData &data); + + // Setters for the input and output OpenTherm interface pins + void set_in_pin(InternalGPIOPin *in_pin) { this->in_pin_ = in_pin; } + void set_out_pin(InternalGPIOPin *out_pin) { this->out_pin_ = out_pin; } + +#define OPENTHERM_SET_SENSOR(entity) \ + void set_##entity(sensor::Sensor *sensor) { this->entity = sensor; } + OPENTHERM_SENSOR_LIST(OPENTHERM_SET_SENSOR, ) + +#define OPENTHERM_SET_BINARY_SENSOR(entity) \ + void set_##entity(binary_sensor::BinarySensor *binary_sensor) { this->entity = binary_sensor; } + OPENTHERM_BINARY_SENSOR_LIST(OPENTHERM_SET_BINARY_SENSOR, ) + +#define OPENTHERM_SET_SWITCH(entity) \ + void set_##entity(OpenthermSwitch *sw) { this->entity = sw; } + OPENTHERM_SWITCH_LIST(OPENTHERM_SET_SWITCH, ) + +#define OPENTHERM_SET_NUMBER(entity) \ + void set_##entity(OpenthermNumber *number) { this->entity = number; } + OPENTHERM_NUMBER_LIST(OPENTHERM_SET_NUMBER, ) + +#define OPENTHERM_SET_OUTPUT(entity) \ + void set_##entity(OpenthermOutput *output) { this->entity = output; } + OPENTHERM_OUTPUT_LIST(OPENTHERM_SET_OUTPUT, ) + +#define OPENTHERM_SET_INPUT_SENSOR(entity) \ + void set_##entity(sensor::Sensor *sensor) { this->entity = sensor; } + OPENTHERM_INPUT_SENSOR_LIST(OPENTHERM_SET_INPUT_SENSOR, ) + + // Add a request to the set of initial requests + void add_initial_message(MessageId message_id) { this->initial_messages_.insert(message_id); } + // Add a request to the set of repeating requests. Note that a large number of repeating + // requests will slow down communication with the boiler. Each request may take up to 1 second, + // so with all sensors enabled, it may take about half a minute before a change in setpoint + // will be processed. + void add_repeating_message(MessageId message_id) { this->repeating_messages_.insert(message_id); } + + // There are five status variables, which can either be set as a simple variable, + // or using a switch. ch_enable and dhw_enable default to true, the others to false. + bool ch_enable = true, dhw_enable = true, cooling_enable = false, otc_active = false, ch2_active = false; + + // Setters for the status variables + void set_ch_enable(bool value) { this->ch_enable = value; } + void set_dhw_enable(bool value) { this->dhw_enable = value; } + void set_cooling_enable(bool value) { this->cooling_enable = value; } + void set_otc_active(bool value) { this->otc_active = value; } + void set_ch2_active(bool value) { this->ch2_active = value; } + + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void setup() override; + void on_shutdown() override; + void loop() override; + void dump_config() override; +}; + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/input.h b/esphome/components/opentherm/input.h new file mode 100644 index 0000000000..2294491373 --- /dev/null +++ b/esphome/components/opentherm/input.h @@ -0,0 +1,18 @@ +#pragma once + +namespace esphome { +namespace opentherm { + +class OpenthermInput { +public: + bool auto_min_value, auto_max_value; + + virtual void set_min_value(float min_value) = 0; + virtual void set_max_value(float max_value) = 0; + + virtual void set_auto_min_value(bool auto_min_value) { this->auto_min_value = auto_min_value; } + virtual void set_auto_max_value(bool auto_max_value) { this->auto_max_value = auto_max_value; } +}; + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/input.py b/esphome/components/opentherm/input.py new file mode 100644 index 0000000000..ef0aef3338 --- /dev/null +++ b/esphome/components/opentherm/input.py @@ -0,0 +1,39 @@ +from typing import Any, Dict + +import esphome.codegen as cg +import esphome.config_validation as cv + +from . import schema, generate + +CONF_min_value = "min_value" +CONF_max_value = "max_value" +CONF_auto_min_value = "auto_min_value" +CONF_auto_max_value = "auto_max_value" +CONF_step = "step" + +OpenthermInput = generate.opentherm_ns.class_("OpenthermInput") + +def validate_min_value_less_than_max_value(conf): + if CONF_min_value in conf and CONF_max_value in conf and conf[CONF_min_value] > conf[CONF_max_value]: + raise cv.Invalid(f"{CONF_min_value} must be less than {CONF_max_value}") + return conf + +def input_schema(entity: schema.InputSchema) -> cv.Schema: + schema = cv.Schema({ + cv.Optional(CONF_min_value, entity["range"][0]): cv.float_range(entity["range"][0], entity["range"][1]), + cv.Optional(CONF_max_value, entity["range"][1]): cv.float_range(entity["range"][0], entity["range"][1]), + }) + if CONF_auto_min_value in entity: + schema = schema.extend({ cv.Optional(CONF_auto_min_value, False): cv.boolean }) + if CONF_auto_max_value in entity: + schema = schema.extend({ cv.Optional(CONF_auto_max_value, False): cv.boolean }) + if CONF_step in entity: + schema = schema.extend({ cv.Optional(CONF_step, False): cv.float_ }) + schema = schema.add_extra(validate_min_value_less_than_max_value) + return schema + +def generate_setters(entity: cg.MockObj, conf: Dict[str, Any]) -> None: + generate.add_property_set(entity, CONF_min_value, conf) + generate.add_property_set(entity, CONF_max_value, conf) + generate.add_property_set(entity, CONF_auto_min_value, conf) + generate.add_property_set(entity, CONF_auto_max_value, conf) diff --git a/esphome/components/opentherm/number/__init__.py b/esphome/components/opentherm/number/__init__.py new file mode 100644 index 0000000000..c2e11e21b6 --- /dev/null +++ b/esphome/components/opentherm/number/__init__.py @@ -0,0 +1,55 @@ +from typing import Any, Dict + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import number +from esphome.const import CONF_ID, CONF_UNIT_OF_MEASUREMENT, CONF_STEP, CONF_INITIAL_VALUE, CONF_RESTORE_VALUE + +from .. import const, schema, validate, input, generate + +DEPENDENCIES = [const.OPENTHERM] +COMPONENT_TYPE = const.NUMBER + +OpenthermNumber = generate.opentherm_ns.class_("OpenthermNumber", number.Number, cg.Component, input.OpenthermInput) + + +async def new_openthermnumber(config: Dict[str, Any]) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await number.register_number(var, config, min_value=config[input.CONF_min_value], + max_value=config[input.CONF_max_value], step=config[input.CONF_step]) + input.generate_setters(var, config) + + if CONF_INITIAL_VALUE in config: + cg.add(var.set_initial_value(config[CONF_INITIAL_VALUE])) + if CONF_RESTORE_VALUE in config: + cg.add(var.set_restore_value(config[CONF_RESTORE_VALUE])) + + return var + + +def get_entity_validation_schema(entity: schema.InputSchema) -> cv.Schema: + return number.NUMBER_SCHEMA \ + .extend({ + cv.GenerateID(): cv.declare_id(OpenthermNumber), + cv.Optional(CONF_UNIT_OF_MEASUREMENT, entity["unit_of_measurement"]): cv.string_strict, + cv.Optional(CONF_STEP, entity["step"]): cv.float_, + cv.Optional(CONF_INITIAL_VALUE): cv.float_, + cv.Optional(CONF_RESTORE_VALUE): cv.boolean, + }) \ + .extend(input.input_schema(entity)) \ + .extend(cv.COMPONENT_SCHEMA) + + +CONFIG_SCHEMA = validate.create_component_schema(schema.INPUTS, get_entity_validation_schema) + + +async def to_code(config: Dict[str, Any]) -> None: + keys = await generate.component_to_code( + COMPONENT_TYPE, + schema.INPUTS, + OpenthermNumber, + generate.create_only_conf(new_openthermnumber), + config + ) + generate.define_readers(COMPONENT_TYPE, keys) diff --git a/esphome/components/opentherm/number/number.cpp b/esphome/components/opentherm/number/number.cpp new file mode 100644 index 0000000000..3b29e69ee8 --- /dev/null +++ b/esphome/components/opentherm/number/number.cpp @@ -0,0 +1,39 @@ +#include "number.h" + +namespace esphome { +namespace opentherm { + +void OpenthermNumber::control(float value) { + this->publish_state(value); + + if (this->restore_value_) + this->pref_.save(&value); +} + +void OpenthermNumber::setup() { + float value; + if (!this->restore_value_) { + value = this->initial_value_; + } else { + this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + if (!this->pref_.load(&value)) { + if (!std::isnan(this->initial_value_)) { + value = this->initial_value_; + } else { + value = this->traits.get_min_value(); + } + } + } + this->publish_state(value); +} + +void OpenthermNumber::dump_config() { + const char* TAG = "opentherm.number"; + LOG_NUMBER("", "OpenTherm Number", this); + ESP_LOGCONFIG(TAG, " Restore value: %d", this->restore_value_); + ESP_LOGCONFIG(TAG, " Initial value: %.2f", this->initial_value_); + ESP_LOGCONFIG(TAG, " Current value: %.2f", this->state); +} + +} +} \ No newline at end of file diff --git a/esphome/components/opentherm/number/number.h b/esphome/components/opentherm/number/number.h new file mode 100644 index 0000000000..edc031b847 --- /dev/null +++ b/esphome/components/opentherm/number/number.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "esphome/core/preferences.h" +#include "esphome/core/log.h" +#include "esphome/components/opentherm/input.h" + +namespace esphome { +namespace opentherm { + +// Just a simple number, which stores the number +class OpenthermNumber : public number::Number, public Component, public OpenthermInput { +protected: + void control(float value) override; + void setup() override; + void dump_config() override; + + float initial_value_{NAN}; + bool restore_value_{false}; + + ESPPreferenceObject pref_; +public: + void set_min_value(float min_value) override { this->traits.set_min_value(min_value); } + void set_max_value(float max_value) override { this->traits.set_max_value(max_value); } + void set_initial_value(float initial_value) { initial_value_ = initial_value; } + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } +}; + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp new file mode 100644 index 0000000000..3a6376a13a --- /dev/null +++ b/esphome/components/opentherm/opentherm.cpp @@ -0,0 +1,510 @@ +/* + * OpenTherm protocol implementation. Originally taken from https://github.com/jpraus/arduino-opentherm, but + * heavily modified to comply with ESPHome coding standards and provide better logging. + * Original code is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International + * Public License, which is compatible with GPLv3 license, which covers C++ part of ESPHome project. + */ + +#include "opentherm.h" +#include "esphome/core/helpers.h" +#ifdef ESP32 +#include "driver/timer.h" +#endif +#ifdef ESP8266 +#include "Arduino.h" +#endif +#include +#include +#include + +namespace esphome { +namespace opentherm { + +using std::string; +using std::bitset; +using std::stringstream; +using std::to_string; + +OpenTherm::OpenTherm(InternalGPIOPin *in_pin, InternalGPIOPin *out_pin, int32_t slave_timeout) + : in_pin_(in_pin), + out_pin_(out_pin), + mode_(OperationMode::IDLE), + error_type_(ProtocolErrorType::NO_ERROR), + capture_(0), + clock_(0), + data_(0), + bit_pos_(0), + active_(false), + timeout_counter_(-1), + timer_initialized_(false), + slave_timeout_(slave_timeout) { + isr_in_pin_ = in_pin->to_isr(); + isr_out_pin_ = out_pin->to_isr(); +} + +void OpenTherm::begin() { +#ifdef ESP8266 + instance_ = this; +#endif + in_pin_->pin_mode(gpio::FLAG_INPUT); + out_pin_->pin_mode(gpio::FLAG_OUTPUT); + out_pin_->digital_write(true); +} + +void OpenTherm::listen() { + stop_(); + this->timeout_counter_ = slave_timeout_ * 5; // timer_ ticks at 5 ticks/ms + + mode_ = OperationMode::LISTEN; + active_ = true; + data_ = 0; + bit_pos_ = 0; + + start_read_timer_(); +} + +void OpenTherm::send(OpenthermData &data) { + stop_(); + data_ = data.type; + data_ = (data_ << 12) | data.id; + data_ = (data_ << 8) | data.valueHB; + data_ = (data_ << 8) | data.valueLB; + if (!check_parity_(data_)) { + data_ = data_ | 0x80000000; + } + + clock_ = 1; // clock starts at HIGH + bit_pos_ = 33; // count down (33 == start bit, 32-1 data, 0 == stop bit) + mode_ = OperationMode::WRITE; + + active_ = true; + start_write_timer_(); +} + +bool OpenTherm::get_message(OpenthermData &data) { + if (mode_ == OperationMode::RECEIVED) { + data.type = (data_ >> 28) & 0x7; + data.id = (data_ >> 16) & 0xFF; + data.valueHB = (data_ >> 8) & 0xFF; + data.valueLB = data_ & 0xFF; + return true; + } + return false; +} + +bool OpenTherm::get_protocol_error(OpenThermError &error) { + if (mode_ != OperationMode::ERROR_PROTOCOL) { + return false; + } + + error.error_type = error_type_; + error.bit_pos = bit_pos_; + error.capture = capture_; + error.clock = clock_; + error.data = data_; + + return true; +} + +void OpenTherm::stop() { + stop_(); + mode_ = OperationMode::IDLE; +} + +void IRAM_ATTR OpenTherm::stop_() { + if (active_) { + stop_timer_(); + active_ = false; + } +} + +void IRAM_ATTR OpenTherm::read_() { + data_ = 0; + bit_pos_ = 0; + mode_ = OperationMode::READ; + capture_ = 1; // reset counter and add as if read start bit + clock_ = 1; // clock is high at the start of comm + start_read_timer_(); // get us into 1/4 of manchester code. 5 timer ticks constitute 1 ms, which is 1 bit period in + // OpenTherm. +} + +bool IRAM_ATTR OpenTherm::timer_isr(OpenTherm *arg) { + if (arg->mode_ == OperationMode::LISTEN) { + if (arg->timeout_counter_ == 0) { + arg->mode_ = OperationMode::ERROR_TIMEOUT; + arg->stop_(); + return false; + } + bool const value = arg->isr_in_pin_.digital_read(); + if (value) { // incoming data (rising signal) + arg->read_(); + } + if (arg->timeout_counter_ > 0) { + arg->timeout_counter_--; + } + } else if (arg->mode_ == OperationMode::READ) { + bool const value = arg->isr_in_pin_.digital_read(); + uint8_t const last = (arg->capture_ & 1); + if (value != last) { + // transition of signal from last sampling + if (arg->clock_ == 1 && arg->capture_ > 0xF) { + // no transition in the middle of the bit + arg->mode_ = OperationMode::ERROR_PROTOCOL; + arg->error_type_ = ProtocolErrorType::NO_TRANSITION; + arg->stop_(); + return false; + } else if (arg->clock_ == 1 || arg->capture_ > 0xF) { + // transition in the middle of the bit OR no transition between two bit, both are valid data points + if (arg->bit_pos_ == BitPositions::STOP_BIT) { + // expecting stop bit + auto stop_bit_error = arg->verify_stop_bit_(last); + if (stop_bit_error == ProtocolErrorType::NO_ERROR) { + arg->mode_ = OperationMode::RECEIVED; + arg->stop_(); + return false; + } else { + // end of data not verified, invalid data + arg->mode_ = OperationMode::ERROR_PROTOCOL; + arg->error_type_ = stop_bit_error; + arg->stop_(); + return false; + } + } else { + // normal data point at clock high + arg->bit_read_(last); + arg->clock_ = 0; + } + } else { + // clock low, not a data point, switch clock + arg->clock_ = 1; + } + arg->capture_ = 1; // reset counter + } else if (arg->capture_ > 0xFF) { + // no change for too long, invalid mancheter encoding + arg->mode_ = OperationMode::ERROR_PROTOCOL; + arg->error_type_ = ProtocolErrorType::NO_CHANGE_TOO_LONG; + arg->stop_(); + return false; + } + arg->capture_ = (arg->capture_ << 1) | value; + } else if (arg->mode_ == OperationMode::WRITE) { + // write data to pin + if (arg->bit_pos_ == 33 || arg->bit_pos_ == 0) { // start bit + arg->write_bit_(1, arg->clock_); + } else { // data bits + arg->write_bit_(bitRead(arg->data_, arg->bit_pos_ - 1), arg->clock_); + } + if (arg->clock_ == 0) { + if (arg->bit_pos_ <= 0) { // check termination + arg->mode_ = OperationMode::SENT; // all data written + arg->stop_(); + } + arg->bit_pos_--; + arg->clock_ = 1; + } else { + arg->clock_ = 0; + } + } + + return false; +} + +#ifdef ESP8266 +void IRAM_ATTR OpenTherm::esp8266_timer_isr() { + timer_isr(instance_); +} +#endif + +void IRAM_ATTR OpenTherm::bit_read_(uint8_t value) { + data_ = (data_ << 1) | value; + bit_pos_++; +} + +ProtocolErrorType OpenTherm::verify_stop_bit_(uint8_t value) { + if (value) { // stop bit detected + return check_parity_(data_) ? ProtocolErrorType::NO_ERROR : ProtocolErrorType::PARITY_ERROR; + } else { // no stop bit detected, error + return ProtocolErrorType::INVALID_STOP_BIT; + } +} + +void IRAM_ATTR OpenTherm::write_bit_(uint8_t high, uint8_t clock) { + if (clock == 1) { // left part of manchester encoding + isr_out_pin_.digital_write(!high); // low means logical 1 to protocol + } else { // right part of manchester encoding + isr_out_pin_.digital_write(high); // high means logical 0 to protocol + } +} + +#ifdef ESP32 + +void IRAM_ATTR OpenTherm::init_timer_() { + if (timer_initialized_) + return; + + timer_config_t const config = { + .alarm_en = TIMER_ALARM_DIS, + .counter_en = TIMER_PAUSE, + .intr_type = TIMER_INTR_LEVEL, + .counter_dir = TIMER_COUNT_UP, + .auto_reload = TIMER_AUTORELOAD_DIS, + .divider = 80, + }; + + timer_init(TIMER_GROUP_0, TIMER_0, &config); + timer_set_counter_value(TIMER_GROUP_0, TIMER_0, 0); + timer_start(TIMER_GROUP_0, TIMER_0); + + timer_initialized_ = true; +} + +void IRAM_ATTR OpenTherm::start_timer_(uint64_t alarm_value) { + timer_isr_callback_add(TIMER_GROUP_0, TIMER_0, reinterpret_cast(timer_isr), this, 0); + timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, alarm_value); + timer_set_auto_reload(TIMER_GROUP_0, TIMER_0, TIMER_AUTORELOAD_EN); + timer_set_alarm(TIMER_GROUP_0, TIMER_0, TIMER_ALARM_EN); +} + +// 5 kHz timer_ +void IRAM_ATTR OpenTherm::start_read_timer_() { + { + InterruptLock const lock; + init_timer_(); + start_timer_(200); + } +} + +// 2 kHz timer_ +void IRAM_ATTR OpenTherm::start_write_timer_() { + { + InterruptLock const lock; + init_timer_(); + start_timer_(500); + } +} + +void IRAM_ATTR OpenTherm::stop_timer_() { + { + InterruptLock const lock; + init_timer_(); + timer_set_alarm(TIMER_GROUP_0, TIMER_0, TIMER_ALARM_DIS); + timer_isr_callback_remove(TIMER_GROUP_0, TIMER_0); + } +} + +#endif // END ESP32 + +#ifdef ESP8266 +// 5 kHz timer_ +void OpenTherm::start_read_timer_() { + InterruptLock const lock; + timer1_attachInterrupt(esp8266_timer_isr); + timer1_enable(TIM_DIV16, TIM_EDGE, TIM_LOOP); // 5MHz (5 ticks/us - 1677721.4 us max) + timer1_write(1000); // 5kHz +} + +// 2 kHz timer_ +void OpenTherm::start_write_timer_() { + InterruptLock const lock; + timer1_attachInterrupt(esp8266_timer_isr); + timer1_enable(TIM_DIV16, TIM_EDGE, TIM_LOOP); // 5MHz (5 ticks/us - 1677721.4 us max) + timer1_write(2500); // 2kHz +} + +void OpenTherm::stop_timer_() { + InterruptLock const lock; + timer1_disable(); + timer1_detachInterrupt(); +} + +#endif // END ESP8266 + +// https://stackoverflow.com/questions/21617970/how-to-check-if-value-has-even-parity-of-bits-or-odd +bool OpenTherm::check_parity_(uint32_t val) { + val ^= val >> 16; + val ^= val >> 8; + val ^= val >> 4; + val ^= val >> 2; + val ^= val >> 1; + return (~val) & 1; +} + +#define TO_STRING_MEMBER(name) \ + case name: \ + return #name; + +const char *OpenTherm::operation_mode_to_str(OperationMode mode) { + switch (mode) { + TO_STRING_MEMBER(IDLE) + TO_STRING_MEMBER(LISTEN) + TO_STRING_MEMBER(READ) + TO_STRING_MEMBER(RECEIVED) + TO_STRING_MEMBER(WRITE) + TO_STRING_MEMBER(SENT) + TO_STRING_MEMBER(ERROR_PROTOCOL) + TO_STRING_MEMBER(ERROR_TIMEOUT) + default: + return ""; + } +} +const char *OpenTherm::protocol_error_to_to_str(ProtocolErrorType error_type) { + switch (error_type) { + TO_STRING_MEMBER(NO_ERROR) + TO_STRING_MEMBER(NO_TRANSITION) + TO_STRING_MEMBER(INVALID_STOP_BIT) + TO_STRING_MEMBER(PARITY_ERROR) + TO_STRING_MEMBER(NO_CHANGE_TOO_LONG) + default: + return ""; + } +} +const char *OpenTherm::message_type_to_str(MessageType message_type) { + switch (message_type) { + TO_STRING_MEMBER(READ_DATA) + TO_STRING_MEMBER(READ_ACK) + TO_STRING_MEMBER(WRITE_DATA) + TO_STRING_MEMBER(WRITE_ACK) + TO_STRING_MEMBER(INVALID_DATA) + TO_STRING_MEMBER(DATA_INVALID) + TO_STRING_MEMBER(UNKNOWN_DATAID) + default: + return ""; + } +} + +const char *OpenTherm::message_id_to_str(MessageId id) { + switch (id) { + TO_STRING_MEMBER(STATUS) + TO_STRING_MEMBER(CH_SETPOINT) + TO_STRING_MEMBER(MASTER_CONFIG) + TO_STRING_MEMBER(SLAVE_CONFIG) + TO_STRING_MEMBER(COMMAND_CODE) + TO_STRING_MEMBER(FAULT_FLAGS) + TO_STRING_MEMBER(REMOTE) + TO_STRING_MEMBER(COOLING_CONTROL) + TO_STRING_MEMBER(CH2_SETPOINT) + TO_STRING_MEMBER(CH_SETPOINT_OVERRIDE) + TO_STRING_MEMBER(TSP_COUNT) + TO_STRING_MEMBER(TSP_COMMAND) + TO_STRING_MEMBER(FHB_SIZE) + TO_STRING_MEMBER(FHB_COMMAND) + TO_STRING_MEMBER(MAX_MODULATION_LEVEL) + TO_STRING_MEMBER(MAX_BOILER_CAPACITY) + TO_STRING_MEMBER(ROOM_SETPOINT) + TO_STRING_MEMBER(MODULATION_LEVEL) + TO_STRING_MEMBER(CH_WATER_PRESSURE) + TO_STRING_MEMBER(DHW_FLOW_RATE) + TO_STRING_MEMBER(DAY_TIME) + TO_STRING_MEMBER(DATE) + TO_STRING_MEMBER(YEAR) + TO_STRING_MEMBER(ROOM_SETPOINT_CH2) + TO_STRING_MEMBER(ROOM_TEMP) + TO_STRING_MEMBER(FEED_TEMP) + TO_STRING_MEMBER(DHW_TEMP) + TO_STRING_MEMBER(OUTSIDE_TEMP) + TO_STRING_MEMBER(RETURN_WATER_TEMP) + TO_STRING_MEMBER(SOLAR_STORE_TEMP) + TO_STRING_MEMBER(SOLAR_COLLECT_TEMP) + TO_STRING_MEMBER(FEED_TEMP_CH2) + TO_STRING_MEMBER(DHW2_TEMP) + TO_STRING_MEMBER(EXHAUST_TEMP) + TO_STRING_MEMBER(DHW_BOUNDS) + TO_STRING_MEMBER(CH_BOUNDS) + TO_STRING_MEMBER(OTC_CURVE_BOUNDS) + TO_STRING_MEMBER(DHW_SETPOINT) + TO_STRING_MEMBER(MAX_CH_SETPOINT) + TO_STRING_MEMBER(OTC_CURVE_RATIO) + TO_STRING_MEMBER(HVAC_STATUS) + TO_STRING_MEMBER(REL_VENT_SETPOINT) + TO_STRING_MEMBER(SLAVE_VENT) + TO_STRING_MEMBER(REL_VENTILATION) + TO_STRING_MEMBER(REL_HUMID_EXHAUST) + TO_STRING_MEMBER(SUPPLY_INLET_TEMP) + TO_STRING_MEMBER(SUPPLY_OUTLET_TEMP) + TO_STRING_MEMBER(EXHAUST_INLET_TEMP) + TO_STRING_MEMBER(EXHAUST_OUTLET_TEMP) + TO_STRING_MEMBER(NOM_REL_VENTILATION) + TO_STRING_MEMBER(OVERRIDE_FUNC) + TO_STRING_MEMBER(OEM_DIAGNOSTIC) + TO_STRING_MEMBER(BURNER_STARTS) + TO_STRING_MEMBER(CH_PUMP_STARTS) + TO_STRING_MEMBER(DHW_PUMP_STARTS) + TO_STRING_MEMBER(DHW_BURNER_STARTS) + TO_STRING_MEMBER(BURNER_HOURS) + TO_STRING_MEMBER(CH_PUMP_HOURS) + TO_STRING_MEMBER(DHW_PUMP_HOURS) + TO_STRING_MEMBER(DHW_BURNER_HOURS) + TO_STRING_MEMBER(OT_VERSION_MASTER) + TO_STRING_MEMBER(OT_VERSION_SLAVE) + TO_STRING_MEMBER(VERSION_MASTER) + TO_STRING_MEMBER(VERSION_SLAVE) + default: + return ""; + } +} + +string OpenTherm::debug_data(OpenthermData &data) { + stringstream result; + result << bitset<8>(data.type) << " " << bitset<8>(data.id) << " " << bitset<8>(data.valueHB) << " " + << bitset<8>(data.valueLB) << "\n"; + result << "type: " << message_type_to_str((MessageType) data.type) << "; "; + result << "id: " << to_string(data.id) << "; "; + result << "HB: " << to_string(data.valueHB) << "; "; + result << "LB: " << to_string(data.valueLB) << "; "; + result << "uint_16: " << to_string(data.u16()) << "; "; + result << "float: " << to_string(data.f88()); + + return result.str(); +} +std::string OpenTherm::debug_error(OpenThermError &error) { + stringstream result; + result << "type: " << protocol_error_to_to_str(error.error_type) << "; "; + result << "data: "; + int_to_hex(result, error.data); + result << "; clock: " << to_string(clock_); + result << "; capture: " << bitset<32>(error.capture); + result << "; bit_pos: " << to_string(error.bit_pos); + + return result.str(); +} + + +float OpenthermData::f88() { + float const value = (int8_t) valueHB; + return value + (float) valueLB / 256.0; +} + +void OpenthermData::f88(float value) { + if (value >= 0) { + valueHB = (uint8_t) value; + float const fraction = (value - valueHB); + valueLB = fraction * 256.0; + } else { + valueHB = (uint8_t) (value - 1); + float const fraction = (value - valueHB - 1); + valueLB = fraction * 256.0; + } +} + +uint16_t OpenthermData::u16() { + uint16_t const value = valueHB; + return (value << 8) | valueLB; +} + +void OpenthermData::u16(uint16_t value) { + valueLB = value & 0xFF; + valueHB = (value >> 8) & 0xFF; +} + +int16_t OpenthermData::s16() { + int16_t const value = valueHB; + return (value << 8) | valueLB; +} + +void OpenthermData::s16(int16_t value) { + valueLB = value & 0xFF; + valueHB = (value >> 8) & 0xFF; +} + +} // namespace opentherm +} // namespace esphome \ No newline at end of file diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h new file mode 100644 index 0000000000..6ca93ab287 --- /dev/null +++ b/esphome/components/opentherm/opentherm.h @@ -0,0 +1,341 @@ +/* + * OpenTherm protocol implementation. Originally taken from https://github.com/jpraus/arduino-opentherm, but + * heavily modified to comply with ESPHome coding standards and provide better logging. + * Original code is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International + * Public License, which is compatible with GPLv3 license, which covers C++ part of ESPHome project. + */ + +#pragma once + +#include +#include +#include +#include "esphome/core/hal.h" + +#ifdef ESP8266 +#ifndef IRAM_ATTR +#define IRAM_ATTR ICACHE_RAM_ATTR +#endif +#endif + +// The only thing we want from Arduino :) +#define bitRead(value, bit) (((value) >> (bit)) & 0x01) +#define bitSet(value, bit) ((value) |= (1UL << (bit))) +#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) +#define bitToggle(value, bit) ((value) ^= (1UL << (bit))) +#define bitWrite(value, bit, bitvalue) ((bitvalue) ? bitSet(value, bit) : bitClear(value, bit)) + +namespace esphome { +namespace opentherm { + +enum OperationMode { + IDLE = 0, // no operation + + LISTEN = 1, // waiting for transmission to start + READ = 2, // reading 32-bit data frame + RECEIVED = 3, // data frame received with valid start and stop bit + + WRITE = 4, // writing data with timer_ + SENT = 5, // all data written to output + + ERROR_PROTOCOL = 8, // manchester protocol data transfer error + ERROR_TIMEOUT = 9 // read timeout +}; + +enum ProtocolErrorType { + NO_ERROR = 0, // No error + NO_TRANSITION = 1, // No transition in the middle of the bit + INVALID_STOP_BIT = 2, // Stop bit wasn't present when expected + PARITY_ERROR = 3, // Parity check didn't pass + NO_CHANGE_TOO_LONG = 4, // No level change for too much timer ticks +}; + +enum MessageType { + READ_DATA = 0, + READ_ACK = 4, + WRITE_DATA = 1, + WRITE_ACK = 5, + INVALID_DATA = 2, + DATA_INVALID = 6, + UNKNOWN_DATAID = 7 +}; + +enum MessageId { + STATUS = 0, + CH_SETPOINT = 1, + MASTER_CONFIG = 2, + SLAVE_CONFIG = 3, + COMMAND_CODE = 4, + FAULT_FLAGS = 5, + REMOTE = 6, + COOLING_CONTROL = 7, + CH2_SETPOINT = 8, + CH_SETPOINT_OVERRIDE = 9, + TSP_COUNT = 10, + TSP_COMMAND = 11, + FHB_SIZE = 12, + FHB_COMMAND = 13, + MAX_MODULATION_LEVEL = 14, + MAX_BOILER_CAPACITY = 15, + ROOM_SETPOINT = 16, + MODULATION_LEVEL = 17, + CH_WATER_PRESSURE = 18, + DHW_FLOW_RATE = 19, + DAY_TIME = 20, + DATE = 21, + YEAR = 22, + ROOM_SETPOINT_CH2 = 23, + ROOM_TEMP = 24, + FEED_TEMP = 25, + DHW_TEMP = 26, + OUTSIDE_TEMP = 27, + RETURN_WATER_TEMP = 28, + SOLAR_STORE_TEMP = 29, + SOLAR_COLLECT_TEMP = 30, + FEED_TEMP_CH2 = 31, + DHW2_TEMP = 32, + EXHAUST_TEMP = 33, + DHW_BOUNDS = 48, + CH_BOUNDS = 49, + OTC_CURVE_BOUNDS = 50, + DHW_SETPOINT = 56, + MAX_CH_SETPOINT = 57, + OTC_CURVE_RATIO = 58, + + // HVAC Specific Message IDs + HVAC_STATUS = 70, + REL_VENT_SETPOINT = 71, + SLAVE_VENT = 74, + REL_VENTILATION = 77, + REL_HUMID_EXHAUST = 78, + SUPPLY_INLET_TEMP = 80, + SUPPLY_OUTLET_TEMP = 81, + EXHAUST_INLET_TEMP = 82, + EXHAUST_OUTLET_TEMP = 83, + NOM_REL_VENTILATION = 87, + + OVERRIDE_FUNC = 100, + OEM_DIAGNOSTIC = 115, + BURNER_STARTS = 116, + CH_PUMP_STARTS = 117, + DHW_PUMP_STARTS = 118, + DHW_BURNER_STARTS = 119, + BURNER_HOURS = 120, + CH_PUMP_HOURS = 121, + DHW_PUMP_HOURS = 122, + DHW_BURNER_HOURS = 123, + OT_VERSION_MASTER = 124, + OT_VERSION_SLAVE = 125, + VERSION_MASTER = 126, + VERSION_SLAVE = 127 +}; + +enum BitPositions { STOP_BIT = 33 }; + +/** + * Structure to hold Opentherm data packet content. + * Use f88(), u16() or s16() functions to get appropriate value of data packet accoridng to id of message. + */ +struct OpenthermData { + uint8_t type; + uint8_t id; + uint8_t valueHB; + uint8_t valueLB; + + OpenthermData() : type(0), id(0), valueHB(0), valueLB(0) {} + + /** + * @return float representation of data packet value + */ + float f88(); + + /** + * @param float number to set as value of this data packet + */ + void f88(float value); + + /** + * @return unsigned 16b integer representation of data packet value + */ + uint16_t u16(); + + /** + * @param unsigned 16b integer number to set as value of this data packet + */ + void u16(uint16_t value); + + /** + * @return signed 16b integer representation of data packet value + */ + int16_t s16(); + + /** + * @param signed 16b integer number to set as value of this data packet + */ + void s16(int16_t value); +}; + +struct OpenThermError { + ProtocolErrorType error_type; + uint32_t capture; + uint8_t clock; + uint32_t data; + uint8_t bit_pos; +}; + +/** + * Opentherm static class that supports either listening or sending Opentherm data packets in the same time + */ +class OpenTherm { + public: + OpenTherm(InternalGPIOPin *in_pin, InternalGPIOPin *out_pin, int32_t slave_timeout = 800); + + /** + * Setup pins. + */ + void begin(); + + /** + * Start listening for Opentherm data packet comming from line connected to given pin. + * If data packet is received then has_message() function returns true and data packet can be retrieved by calling + * get_message() function. If timeout > 0 then this function waits for incomming data package for timeout millis and + * if no data packet is recevived, error state is indicated by is_error() function. If either data packet is received + * or timeout is reached listening is stopped. + */ + void listen(); + + /** + * Use this function to check whether listen() function already captured a valid data packet. + * + * @return true if data packet has been captured from line by listen() function. + */ + bool has_message() { return mode_ == OperationMode::RECEIVED; } + + /** + * Use this to retrive data packed captured by listen() function. Data packet is ready when has_message() function + * returns true. This function can be called multiple times until stop() is called. + * + * @param data reference to data structure to which fill the data packet data. + * @return true if packet was ready and was filled into data structure passed, false otherwise. + */ + bool get_message(OpenthermData &data); + + /** + * Immediately send out Opentherm data packet to line connected on given pin. + * Completed data transfer is indicated by is_sent() function. + * Error state is indicated by is_error() function. + * + * @param data Opentherm data packet. + */ + void send(OpenthermData &data); + + /** + * Stops listening for data packet or sending out data packet and resets internal state of this class. + * Stops all timers and unattaches all interrupts. + */ + void stop(); + + /** + * Get protocol error details in case a protocol error occured. + * @param error reference to data structure to which fill the error details + * @return true if protocol error occured during last conversation, false otherwise. + */ + bool get_protocol_error(OpenThermError &error); + + /** + * Use this function to check whether send() function already finished sending data packed to line. + * + * @return true if data packet has been sent, false otherwise. + */ + bool is_sent() { return mode_ == OperationMode::SENT; } + + /** + * Indicates whether listinig or sending is not in progress. + * That also means that no timers are running and no interrupts are attached. + * + * @return true if listening nor sending is in progress. + */ + bool is_idle() { return mode_ == OperationMode::IDLE; } + + /** + * Indicates whether last listen() or send() operation ends up with an error. Includes both timeout and + * protocol errors. + * + * @return true if last listen() or send() operation ends up with an error. + */ + bool is_error() { return mode_ == OperationMode::ERROR_TIMEOUT || mode_ == OperationMode::ERROR_PROTOCOL; } + + /** + * Indicates whether last listen() or send() operation ends up with a *timeout* error + * @return true if last listen() or send() operation ends up with a *timeout* error. + */ + bool is_timeout() { return mode_ == OperationMode::ERROR_TIMEOUT; } + + /** + * Indicates whether last listen() or send() operation ends up with a *protocol* error + * @return true if last listen() or send() operation ends up with a *protocol* error. + */ + bool is_protocol_error() { return mode_ == OperationMode::ERROR_PROTOCOL; } + + bool is_active() { return active_; } + + OperationMode get_mode() { return mode_; } + + std::string debug_data(OpenthermData &data); + std::string debug_error(OpenThermError &error); + + const char *protocol_error_to_to_str(ProtocolErrorType error_type); + const char *message_type_to_str(MessageType message_type); + const char *operation_mode_to_str(OperationMode mode); + const char *message_id_to_str(MessageId id); + + static bool timer_isr(OpenTherm *arg); + +#ifdef ESP8266 + static void esp8266_timer_isr(); +#endif + + private: + InternalGPIOPin *in_pin_; + InternalGPIOPin *out_pin_; + ISRInternalGPIOPin isr_in_pin_; + ISRInternalGPIOPin isr_out_pin_; + + volatile OperationMode mode_; + volatile ProtocolErrorType error_type_; + volatile uint32_t capture_; + volatile uint8_t clock_; + volatile uint32_t data_; + volatile uint8_t bit_pos_; + volatile bool active_; + volatile int32_t timeout_counter_; // <0 no timeout + volatile bool timer_initialized_; + + int32_t slave_timeout_; + + void read_(); // data detected start reading + void stop_(); // stop timers and interrupts + void init_timer_(); + void start_timer_(uint64_t alarm_value); + void start_read_timer_(); // reading timer_ to sample at 1/5 of manchester code bit length (at 5kHz) + void start_write_timer_(); // writing timer_ to send manchester code (at 2kHz) + void stop_timer_(); + bool check_parity_(uint32_t val); + + void bit_read_(uint8_t value); + ProtocolErrorType verify_stop_bit_(uint8_t value); + void write_bit_(uint8_t high, uint8_t clock); + +#ifdef ESP8266 + // ESP8266 timer can accept callback with no parameters, so we have this hack to save a static instance of OpenTherm + static OpenTherm* instance_; +#endif +}; + +template void int_to_hex(std::stringstream &stream, T i) { + std::ostream out(stream.rdbuf()); + out << std::showbase << std::setfill('0') << std::setw(sizeof(T) * 2) << std::hex << i; +} + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/output/__init__.py b/esphome/components/opentherm/output/__init__.py new file mode 100644 index 0000000000..fe50518b6c --- /dev/null +++ b/esphome/components/opentherm/output/__init__.py @@ -0,0 +1,43 @@ +from typing import Any, Dict + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import output +from esphome.const import CONF_ID + +from .. import const, schema, validate, input, generate + +DEPENDENCIES = [const.OPENTHERM] +COMPONENT_TYPE = const.OUTPUT + +OpenthermOutput = generate.opentherm_ns.class_("OpenthermOutput", output.FloatOutput, cg.Component, + input.OpenthermInput) + +async def new_openthermoutput(config: Dict[str, Any], key: str, _hub: cg.MockObj) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await output.register_output(var, config) + cg.add(getattr(var, "set_id")(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) + input.generate_setters(var, config) + return var + + +def get_entity_validation_schema(entity: schema.InputSchema) -> cv.Schema: + return output.FLOAT_OUTPUT_SCHEMA \ + .extend({cv.GenerateID(): cv.declare_id(OpenthermOutput)}) \ + .extend(input.input_schema(entity)) \ + .extend(cv.COMPONENT_SCHEMA) + + +CONFIG_SCHEMA = validate.create_component_schema(schema.INPUTS, get_entity_validation_schema) + + +async def to_code(config: Dict[str, Any]) -> None: + keys = await generate.component_to_code( + COMPONENT_TYPE, + schema.INPUTS, + OpenthermOutput, + new_openthermoutput, + config + ) + generate.define_readers(COMPONENT_TYPE, keys) diff --git a/esphome/components/opentherm/output/output.h b/esphome/components/opentherm/output/output.h new file mode 100644 index 0000000000..e8b5189178 --- /dev/null +++ b/esphome/components/opentherm/output/output.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/output/float_output.h" +#include "esphome/components/opentherm/input.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace opentherm { + +class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { +protected: + bool has_state_ = false; + const char* id = nullptr; + + float min_value, max_value; + +public: + float state; + + void set_id(const char* id) { this->id = id; } + + void write_state(float state) override { + ESP_LOGD("opentherm.output", "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value, max_value); + this->state = state < 0.003 && this->zero_means_zero_ ? 0.0 : min_value + state * (max_value - min_value); + this->has_state_ = true; + ESP_LOGD("opentherm.output", "Output %s set to %.2f", this->id, this->state); + }; + + bool has_state() { return this->has_state_; }; + + void set_min_value(float min_value) override { this->min_value = min_value; } + void set_max_value(float max_value) override { this->max_value = max_value; } +}; + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py new file mode 100644 index 0000000000..60edf547a8 --- /dev/null +++ b/esphome/components/opentherm/schema.py @@ -0,0 +1,701 @@ +# This file contains a schema for all supported sensors, binary sensors and +# inputs of the OpenTherm component. + +from typing import Dict, Generic, Tuple, TypeVar, TypedDict, Optional + +from esphome.const import ( + UNIT_CELSIUS, + UNIT_PERCENT, + DEVICE_CLASS_COLD, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) + +T = TypeVar("T") + + +class Schema(Generic[T], Dict[str, T]): + pass + + +class EntitySchema(TypedDict): + description: str + """Description of the item, based on the OpenTherm spec""" + + message: str + """OpenTherm message id used to read or write the value""" + + keep_updated: bool + """Whether the value should be read or write repeatedly (True) or only during + the initialization phase (False) + """ + + message_data: str + """Instructions on how to interpret the data in the message + - flag8_[hb|lb]_[0-7]: data is a byte of single bit flags, + this flag is set in the high (hb) or low byte (lb), + at position 0 to 7 + - u8_[hb|lb]: data is an unsigned 8-bit integer, + in the high (hb) or low byte (lb) + - s8_[hb|lb]: data is an signed 8-bit integer, + in the high (hb) or low byte (lb) + - f88: data is a signed fixed point value with + 1 sign bit, 7 integer bits, 8 fractional bits + - u16: data is an unsigned 16-bit integer + - s16: data is a signed 16-bit integer + """ + + +class SensorSchema(EntitySchema): + unit_of_measurement: Optional[str] + accuracy_decimals: int + device_class: Optional[str] + icon: Optional[str] + state_class: str + + +SENSORS: Schema[SensorSchema] = Schema( + { + "rel_mod_level": SensorSchema( + { + "description": "Relative modulation level", + "unit_of_measurement": UNIT_PERCENT, + "accuracy_decimals": 2, + "icon": "mdi:percent", + "state_class": STATE_CLASS_MEASUREMENT, + "message": "MODULATION_LEVEL", + "keep_updated": True, + "message_data": "f88", + } + ), + "ch_pressure": SensorSchema( + { + "description": "Water pressure in CH circuit", + "unit_of_measurement": "bar", + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_PRESSURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "CH_WATER_PRESSURE", + "keep_updated": True, + "message_data": "f88", + } + ), + "dhw_flow_rate": SensorSchema( + { + "description": "Water flow rate in DHW circuit", + "unit_of_measurement": "l/min", + "accuracy_decimals": 2, + "icon": "mdi:waves-arrow-right", + "state_class": STATE_CLASS_MEASUREMENT, + "message": "DHW_FLOW_RATE", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_boiler": SensorSchema( + { + "description": "Boiler water temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "FEED_TEMP", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_dhw": SensorSchema( + { + "description": "DHW temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "DHW_TEMP", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_outside": SensorSchema( + { + "description": "Outside temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "OUTSIDE_TEMP", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_ret": SensorSchema( + { + "description": "Return water temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "RETURN_WATER_TEMP", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_storage": SensorSchema( + { + "description": "Solar storage temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "SOLAR_STORE_TEMP", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_collector": SensorSchema( + { + "description": "Solar collector temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 0, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "SOLAR_COLLECT_TEMP", + "keep_updated": True, + "message_data": "s16", + } + ), + "t_flow_ch2": SensorSchema( + { + "description": "Flow water temperature CH2 circuit", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "FEED_TEMP_CH2", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_dhw2": SensorSchema( + { + "description": "Domestic hot water temperature 2", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "DHW2_TEMP", + "keep_updated": True, + "message_data": "f88", + } + ), + "t_exhaust": SensorSchema( + { + "description": "Boiler exhaust temperature", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 0, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "EXHAUST_TEMP", + "keep_updated": True, + "message_data": "s16", + } + ), + "burner_starts": SensorSchema( + { + "description": "Number of starts burner", + "accuracy_decimals": 0, + "icon": "mdi:gas-burner", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "BURNER_STARTS", + "keep_updated": True, + "message_data": "u16", + } + ), + "ch_pump_starts": SensorSchema( + { + "description": "Number of starts CH pump", + "accuracy_decimals": 0, + "icon": "mdi:pump", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "CH_PUMP_STARTS", + "keep_updated": True, + "message_data": "u16", + } + ), + "dhw_pump_valve_starts": SensorSchema( + { + "description": "Number of starts DHW pump/valve", + "accuracy_decimals": 0, + "icon": "mdi:water-pump", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "DHW_PUMP_STARTS", + "keep_updated": True, + "message_data": "u16", + } + ), + "dhw_burner_starts": SensorSchema( + { + "description": "Number of starts burner during DHW mode", + "accuracy_decimals": 0, + "icon": "mdi:gas-burner", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "DHW_BURNER_STARTS", + "keep_updated": True, + "message_data": "u16", + } + ), + "burner_operation_hours": SensorSchema( + { + "description": "Number of hours that burner is in operation", + "accuracy_decimals": 0, + "icon": "mdi:clock-outline", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "BURNER_HOURS", + "keep_updated": True, + "message_data": "u16", + } + ), + "ch_pump_operation_hours": SensorSchema( + { + "description": "Number of hours that CH pump has been running", + "accuracy_decimals": 0, + "icon": "mdi:clock-outline", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "CH_PUMP_HOURS", + "keep_updated": True, + "message_data": "u16", + } + ), + "dhw_pump_valve_operation_hours": SensorSchema( + { + "description": "Number of hours that DHW pump has been running or DHW valve has been opened", + "accuracy_decimals": 0, + "icon": "mdi:clock-outline", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "DHW_PUMP_HOURS", + "keep_updated": True, + "message_data": "u16", + } + ), + "dhw_burner_operation_hours": SensorSchema( + { + "description": "Number of hours that burner is in operation during DHW mode", + "accuracy_decimals": 0, + "icon": "mdi:clock-outline", + "state_class": STATE_CLASS_TOTAL_INCREASING, + "message": "DHW_BURNER_HOURS", + "keep_updated": True, + "message_data": "u16", + } + ), + "t_dhw_set_ub": SensorSchema( + { + "description": "Upper bound for adjustment of DHW setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 0, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "DHW_BOUNDS", + "keep_updated": False, + "message_data": "s8_hb", + } + ), + "t_dhw_set_lb": SensorSchema( + { + "description": "Lower bound for adjustment of DHW setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 0, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "DHW_BOUNDS", + "keep_updated": False, + "message_data": "s8_lb", + } + ), + "max_t_set_ub": SensorSchema( + { + "description": "Upper bound for adjustment of max CH setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 0, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "CH_BOUNDS", + "keep_updated": False, + "message_data": "s8_hb", + } + ), + "max_t_set_lb": SensorSchema( + { + "description": "Lower bound for adjustment of max CH setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 0, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "CH_BOUNDS", + "keep_updated": False, + "message_data": "s8_lb", + } + ), + "t_dhw_set": SensorSchema( + { + "description": "Domestic hot water temperature setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "DHW_SETPOINT", + "keep_updated": True, + "message_data": "f88", + } + ), + "max_t_set": SensorSchema( + { + "description": "Maximum allowable CH water setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "accuracy_decimals": 2, + "device_class": DEVICE_CLASS_TEMPERATURE, + "state_class": STATE_CLASS_MEASUREMENT, + "message": "MAX_CH_SETPOINT", + "keep_updated": True, + "message_data": "f88", + } + ), + } +) + + +class BinarySensorSchema(EntitySchema): + device_class: Optional[str] + icon: Optional[str] + + +BINARY_SENSORS: Schema = Schema( + { + "fault_indication": BinarySensorSchema( + { + "description": "Status: Fault indication", + "device_class": DEVICE_CLASS_PROBLEM, + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_0", + } + ), + "ch_active": BinarySensorSchema( + { + "description": "Status: Central Heating active", + "device_class": DEVICE_CLASS_HEAT, + "icon": "mdi:radiator", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_1", + } + ), + "dhw_active": BinarySensorSchema( + { + "description": "Status: Domestic Hot Water active", + "device_class": DEVICE_CLASS_HEAT, + "icon": "mdi:faucet", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_2", + } + ), + "flame_on": BinarySensorSchema( + { + "description": "Status: Flame on", + "device_class": DEVICE_CLASS_HEAT, + "icon": "mdi:fire", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_3", + } + ), + "cooling_active": BinarySensorSchema( + { + "description": "Status: Cooling active", + "device_class": DEVICE_CLASS_COLD, + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_4", + } + ), + "ch2_active": BinarySensorSchema( + { + "description": "Status: Central Heating 2 active", + "device_class": DEVICE_CLASS_HEAT, + "icon": "mdi:radiator", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_5", + } + ), + "diagnostic_indication": BinarySensorSchema( + { + "description": "Status: Diagnostic event", + "device_class": DEVICE_CLASS_PROBLEM, + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_lb_6", + } + ), + "dhw_present": BinarySensorSchema( + { + "description": "Configuration: DHW present", + "message": "SLAVE_CONFIG", + "keep_updated": False, + "message_data": "flag8_hb_0", + } + ), + "control_type_on_off": BinarySensorSchema( + { + "description": "Configuration: Control type is on/off", + "message": "SLAVE_CONFIG", + "keep_updated": False, + "message_data": "flag8_hb_1", + } + ), + "cooling_supported": BinarySensorSchema( + { + "description": "Configuration: Cooling supported", + "message": "SLAVE_CONFIG", + "keep_updated": False, + "message_data": "flag8_hb_2", + } + ), + "dhw_storage_tank": BinarySensorSchema( + { + "description": "Configuration: DHW storage tank", + "message": "SLAVE_CONFIG", + "keep_updated": False, + "message_data": "flag8_hb_3", + } + ), + "master_pump_control_allowed": BinarySensorSchema( + { + "description": "Configuration: Master pump control allowed", + "message": "SLAVE_CONFIG", + "keep_updated": False, + "message_data": "flag8_hb_4", + } + ), + "ch2_present": BinarySensorSchema( + { + "description": "Configuration: CH2 present", + "message": "SLAVE_CONFIG", + "keep_updated": False, + "message_data": "flag8_hb_5", + } + ), + "dhw_setpoint_transfer_enabled": BinarySensorSchema( + { + "description": "Remote boiler parameters: DHW setpoint transfer enabled", + "message": "REMOTE", + "keep_updated": False, + "message_data": "flag8_hb_0", + } + ), + "max_ch_setpoint_transfer_enabled": BinarySensorSchema( + { + "description": "Remote boiler parameters: CH maximum setpoint transfer enabled", + "message": "REMOTE", + "keep_updated": False, + "message_data": "flag8_hb_1", + } + ), + "dhw_setpoint_rw": BinarySensorSchema( + { + "description": "Remote boiler parameters: DHW setpoint read/write", + "message": "REMOTE", + "keep_updated": False, + "message_data": "flag8_lb_0", + } + ), + "max_ch_setpoint_rw": BinarySensorSchema( + { + "description": "Remote boiler parameters: CH maximum setpoint read/write", + "message": "REMOTE", + "keep_updated": False, + "message_data": "flag8_lb_1", + } + ), + } +) + + +class SwitchSchema(EntitySchema): + pass + + +SWITCHES: Schema[SwitchSchema] = Schema( + { + "ch_enable": SwitchSchema( + { + "description": "Central Heating enabled", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_hb_0", + } + ), + "dhw_enable": SwitchSchema( + { + "description": "Domestic Hot Water enabled", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_hb_1", + } + ), + "cooling_enable": SwitchSchema( + { + "description": "Cooling enabled", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_hb_2", + } + ), + "otc_active": SwitchSchema( + { + "description": "Outside temperature compensation active", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_hb_3", + } + ), + "ch2_active": SwitchSchema( + { + "description": "Central Heating 2 active", + "message": "STATUS", + "keep_updated": True, + "message_data": "flag8_hb_4", + } + ), + } +) + + +class AutoConfigure(TypedDict): + message: str + message_data: str + + +class InputSchema(EntitySchema): + unit_of_measurement: str + step: float + range: Tuple[int, int] + auto_max_value: Optional[AutoConfigure] + auto_min_value: Optional[AutoConfigure] + + +INPUTS: Schema[InputSchema] = Schema( + { + "t_set": InputSchema( + { + "description": "Control setpoint: temperature setpoint for the boiler's supply water", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "CH_SETPOINT", + "keep_updated": True, + "message_data": "f88", + "range": (0, 100), + "auto_max_value": {"message": "MAX_CH_SETPOINT", "message_data": "f88"}, + } + ), + "t_set_ch2": InputSchema( + { + "description": "Control setpoint 2: temperature setpoint for the boiler's supply water on the second heating circuit", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "CH2_SETPOINT", + "keep_updated": True, + "message_data": "f88", + "range": (0, 100), + "auto_max_value": {"message": "MAX_CH_SETPOINT", "message_data": "f88"}, + } + ), + "cooling_control": InputSchema( + { + "description": "Cooling control signal", + "unit_of_measurement": UNIT_PERCENT, + "step": 1.0, + "message": "COOLING_CONTROL", + "keep_updated": True, + "message_data": "f88", + "range": (0, 100), + } + ), + "t_dhw_set": InputSchema( + { + "description": "Domestic hot water temperature setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "DHW_SETPOINT", + "keep_updated": True, + "message_data": "f88", + "range": (0, 127), + "auto_min_value": { + "message": "DHW_BOUNDS", + "message_data": "s8_lb", + }, + "auto_max_value": { + "message": "DHW_BOUNDS", + "message_data": "s8_hb", + }, + } + ), + "max_t_set": InputSchema( + { + "description": "Maximum allowable CH water setpoint", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "MAX_CH_SETPOINT", + "keep_updated": True, + "message_data": "f88", + "range": (0, 127), + "auto_min_value": { + "message": "CH_BOUNDS", + "message_data": "s8_lb", + }, + "auto_max_value": { + "message": "CH_BOUNDS", + "message_data": "s8_hb", + }, + } + ), + "t_room_set": InputSchema( + { + "description": "Current room temperature setpoint (informational)", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "ROOM_SETPOINT", + "keep_updated": True, + "message_data": "f88", + "range": (-40, 127), + } + ), + "t_room_set_ch2": InputSchema( + { + "description": "Current room temperature setpoint on CH2 (informational)", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "ROOM_SETPOINT_CH2", + "keep_updated": True, + "message_data": "f88", + "range": (-40, 127), + } + ), + "t_room": InputSchema( + { + "description": "Current sensed room temperature (informational)", + "unit_of_measurement": UNIT_CELSIUS, + "step": 0.1, + "message": "ROOM_TEMP", + "keep_updated": True, + "message_data": "f88", + "range": (-40, 127), + } + ), + } +) diff --git a/esphome/components/opentherm/sensor/__init__.py b/esphome/components/opentherm/sensor/__init__.py new file mode 100644 index 0000000000..da66bc44f9 --- /dev/null +++ b/esphome/components/opentherm/sensor/__init__.py @@ -0,0 +1,32 @@ +from typing import Any, Dict + +import esphome.config_validation as cv +from esphome.components import sensor + +from .. import const, schema, validate, generate + +DEPENDENCIES = [const.OPENTHERM] +COMPONENT_TYPE = const.SENSOR + + +def get_entity_validation_schema(entity: schema.SensorSchema) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=entity["unit_of_measurement"] if "unit_of_measurement" in entity else sensor._UNDEF, + accuracy_decimals=entity["accuracy_decimals"], + device_class=entity["device_class"] if "device_class" in entity else sensor._UNDEF, + icon=entity["icon"] if "icon" in entity else sensor._UNDEF, + state_class=entity["state_class"] + ) + + +CONFIG_SCHEMA = validate.create_component_schema(schema.SENSORS, get_entity_validation_schema) + + +async def to_code(config: Dict[str, Any]) -> None: + await generate.component_to_code( + COMPONENT_TYPE, + schema.SENSORS, + sensor.Sensor, + generate.create_only_conf(sensor.new_sensor), + config + ) diff --git a/esphome/components/opentherm/switch/__init__.py b/esphome/components/opentherm/switch/__init__.py new file mode 100644 index 0000000000..d1a4ba6a42 --- /dev/null +++ b/esphome/components/opentherm/switch/__init__.py @@ -0,0 +1,40 @@ +from typing import Any, Dict + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import switch +from esphome.const import CONF_ID + +from .. import const, schema, validate, generate + +DEPENDENCIES = [const.OPENTHERM] +COMPONENT_TYPE = const.SWITCH + +OpenthermSwitch = generate.opentherm_ns.class_("OpenthermSwitch", switch.Switch, cg.Component) + + +async def new_openthermswitch(config: Dict[str, Any]) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await switch.register_switch(var, config) + return var + + +def get_entity_validation_schema(entity: schema.SwitchSchema) -> cv.Schema: + return switch.SWITCH_SCHEMA.extend({ + cv.GenerateID(): cv.declare_id(OpenthermSwitch) + }).extend(cv.COMPONENT_SCHEMA) + + +CONFIG_SCHEMA = validate.create_component_schema(schema.SWITCHES, get_entity_validation_schema) + + +async def to_code(config: Dict[str, Any]) -> None: + keys = await generate.component_to_code( + COMPONENT_TYPE, + schema.SWITCHES, + OpenthermSwitch, + generate.create_only_conf(new_openthermswitch), + config + ) + generate.define_readers(COMPONENT_TYPE, keys) diff --git a/esphome/components/opentherm/switch/switch.cpp b/esphome/components/opentherm/switch/switch.cpp new file mode 100644 index 0000000000..94c55fbc62 --- /dev/null +++ b/esphome/components/opentherm/switch/switch.cpp @@ -0,0 +1,31 @@ +#include "switch.h" + +namespace esphome { +namespace opentherm { + +void OpenthermSwitch::write_state(bool state) { + this->publish_state(state); +} + +void OpenthermSwitch::setup() { + auto restored = this->get_initial_state_with_restore_mode(); + bool state = false; + if (!restored.has_value()) + { + ESP_LOGD("opentherm.switch", "Couldn't restore state for OpenTherm switch '%s'", this->get_name().c_str()); + } + else + { + ESP_LOGD("opentherm.switch", "Restored state for OpenTherm switch '%s': %d", this->get_name().c_str(), restored.value()); + state = restored.value(); + } + this->write_state(state); +} + +void OpenthermSwitch::dump_config() { + esphome::switch_::log_switch("opentherm.switch", "", "OpenTherm Switch", this); + ESP_LOGCONFIG("opentherm.switch", " Current state: %d", this->state); +} + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/switch/switch.h b/esphome/components/opentherm/switch/switch.h new file mode 100644 index 0000000000..ae71a4ed2f --- /dev/null +++ b/esphome/components/opentherm/switch/switch.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace opentherm { + + +class OpenthermSwitch : public switch_::Switch, public Component { +protected: + void write_state(bool state) override; + +public: + void setup() override; + void dump_config() override; +}; + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/validate.py b/esphome/components/opentherm/validate.py new file mode 100644 index 0000000000..c5d86852df --- /dev/null +++ b/esphome/components/opentherm/validate.py @@ -0,0 +1,16 @@ +from typing import Callable + +import esphome.config_validation as cv + +from . import const, schema, generate + +def create_entities_schema(entities: schema.Schema[schema.T], get_entity_validation_schema: Callable[[schema.T], cv.Schema]) -> cv.Schema: + schema = {} + for key, entity in entities.items(): + schema[cv.Optional(key)] = get_entity_validation_schema(entity) + return cv.Schema(schema) + +def create_component_schema(entities: schema.Schema[schema.T], get_entity_validation_schema: Callable[[schema.T], cv.Schema]) -> cv.Schema: + return cv.Schema({ cv.GenerateID(const.CONF_OPENTHERM_ID): cv.use_id(generate.OpenthermHub) }) \ + .extend(create_entities_schema(entities, get_entity_validation_schema)) \ + .extend(cv.COMPONENT_SCHEMA) diff --git a/tests/test1.1.yaml b/tests/test1.1.yaml index c71aa6e0ef..3f959dbe0c 100644 --- a/tests/test1.1.yaml +++ b/tests/test1.1.yaml @@ -77,6 +77,12 @@ output: - platform: pca9685 id: pca_2 channel: 2 + - platform: opentherm + t_set: + id: opentherm_ch_setpoint + min_value: 40 + max_value: 60 + zero_means_zero: true light: - platform: rgb @@ -230,3 +236,59 @@ button: - canbus.send: "abc" - canbus.send: [0, 1, 2] - canbus.send: !lambda return {0, 1, 2}; + +opentherm: + in_pin: 20 + out_pin: 19 + +number: + # Boiler config + - platform: opentherm + t_dhw_set: + id: opentherm_dhw_setpoint + name: "Hot Water target temperature" + min_value: 20 + max_value: 60 + restore_value: true + initial_value: 60 + +sensor: + # Boiler sensors + - platform: opentherm + rel_mod_level: + id: opentherm_rel_mod_level + name: "Boiler Relative modulation level" + t_boiler: + id: boilotron_temperature_ch + name: "Boiler Feed Temperature" + +binary_sensor: + - platform: opentherm + ch_active: + id: opentherm_ch_active + name: "Boiler Central Heating active" + dhw_active: + id: opentherm_dhw_active + name: "Boiler Hot Water active" + flame_on: + id: opentherm_boiler_flame_on + name: "Boiler Flame on" + fault_indication: + id: opentherm_boiler_fault + name: "Boiler Fault" + entity_category: diagnostic + diagnostic_indication: + id: opentherm_boiler_diagnostic + name: "Boiler Diagnostic" + entity_category: diagnostic + +switch: + - platform: opentherm + ch_enable: + id: opentherm_ch_enable + name: "Central Heating enabled" + restore_mode: RESTORE_DEFAULT_ON + dhw_enable: + id: opentherm_dhw_enable + name: "Hot Water enabled" + restore_mode: RESTORE_DEFAULT_ON