diff --git a/CODEOWNERS b/CODEOWNERS index 0b1b88fbc8..f7fbbf9374 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -289,6 +289,7 @@ esphome/components/noblex/* @AGalfra esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb esphome/components/online_image/* @guillempages +esphome/components/opentherm/* @olegtarasov esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/pca6416a/* @Mat931 diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py new file mode 100644 index 0000000000..23443a4028 --- /dev/null +++ b/esphome/components/opentherm/__init__.py @@ -0,0 +1,57 @@ +from typing import Any + +from esphome import pins +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 + +CODEOWNERS = ["@olegtarasov"] +MULTI_CONF = True + +CONF_IN_PIN = "in_pin" +CONF_OUT_PIN = "out_pin" +CONF_CH_ENABLE = "ch_enable" +CONF_DHW_ENABLE = "dhw_enable" +CONF_COOLING_ENABLE = "cooling_enable" +CONF_OTC_ACTIVE = "otc_active" +CONF_CH2_ACTIVE = "ch2_active" +CONF_SYNC_MODE = "sync_mode" + +opentherm_ns = cg.esphome_ns.namespace("opentherm") +OpenthermHub = opentherm_ns.class_("OpenthermHub", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(OpenthermHub), + cv.Required(CONF_IN_PIN): pins.internal_gpio_input_pin_schema, + cv.Required(CONF_OUT_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_CH_ENABLE, True): cv.boolean, + cv.Optional(CONF_DHW_ENABLE, True): cv.boolean, + cv.Optional(CONF_COOLING_ENABLE, False): cv.boolean, + cv.Optional(CONF_OTC_ACTIVE, False): cv.boolean, + cv.Optional(CONF_CH2_ACTIVE, False): cv.boolean, + cv.Optional(CONF_SYNC_MODE, False): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), +) + + +async def to_code(config: dict[str, Any]) -> None: + # 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[CONF_IN_PIN]) + cg.add(var.set_in_pin(in_pin)) + + out_pin = await cg.gpio_pin_expression(config[CONF_OUT_PIN]) + cg.add(var.set_out_pin(out_pin)) + + non_sensors = {CONF_ID, CONF_IN_PIN, CONF_OUT_PIN} + for key, value in config.items(): + if key not in non_sensors: + cg.add(getattr(var, f"set_{key}")(value)) diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp new file mode 100644 index 0000000000..c26fbced32 --- /dev/null +++ b/esphome/components/opentherm/hub.cpp @@ -0,0 +1,277 @@ +#include "hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome { +namespace opentherm { + +static const char *const TAG = "opentherm"; + +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) { + bool const ch_enabled = this->ch_enable; + bool dhw_enabled = this->dhw_enable; + bool cooling_enabled = this->cooling_enable; + bool otc_enabled = this->otc_active; + bool ch2_enabled = this->ch2_active; + + 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); + +// 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" + + // TODO: This is a placeholder for an auto-generated switch statement which builds request structure based on + // which sensors are enabled in config. + +#pragma GCC diagnostic pop + + return data; + } + return OpenthermData(); +} + +OpenthermHub::OpenthermHub() : Component() {} + +void OpenthermHub::process_response(OpenthermData &data) { + ESP_LOGD(TAG, "Received OpenTherm response with id %d (%s)", data.id, + this->opentherm_->message_id_to_str((MessageId) data.id)); + ESP_LOGD(TAG, "%s", this->opentherm_->debug_data(data).c_str()); +} + +void OpenthermHub::setup() { + ESP_LOGD(TAG, "Setting up OpenTherm component"); + this->opentherm_ = make_unique(this->in_pin_, this->out_pin_); + if (!this->opentherm_->initialize()) { + ESP_LOGE(TAG, "Failed to initialize OpenTherm protocol. See previous log messages for details."); + this->mark_failed(); + return; + } + + // 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->sync_mode_) { + this->sync_loop_(); + return; + } + + auto cur_time = millis(); + auto const cur_mode = this->opentherm_->get_mode(); + switch (cur_mode) { + case OperationMode::WRITE: + case OperationMode::READ: + case OperationMode::LISTEN: + if (!this->check_timings_(cur_time)) { + break; + } + this->last_mode_ = cur_mode; + break; + case OperationMode::ERROR_PROTOCOL: + if (this->last_mode_ == OperationMode::WRITE) { + this->handle_protocol_write_error_(); + } else if (this->last_mode_ == OperationMode::READ) { + this->handle_protocol_read_error_(); + } + + this->stop_opentherm_(); + break; + case OperationMode::ERROR_TIMEOUT: + this->handle_timeout_error_(); + this->stop_opentherm_(); + break; + case OperationMode::IDLE: + if (this->should_skip_loop_(cur_time)) { + break; + } + this->start_conversation_(); + break; + case OperationMode::SENT: + // Message sent, now listen for the response. + this->opentherm_->listen(); + break; + case OperationMode::RECEIVED: + this->read_response_(); + break; + } +} + +void OpenthermHub::sync_loop_() { + if (!this->opentherm_->is_idle()) { + ESP_LOGE(TAG, "OpenTherm is not idle at the start of the loop"); + return; + } + + auto cur_time = millis(); + + this->check_timings_(cur_time); + + if (this->should_skip_loop_(cur_time)) { + return; + } + + this->start_conversation_(); + + if (!this->spin_wait_(1150, [&] { return this->opentherm_->is_active(); })) { + ESP_LOGE(TAG, "Hub timeout triggered during send"); + this->stop_opentherm_(); + return; + } + + if (this->opentherm_->is_error()) { + this->handle_protocol_write_error_(); + this->stop_opentherm_(); + return; + } else if (!this->opentherm_->is_sent()) { + ESP_LOGW(TAG, "Unexpected state after sending request: %s", + this->opentherm_->operation_mode_to_str(this->opentherm_->get_mode())); + this->stop_opentherm_(); + return; + } + + // Listen for the response + this->opentherm_->listen(); + if (!this->spin_wait_(1150, [&] { return this->opentherm_->is_active(); })) { + ESP_LOGE(TAG, "Hub timeout triggered during receive"); + this->stop_opentherm_(); + return; + } + + if (this->opentherm_->is_timeout()) { + this->handle_timeout_error_(); + this->stop_opentherm_(); + return; + } else if (this->opentherm_->is_protocol_error()) { + this->handle_protocol_read_error_(); + this->stop_opentherm_(); + return; + } else if (!this->opentherm_->has_message()) { + ESP_LOGW(TAG, "Unexpected state after receiving response: %s", + this->opentherm_->operation_mode_to_str(this->opentherm_->get_mode())); + this->stop_opentherm_(); + return; + } + + this->read_response_(); +} + +bool OpenthermHub::check_timings_(uint32_t cur_time) { + if (this->last_conversation_start_ > 0 && (cur_time - this->last_conversation_start_) > 1150) { + ESP_LOGW(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.", + (int) (cur_time - this->last_conversation_start_)); + this->stop_opentherm_(); + return false; + } + + return true; +} + +bool OpenthermHub::should_skip_loop_(uint32_t cur_time) const { + if (this->last_conversation_end_ > 0 && (cur_time - this->last_conversation_end_) < 100) { + ESP_LOGV(TAG, "Less than 100 ms elapsed since last convo, skipping this iteration"); + return true; + } + + return false; +} + +void OpenthermHub::start_conversation_() { + if (this->sending_initial_ && this->current_message_iterator_ == this->initial_messages_.end()) { + this->sending_initial_ = 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 request = this->build_request_(*this->current_message_iterator_); + + ESP_LOGD(TAG, "Sending request with id %d (%s)", request.id, + this->opentherm_->message_id_to_str((MessageId) request.id)); + ESP_LOGD(TAG, "%s", this->opentherm_->debug_data(request).c_str()); + // Send the request + this->last_conversation_start_ = millis(); + this->opentherm_->send(request); +} + +void OpenthermHub::read_response_() { + OpenthermData response; + if (!this->opentherm_->get_message(response)) { + ESP_LOGW(TAG, "Couldn't get the response, but flags indicated success. This is a bug."); + this->stop_opentherm_(); + return; + } + + this->stop_opentherm_(); + + this->process_response(response); + + this->current_message_iterator_++; +} + +void OpenthermHub::stop_opentherm_() { + this->opentherm_->stop(); + this->last_conversation_end_ = millis(); +} + +void OpenthermHub::handle_protocol_write_error_() { + ESP_LOGW(TAG, "Error while sending request: %s", + this->opentherm_->operation_mode_to_str(this->opentherm_->get_mode())); + ESP_LOGW(TAG, "%s", this->opentherm_->debug_data(this->last_request_).c_str()); +} + +void OpenthermHub::handle_protocol_read_error_() { + OpenThermError error; + this->opentherm_->get_protocol_error(error); + ESP_LOGW(TAG, "Protocol error occured while receiving response: %s", this->opentherm_->debug_error(error).c_str()); +} + +void OpenthermHub::handle_timeout_error_() { + ESP_LOGW(TAG, "Receive response timed out at a protocol level"); + this->stop_opentherm_(); +} + +#define ID(x) x +#define SHOW2(x) #x +#define SHOW(x) SHOW2(x) + +void OpenthermHub::dump_config() { + ESP_LOGCONFIG(TAG, "OpenTherm:"); + LOG_PIN(" In: ", this->in_pin_); + LOG_PIN(" Out: ", this->out_pin_); + ESP_LOGCONFIG(TAG, " Sync mode: %d", this->sync_mode_); + ESP_LOGCONFIG(TAG, " Initial requests:"); + for (auto type : this->initial_messages_) { + ESP_LOGCONFIG(TAG, " - %d", type); + } + ESP_LOGCONFIG(TAG, " Repeating requests:"); + for (auto type : this->repeating_messages_) { + ESP_LOGCONFIG(TAG, " - %d", type); + } +} + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h new file mode 100644 index 0000000000..ce9f09fe33 --- /dev/null +++ b/esphome/components/opentherm/hub.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +#include "opentherm.h" + +#include +#include +#include +#include + +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 + std::unique_ptr opentherm_; + + // 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 sending_initial_ = 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; + OperationMode last_mode_ = IDLE; + OpenthermData last_request_; + + // Synchronous communication mode prevents other components from disabling interrupts while + // we are talking to the boiler. Enable if you experience random intermittent invalid response errors. + // Very likely to happen while using Dallas temperature sensors. + bool sync_mode_ = false; + + // Create OpenTherm messages based on the message id + OpenthermData build_request_(MessageId request_id); + void handle_protocol_write_error_(); + void handle_protocol_read_error_(); + void handle_timeout_error_(); + void stop_opentherm_(); + void start_conversation_(); + void read_response_(); + bool check_timings_(uint32_t cur_time); + bool should_skip_loop_(uint32_t cur_time) const; + void sync_loop_(); + + 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; } + + // 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; } + void set_sync_mode(bool sync_mode) { this->sync_mode_ = sync_mode; } + + 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/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp new file mode 100644 index 0000000000..b830cc01d3 --- /dev/null +++ b/esphome/components/opentherm/opentherm.cpp @@ -0,0 +1,568 @@ +/* + * 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" +#if defined(ESP32) || defined(USE_ESP_IDF) +#include "driver/timer.h" +#include "esp_err.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; + +static const char *const TAG = "opentherm"; + +#ifdef ESP8266 +OpenTherm *OpenTherm::instance_ = nullptr; +#endif + +OpenTherm::OpenTherm(InternalGPIOPin *in_pin, InternalGPIOPin *out_pin, int32_t device_timeout) + : in_pin_(in_pin), + out_pin_(out_pin), +#if defined(ESP32) || defined(USE_ESP_IDF) + timer_group_(TIMER_GROUP_0), + timer_idx_(TIMER_0), +#endif + mode_(OperationMode::IDLE), + error_type_(ProtocolErrorType::NO_ERROR), + capture_(0), + clock_(0), + data_(0), + bit_pos_(0), + timeout_counter_(-1), + device_timeout_(device_timeout) { + this->isr_in_pin_ = in_pin->to_isr(); + this->isr_out_pin_ = out_pin->to_isr(); +} + +bool OpenTherm::initialize() { +#ifdef ESP8266 + OpenTherm::instance_ = this; +#endif + this->in_pin_->pin_mode(gpio::FLAG_INPUT); + this->out_pin_->pin_mode(gpio::FLAG_OUTPUT); + this->out_pin_->digital_write(true); + +#if defined(ESP32) || defined(USE_ESP_IDF) + return this->init_esp32_timer_(); +#else + return true; +#endif +} + +void OpenTherm::listen() { + this->stop_timer_(); + this->timeout_counter_ = this->device_timeout_ * 5; // timer_ ticks at 5 ticks/ms + + this->mode_ = OperationMode::LISTEN; + this->data_ = 0; + this->bit_pos_ = 0; + + this->start_read_timer_(); +} + +void OpenTherm::send(OpenthermData &data) { + this->stop_timer_(); + this->data_ = data.type; + this->data_ = (this->data_ << 12) | data.id; + this->data_ = (this->data_ << 8) | data.valueHB; + this->data_ = (this->data_ << 8) | data.valueLB; + if (!check_parity_(this->data_)) { + this->data_ = this->data_ | 0x80000000; + } + + this->clock_ = 1; // clock starts at HIGH + this->bit_pos_ = 33; // count down (33 == start bit, 32-1 data, 0 == stop bit) + this->mode_ = OperationMode::WRITE; + + this->start_write_timer_(); +} + +bool OpenTherm::get_message(OpenthermData &data) { + if (this->mode_ == OperationMode::RECEIVED) { + data.type = (this->data_ >> 28) & 0x7; + data.id = (this->data_ >> 16) & 0xFF; + data.valueHB = (this->data_ >> 8) & 0xFF; + data.valueLB = this->data_ & 0xFF; + return true; + } + return false; +} + +bool OpenTherm::get_protocol_error(OpenThermError &error) { + if (this->mode_ != OperationMode::ERROR_PROTOCOL) { + return false; + } + + error.error_type = this->error_type_; + error.bit_pos = this->bit_pos_; + error.capture = this->capture_; + error.clock = this->clock_; + error.data = this->data_; + + return true; +} + +void OpenTherm::stop() { + this->stop_timer_(); + this->mode_ = OperationMode::IDLE; +} + +void IRAM_ATTR OpenTherm::read_() { + this->data_ = 0; + this->bit_pos_ = 0; + this->mode_ = OperationMode::READ; + this->capture_ = 1; // reset counter and add as if read start bit + this->clock_ = 1; // clock is high at the start of comm + this->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_timer_(); + 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_timer_(); + 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_timer_(); + return false; + } else { + // end of data not verified, invalid data + arg->mode_ = OperationMode::ERROR_PROTOCOL; + arg->error_type_ = stop_bit_error; + arg->stop_timer_(); + 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_timer_(); + 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_(read_bit(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_timer_(); + } + arg->bit_pos_--; + arg->clock_ = 1; + } else { + arg->clock_ = 0; + } + } + + return false; +} + +#ifdef ESP8266 +void IRAM_ATTR OpenTherm::esp8266_timer_isr() { OpenTherm::timer_isr(OpenTherm::instance_); } +#endif + +void IRAM_ATTR OpenTherm::bit_read_(uint8_t value) { + this->data_ = (this->data_ << 1) | value; + this->bit_pos_++; +} + +ProtocolErrorType OpenTherm::verify_stop_bit_(uint8_t value) { + if (value) { // stop bit detected + return check_parity_(this->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 + this->isr_out_pin_.digital_write(!high); // low means logical 1 to protocol + } else { // right part of manchester encoding + this->isr_out_pin_.digital_write(high); // high means logical 0 to protocol + } +} + +#if defined(ESP32) || defined(USE_ESP_IDF) + +bool OpenTherm::init_esp32_timer_() { + // Search for a free timer. Maybe unstable, we'll see. + int cur_timer = 0; + timer_group_t timer_group = TIMER_GROUP_0; + timer_idx_t timer_idx = TIMER_0; + bool timer_found = false; + + for (; cur_timer < SOC_TIMER_GROUP_TOTAL_TIMERS; cur_timer++) { + timer_config_t temp_config; + timer_group = cur_timer < 2 ? TIMER_GROUP_0 : TIMER_GROUP_1; + timer_idx = cur_timer < 2 ? (timer_idx_t) cur_timer : (timer_idx_t) (cur_timer - 2); + + auto err = timer_get_config(timer_group, timer_idx, &temp_config); + if (err == ESP_ERR_INVALID_ARG) { + // Error means timer was not initialized (or other things, but we are careful with our args) + timer_found = true; + break; + } + + ESP_LOGD(TAG, "Timer %d:%d seems to be occupied, will try another", timer_group, timer_idx); + } + + if (!timer_found) { + ESP_LOGE(TAG, "No free timer was found! OpenTherm cannot function without a timer."); + return false; + } + + ESP_LOGD(TAG, "Found free timer %d:%d", timer_group, timer_idx); + this->timer_group_ = timer_group; + this->timer_idx_ = timer_idx; + + timer_config_t const config = { + .alarm_en = TIMER_ALARM_EN, + .counter_en = TIMER_PAUSE, + .intr_type = TIMER_INTR_LEVEL, + .counter_dir = TIMER_COUNT_UP, + .auto_reload = TIMER_AUTORELOAD_EN, +#if ESP_IDF_VERSION_MAJOR >= 5 + .clk_src = TIMER_SRC_CLK_DEFAULT, +#endif + .divider = 80, + }; + + esp_err_t result; + + result = timer_init(this->timer_group_, this->timer_idx_, &config); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to init timer. Error: %s", error); + return false; + } + + result = timer_set_counter_value(this->timer_group_, this->timer_idx_, 0); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to set counter value. Error: %s", error); + return false; + } + + result = timer_isr_callback_add(this->timer_group_, this->timer_idx_, reinterpret_cast(timer_isr), + this, 0); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to register timer interrupt. Error: %s", error); + return false; + } + + return true; +} + +void IRAM_ATTR OpenTherm::start_esp32_timer_(uint64_t alarm_value) { + esp_err_t result; + + result = timer_set_alarm_value(this->timer_group_, this->timer_idx_, alarm_value); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to set alarm value. Error: %s", error); + return; + } + + result = timer_start(this->timer_group_, this->timer_idx_); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to start the timer. Error: %s", error); + return; + } +} + +// 5 kHz timer_ +void IRAM_ATTR OpenTherm::start_read_timer_() { + InterruptLock const lock; + this->start_esp32_timer_(200); +} + +// 2 kHz timer_ +void IRAM_ATTR OpenTherm::start_write_timer_() { + InterruptLock const lock; + this->start_esp32_timer_(500); +} + +void IRAM_ATTR OpenTherm::stop_timer_() { + InterruptLock const lock; + + esp_err_t result; + + result = timer_pause(this->timer_group_, this->timer_idx_); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to pause the timer. Error: %s", error); + return; + } + + result = timer_set_counter_value(this->timer_group_, this->timer_idx_, 0); + if (result != ESP_OK) { + const auto *error = esp_err_to_name(result); + ESP_LOGE(TAG, "Failed to set timer counter to 0 after pausing. Error: %s", error); + return; + } +} + +#endif // END ESP32 + +#ifdef ESP8266 +// 5 kHz timer_ +void OpenTherm::start_read_timer_() { + InterruptLock const lock; + timer1_attachInterrupt(OpenTherm::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(OpenTherm::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(CONTROLLER_CONFIG) + TO_STRING_MEMBER(DEVICE_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(FAN_SPEED) + TO_STRING_MEMBER(FLAME_CURRENT) + 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(DEVICE_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_CONTROLLER) + TO_STRING_MEMBER(OT_VERSION_DEVICE) + TO_STRING_MEMBER(VERSION_CONTROLLER) + TO_STRING_MEMBER(VERSION_DEVICE) + 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: " << this->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: " << this->protocol_error_to_to_str(error.error_type) << "; "; + result << "data: "; + result << format_hex(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() { return ((float) this->s16()) / 256.0; } + +void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } + +uint16_t OpenthermData::u16() { + uint16_t const value = this->valueHB; + return (value << 8) | this->valueLB; +} + +void OpenthermData::u16(uint16_t value) { + this->valueLB = value & 0xFF; + this->valueHB = (value >> 8) & 0xFF; +} + +int16_t OpenthermData::s16() { + int16_t const value = this->valueHB; + return (value << 8) | this->valueLB; +} + +void OpenthermData::s16(int16_t value) { + this->valueLB = value & 0xFF; + this->valueHB = (value >> 8) & 0xFF; +} + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h new file mode 100644 index 0000000000..609cfb6243 --- /dev/null +++ b/esphome/components/opentherm/opentherm.h @@ -0,0 +1,347 @@ +/* + * 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" +#include "esphome/core/log.h" + +#if defined(ESP32) || defined(USE_ESP_IDF) +#include "driver/timer.h" +#endif + +namespace esphome { +namespace opentherm { + +// TODO: Account for immutable semantics change in hub.cpp when doing later installments of OpenTherm PR +template constexpr T read_bit(T value, uint8_t bit) { return (value >> bit) & 0x01; } + +template constexpr T set_bit(T value, uint8_t bit) { return value |= (1UL << bit); } + +template constexpr T clear_bit(T value, uint8_t bit) { return value &= ~(1UL << bit); } + +template constexpr T write_bit(T value, uint8_t bit, uint8_t bit_value) { + return bit_value ? setBit(value, bit) : clearBit(value, bit); +} + +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, + CONTROLLER_CONFIG = 2, + DEVICE_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, // u8_hb - u8_lb gives min modulation level + 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, + FAN_SPEED = 35, + FLAME_CURRENT = 36, + 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, + DEVICE_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_CONTROLLER = 124, + OT_VERSION_DEVICE = 125, + VERSION_CONTROLLER = 126, + VERSION_DEVICE = 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 device_timeout = 800); + + /** + * Setup pins. + */ + bool initialize(); + + /** + * 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 mode_ == LISTEN || mode_ == READ || mode_ == WRITE; } + + 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_; + +#if defined(ESP32) || defined(USE_ESP_IDF) + timer_group_t timer_group_; + timer_idx_t timer_idx_; +#endif + + OperationMode mode_; + ProtocolErrorType error_type_; + uint32_t capture_; + uint8_t clock_; + uint32_t data_; + uint8_t bit_pos_; + int32_t timeout_counter_; // <0 no timeout + + int32_t device_timeout_; + +#if defined(ESP32) || defined(USE_ESP_IDF) + bool init_esp32_timer_(); + void start_esp32_timer_(uint64_t alarm_value); +#endif + + void stop_timer_(); + + void read_(); // data detected start reading + 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) + 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 +}; + +} // namespace opentherm +} // namespace esphome diff --git a/tests/components/opentherm/common.yaml b/tests/components/opentherm/common.yaml new file mode 100644 index 0000000000..4148b280d0 --- /dev/null +++ b/tests/components/opentherm/common.yaml @@ -0,0 +1,3 @@ +opentherm: + in_pin: 1 + out_pin: 2 diff --git a/tests/components/opentherm/test.esp32-ard.yaml b/tests/components/opentherm/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/opentherm/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/opentherm/test.esp32-c3-ard.yaml b/tests/components/opentherm/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/opentherm/test.esp32-c3-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/opentherm/test.esp32-c3-idf.yaml b/tests/components/opentherm/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/opentherm/test.esp32-c3-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/opentherm/test.esp32-idf.yaml b/tests/components/opentherm/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/opentherm/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/opentherm/test.esp8266-ard.yaml b/tests/components/opentherm/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/opentherm/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml