Added a new OpenTherm component

This commit is contained in:
Oleg Tarasov 2024-04-26 18:13:55 +03:00
parent 9bfb36f58b
commit ee72c7b6f3
24 changed files with 2823 additions and 0 deletions

View File

@ -0,0 +1 @@
__pycache__/

View File

@ -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.

View File

@ -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)

View File

@ -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
)

View File

@ -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"

View File

@ -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

View File

@ -0,0 +1,355 @@
#include "hub.h"
#include <string>
// 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

View File

@ -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 <unordered_map>
#include <unordered_set>
#include <functional>
#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<MessageId> initial_messages_;
// and the repeating messages which are sent repeatedly to update various sensors
// and boiler parameters (like the setpoint).
std::unordered_set<MessageId> 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<MessageId>::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<typename F> 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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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<float>(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);
}
}
}

View File

@ -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

View File

@ -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 <string>
#include <sstream>
#include <bitset>
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<bool (*)(void *)>(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 "<INVALID>";
}
}
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 "<INVALID>";
}
}
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 "<INVALID>";
}
}
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 "<INVALID>";
}
}
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

View File

@ -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 <string>
#include <sstream>
#include <iomanip>
#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<typename T> 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

View File

@ -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)

View File

@ -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

View File

@ -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),
}
),
}
)

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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