From 21cb941bbe7afb7096ee342fae2c88bdaa27d28b Mon Sep 17 00:00:00 2001 From: Oleg Tarasov Date: Fri, 25 Oct 2024 05:00:28 +0300 Subject: [PATCH] Add OpenTherm component (part 2.1: sensor platform) (#7529) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/opentherm/__init__.py | 20 +- esphome/components/opentherm/const.py | 5 + esphome/components/opentherm/generate.py | 140 ++++++ esphome/components/opentherm/hub.cpp | 108 ++++- esphome/components/opentherm/hub.h | 19 +- esphome/components/opentherm/opentherm.cpp | 3 + esphome/components/opentherm/opentherm.h | 3 +- .../components/opentherm/opentherm_macros.h | 91 ++++ esphome/components/opentherm/schema.py | 438 ++++++++++++++++++ .../components/opentherm/sensor/__init__.py | 35 ++ esphome/components/opentherm/validate.py | 31 ++ tests/components/opentherm/common.yaml | 77 ++- 12 files changed, 933 insertions(+), 37 deletions(-) create mode 100644 esphome/components/opentherm/const.py create mode 100644 esphome/components/opentherm/generate.py create mode 100644 esphome/components/opentherm/opentherm_macros.h create mode 100644 esphome/components/opentherm/schema.py create mode 100644 esphome/components/opentherm/sensor/__init__.py create mode 100644 esphome/components/opentherm/validate.py diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 23443a4028..ee19818a29 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -1,9 +1,10 @@ from typing import Any -from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv +from esphome import pins from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +from . import generate CODEOWNERS = ["@olegtarasov"] MULTI_CONF = True @@ -15,15 +16,14 @@ CONF_DHW_ENABLE = "dhw_enable" CONF_COOLING_ENABLE = "cooling_enable" CONF_OTC_ACTIVE = "otc_active" CONF_CH2_ACTIVE = "ch2_active" +CONF_SUMMER_MODE_ACTIVE = "summer_mode_active" +CONF_DHW_BLOCK = "dhw_block" 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.GenerateID(): cv.declare_id(generate.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, @@ -31,6 +31,8 @@ CONFIG_SCHEMA = cv.All( 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_SUMMER_MODE_ACTIVE, False): cv.boolean, + cv.Optional(CONF_DHW_BLOCK, False): cv.boolean, cv.Optional(CONF_SYNC_MODE, False): cv.boolean, } ).extend(cv.COMPONENT_SCHEMA), @@ -39,8 +41,6 @@ CONFIG_SCHEMA = cv.All( 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) @@ -53,5 +53,7 @@ async def to_code(config: dict[str, Any]) -> None: 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)) + if key in non_sensors: + continue + + cg.add(getattr(var, f"set_{key}")(value)) diff --git a/esphome/components/opentherm/const.py b/esphome/components/opentherm/const.py new file mode 100644 index 0000000000..1f997c5d9c --- /dev/null +++ b/esphome/components/opentherm/const.py @@ -0,0 +1,5 @@ +OPENTHERM = "opentherm" + +CONF_OPENTHERM_ID = "opentherm_id" + +SENSOR = "sensor" diff --git a/esphome/components/opentherm/generate.py b/esphome/components/opentherm/generate.py new file mode 100644 index 0000000000..6a97835a57 --- /dev/null +++ b/esphome/components/opentherm/generate.py @@ -0,0 +1,140 @@ +from collections.abc import Awaitable +from typing import Any, Callable + +import esphome.codegen as cg +from esphome.const import CONF_ID +from . import const +from .schema import TSchema + +opentherm_ns = cg.esphome_ns.namespace("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}") + + +def define_message_handler( + component_type: str, keys: list[str], schemas: dict[str, 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); + # } + + messages: dict[str, list[tuple[str, str]]] = {} + for key in keys: + msg = schemas[key].message + if msg not in messages: + messages[msg] = [] + messages[msg].append((key, schemas[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], schemas: dict[str, TSchema]): + messages: set[tuple[str, bool]] = set() + for key in keys: + messages.add((schemas[key].message, schemas[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, + schemas: dict[str, 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, schemas) + add_messages(hub, keys, schemas) + + return keys diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index c26fbced32..770bbd82b7 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -7,50 +7,114 @@ namespace esphome { namespace opentherm { static const char *const TAG = "opentherm"; +namespace message_data { +bool parse_flag8_lb_0(OpenthermData &data) { return read_bit(data.valueLB, 0); } +bool parse_flag8_lb_1(OpenthermData &data) { return read_bit(data.valueLB, 1); } +bool parse_flag8_lb_2(OpenthermData &data) { return read_bit(data.valueLB, 2); } +bool parse_flag8_lb_3(OpenthermData &data) { return read_bit(data.valueLB, 3); } +bool parse_flag8_lb_4(OpenthermData &data) { return read_bit(data.valueLB, 4); } +bool parse_flag8_lb_5(OpenthermData &data) { return read_bit(data.valueLB, 5); } +bool parse_flag8_lb_6(OpenthermData &data) { return read_bit(data.valueLB, 6); } +bool parse_flag8_lb_7(OpenthermData &data) { return read_bit(data.valueLB, 7); } +bool parse_flag8_hb_0(OpenthermData &data) { return read_bit(data.valueHB, 0); } +bool parse_flag8_hb_1(OpenthermData &data) { return read_bit(data.valueHB, 1); } +bool parse_flag8_hb_2(OpenthermData &data) { return read_bit(data.valueHB, 2); } +bool parse_flag8_hb_3(OpenthermData &data) { return read_bit(data.valueHB, 3); } +bool parse_flag8_hb_4(OpenthermData &data) { return read_bit(data.valueHB, 4); } +bool parse_flag8_hb_5(OpenthermData &data) { return read_bit(data.valueHB, 5); } +bool parse_flag8_hb_6(OpenthermData &data) { return read_bit(data.valueHB, 6); } +bool parse_flag8_hb_7(OpenthermData &data) { return read_bit(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(); } -OpenthermData OpenthermHub::build_request_(MessageId request_id) { +void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); } +void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); } +void write_flag8_lb_2(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 2, value); } +void write_flag8_lb_3(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 3, value); } +void write_flag8_lb_4(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 4, value); } +void write_flag8_lb_5(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 5, value); } +void write_flag8_lb_6(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 6, value); } +void write_flag8_lb_7(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 7, value); } +void write_flag8_hb_0(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 0, value); } +void write_flag8_hb_1(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 1, value); } +void write_flag8_hb_2(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 2, value); } +void write_flag8_hb_3(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 3, value); } +void write_flag8_hb_4(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 4, value); } +void write_flag8_hb_5(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 5, value); } +void write_flag8_hb_6(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 6, value); } +void write_flag8_hb_7(const bool value, OpenthermData &data) { data.valueHB = write_bit(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 + +OpenthermData OpenthermHub::build_request_(MessageId request_id) const { 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. + // We need this special logic for STATUS message because we have two options for specifying boiler modes: + // with static config values in the hub, or with separate switches. 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; + // NOLINTBEGIN + bool const ch_enabled = this->ch_enable && OPENTHERM_READ_ch_enable && OPENTHERM_READ_t_set > 0.0; + bool const dhw_enabled = this->dhw_enable && OPENTHERM_READ_dhw_enable; + bool const cooling_enabled = + this->cooling_enable && OPENTHERM_READ_cooling_enable && OPENTHERM_READ_cooling_control > 0.0; + bool const otc_enabled = this->otc_active && OPENTHERM_READ_otc_active; + bool const ch2_enabled = this->ch2_active && OPENTHERM_READ_ch2_active && OPENTHERM_READ_t_set_ch2 > 0.0; + bool const summer_mode_is_active = this->summer_mode_active && OPENTHERM_READ_summer_mode_active; + bool const dhw_blocked = this->dhw_block && OPENTHERM_READ_dhw_block; + // 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); + data.valueHB = ch_enabled | (dhw_enabled << 1) | (cooling_enabled << 2) | (otc_enabled << 3) | (ch2_enabled << 4) | + (summer_mode_is_active << 5) | (dhw_blocked << 6); + + return data; + } // 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. + switch (request_id) { OPENTHERM_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_READ_MESSAGE, OPENTHERM_IGNORE, , , ) } #pragma GCC diagnostic pop - return data; - } - return OpenthermData(); + // 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(TAG, "Tried to create a request with unknown id %d. This should never happen, so please open an issue.", + request_id); + return {}; } -OpenthermHub::OpenthermHub() : Component() {} +OpenthermHub::OpenthermHub() : Component(), in_pin_{}, out_pin_{} {} 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()); + + switch (data.id) { + OPENTHERM_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_RESPONSE_MESSAGE, OPENTHERM_MESSAGE_RESPONSE_ENTITY, , + OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT, ) + } } void OpenthermHub::setup() { @@ -254,15 +318,17 @@ void OpenthermHub::handle_timeout_error_() { 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, " Sensors: %s", SHOW(OPENTHERM_SENSOR_LIST(ID, ))); + ESP_LOGCONFIG(TAG, " Binary sensors: %s", SHOW(OPENTHERM_BINARY_SENSOR_LIST(ID, ))); + ESP_LOGCONFIG(TAG, " Switches: %s", SHOW(OPENTHERM_SWITCH_LIST(ID, ))); + ESP_LOGCONFIG(TAG, " Input sensors: %s", SHOW(OPENTHERM_INPUT_SENSOR_LIST(ID, ))); + ESP_LOGCONFIG(TAG, " Outputs: %s", SHOW(OPENTHERM_OUTPUT_LIST(ID, ))); + ESP_LOGCONFIG(TAG, " Numbers: %s", SHOW(OPENTHERM_NUMBER_LIST(ID, ))); ESP_LOGCONFIG(TAG, " Initial requests:"); for (auto type : this->initial_messages_) { ESP_LOGCONFIG(TAG, " - %d", type); diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index ce9f09fe33..3b90cdf427 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -7,11 +7,17 @@ #include "opentherm.h" +#ifdef OPENTHERM_USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + #include #include #include #include +#include "opentherm_macros.h" + namespace esphome { namespace opentherm { @@ -23,6 +29,8 @@ class OpenthermHub : public Component { // The OpenTherm interface std::unique_ptr opentherm_; + OPENTHERM_SENSOR_LIST(OPENTHERM_DECLARE_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 @@ -44,7 +52,7 @@ class OpenthermHub : public Component { bool sync_mode_ = false; // Create OpenTherm messages based on the message id - OpenthermData build_request_(MessageId request_id); + OpenthermData build_request_(MessageId request_id) const; void handle_protocol_write_error_(); void handle_protocol_read_error_(); void handle_timeout_error_(); @@ -78,6 +86,8 @@ class OpenthermHub : public Component { void set_in_pin(InternalGPIOPin *in_pin) { this->in_pin_ = in_pin; } void set_out_pin(InternalGPIOPin *out_pin) { this->out_pin_ = out_pin; } + OPENTHERM_SENSOR_LIST(OPENTHERM_SET_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 @@ -86,9 +96,10 @@ class OpenthermHub : public Component { // 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, + // There are seven 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; + bool ch_enable = true, dhw_enable = true, cooling_enable = false, otc_active = false, ch2_active = false, + summer_mode_active = false, dhw_block = false; // Setters for the status variables void set_ch_enable(bool value) { this->ch_enable = value; } @@ -96,6 +107,8 @@ class OpenthermHub : public Component { 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_summer_mode_active(bool value) { this->summer_mode_active = value; } + void set_dhw_block(bool value) { this->dhw_block = value; } void set_sync_mode(bool sync_mode) { this->sync_mode_ = sync_mode; } float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index b830cc01d3..4a23bb94cf 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -283,6 +283,9 @@ bool OpenTherm::init_esp32_timer_() { .clk_src = TIMER_SRC_CLK_DEFAULT, #endif .divider = 80, +#if defined(SOC_TIMER_GROUP_SUPPORT_XTAL) && ESP_IDF_VERSION_MAJOR < 5 + .clk_src = TIMER_SRC_CLK_APB +#endif }; esp_err_t result; diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index 609cfb6243..23f4b39a1a 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -20,7 +20,6 @@ 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); } @@ -28,7 +27,7 @@ template constexpr T set_bit(T value, uint8_t bit) { return value |= (1 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); + return bit_value ? set_bit(value, bit) : clear_bit(value, bit); } enum OperationMode { diff --git a/esphome/components/opentherm/opentherm_macros.h b/esphome/components/opentherm/opentherm_macros.h new file mode 100644 index 0000000000..0389e975ff --- /dev/null +++ b/esphome/components/opentherm/opentherm_macros.h @@ -0,0 +1,91 @@ +#pragma once +namespace esphome { +namespace opentherm { + +// ===== hub.h macros ===== + +// *_LIST macros will be generated in defines.h if at least one sensor from each platform is used. +// These lists will look like this: +// #define OPENTHERM_BINARY_SENSOR_LIST(F, sep) F(sensor_1) sep F(sensor_2) +// These lists will be used in hub.h to define sensor fields (passing macros like OPENTHERM_DECLARE_SENSOR as F) +// and setters (passing macros like OPENTHERM_SET_SENSOR as F) (see below) +// In order for things not to break, we define empty lists here in case some platforms are not used in config. +#ifndef OPENTHERM_SENSOR_LIST +#define OPENTHERM_SENSOR_LIST(F, sep) +#endif + +// Use macros to create fields for every entity specified in the ESPHome configuration +#define OPENTHERM_DECLARE_SENSOR(entity) sensor::Sensor *entity; + +// Setter macros +#define OPENTHERM_SET_SENSOR(entity) \ + void set_##entity(sensor::Sensor *sensor) { this->entity = sensor; } + +// ===== hub.cpp macros ===== + +// *_MESSAGE_HANDLERS are generated in defines.h and look like this: +// OPENTHERM_NUMBER_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) MESSAGE(COOLING_CONTROL) +// ENTITY(cooling_control_number, f88) postscript msg_sep They contain placeholders for message part and entities parts, +// since one message can contain multiple entities. MESSAGE part is substituted with OPENTHERM_MESSAGE_WRITE_MESSAGE, +// OPENTHERM_MESSAGE_READ_MESSAGE or OPENTHERM_MESSAGE_RESPONSE_MESSAGE. ENTITY part is substituted with +// OPENTHERM_MESSAGE_WRITE_ENTITY or OPENTHERM_MESSAGE_RESPONSE_ENTITY. OPENTHERM_IGNORE is used for sensor read +// requests since no data needs to be sent or processed, just the data id. + +// In order for things not to break, we define empty lists here in case some platforms are not used in config. +#ifndef OPENTHERM_SENSOR_MESSAGE_HANDLERS +#define OPENTHERM_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) +#endif + +// Read data request builder +#define OPENTHERM_MESSAGE_READ_MESSAGE(msg) \ + case MessageId::msg: \ + data.type = MessageType::READ_DATA; \ + data.id = request_id; \ + return data; + +// Data processing builders +#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; + +#define OPENTHERM_IGNORE(x, y) + +// Default macros for STATUS entities +#ifndef OPENTHERM_READ_ch_enable +#define OPENTHERM_READ_ch_enable true +#endif +#ifndef OPENTHERM_READ_dhw_enable +#define OPENTHERM_READ_dhw_enable true +#endif +#ifndef OPENTHERM_READ_t_set +#define OPENTHERM_READ_t_set 0.0 +#endif +#ifndef OPENTHERM_READ_cooling_enable +#define OPENTHERM_READ_cooling_enable false +#endif +#ifndef OPENTHERM_READ_cooling_control +#define OPENTHERM_READ_cooling_control 0.0 +#endif +#ifndef OPENTHERM_READ_otc_active +#define OPENTHERM_READ_otc_active false +#endif +#ifndef OPENTHERM_READ_ch2_active +#define OPENTHERM_READ_ch2_active false +#endif +#ifndef OPENTHERM_READ_t_set_ch2 +#define OPENTHERM_READ_t_set_ch2 0.0 +#endif +#ifndef OPENTHERM_READ_summer_mode_active +#define OPENTHERM_READ_summer_mode_active false +#endif +#ifndef OPENTHERM_READ_dhw_block +#define OPENTHERM_READ_dhw_block false +#endif + +// These macros utilize the structure of *_LIST macros in order +#define ID(x) x +#define SHOW_INNER(x) #x +#define SHOW(x) SHOW_INNER(x) + +} // namespace opentherm +} // namespace esphome diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py new file mode 100644 index 0000000000..6ed0029437 --- /dev/null +++ b/esphome/components/opentherm/schema.py @@ -0,0 +1,438 @@ +# This file contains a schema for all supported sensors, binary sensors and +# inputs of the OpenTherm component. + +from dataclasses import dataclass +from typing import Optional, TypeVar + +from esphome.const import ( + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_KILOWATT, + UNIT_MICROAMP, + UNIT_PERCENT, + UNIT_REVOLUTIONS_PER_MINUTE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_EMPTY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_NONE, + STATE_CLASS_TOTAL_INCREASING, +) + + +@dataclass +class EntitySchema: + 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 + """ + + +TSchema = TypeVar("TSchema", bound=EntitySchema) + + +@dataclass +class SensorSchema(EntitySchema): + accuracy_decimals: int + state_class: str + unit_of_measurement: Optional[str] = None + icon: Optional[str] = None + device_class: Optional[str] = None + disabled_by_default: bool = False + + +SENSORS: dict[str, SensorSchema] = { + "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", + ), + "fan_speed": SensorSchema( + description="Boiler fan speed", + unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + message="FAN_SPEED", + keep_updated=True, + message_data="u16", + ), + "flame_current": SensorSchema( + description="Boiler flame current", + unit_of_measurement=UNIT_MICROAMP, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + message="FLAME_CURRENT", + keep_updated=True, + message_data="f88", + ), + "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", + ), + "oem_fault_code": SensorSchema( + description="OEM fault code", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + message="FAULT_FLAGS", + keep_updated=True, + message_data="u8_lb", + ), + "oem_diagnostic_code": SensorSchema( + description="OEM diagnostic code", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + message="OEM_DIAGNOSTIC", + keep_updated=True, + message_data="u16", + ), + "max_capacity": SensorSchema( + description="Maximum boiler capacity (KW)", + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + disabled_by_default=True, + message="MAX_BOILER_CAPACITY", + keep_updated=False, + message_data="u8_hb", + ), + "min_mod_level": SensorSchema( + description="Minimum modulation level", + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + icon="mdi:percent", + disabled_by_default=True, + state_class=STATE_CLASS_MEASUREMENT, + message="MAX_BOILER_CAPACITY", + keep_updated=False, + message_data="u8_lb", + ), + "opentherm_version_device": SensorSchema( + description="Version of OpenTherm implemented by device", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + disabled_by_default=True, + message="OT_VERSION_DEVICE", + keep_updated=False, + message_data="f88", + ), + "device_type": SensorSchema( + description="Device product type", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + disabled_by_default=True, + message="VERSION_DEVICE", + keep_updated=False, + message_data="u8_hb", + ), + "device_version": SensorSchema( + description="Device product version", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + disabled_by_default=True, + message="VERSION_DEVICE", + keep_updated=False, + message_data="u8_lb", + ), + "device_id": SensorSchema( + description="Device ID code", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + disabled_by_default=True, + message="DEVICE_CONFIG", + keep_updated=False, + message_data="u8_lb", + ), + "otc_hc_ratio_ub": SensorSchema( + description="OTC heat curve ratio upper bound", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + disabled_by_default=True, + message="OTC_CURVE_BOUNDS", + keep_updated=False, + message_data="u8_hb", + ), + "otc_hc_ratio_lb": SensorSchema( + description="OTC heat curve ratio lower bound", + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_NONE, + disabled_by_default=True, + message="OTC_CURVE_BOUNDS", + keep_updated=False, + message_data="u8_lb", + ), +} diff --git a/esphome/components/opentherm/sensor/__init__.py b/esphome/components/opentherm/sensor/__init__.py new file mode 100644 index 0000000000..20224e0eda --- /dev/null +++ b/esphome/components/opentherm/sensor/__init__.py @@ -0,0 +1,35 @@ +from typing import Any + +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 + or sensor._UNDEF, # pylint: disable=protected-access + accuracy_decimals=entity.accuracy_decimals, + device_class=entity.device_class + or sensor._UNDEF, # pylint: disable=protected-access + icon=entity.icon or sensor._UNDEF, # pylint: disable=protected-access + 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/validate.py b/esphome/components/opentherm/validate.py new file mode 100644 index 0000000000..d4507672a5 --- /dev/null +++ b/esphome/components/opentherm/validate.py @@ -0,0 +1,31 @@ +from typing import Callable + +from voluptuous import Schema + +import esphome.config_validation as cv + +from . import const, schema, generate +from .schema import TSchema + + +def create_entities_schema( + entities: dict[str, schema.EntitySchema], + get_entity_validation_schema: Callable[[TSchema], cv.Schema], +) -> Schema: + entity_schema = {} + for key, entity in entities.items(): + entity_schema[cv.Optional(key)] = get_entity_validation_schema(entity) + return cv.Schema(entity_schema) + + +def create_component_schema( + entities: dict[str, schema.EntitySchema], + get_entity_validation_schema: Callable[[TSchema], cv.Schema], +) -> 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/components/opentherm/common.yaml b/tests/components/opentherm/common.yaml index 4148b280d0..27cbae280a 100644 --- a/tests/components/opentherm/common.yaml +++ b/tests/components/opentherm/common.yaml @@ -1,3 +1,76 @@ +api: +wifi: + ap: + ssid: "Thermostat" + password: "MySecretThemostat" + opentherm: - in_pin: 1 - out_pin: 2 + in_pin: 4 + out_pin: 5 + ch_enable: true + dhw_enable: false + cooling_enable: false + otc_active: false + ch2_active: true + summer_mode_active: true + dhw_block: true + sync_mode: true + +sensor: + - platform: opentherm + rel_mod_level: + name: "Boiler Relative modulation level" + ch_pressure: + name: "Boiler Water pressure in CH circuit" + dhw_flow_rate: + name: "Boiler Water flow rate in DHW circuit" + t_boiler: + name: "Boiler water temperature" + t_dhw: + name: "Boiler DHW temperature" + t_outside: + name: "Boiler Outside temperature" + t_ret: + name: "Boiler Return water temperature" + t_storage: + name: "Boiler Solar storage temperature" + t_collector: + name: "Boiler Solar collector temperature" + t_flow_ch2: + name: "Boiler Flow water temperature CH2 circuit" + t_dhw2: + name: "Boiler Domestic hot water temperature 2" + t_exhaust: + name: "Boiler Exhaust temperature" + burner_starts: + name: "Boiler Number of starts burner" + ch_pump_starts: + name: "Boiler Number of starts CH pump" + dhw_pump_valve_starts: + name: "Boiler Number of starts DHW pump/valve" + dhw_burner_starts: + name: "Boiler Number of starts burner during DHW mode" + burner_operation_hours: + name: "Boiler Number of hours that burner is in operation (i.e. flame on)" + ch_pump_operation_hours: + name: "Boiler Number of hours that CH pump has been running" + dhw_pump_valve_operation_hours: + name: "Boiler Number of hours that DHW pump has been running or DHW valve has been opened" + dhw_burner_operation_hours: + name: "Boiler Number of hours that burner is in operation during DHW mode" + t_dhw_set_ub: + name: "Boiler Upper bound for adjustement of DHW setpoint" + t_dhw_set_lb: + name: "Boiler Lower bound for adjustement of DHW setpoint" + max_t_set_ub: + name: "Boiler Upper bound for adjustement of max CH setpoint" + max_t_set_lb: + name: "Boiler Lower bound for adjustement of max CH setpoint" + t_dhw_set: + name: "Boiler Domestic hot water temperature setpoint" + max_t_set: + name: "Boiler Maximum allowable CH water setpoint" + otc_hc_ratio_ub: + name: "OTC heat curve ratio upper bound" + otc_hc_ratio_lb: + name: "OTC heat curve ratio lower bound"