Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenTherm component #6645

Open
wants to merge 13 commits into
base: dev
Choose a base branch
from
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ esphome/components/nextion/text_sensor/* @senexcrenshaw
esphome/components/nfc/* @jesserockz @kbx81
esphome/components/noblex/* @AGalfra
esphome/components/number/* @esphome/core
esphome/components/opentherm/* @olegtarasov
esphome/components/ota/* @esphome/core
esphome/components/output/* @esphome/core
esphome/components/pca6416a/* @Mat931
Expand Down
1 change: 1 addition & 0 deletions esphome/components/opentherm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
5 changes: 5 additions & 0 deletions esphome/components/opentherm/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions esphome/components/opentherm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from typing import Any

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome import pins
from esphome.components import sensor
from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266
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:
# 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:
input_sensor = await cg.get_variable(value)
cg.add(
getattr(var, f"set_{key}_{const.INPUT_SENSOR.lower()}")(
input_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)
38 changes: 38 additions & 0 deletions esphome/components/opentherm/binary_sensor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from typing import Any

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 # pylint: disable=protected-access
),
icon=(
entity["icon"]
if "icon" in entity
else binary_sensor._UNDEF # pylint: disable=protected-access
),
)


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,
)
10 changes: 10 additions & 0 deletions esphome/components/opentherm/const.py
Original file line number Diff line number Diff line change
@@ -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"
145 changes: 145 additions & 0 deletions esphome/components/opentherm/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from collections.abc import Awaitable
from typing import Any, Callable, 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