├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── compile_all.py ├── components └── opentherm │ ├── .gitignore │ ├── __init__.py │ ├── binary_sensor.py │ ├── const.py │ ├── generate.py │ ├── hub.cpp │ ├── hub.h │ ├── input.h │ ├── input.py │ ├── number.h │ ├── number.py │ ├── output.h │ ├── output.py │ ├── schema.py │ ├── sensor.py │ ├── switch.cpp │ ├── switch.h │ ├── switch.py │ └── validate.py ├── examples ├── .gitignore ├── thermostat-number-minimal.yaml ├── thermostat-pid-basic.yaml └── thermostat-pid-complete.yaml ├── generate_schema_docs.py ├── mypy.ini ├── read_changelog.py └── release.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [ push, pull_request, workflow_dispatch ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | container: esphome/esphome:latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - run: pip3 install mypy 12 | - run: mypy 13 | - run: python3 compile_all.py 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v[0-9]+.[0-9]+.[0-9]+" 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - run: python3 read_changelog.py ${{ github.ref_name }} 12 | - uses: ncipollo/release-action@v1 13 | with: 14 | bodyFile: CHANGELOG.md.tmp 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ESPHome.esphome-vscode" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "esphome" 4 | }, 5 | "[esphome]": { 6 | "editor.tabSize": 2, 7 | } 8 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## vNext 4 | 5 | ## v0.1.0 - 2022-10-06 6 | Initial release 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2022, Arthur Rump 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenTherm Master for ESPHome 2 | 3 | An external ESPHome component to control a boiler (or other supported HVAC appliances) over the OpenTherm protocol. Note that special hardware is required, like the [DIYLESS Master OpenTherm Shield](https://diyless.com/product/master-opentherm-shield) or [Ihor Melnyk's OpenTherm Adapter](http://ihormelnyk.com/opentherm_adapter). This component acts only as an OpenTherm master (i.e. a thermostat or controller) and not as a slave or gateway. You can no longer use your existing thermostat if you control your boiler through ESPHome with this component. 4 | 5 | We aim for maximum flexibility in this component by exposing most of the information available through the OpenTherm protocol, while allowing all configuration in YAML. (No custom component code required!) Since every boiler and every situation is different, you have to play around a bit with the sensors you'd want to read. There is no requirement for a boiler to support everything in the protocol, so not every sensor in this component will work with your boiler. (For example, my Remeha Avanta does not report `ch_pressure`, `dhw_flow_rate` or `t_dhw`.) 6 | 7 | This component uses [@ihormelnyk's OpenTherm Library](https://github.com/ihormelnyk/opentherm_library) (MIT licensed) as its communication layer. The message loop is inspired by code for the [DIYLESS ESP32 Wi-Fi Thermostat](https://github.com/diyless/esp32-wifi-thermostat) (MIT licensed). 8 | 9 | Alternatives: 10 | - [ESPHome-OpenTherm by @rsciriano](https://github.com/rsciriano/ESPHome-OpenTherm), a custom component based on the same library as this project 11 | - [esphome-opentherm by @wichers](https://github.com/wichers/esphome-opentherm), which works as a gateway, rather than a master, allowing you to combine the system with your own thermostat 12 | - And more options if you [search on GitHub](https://github.com/search?q=esphome+opentherm) 13 | 14 | ## Quick glossary 15 | 16 | - CH: Central Heating 17 | - DHW: Domestic Hot Water 18 | 19 | ## Usage 20 | 21 | The OpenTherm Master component is available as an external component in ESPHome and can be included in your configuration as follows: 22 | 23 | ```yaml 24 | external_components: 25 | source: github://arthurrump/esphome-opentherm@main 26 | ``` 27 | 28 | This references the main branch, which is cool if you want to stay up to date, but may also break your configuration if breaking changes happen here. A better idea would be to reference a specific version, see the tags for available versions. Instead of a specific version, you could also choose to follow a major version by specifying `@v1` etc. 29 | 30 | Then you can define the OpenTherm hub in your configuration: 31 | 32 | ```yaml 33 | opentherm: 34 | in_pin: 4 35 | out_pin: 5 36 | ``` 37 | 38 | ### Usage as a thermostat 39 | 40 | The most important function for a thermostat is to set the boiler temperature setpoint. This component has three ways to provide this input: using a sensor from which the setpoint can be read, using a [number](https://esphome.io/components/number/index.html), or defining an output to which other components can write. For most users, the last option is the most useful one, as it can be combined with the [PID Climate](https://esphome.io/components/climate/pid.html) component to create a thermostat that works as you would expect a thermostat to work. See [thermostat-pid-basic.yaml](examples/thermostat-pid-basic.yaml) for an example. 41 | 42 | ### Numerical input 43 | 44 | There are three ways to set an input value: 45 | 46 | - As an input sensor, defined in the hub configuration: 47 | 48 | ```yaml 49 | opentherm: 50 | t_set: setpoint_sensor 51 | 52 | sensor: 53 | - platform: homeassistant 54 | id: setpoint_sensor 55 | entity_id: sensor.boiler_setpoint 56 | ``` 57 | 58 | This can be useful if you have an external thermostat-like device that provides the setpoint as a sensor. 59 | - As a number: 60 | 61 | ```yaml 62 | number: 63 | - platform: opentherm 64 | t_set: 65 | name: Boiler Setpoint 66 | ``` 67 | 68 | This is useful if you want full control over your boiler and want to manually set all values. 69 | - As an output: 70 | 71 | ```yaml 72 | output: 73 | - platform: opentherm 74 | t_set: 75 | id: setpoint 76 | ``` 77 | 78 | This is especially useful in combination with the PID Climate component: 79 | 80 | ```yaml 81 | climate: 82 | - platform: pid 83 | heat_output: setpoint 84 | # ... 85 | ``` 86 | 87 | For the output and number variants, there are four more properties you can configure beyond those included in the output and number components by default: 88 | 89 | - `min_value` (float): The minimum value. For a number this is the minimum value you are allowed to input. For an output this is the number that will be sent to the boiler when the output is at 0%. 90 | - `max_value` (float): The maximum value. For a number this is the maximum value you are allowed to input. For an output this is the number that will be sent to the boiler when the output is at 100%. 91 | - `auto_max_value` (boolean): Automatically configure the maximum value to a value reported by the boiler. Not available for all inputs. 92 | - `auto_min_value` (boolean): Automatically configure the minimum value to a value reported by the boiler. Not available for all inputs. 93 | 94 | The following inputs are available: 95 | 96 | 97 | - `t_set`: Control setpoint: temperature setpoint for the boiler's supply water (°C) 98 | Default `min_value`: 0 99 | Default `max_value`: 100 100 | Supports `auto_max_value` 101 | - `t_set_ch2`: Control setpoint 2: temperature setpoint for the boiler's supply water on the second heating circuit (°C) 102 | Default `min_value`: 0 103 | Default `max_value`: 100 104 | Supports `auto_max_value` 105 | - `cooling_control`: Cooling control signal (%) 106 | Default `min_value`: 0 107 | Default `max_value`: 100 108 | - `t_dhw_set`: Domestic hot water temperature setpoint (°C) 109 | Default `min_value`: 0 110 | Default `max_value`: 127 111 | Supports `auto_min_value` 112 | Supports `auto_max_value` 113 | - `max_t_set`: Maximum allowable CH water setpoint (°C) 114 | Default `min_value`: 0 115 | Default `max_value`: 127 116 | Supports `auto_min_value` 117 | Supports `auto_max_value` 118 | - `t_room_set`: Current room temperature setpoint (informational) (°C) 119 | Default `min_value`: -40 120 | Default `max_value`: 127 121 | - `t_room_set_ch2`: Current room temperature setpoint on CH2 (informational) (°C) 122 | Default `min_value`: -40 123 | Default `max_value`: 127 124 | - `t_room`: Current sensed room temperature (informational) (°C) 125 | Default `min_value`: -40 126 | Default `max_value`: 127 127 | 128 | 129 | ### Switch 130 | 131 | For five status codes, switches are available to toggle them manually. The same values can be set in the hub configuration, like so: 132 | 133 | ```yaml 134 | opentherm: 135 | ch_enable: true 136 | dhw_enable: true 137 | ``` 138 | 139 | This can be used to set the value without the need for a switch if you'd never want to toggle it after the initial configuration. The default values for these configuration options are listed below. 140 | 141 | For enabling of central heating and cooling, the enable-flag is only sent to the boiler if the following conditions are met: 142 | - the flag is set to true in the hub configuration, 143 | - the switch is on, if it is configured, 144 | - the setpoint or cooling control value is not 0, if it is configured. 145 | 146 | For domestic hot water and outside temperature compensation, only the first two conditions are necessary. 147 | 148 | The last point ensures that central heating is not enabled if no heating is requested as indicated by a setpoint of 0. If you use a number as the setpoint input and use a minimum value higher than 0, you NEED to use the ch_enable switch to turn off your central heating. In that case the flag will be set to true in the hub configuration, and setpoint is always larger than 0, so including a switch is the only way you can turn off central heating. (This also holds for cooling and CH2.) 149 | 150 | The following switches are available: 151 | 152 | 153 | - `ch_enable`: Central Heating enabled 154 | Defaults to *True* 155 | - `dhw_enable`: Domestic Hot Water enabled 156 | Defaults to *True* 157 | - `cooling_enable`: Cooling enabled 158 | Defaults to *False* 159 | - `otc_active`: Outside temperature compensation active 160 | Defaults to *False* 161 | - `ch2_active`: Central Heating 2 active 162 | Defaults to *False* 163 | 164 | 165 | ### Binary sensor 166 | 167 | The component can report boiler status on several binary sensors. The *Status* sensors are updated in each message cycle, while the others are only set during initialization, as they are unlikely to change without restarting the boiler. 168 | 169 | 170 | - `fault_indication`: Status: Fault indication 171 | - `ch_active`: Status: Central Heating active 172 | - `dhw_active`: Status: Domestic Hot Water active 173 | - `flame_on`: Status: Flame on 174 | - `cooling_active`: Status: Cooling active 175 | - `ch2_active`: Status: Central Heating 2 active 176 | - `diagnostic_indication`: Status: Diagnostic event 177 | - `dhw_present`: Configuration: DHW present 178 | - `control_type_on_off`: Configuration: Control type is on/off 179 | - `cooling_supported`: Configuration: Cooling supported 180 | - `dhw_storage_tank`: Configuration: DHW storage tank 181 | - `master_pump_control_allowed`: Configuration: Master pump control allowed 182 | - `ch2_present`: Configuration: CH2 present 183 | - `dhw_setpoint_transfer_enabled`: Remote boiler parameters: DHW setpoint transfer enabled 184 | - `max_ch_setpoint_transfer_enabled`: Remote boiler parameters: CH maximum setpoint transfer enabled 185 | - `dhw_setpoint_rw`: Remote boiler parameters: DHW setpoint read/write 186 | - `max_ch_setpoint_rw`: Remote boiler parameters: CH maximum setpoint read/write 187 | 188 | 189 | ### Sensor 190 | 191 | The boiler can also report several numerical values, which are available through sensors. Your boiler may not support all of these values, in which case there won't be any value published to that sensor. The following sensors are available: 192 | 193 | 194 | - `rel_mod_level`: Relative modulation level (%) 195 | - `ch_pressure`: Water pressure in CH circuit (bar) 196 | - `dhw_flow_rate`: Water flow rate in DHW circuit (l/min) 197 | - `t_boiler`: Boiler water temperature (°C) 198 | - `t_dhw`: DHW temperature (°C) 199 | - `t_outside`: Outside temperature (°C) 200 | - `t_ret`: Return water temperature (°C) 201 | - `t_storage`: Solar storage temperature (°C) 202 | - `t_collector`: Solar collector temperature (°C) 203 | - `t_flow_ch2`: Flow water temperature CH2 circuit (°C) 204 | - `t_dhw2`: Domestic hot water temperature 2 (°C) 205 | - `t_exhaust`: Boiler exhaust temperature (°C) 206 | - `burner_starts`: Number of starts burner 207 | - `ch_pump_starts`: Number of starts CH pump 208 | - `dhw_pump_valve_starts`: Number of starts DHW pump/valve 209 | - `dhw_burner_starts`: Number of starts burner during DHW mode 210 | - `burner_operation_hours`: Number of hours that burner is in operation 211 | - `ch_pump_operation_hours`: Number of hours that CH pump has been running 212 | - `dhw_pump_valve_operation_hours`: Number of hours that DHW pump has been running or DHW valve has been opened 213 | - `dhw_burner_operation_hours`: Number of hours that burner is in operation during DHW mode 214 | - `t_dhw_set_ub`: Upper bound for adjustment of DHW setpoint (°C) 215 | - `t_dhw_set_lb`: Lower bound for adjustment of DHW setpoint (°C) 216 | - `max_t_set_ub`: Upper bound for adjustment of max CH setpoint (°C) 217 | - `max_t_set_lb`: Lower bound for adjustment of max CH setpoint (°C) 218 | - `t_dhw_set`: Domestic hot water temperature setpoint (°C) 219 | - `max_t_set`: Maximum allowable CH water setpoint (°C) 220 | 221 | 222 | ## Troubleshooting 223 | 224 | ### `Component not found: opentherm.` 225 | 226 | If ESPHome reports that it is unable to find the component, this might be due to the use of an older version of Python. It should work on version 3.9 (which is what runs in CI) and higher, but older versions may not support all typing features used in this project. You can update to a newer Python version, or install the backported typing library with `pip install typing-extensions`. (Thanks to [@Arise for figuring this out](https://github.com/arthurrump/esphome-opentherm/issues/10)!) 227 | -------------------------------------------------------------------------------- /compile_all.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | status = 0 5 | results = {} 6 | 7 | os.chdir("examples") 8 | 9 | for file in os.listdir(): 10 | if os.path.isfile(file) and file.endswith(".yaml"): 11 | print(f"------- Compiling {file} -------") 12 | res = os.system(f"esphome compile {file}") 13 | print(f"------- Finished compiling {file} with status {res} -------") 14 | status += res 15 | results[file] = res 16 | 17 | print("======= Results =======") 18 | for file, res in results.items(): 19 | print(f"{'✅' if res == 0 else '❌'} {file}") 20 | if res != 0: 21 | print(f" Status: {res}") 22 | print("=======================") 23 | 24 | if status != 0: 25 | sys.exit(1) 26 | -------------------------------------------------------------------------------- /components/opentherm/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /components/opentherm/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | from esphome.components import sensor 6 | from esphome.const import CONF_ID 7 | 8 | from . import const, schema, validate, generate 9 | 10 | AUTO_LOAD = [ "binary_sensor", "sensor", "switch", "number", "output" ] 11 | MULTI_CONF = True 12 | 13 | CONFIG_SCHEMA = cv.All( 14 | cv.Schema({ 15 | cv.GenerateID(): cv.declare_id(generate.OpenthermHub), 16 | cv.Optional("in_pin", 4): cv.int_, 17 | cv.Optional("out_pin", 5): cv.int_, 18 | cv.Optional("ch_enable", True): cv.boolean, 19 | cv.Optional("dhw_enable", True): cv.boolean, 20 | cv.Optional("cooling_enable", False): cv.boolean, 21 | cv.Optional("otc_active", False): cv.boolean, 22 | cv.Optional("ch2_active", False): cv.boolean, 23 | }).extend(validate.create_entities_schema(schema.INPUTS, (lambda _: cv.use_id(sensor.Sensor)))) 24 | .extend(cv.COMPONENT_SCHEMA), 25 | cv.only_with_arduino, 26 | ) 27 | 28 | async def to_code(config: Dict[str, Any]) -> None: 29 | id = str(config[CONF_ID]) 30 | # Create the hub, passing the two callbacks defined below 31 | # Since the hub is used in the callbacks, we need to define it first 32 | var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(id + "_handle_interrupt"), cg.RawExpression(id + "_process_response")) 33 | # Define two global callbacks to process responses on interrupt 34 | cg.add_global(cg.RawStatement("void IRAM_ATTR " + id + "_handle_interrupt() { " + id + "->handle_interrupt(); }")) 35 | cg.add_global(cg.RawStatement("void " + id + "_process_response(unsigned long response, OpenThermResponseStatus status) { " + id + "->process_response(response, status); }")) 36 | await cg.register_component(var, config) 37 | 38 | input_sensors = [] 39 | for key, value in config.items(): 40 | if key != CONF_ID: 41 | if key in schema.INPUTS: 42 | sensor = await cg.get_variable(value) 43 | cg.add(getattr(var, f"set_{key}_{const.INPUT_SENSOR.lower()}")(sensor)) 44 | input_sensors.append(key) 45 | else: 46 | cg.add(getattr(var, f"set_{key}")(value)) 47 | 48 | if len(input_sensors) > 0: 49 | generate.define_has_component(const.INPUT_SENSOR, input_sensors) 50 | generate.define_message_handler(const.INPUT_SENSOR, input_sensors, schema.INPUTS) 51 | generate.define_readers(const.INPUT_SENSOR, input_sensors) 52 | generate.add_messages(var, input_sensors, schema.INPUTS) 53 | 54 | cg.add_library("ihormelnyk/OpenTherm Library", "1.1.3") 55 | -------------------------------------------------------------------------------- /components/opentherm/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.config_validation as cv 4 | from esphome.components import binary_sensor 5 | 6 | from . import const, schema, validate, generate 7 | 8 | DEPENDENCIES = [ const.OPENTHERM ] 9 | COMPONENT_TYPE = const.BINARY_SENSOR 10 | 11 | def get_entity_validation_schema(entity: schema.BinarySensorSchema) -> cv.Schema: 12 | return binary_sensor.binary_sensor_schema( 13 | device_class = entity["device_class"] if "device_class" in entity else binary_sensor._UNDEF, 14 | icon = entity["icon"] if "icon" in entity else binary_sensor._UNDEF 15 | ) 16 | 17 | CONFIG_SCHEMA = validate.create_component_schema(schema.BINARY_SENSORS, get_entity_validation_schema) 18 | 19 | async def to_code(config: Dict[str, Any]) -> None: 20 | await generate.component_to_code( 21 | COMPONENT_TYPE, 22 | schema.BINARY_SENSORS, 23 | binary_sensor.BinarySensor, 24 | generate.create_only_conf(binary_sensor.new_binary_sensor), 25 | config 26 | ) 27 | -------------------------------------------------------------------------------- /components/opentherm/const.py: -------------------------------------------------------------------------------- 1 | OPENTHERM = "opentherm" 2 | 3 | CONF_OPENTHERM_ID = "opentherm_id" 4 | 5 | SENSOR = "sensor" 6 | BINARY_SENSOR = "binary_sensor" 7 | SWITCH = "switch" 8 | NUMBER = "number" 9 | OUTPUT = "output" 10 | INPUT_SENSOR = "input_sensor" 11 | -------------------------------------------------------------------------------- /components/opentherm/generate.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict, List, Set, Tuple, TypeVar 2 | 3 | import esphome.codegen as cg 4 | from esphome.const import CONF_ID 5 | 6 | from . import const, schema 7 | 8 | opentherm_ns = cg.esphome_ns.namespace("esphome::opentherm") 9 | OpenthermHub = opentherm_ns.class_("OpenthermHub", cg.Component) 10 | 11 | def define_has_component(component_type: str, keys: List[str]) -> None: 12 | cg.add_define( 13 | f"OPENTHERM_{component_type.upper()}_LIST(F, sep)", 14 | cg.RawExpression(" sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys))) 15 | ) 16 | for key in keys: 17 | cg.add_define(f"OPENTHERM_HAS_{component_type.upper()}_{key}") 18 | 19 | TSchema = TypeVar("TSchema", bound=schema.EntitySchema) 20 | 21 | def define_message_handler(component_type: str, keys: List[str], schema_: schema.Schema[TSchema]) -> None: 22 | 23 | # The macros defined here should be able to generate things like this: 24 | # // Parsing a message and publishing to sensors 25 | # case OpenthermMessageID::Message: 26 | # // Can have multiple sensors here, for example for a Status message with multiple flags 27 | # this->thing_binary_sensor->publish_state(parse_flag8_lb_0(response)); 28 | # this->other_binary_sensor->publish_state(parse_flag8_lb_1(response)); 29 | # break; 30 | # // Building a message for a write request 31 | # case OpenthermMessageID::Message: { 32 | # unsigned int data = 0; 33 | # data = write_flag8_lb_0(some_input_switch->state, data); // Where input_sensor can also be a number/output/switch 34 | # data = write_u8_hb(some_number->state, data); 35 | # return ot->buildRequest(OpenthermMessageType::WriteData, OpenthermMessageID::Message, data); 36 | # } 37 | 38 | # There doesn't seem to be a way to combine the handlers for different components, so we'll 39 | # have to call them seperately in C++. 40 | 41 | messages: Dict[str, List[Tuple[str, str]]] = {} 42 | for key in keys: 43 | msg = schema_[key]["message"] 44 | if msg not in messages: 45 | messages[msg] = [] 46 | messages[msg].append((key, schema_[key]["message_data"])) 47 | 48 | cg.add_define( 49 | f"OPENTHERM_{component_type.upper()}_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep)", 50 | cg.RawExpression( 51 | " msg_sep ".join([ 52 | f"MESSAGE({msg}) " 53 | + " entity_sep ".join([ f"ENTITY({key}_{component_type.lower()}, {msg_data})" for key, msg_data in keys ]) 54 | + " postscript" 55 | for msg, keys in messages.items() 56 | ]) 57 | ) 58 | ) 59 | 60 | def define_readers(component_type: str, keys: List[str]) -> None: 61 | for key in keys: 62 | cg.add_define(f"OPENTHERM_READ_{key}", cg.RawExpression(f"this->{key}_{component_type.lower()}->state")) 63 | 64 | def add_messages(hub: cg.MockObj, keys: List[str], schema_: schema.Schema[TSchema]): 65 | messages: Set[Tuple[str, bool]] = set() 66 | for key in keys: 67 | messages.add((schema_[key]["message"], schema_[key]["keep_updated"])) 68 | for msg, keep_updated in messages: 69 | msg_expr = cg.RawExpression(f"OpenThermMessageID::{msg}") 70 | if keep_updated: 71 | cg.add(hub.add_repeating_message(msg_expr)) 72 | else: 73 | cg.add(hub.add_initial_message(msg_expr)) 74 | 75 | def add_property_set(var: cg.MockObj, config_key: str, config: Dict[str, Any]) -> None: 76 | if config_key in config: 77 | cg.add(getattr(var, f"set_{config_key}")(config[config_key])) 78 | 79 | Create = Callable[[Dict[str, Any], str, cg.MockObj], Awaitable[cg.Pvariable]] 80 | 81 | def create_only_conf(create: Callable[[Dict[str, Any]], Awaitable[cg.Pvariable]]) -> Create: 82 | return lambda conf, _key, _hub: create(conf) 83 | 84 | async def component_to_code(component_type: str, schema_: schema.Schema[TSchema], type: cg.MockObjClass, create: Create, config: Dict[str, Any]) -> List[str]: 85 | """Generate the code for each configured component in the schema of a component type. 86 | 87 | Parameters: 88 | - component_type: The type of component, e.g. "sensor" or "binary_sensor" 89 | - schema_: The schema for that component type, a list of available components 90 | - type: The type of the component, e.g. sensor.Sensor or OpenthermOutput 91 | - create: A constructor function for the component, which receives the config, 92 | the key and the hub and should asynchronously return the new component 93 | - config: The configuration for this component type 94 | 95 | Returns: The list of keys for the created components 96 | """ 97 | cg.add_define(f"OPENTHERM_USE_{component_type.upper()}") 98 | 99 | hub = await cg.get_variable(config[const.CONF_OPENTHERM_ID]) 100 | 101 | keys: List[str] = [] 102 | for key, conf in config.items(): 103 | if not isinstance(conf, dict): 104 | continue 105 | id = conf[CONF_ID] 106 | if id and id.type == type: 107 | entity = await create(conf, key, hub) 108 | cg.add(getattr(hub, f"set_{key}_{component_type.lower()}")(entity)) 109 | keys.append(key) 110 | 111 | define_has_component(component_type, keys) 112 | define_message_handler(component_type, keys, schema_) 113 | add_messages(hub, keys, schema_) 114 | 115 | return keys 116 | -------------------------------------------------------------------------------- /components/opentherm/hub.cpp: -------------------------------------------------------------------------------- 1 | #include "hub.h" 2 | 3 | // Disable incomplete switch statement warnings, because the cases in each 4 | // switch are generated based on the configured sensors and inputs. 5 | #pragma GCC diagnostic push 6 | #pragma GCC diagnostic ignored "-Wswitch" 7 | 8 | namespace esphome { 9 | namespace opentherm { 10 | 11 | static const char *TAG = "opentherm"; 12 | 13 | namespace message_data { 14 | bool parse_flag8_lb_0(const unsigned long response) { return response & 0b0000000000000001; } 15 | bool parse_flag8_lb_1(const unsigned long response) { return response & 0b0000000000000010; } 16 | bool parse_flag8_lb_2(const unsigned long response) { return response & 0b0000000000000100; } 17 | bool parse_flag8_lb_3(const unsigned long response) { return response & 0b0000000000001000; } 18 | bool parse_flag8_lb_4(const unsigned long response) { return response & 0b0000000000010000; } 19 | bool parse_flag8_lb_5(const unsigned long response) { return response & 0b0000000000100000; } 20 | bool parse_flag8_lb_6(const unsigned long response) { return response & 0b0000000001000000; } 21 | bool parse_flag8_lb_7(const unsigned long response) { return response & 0b0000000010000000; } 22 | bool parse_flag8_hb_0(const unsigned long response) { return response & 0b0000000100000000; } 23 | bool parse_flag8_hb_1(const unsigned long response) { return response & 0b0000001000000000; } 24 | bool parse_flag8_hb_2(const unsigned long response) { return response & 0b0000010000000000; } 25 | bool parse_flag8_hb_3(const unsigned long response) { return response & 0b0000100000000000; } 26 | bool parse_flag8_hb_4(const unsigned long response) { return response & 0b0001000000000000; } 27 | bool parse_flag8_hb_5(const unsigned long response) { return response & 0b0010000000000000; } 28 | bool parse_flag8_hb_6(const unsigned long response) { return response & 0b0100000000000000; } 29 | bool parse_flag8_hb_7(const unsigned long response) { return response & 0b1000000000000000; } 30 | uint8_t parse_u8_lb(const unsigned long response) { return (uint8_t) (response & 0xff); } 31 | uint8_t parse_u8_hb(const unsigned long response) { return (uint8_t) ((response >> 8) & 0xff); } 32 | int8_t parse_s8_lb(const unsigned long response) { return (int8_t) (response & 0xff); } 33 | int8_t parse_s8_hb(const unsigned long response) { return (int8_t) ((response >> 8) & 0xff); } 34 | uint16_t parse_u16(const unsigned long response) { return (uint16_t) (response & 0xffff); } 35 | int16_t parse_s16(const unsigned long response) { return (int16_t) (response & 0xffff); } 36 | float parse_f88(const unsigned long response) { 37 | unsigned int data = response & 0xffff; 38 | return (data & 0x8000) ? -(0x10000L - data) / 256.0f : data / 256.0f; 39 | } 40 | 41 | unsigned int write_flag8_lb_0(const bool value, const unsigned int data) { return value ? data | 0b0000000000000001 : data & 0b1111111111111110; } 42 | unsigned int write_flag8_lb_1(const bool value, const unsigned int data) { return value ? data | 0b0000000000000010 : data & 0b1111111111111101; } 43 | unsigned int write_flag8_lb_2(const bool value, const unsigned int data) { return value ? data | 0b0000000000000100 : data & 0b1111111111111011; } 44 | unsigned int write_flag8_lb_3(const bool value, const unsigned int data) { return value ? data | 0b0000000000001000 : data & 0b1111111111110111; } 45 | unsigned int write_flag8_lb_4(const bool value, const unsigned int data) { return value ? data | 0b0000000000010000 : data & 0b1111111111101111; } 46 | unsigned int write_flag8_lb_5(const bool value, const unsigned int data) { return value ? data | 0b0000000000100000 : data & 0b1111111111011111; } 47 | unsigned int write_flag8_lb_6(const bool value, const unsigned int data) { return value ? data | 0b0000000001000000 : data & 0b1111111110111111; } 48 | unsigned int write_flag8_lb_7(const bool value, const unsigned int data) { return value ? data | 0b0000000010000000 : data & 0b1111111101111111; } 49 | unsigned int write_flag8_hb_0(const bool value, const unsigned int data) { return value ? data | 0b0000000100000000 : data & 0b1111111011111111; } 50 | unsigned int write_flag8_hb_1(const bool value, const unsigned int data) { return value ? data | 0b0000001000000000 : data & 0b1111110111111111; } 51 | unsigned int write_flag8_hb_2(const bool value, const unsigned int data) { return value ? data | 0b0000010000000000 : data & 0b1111101111111111; } 52 | unsigned int write_flag8_hb_3(const bool value, const unsigned int data) { return value ? data | 0b0000100000000000 : data & 0b1111011111111111; } 53 | unsigned int write_flag8_hb_4(const bool value, const unsigned int data) { return value ? data | 0b0001000000000000 : data & 0b1110111111111111; } 54 | unsigned int write_flag8_hb_5(const bool value, const unsigned int data) { return value ? data | 0b0010000000000000 : data & 0b1101111111111111; } 55 | unsigned int write_flag8_hb_6(const bool value, const unsigned int data) { return value ? data | 0b0100000000000000 : data & 0b1011111111111111; } 56 | unsigned int write_flag8_hb_7(const bool value, const unsigned int data) { return value ? data | 0b1000000000000000 : data & 0b0111111111111111; } 57 | unsigned int write_u8_lb(const uint8_t value, const unsigned int data) { return (data & 0xff00) | value; } 58 | unsigned int write_u8_hb(const uint8_t value, const unsigned int data) { return (data & 0x00ff) | (value << 8); } 59 | unsigned int write_s8_lb(const int8_t value, const unsigned int data) { return (data & 0xff00) | value; } 60 | unsigned int write_s8_hb(const int8_t value, const unsigned int data) { return (data & 0x00ff) | (value << 8); } 61 | unsigned int write_u16(const uint16_t value, const unsigned int data) { return value; } 62 | unsigned int write_s16(const int16_t value, const unsigned int data) { return value; } 63 | unsigned int write_f88(const float value, const unsigned int data) { return (unsigned int) (value * 256.0f); } 64 | } // namespace message_data 65 | 66 | #define OPENTHERM_IGNORE_1(x) 67 | #define OPENTHERM_IGNORE_2(x, y) 68 | 69 | unsigned int OpenthermHub::build_request(OpenThermMessageID request_id) { 70 | // First, handle the status request. This requires special logic, because we 71 | // wouldn't want to inadvertently disable domestic hot water, for example. 72 | // It is also included in the macro-generated code below, but that will 73 | // never be executed, because we short-circuit it here. 74 | if (request_id == OpenThermMessageID::Status) { 75 | ESP_LOGD(TAG, "Building Status request"); 76 | bool ch_enable = 77 | this->ch_enable 78 | && 79 | #ifdef OPENTHERM_READ_ch_enable 80 | OPENTHERM_READ_ch_enable 81 | #else 82 | true 83 | #endif 84 | && 85 | #ifdef OPENTHERM_READ_t_set 86 | OPENTHERM_READ_t_set > 0.0 87 | #else 88 | true 89 | #endif 90 | ; 91 | bool dhw_enable = 92 | this->dhw_enable 93 | && 94 | #ifdef OPENTHERM_READ_dhw_enable 95 | OPENTHERM_READ_dhw_enable 96 | #else 97 | true 98 | #endif 99 | ; 100 | bool cooling_enable = 101 | this->cooling_enable 102 | && 103 | #ifdef OPENTHERM_READ_cooling_enable 104 | OPENTHERM_READ_cooling_enable 105 | #else 106 | true 107 | #endif 108 | && 109 | #ifdef OPENTHERM_READ_cooling_control 110 | OPENTHERM_READ_cooling_control > 0.0 111 | #else 112 | true 113 | #endif 114 | ; 115 | bool otc_active = 116 | this->otc_active 117 | && 118 | #ifdef OPENTHERM_READ_otc_active 119 | OPENTHERM_READ_otc_active 120 | #else 121 | true 122 | #endif 123 | ; 124 | bool ch2_active = 125 | this->ch2_active 126 | && 127 | #ifdef OPENTHERM_READ_ch2_active 128 | OPENTHERM_READ_ch2_active 129 | #else 130 | true 131 | #endif 132 | && 133 | #ifdef OPENTHERM_READ_t_set_ch2 134 | OPENTHERM_READ_t_set_ch2 > 0.0 135 | #else 136 | true 137 | #endif 138 | ; 139 | return ot->buildSetBoilerStatusRequest(ch_enable, dhw_enable, cooling_enable, otc_active, ch2_active); 140 | } 141 | 142 | // Next, we start with the write requests from switches and other inputs, 143 | // because we would want to write that data if it is available, rather than 144 | // request a read for that type (in the case that both read and write are 145 | // supported). 146 | #define OPENTHERM_MESSAGE_WRITE_MESSAGE(msg) \ 147 | case OpenThermMessageID::msg: { \ 148 | ESP_LOGD(TAG, "Building %s write request", #msg); \ 149 | unsigned int data = 0; 150 | #define OPENTHERM_MESSAGE_WRITE_ENTITY(key, msg_data) \ 151 | data = message_data::write_ ## msg_data(this->key->state, data); 152 | #define OPENTHERM_MESSAGE_WRITE_POSTSCRIPT \ 153 | return ot->buildRequest(OpenThermMessageType::WRITE_DATA, request_id, data); \ 154 | } 155 | switch (request_id) { 156 | OPENTHERM_SWITCH_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) 157 | OPENTHERM_NUMBER_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) 158 | OPENTHERM_OUTPUT_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) 159 | OPENTHERM_INPUT_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_WRITE_MESSAGE, OPENTHERM_MESSAGE_WRITE_ENTITY, , OPENTHERM_MESSAGE_WRITE_POSTSCRIPT, ) 160 | } 161 | 162 | // Finally, handle the simple read requests, which only change with the message id. 163 | #define OPENTHERM_MESSAGE_READ_MESSAGE(msg) \ 164 | case OpenThermMessageID::msg: \ 165 | ESP_LOGD(TAG, "Building %s read request", #msg); \ 166 | return ot->buildRequest(OpenThermMessageType::READ_DATA, request_id, 0); 167 | switch (request_id) { 168 | OPENTHERM_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_READ_MESSAGE, OPENTHERM_IGNORE_2, , , ) 169 | OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_READ_MESSAGE, OPENTHERM_IGNORE_2, , , ) 170 | } 171 | 172 | // And if we get here, a message was requested which somehow wasn't handled. 173 | // This shouldn't happen due to the way the defines are configured, so we 174 | // log an error and just return a 0 message. 175 | ESP_LOGE(TAG, "Tried to create a request with unknown id %d. This should never happen, so please open an issue.", request_id); 176 | return 0; 177 | } 178 | 179 | OpenthermHub::OpenthermHub(void(*handle_interrupt_callback)(void), void(*process_response_callback)(unsigned long, OpenThermResponseStatus)) 180 | : Component(), handle_interrupt_callback(handle_interrupt_callback), process_response_callback(process_response_callback) { 181 | } 182 | 183 | void IRAM_ATTR OpenthermHub::handle_interrupt() { 184 | this->ot->handleInterrupt(); 185 | } 186 | 187 | void OpenthermHub::process_response(unsigned long response, OpenThermResponseStatus status) { 188 | // First check if the response is valid and short-circuit execution if it isn't. 189 | if (!ot->isValidResponse(response)) { 190 | ESP_LOGW( 191 | TAG, 192 | "Received invalid OpenTherm response: %s, status=%s", 193 | String(response, HEX).c_str(), 194 | String(ot->getLastResponseStatus()).c_str() 195 | ); 196 | return; 197 | } 198 | 199 | // Read the second byte of the response, which is the message id. 200 | byte id = (response >> 16 & 0xFF); 201 | ESP_LOGD(TAG, "Received OpenTherm response with id %d: %s", id, String(response, HEX).c_str()); 202 | 203 | // Define the handler helpers to publish the results to all sensors 204 | #define OPENTHERM_MESSAGE_RESPONSE_MESSAGE(msg) \ 205 | case OpenThermMessageID::msg: \ 206 | ESP_LOGD(TAG, "Received %s response", #msg); 207 | #define OPENTHERM_MESSAGE_RESPONSE_ENTITY(key, msg_data) \ 208 | this->key->publish_state(message_data::parse_ ## msg_data(response)); 209 | #define OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT \ 210 | break; 211 | 212 | // Then use those to create a switch statement for each thing we would want 213 | // to report. We use a separate switch statement for each type, because some 214 | // messages include results for multiple types, like flags and a number. 215 | switch (id) { 216 | OPENTHERM_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_RESPONSE_MESSAGE, OPENTHERM_MESSAGE_RESPONSE_ENTITY, , OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT, ) 217 | } 218 | switch (id) { 219 | OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS(OPENTHERM_MESSAGE_RESPONSE_MESSAGE, OPENTHERM_MESSAGE_RESPONSE_ENTITY, , OPENTHERM_MESSAGE_RESPONSE_POSTSCRIPT, ) 220 | } 221 | } 222 | 223 | void OpenthermHub::setup() { 224 | ESP_LOGD(TAG, "Setting up OpenTherm component"); 225 | this->ot = new OpenTherm(this->in_pin, this->out_pin, false); 226 | this->ot->begin(this->handle_interrupt_callback, this->process_response_callback); 227 | 228 | // Ensure that there is at least one request, as we are required to 229 | // communicate at least once every second. Sending the status request is 230 | // good practice anyway. 231 | this->add_repeating_message(OpenThermMessageID::Status); 232 | 233 | this->current_message_iterator = this->initial_messages.begin(); 234 | } 235 | 236 | void OpenthermHub::on_shutdown() { 237 | this->ot->end(); 238 | } 239 | 240 | void OpenthermHub::loop() { 241 | if (this->ot->isReady()) { 242 | if (this->initializing && this->current_message_iterator == this->initial_messages.end()) { 243 | this->initializing = false; 244 | this->current_message_iterator = this->repeating_messages.begin(); 245 | } else if (this->current_message_iterator == this->repeating_messages.end()) { 246 | this->current_message_iterator = this->repeating_messages.begin(); 247 | } 248 | 249 | unsigned int request = this->build_request(*this->current_message_iterator); 250 | this->ot->sendRequestAync(request); 251 | ESP_LOGD(TAG, "Sent OpenTherm request: %s", String(request, HEX).c_str()); 252 | this->current_message_iterator++; 253 | } 254 | this->ot->process(); 255 | } 256 | 257 | #define ID(x) x 258 | #define SHOW2(x) #x 259 | #define SHOW(x) SHOW2(x) 260 | 261 | void OpenthermHub::dump_config() { 262 | ESP_LOGCONFIG(TAG, "OpenTherm:"); 263 | ESP_LOGCONFIG(TAG, " In: GPIO%d", this->in_pin); 264 | ESP_LOGCONFIG(TAG, " Out: GPIO%d", this->out_pin); 265 | ESP_LOGCONFIG(TAG, " Sensors: %s", SHOW(OPENTHERM_SENSOR_LIST(ID, ))); 266 | ESP_LOGCONFIG(TAG, " Binary sensors: %s", SHOW(OPENTHERM_BINARY_SENSOR_LIST(ID, ))); 267 | ESP_LOGCONFIG(TAG, " Switches: %s", SHOW(OPENTHERM_SWITCH_LIST(ID, ))); 268 | ESP_LOGCONFIG(TAG, " Input sensors: %s", SHOW(OPENTHERM_INPUT_SENSOR_LIST(ID, ))); 269 | ESP_LOGCONFIG(TAG, " Outputs: %s", SHOW(OPENTHERM_OUTPUT_LIST(ID, ))); 270 | ESP_LOGCONFIG(TAG, " Numbers: %s", SHOW(OPENTHERM_NUMBER_LIST(ID, ))); 271 | ESP_LOGCONFIG(TAG, " Initial requests:"); 272 | for (auto type : this->initial_messages) { 273 | ESP_LOGCONFIG(TAG, " - %d", type); 274 | } 275 | ESP_LOGCONFIG(TAG, " Repeating requests:"); 276 | for (auto type : this->repeating_messages) { 277 | ESP_LOGCONFIG(TAG, " - %d", type); 278 | } 279 | } 280 | 281 | } // namespace opentherm 282 | } // namespace esphome 283 | 284 | #pragma GCC diagnostic pop 285 | -------------------------------------------------------------------------------- /components/opentherm/hub.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/core/log.h" 5 | 6 | #include "OpenTherm.h" 7 | 8 | #include "esphome/components/sensor/sensor.h" 9 | #include "esphome/components/binary_sensor/binary_sensor.h" 10 | 11 | #include "switch.h" 12 | #include "number.h" 13 | #include "output.h" 14 | 15 | #include 16 | #include 17 | 18 | // Ensure that all component macros are defined, even if the component is not used 19 | #ifndef OPENTHERM_SENSOR_LIST 20 | #define OPENTHERM_SENSOR_LIST(F, sep) 21 | #endif 22 | #ifndef OPENTHERM_BINARY_SENSOR_LIST 23 | #define OPENTHERM_BINARY_SENSOR_LIST(F, sep) 24 | #endif 25 | #ifndef OPENTHERM_SWITCH_LIST 26 | #define OPENTHERM_SWITCH_LIST(F, sep) 27 | #endif 28 | #ifndef OPENTHERM_NUMBER_LIST 29 | #define OPENTHERM_NUMBER_LIST(F, sep) 30 | #endif 31 | #ifndef OPENTHERM_OUTPUT_LIST 32 | #define OPENTHERM_OUTPUT_LIST(F, sep) 33 | #endif 34 | #ifndef OPENTHERM_INPUT_SENSOR_LIST 35 | #define OPENTHERM_INPUT_SENSOR_LIST(F, sep) 36 | #endif 37 | 38 | #ifndef OPENTHERM_SENSOR_MESSAGE_HANDLERS 39 | #define OPENTHERM_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) 40 | #endif 41 | #ifndef OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS 42 | #define OPENTHERM_BINARY_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) 43 | #endif 44 | #ifndef OPENTHERM_SWITCH_MESSAGE_HANDLERS 45 | #define OPENTHERM_SWITCH_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) 46 | #endif 47 | #ifndef OPENTHERM_NUMBER_MESSAGE_HANDLERS 48 | #define OPENTHERM_NUMBER_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) 49 | #endif 50 | #ifndef OPENTHERM_OUTPUT_MESSAGE_HANDLERS 51 | #define OPENTHERM_OUTPUT_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) 52 | #endif 53 | #ifndef OPENTHERM_INPUT_SENSOR_MESSAGE_HANDLERS 54 | #define OPENTHERM_INPUT_SENSOR_MESSAGE_HANDLERS(MESSAGE, ENTITY, entity_sep, postscript, msg_sep) 55 | #endif 56 | 57 | namespace esphome { 58 | namespace opentherm { 59 | 60 | // OpenTherm component for ESPHome 61 | class OpenthermHub : public Component { 62 | protected: 63 | // Communication pins for the OpenTherm interface 64 | int in_pin, out_pin; 65 | // The OpenTherm interface from @ihormelnyk's library 66 | OpenTherm* ot; 67 | 68 | // Use macros to create fields for every entity specified in the ESPHome configuration 69 | #define OPENTHERM_DECLARE_SENSOR(entity) sensor::Sensor* entity; 70 | OPENTHERM_SENSOR_LIST(OPENTHERM_DECLARE_SENSOR, ) 71 | 72 | #define OPENTHERM_DECLARE_BINARY_SENSOR(entity) binary_sensor::BinarySensor* entity; 73 | OPENTHERM_BINARY_SENSOR_LIST(OPENTHERM_DECLARE_BINARY_SENSOR, ) 74 | 75 | #define OPENTHERM_DECLARE_SWITCH(entity) OpenthermSwitch* entity; 76 | OPENTHERM_SWITCH_LIST(OPENTHERM_DECLARE_SWITCH, ) 77 | 78 | #define OPENTHERM_DECLARE_NUMBER(entity) OpenthermNumber* entity; 79 | OPENTHERM_NUMBER_LIST(OPENTHERM_DECLARE_NUMBER, ) 80 | 81 | #define OPENTHERM_DECLARE_OUTPUT(entity) OpenthermOutput* entity; 82 | OPENTHERM_OUTPUT_LIST(OPENTHERM_DECLARE_OUTPUT, ) 83 | 84 | #define OPENTHERM_DECLARE_INPUT_SENSOR(entity) sensor::Sensor* entity; 85 | OPENTHERM_INPUT_SENSOR_LIST(OPENTHERM_DECLARE_INPUT_SENSOR, ) 86 | 87 | // The set of initial messages to send on starting communication with the boiler 88 | std::unordered_set initial_messages; 89 | // and the repeating messages which are sent repeatedly to update various sensors 90 | // and boiler parameters (like the setpoint). 91 | std::unordered_set repeating_messages; 92 | // Indicates if we are still working on the initial requests or not 93 | bool initializing = true; 94 | // Index for the current request in one of the _requests sets. 95 | std::unordered_set::const_iterator current_message_iterator; 96 | 97 | // Create OpenTherm messages based on the message id 98 | unsigned int build_request(OpenThermMessageID request_id); 99 | 100 | // Callbacks to pass to OpenTherm interface for globally defined interrupts 101 | void(*handle_interrupt_callback)(); 102 | void(*process_response_callback)(unsigned long, OpenThermResponseStatus); 103 | 104 | public: 105 | // Constructor with references to the global interrupt handlers 106 | OpenthermHub(void(*handle_interrupt_callback)(void), void(*process_response_callback)(unsigned long, OpenThermResponseStatus)); 107 | 108 | // Interrupt handler, which notifies the OpenTherm interface of an interrupt 109 | void IRAM_ATTR handle_interrupt(); 110 | 111 | // Handle responses from the OpenTherm interface 112 | void process_response(unsigned long response, OpenThermResponseStatus status); 113 | 114 | // Setters for the input and output OpenTherm interface pins 115 | void set_in_pin(int in_pin) { this->in_pin = in_pin; } 116 | void set_out_pin(int out_pin) { this->out_pin = out_pin; } 117 | 118 | #define OPENTHERM_SET_SENSOR(entity) void set_ ## entity(sensor::Sensor* sensor) { this->entity = sensor; } 119 | OPENTHERM_SENSOR_LIST(OPENTHERM_SET_SENSOR, ) 120 | 121 | #define OPENTHERM_SET_BINARY_SENSOR(entity) void set_ ## entity(binary_sensor::BinarySensor* binary_sensor) { this->entity = binary_sensor; } 122 | OPENTHERM_BINARY_SENSOR_LIST(OPENTHERM_SET_BINARY_SENSOR, ) 123 | 124 | #define OPENTHERM_SET_SWITCH(entity) void set_ ## entity(OpenthermSwitch* sw) { this->entity = sw; } 125 | OPENTHERM_SWITCH_LIST(OPENTHERM_SET_SWITCH, ) 126 | 127 | #define OPENTHERM_SET_NUMBER(entity) void set_ ## entity(OpenthermNumber* number) { this->entity = number; } 128 | OPENTHERM_NUMBER_LIST(OPENTHERM_SET_NUMBER, ) 129 | 130 | #define OPENTHERM_SET_OUTPUT(entity) void set_ ## entity(OpenthermOutput* output) { this->entity = output; } 131 | OPENTHERM_OUTPUT_LIST(OPENTHERM_SET_OUTPUT, ) 132 | 133 | #define OPENTHERM_SET_INPUT_SENSOR(entity) void set_ ## entity(sensor::Sensor* sensor) { this->entity = sensor; } 134 | OPENTHERM_INPUT_SENSOR_LIST(OPENTHERM_SET_INPUT_SENSOR, ) 135 | 136 | // Add a request to the set of initial requests 137 | void add_initial_message(OpenThermMessageID message_id) { this->initial_messages.insert(message_id); } 138 | // Add a request to the set of repeating requests. Note that a large number of repeating 139 | // requests will slow down communication with the boiler. Each request may take up to 1 second, 140 | // so with all sensors enabled, it may take about half a minute before a change in setpoint 141 | // will be processed. 142 | void add_repeating_message(OpenThermMessageID message_id) { this->repeating_messages.insert(message_id); } 143 | 144 | // There are five status variables, which can either be set as a simple variable, 145 | // or using a switch. ch_enable and dhw_enable default to true, the others to false. 146 | bool ch_enable = true, dhw_enable = true, cooling_enable, otc_active, ch2_active; 147 | 148 | // Setters for the status variables 149 | void set_ch_enable(bool ch_enable) { this->ch_enable = ch_enable; } 150 | void set_dhw_enable(bool dhw_enable) { this->dhw_enable = dhw_enable; } 151 | void set_cooling_enable(bool cooling_enable) { this->cooling_enable = cooling_enable; } 152 | void set_otc_active(bool otc_active) { this->otc_active = otc_active; } 153 | void set_ch2_active(bool ch2_active) { this->ch2_active = ch2_active; } 154 | 155 | float get_setup_priority() const override{ 156 | return setup_priority::HARDWARE; 157 | } 158 | 159 | void setup() override; 160 | void on_shutdown() override; 161 | void loop() override; 162 | void dump_config() override; 163 | }; 164 | 165 | } // namespace opentherm 166 | } // namespace esphome 167 | -------------------------------------------------------------------------------- /components/opentherm/input.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | namespace esphome { 4 | namespace opentherm { 5 | 6 | class OpenthermInput { 7 | public: 8 | bool auto_min_value, auto_max_value; 9 | 10 | virtual void set_min_value(float min_value) = 0; 11 | virtual void set_max_value(float max_value) = 0; 12 | 13 | virtual void set_auto_min_value(bool auto_min_value) { this->auto_min_value = auto_min_value; } 14 | virtual void set_auto_max_value(bool auto_max_value) { this->auto_max_value = auto_max_value; } 15 | }; 16 | 17 | } // namespace opentherm 18 | } // namespace esphome 19 | -------------------------------------------------------------------------------- /components/opentherm/input.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | 6 | from . import schema, generate 7 | 8 | CONF_min_value = "min_value" 9 | CONF_max_value = "max_value" 10 | CONF_auto_min_value = "auto_min_value" 11 | CONF_auto_max_value = "auto_max_value" 12 | 13 | OpenthermInput = generate.opentherm_ns.class_("OpenthermInput") 14 | 15 | def validate_min_value_less_than_max_value(conf): 16 | if CONF_min_value in conf and CONF_max_value in conf and conf[CONF_min_value] > conf[CONF_max_value]: 17 | raise cv.Invalid(f"{CONF_min_value} must be less than {CONF_max_value}") 18 | return conf 19 | 20 | def input_schema(entity: schema.InputSchema) -> cv.Schema: 21 | schema = cv.Schema({ 22 | cv.Optional(CONF_min_value, entity["range"][0]): cv.float_range(entity["range"][0], entity["range"][1]), 23 | cv.Optional(CONF_max_value, entity["range"][1]): cv.float_range(entity["range"][0], entity["range"][1]), 24 | }) 25 | if CONF_auto_min_value in entity: 26 | schema = schema.extend({ cv.Optional(CONF_auto_min_value, False): cv.boolean }) 27 | if CONF_auto_max_value in entity: 28 | schema = schema.extend({ cv.Optional(CONF_auto_max_value, False): cv.boolean }) 29 | schema = schema.add_extra(validate_min_value_less_than_max_value) 30 | return schema 31 | 32 | def generate_setters(entity: cg.MockObj, conf: Dict[str, Any]) -> None: 33 | generate.add_property_set(entity, CONF_min_value, conf) 34 | generate.add_property_set(entity, CONF_max_value, conf) 35 | generate.add_property_set(entity, CONF_auto_min_value, conf) 36 | generate.add_property_set(entity, CONF_auto_max_value, conf) 37 | -------------------------------------------------------------------------------- /components/opentherm/number.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/components/number/number.h" 4 | #include "input.h" 5 | 6 | namespace esphome { 7 | namespace opentherm { 8 | 9 | // Just a simple number, which stores the number 10 | class OpenthermNumber : public number::Number, public Component, public OpenthermInput { 11 | protected: 12 | void control(float value) override { 13 | this->publish_state(value); 14 | } 15 | 16 | public: 17 | void set_min_value(float min_value) override { this->traits.set_min_value(min_value); } 18 | void set_max_value(float max_value) override { this->traits.set_max_value(max_value); } 19 | }; 20 | 21 | } // namespace opentherm 22 | } // namespace esphome 23 | -------------------------------------------------------------------------------- /components/opentherm/number.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | from esphome.components import number 6 | from esphome.const import CONF_ID, CONF_UNIT_OF_MEASUREMENT 7 | 8 | from . import const, schema, validate, input, generate 9 | 10 | DEPENDENCIES = [ const.OPENTHERM ] 11 | COMPONENT_TYPE = const.NUMBER 12 | 13 | OpenthermNumber = generate.opentherm_ns.class_("OpenthermNumber", number.Number, cg.Component, input.OpenthermInput) 14 | 15 | async def new_openthermnumber(config: Dict[str, Any]) -> cg.Pvariable: 16 | var = cg.new_Pvariable(config[CONF_ID]) 17 | await cg.register_component(var, config) 18 | await number.register_number(var, config, min_value = config[input.CONF_min_value], max_value = config[input.CONF_max_value]) 19 | input.generate_setters(var, config) 20 | return var 21 | 22 | def get_entity_validation_schema(entity: schema.InputSchema) -> cv.Schema: 23 | return number.NUMBER_SCHEMA \ 24 | .extend({ 25 | cv.GenerateID(): cv.declare_id(OpenthermNumber), 26 | cv.Optional(CONF_UNIT_OF_MEASUREMENT, entity["unit_of_measurement"]): cv.string_strict 27 | }) \ 28 | .extend(input.input_schema(entity)) \ 29 | .extend(cv.COMPONENT_SCHEMA) 30 | 31 | CONFIG_SCHEMA = validate.create_component_schema(schema.INPUTS, get_entity_validation_schema) 32 | 33 | async def to_code(config: Dict[str, Any]) -> None: 34 | keys = await generate.component_to_code( 35 | COMPONENT_TYPE, 36 | schema.INPUTS, 37 | OpenthermNumber, 38 | generate.create_only_conf(new_openthermnumber), 39 | config 40 | ) 41 | generate.define_readers(COMPONENT_TYPE, keys) 42 | -------------------------------------------------------------------------------- /components/opentherm/output.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/components/output/float_output.h" 4 | #include "input.h" 5 | 6 | namespace esphome { 7 | namespace opentherm { 8 | 9 | class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { 10 | protected: 11 | bool has_state_ = false; 12 | const char* id = nullptr; 13 | 14 | float min_value, max_value; 15 | 16 | public: 17 | float state; 18 | 19 | void set_id(const char* id) { this->id = id; } 20 | 21 | void write_state(float state) override { 22 | this->state = state < 0.003 && this->zero_means_zero_ ? 0.0 : min_value + state * (max_value - min_value); 23 | this->has_state_ = true; 24 | ESP_LOGD("opentherm.output", "Output %s set to %.2f", this->id, this->state); 25 | }; 26 | 27 | bool has_state() { return this->has_state_; }; 28 | 29 | void set_min_value(float min_value) override { this->min_value = min_value; } 30 | void set_max_value(float max_value) override { this->max_value = max_value; } 31 | }; 32 | 33 | } // namespace opentherm 34 | } // namespace esphome 35 | -------------------------------------------------------------------------------- /components/opentherm/output.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | from esphome.components import output 6 | from esphome.const import CONF_ID 7 | 8 | from . import const, schema, validate, input, generate 9 | 10 | DEPENDENCIES = [ const.OPENTHERM ] 11 | COMPONENT_TYPE = const.OUTPUT 12 | 13 | OpenthermOutput = generate.opentherm_ns.class_("OpenthermOutput", output.FloatOutput, cg.Component, input.OpenthermInput) 14 | 15 | async def new_openthermoutput(config: Dict[str, Any], key: str, _hub: cg.MockObj) -> cg.Pvariable: 16 | var = cg.new_Pvariable(config[CONF_ID]) 17 | await cg.register_component(var, config) 18 | await output.register_output(var, config) 19 | cg.add(getattr(var, "set_id")(cg.RawExpression(f'"{key}_{config[CONF_ID]}"'))) 20 | input.generate_setters(var, config) 21 | return var 22 | 23 | def get_entity_validation_schema(entity: schema.InputSchema) -> cv.Schema: 24 | return output.FLOAT_OUTPUT_SCHEMA \ 25 | .extend({ cv.GenerateID(): cv.declare_id(OpenthermOutput) }) \ 26 | .extend(input.input_schema(entity)) \ 27 | .extend(cv.COMPONENT_SCHEMA) 28 | 29 | CONFIG_SCHEMA = validate.create_component_schema(schema.INPUTS, get_entity_validation_schema) 30 | 31 | async def to_code(config: Dict[str, Any]) -> None: 32 | keys = await generate.component_to_code( 33 | COMPONENT_TYPE, 34 | schema.INPUTS, 35 | OpenthermOutput, 36 | new_openthermoutput, 37 | config 38 | ) 39 | generate.define_readers(COMPONENT_TYPE, keys) 40 | -------------------------------------------------------------------------------- /components/opentherm/schema.py: -------------------------------------------------------------------------------- 1 | # This file contains a schema for all supported sensors, binary sensors and 2 | # inputs of the OpenTherm component. 3 | 4 | from typing import Dict, Generic, Tuple, TypeVar, TypedDict 5 | from typing_extensions import NotRequired 6 | 7 | from esphome.const import ( 8 | UNIT_CELSIUS, 9 | UNIT_PERCENT, 10 | DEVICE_CLASS_COLD, 11 | DEVICE_CLASS_HEAT, 12 | DEVICE_CLASS_PRESSURE, 13 | DEVICE_CLASS_PROBLEM, 14 | DEVICE_CLASS_TEMPERATURE, 15 | STATE_CLASS_MEASUREMENT, 16 | STATE_CLASS_TOTAL_INCREASING, 17 | ) 18 | 19 | T = TypeVar("T") 20 | class Schema(Generic[T], Dict[str, T]): 21 | pass 22 | 23 | class EntitySchema(TypedDict): 24 | description: str 25 | """Description of the item, based on the OpenTherm spec""" 26 | 27 | message: str 28 | """OpenTherm message id used to read or write the value""" 29 | 30 | keep_updated: bool 31 | """Whether the value should be read or write repeatedly (True) or only during 32 | the initialization phase (False) 33 | """ 34 | 35 | message_data: str 36 | """Instructions on how to interpret the data in the message 37 | - flag8_[hb|lb]_[0-7]: data is a byte of single bit flags, 38 | this flag is set in the high (hb) or low byte (lb), 39 | at position 0 to 7 40 | - u8_[hb|lb]: data is an unsigned 8-bit integer, 41 | in the high (hb) or low byte (lb) 42 | - s8_[hb|lb]: data is an signed 8-bit integer, 43 | in the high (hb) or low byte (lb) 44 | - f88: data is a signed fixed point value with 45 | 1 sign bit, 7 integer bits, 8 fractional bits 46 | - u16: data is an unsigned 16-bit integer 47 | - s16: data is a signed 16-bit integer 48 | """ 49 | 50 | class SensorSchema(EntitySchema): 51 | unit_of_measurement: NotRequired[str] 52 | accuracy_decimals: int 53 | device_class: NotRequired[str] 54 | icon: NotRequired[str] 55 | state_class: str 56 | 57 | SENSORS: Schema[SensorSchema] = Schema({ 58 | "rel_mod_level": SensorSchema({ 59 | "description": "Relative modulation level", 60 | "unit_of_measurement": UNIT_PERCENT, 61 | "accuracy_decimals": 2, 62 | "icon": "mdi:percent", 63 | "state_class": STATE_CLASS_MEASUREMENT, 64 | "message": "RelModLevel", 65 | "keep_updated": True, 66 | "message_data": "f88", 67 | }), 68 | "ch_pressure": SensorSchema({ 69 | "description": "Water pressure in CH circuit", 70 | "unit_of_measurement": "bar", 71 | "accuracy_decimals": 2, 72 | "device_class": DEVICE_CLASS_PRESSURE, 73 | "state_class": STATE_CLASS_MEASUREMENT, 74 | "message": "CHPressure", 75 | "keep_updated": True, 76 | "message_data": "f88", 77 | }), 78 | "dhw_flow_rate": SensorSchema({ 79 | "description": "Water flow rate in DHW circuit", 80 | "unit_of_measurement": "l/min", 81 | "accuracy_decimals": 2, 82 | "icon": "mdi:waves-arrow-right", 83 | "state_class": STATE_CLASS_MEASUREMENT, 84 | "message": "DHWFlowRate", 85 | "keep_updated": True, 86 | "message_data": "f88", 87 | }), 88 | "t_boiler": SensorSchema({ 89 | "description": "Boiler water temperature", 90 | "unit_of_measurement": UNIT_CELSIUS, 91 | "accuracy_decimals": 2, 92 | "device_class": DEVICE_CLASS_TEMPERATURE, 93 | "state_class": STATE_CLASS_MEASUREMENT, 94 | "message": "Tboiler", 95 | "keep_updated": True, 96 | "message_data": "f88", 97 | }), 98 | "t_dhw": SensorSchema({ 99 | "description": "DHW temperature", 100 | "unit_of_measurement": UNIT_CELSIUS, 101 | "accuracy_decimals": 2, 102 | "device_class": DEVICE_CLASS_TEMPERATURE, 103 | "state_class": STATE_CLASS_MEASUREMENT, 104 | "message": "Tdhw", 105 | "keep_updated": True, 106 | "message_data": "f88", 107 | }), 108 | "t_outside": SensorSchema({ 109 | "description": "Outside temperature", 110 | "unit_of_measurement": UNIT_CELSIUS, 111 | "accuracy_decimals": 2, 112 | "device_class": DEVICE_CLASS_TEMPERATURE, 113 | "state_class": STATE_CLASS_MEASUREMENT, 114 | "message": "Toutside", 115 | "keep_updated": True, 116 | "message_data": "f88", 117 | }), 118 | "t_ret": SensorSchema({ 119 | "description": "Return water temperature", 120 | "unit_of_measurement": UNIT_CELSIUS, 121 | "accuracy_decimals": 2, 122 | "device_class": DEVICE_CLASS_TEMPERATURE, 123 | "state_class": STATE_CLASS_MEASUREMENT, 124 | "message": "Tret", 125 | "keep_updated": True, 126 | "message_data": "f88", 127 | }), 128 | "t_storage": SensorSchema({ 129 | "description": "Solar storage temperature", 130 | "unit_of_measurement": UNIT_CELSIUS, 131 | "accuracy_decimals": 2, 132 | "device_class": DEVICE_CLASS_TEMPERATURE, 133 | "state_class": STATE_CLASS_MEASUREMENT, 134 | "message": "Tstorage", 135 | "keep_updated": True, 136 | "message_data": "f88", 137 | }), 138 | "t_collector": SensorSchema({ 139 | "description": "Solar collector temperature", 140 | "unit_of_measurement": UNIT_CELSIUS, 141 | "accuracy_decimals": 0, 142 | "device_class": DEVICE_CLASS_TEMPERATURE, 143 | "state_class": STATE_CLASS_MEASUREMENT, 144 | "message": "Tcollector", 145 | "keep_updated": True, 146 | "message_data": "s16", 147 | }), 148 | "t_flow_ch2": SensorSchema({ 149 | "description": "Flow water temperature CH2 circuit", 150 | "unit_of_measurement": UNIT_CELSIUS, 151 | "accuracy_decimals": 2, 152 | "device_class": DEVICE_CLASS_TEMPERATURE, 153 | "state_class": STATE_CLASS_MEASUREMENT, 154 | "message": "TflowCH2", 155 | "keep_updated": True, 156 | "message_data": "f88", 157 | }), 158 | "t_dhw2": SensorSchema({ 159 | "description": "Domestic hot water temperature 2", 160 | "unit_of_measurement": UNIT_CELSIUS, 161 | "accuracy_decimals": 2, 162 | "device_class": DEVICE_CLASS_TEMPERATURE, 163 | "state_class": STATE_CLASS_MEASUREMENT, 164 | "message": "Tdhw2", 165 | "keep_updated": True, 166 | "message_data": "f88", 167 | }), 168 | "t_exhaust": SensorSchema({ 169 | "description": "Boiler exhaust temperature", 170 | "unit_of_measurement": UNIT_CELSIUS, 171 | "accuracy_decimals": 0, 172 | "device_class": DEVICE_CLASS_TEMPERATURE, 173 | "state_class": STATE_CLASS_MEASUREMENT, 174 | "message": "Texhaust", 175 | "keep_updated": True, 176 | "message_data": "s16", 177 | }), 178 | "burner_starts": SensorSchema({ 179 | "description": "Number of starts burner", 180 | "accuracy_decimals": 0, 181 | "icon": "mdi:gas-burner", 182 | "state_class": STATE_CLASS_TOTAL_INCREASING, 183 | "message": "BurnerStarts", 184 | "keep_updated": True, 185 | "message_data": "u16", 186 | }), 187 | "ch_pump_starts": SensorSchema({ 188 | "description": "Number of starts CH pump", 189 | "accuracy_decimals": 0, 190 | "icon": "mdi:pump", 191 | "state_class": STATE_CLASS_TOTAL_INCREASING, 192 | "message": "CHPumpStarts", 193 | "keep_updated": True, 194 | "message_data": "u16", 195 | }), 196 | "dhw_pump_valve_starts": SensorSchema({ 197 | "description": "Number of starts DHW pump/valve", 198 | "accuracy_decimals": 0, 199 | "icon": "mdi:water-pump", 200 | "state_class": STATE_CLASS_TOTAL_INCREASING, 201 | "message": "DHWPumpValveStarts", 202 | "keep_updated": True, 203 | "message_data": "u16", 204 | }), 205 | "dhw_burner_starts": SensorSchema({ 206 | "description": "Number of starts burner during DHW mode", 207 | "accuracy_decimals": 0, 208 | "icon": "mdi:gas-burner", 209 | "state_class": STATE_CLASS_TOTAL_INCREASING, 210 | "message": "DHWBurnerStarts", 211 | "keep_updated": True, 212 | "message_data": "u16", 213 | }), 214 | "burner_operation_hours": SensorSchema({ 215 | "description": "Number of hours that burner is in operation", 216 | "accuracy_decimals": 0, 217 | "icon": "mdi:clock-outline", 218 | "state_class": STATE_CLASS_TOTAL_INCREASING, 219 | "message": "BurnerOperationHours", 220 | "keep_updated": True, 221 | "message_data": "u16", 222 | }), 223 | "ch_pump_operation_hours": SensorSchema({ 224 | "description": "Number of hours that CH pump has been running", 225 | "accuracy_decimals": 0, 226 | "icon": "mdi:clock-outline", 227 | "state_class": STATE_CLASS_TOTAL_INCREASING, 228 | "message": "CHPumpOperationHours", 229 | "keep_updated": True, 230 | "message_data": "u16", 231 | }), 232 | "dhw_pump_valve_operation_hours": SensorSchema({ 233 | "description": "Number of hours that DHW pump has been running or DHW valve has been opened", 234 | "accuracy_decimals": 0, 235 | "icon": "mdi:clock-outline", 236 | "state_class": STATE_CLASS_TOTAL_INCREASING, 237 | "message": "DHWPumpValveOperationHours", 238 | "keep_updated": True, 239 | "message_data": "u16", 240 | }), 241 | "dhw_burner_operation_hours": SensorSchema({ 242 | "description": "Number of hours that burner is in operation during DHW mode", 243 | "accuracy_decimals": 0, 244 | "icon": "mdi:clock-outline", 245 | "state_class": STATE_CLASS_TOTAL_INCREASING, 246 | "message": "DHWBurnerOperationHours", 247 | "keep_updated": True, 248 | "message_data": "u16", 249 | }), 250 | "t_dhw_set_ub": SensorSchema({ 251 | "description": "Upper bound for adjustment of DHW setpoint", 252 | "unit_of_measurement": UNIT_CELSIUS, 253 | "accuracy_decimals": 0, 254 | "device_class": DEVICE_CLASS_TEMPERATURE, 255 | "state_class": STATE_CLASS_MEASUREMENT, 256 | "message": "TdhwSetUBTdhwSetLB", 257 | "keep_updated": False, 258 | "message_data": "s8_hb", 259 | }), 260 | "t_dhw_set_lb": SensorSchema({ 261 | "description": "Lower bound for adjustment of DHW setpoint", 262 | "unit_of_measurement": UNIT_CELSIUS, 263 | "accuracy_decimals": 0, 264 | "device_class": DEVICE_CLASS_TEMPERATURE, 265 | "state_class": STATE_CLASS_MEASUREMENT, 266 | "message": "TdhwSetUBTdhwSetLB", 267 | "keep_updated": False, 268 | "message_data": "s8_lb", 269 | }), 270 | "max_t_set_ub": SensorSchema({ 271 | "description": "Upper bound for adjustment of max CH setpoint", 272 | "unit_of_measurement": UNIT_CELSIUS, 273 | "accuracy_decimals": 0, 274 | "device_class": DEVICE_CLASS_TEMPERATURE, 275 | "state_class": STATE_CLASS_MEASUREMENT, 276 | "message": "MaxTSetUBMaxTSetLB", 277 | "keep_updated": False, 278 | "message_data": "s8_hb", 279 | }), 280 | "max_t_set_lb": SensorSchema({ 281 | "description": "Lower bound for adjustment of max CH setpoint", 282 | "unit_of_measurement": UNIT_CELSIUS, 283 | "accuracy_decimals": 0, 284 | "device_class": DEVICE_CLASS_TEMPERATURE, 285 | "state_class": STATE_CLASS_MEASUREMENT, 286 | "message": "MaxTSetUBMaxTSetLB", 287 | "keep_updated": False, 288 | "message_data": "s8_lb", 289 | }), 290 | "t_dhw_set": SensorSchema({ 291 | "description": "Domestic hot water temperature setpoint", 292 | "unit_of_measurement": UNIT_CELSIUS, 293 | "accuracy_decimals": 2, 294 | "device_class": DEVICE_CLASS_TEMPERATURE, 295 | "state_class": STATE_CLASS_MEASUREMENT, 296 | "message": "TdhwSet", 297 | "keep_updated": True, 298 | "message_data": "f88", 299 | }), 300 | "max_t_set": SensorSchema({ 301 | "description": "Maximum allowable CH water setpoint", 302 | "unit_of_measurement": UNIT_CELSIUS, 303 | "accuracy_decimals": 2, 304 | "device_class": DEVICE_CLASS_TEMPERATURE, 305 | "state_class": STATE_CLASS_MEASUREMENT, 306 | "message": "MaxTSet", 307 | "keep_updated": True, 308 | "message_data": "f88", 309 | }), 310 | }) 311 | 312 | class BinarySensorSchema(EntitySchema): 313 | device_class: NotRequired[str] 314 | icon: NotRequired[str] 315 | 316 | BINARY_SENSORS: Schema = Schema({ 317 | "fault_indication": BinarySensorSchema({ 318 | "description": "Status: Fault indication", 319 | "device_class": DEVICE_CLASS_PROBLEM, 320 | "message": "Status", 321 | "keep_updated": True, 322 | "message_data": "flag8_lb_0", 323 | }), 324 | "ch_active": BinarySensorSchema({ 325 | "description": "Status: Central Heating active", 326 | "device_class": DEVICE_CLASS_HEAT, 327 | "icon": "mdi:radiator", 328 | "message": "Status", 329 | "keep_updated": True, 330 | "message_data": "flag8_lb_1", 331 | }), 332 | "dhw_active": BinarySensorSchema({ 333 | "description": "Status: Domestic Hot Water active", 334 | "device_class": DEVICE_CLASS_HEAT, 335 | "icon": "mdi:faucet", 336 | "message": "Status", 337 | "keep_updated": True, 338 | "message_data": "flag8_lb_2", 339 | }), 340 | "flame_on": BinarySensorSchema({ 341 | "description": "Status: Flame on", 342 | "device_class": DEVICE_CLASS_HEAT, 343 | "icon": "mdi:fire", 344 | "message": "Status", 345 | "keep_updated": True, 346 | "message_data": "flag8_lb_3", 347 | }), 348 | "cooling_active": BinarySensorSchema({ 349 | "description": "Status: Cooling active", 350 | "device_class": DEVICE_CLASS_COLD, 351 | "message": "Status", 352 | "keep_updated": True, 353 | "message_data": "flag8_lb_4", 354 | }), 355 | "ch2_active": BinarySensorSchema({ 356 | "description": "Status: Central Heating 2 active", 357 | "device_class": DEVICE_CLASS_HEAT, 358 | "icon": "mdi:radiator", 359 | "message": "Status", 360 | "keep_updated": True, 361 | "message_data": "flag8_lb_5", 362 | }), 363 | "diagnostic_indication": BinarySensorSchema({ 364 | "description": "Status: Diagnostic event", 365 | "device_class": DEVICE_CLASS_PROBLEM, 366 | "message": "Status", 367 | "keep_updated": True, 368 | "message_data": "flag8_lb_6", 369 | }), 370 | "dhw_present": BinarySensorSchema({ 371 | "description": "Configuration: DHW present", 372 | "message": "SConfigSMemberIDcode", 373 | "keep_updated": False, 374 | "message_data": "flag8_hb_0", 375 | }), 376 | "control_type_on_off": BinarySensorSchema({ 377 | "description": "Configuration: Control type is on/off", 378 | "message": "SConfigSMemberIDcode", 379 | "keep_updated": False, 380 | "message_data": "flag8_hb_1", 381 | }), 382 | "cooling_supported": BinarySensorSchema({ 383 | "description": "Configuration: Cooling supported", 384 | "message": "SConfigSMemberIDcode", 385 | "keep_updated": False, 386 | "message_data": "flag8_hb_2", 387 | }), 388 | "dhw_storage_tank": BinarySensorSchema({ 389 | "description": "Configuration: DHW storage tank", 390 | "message": "SConfigSMemberIDcode", 391 | "keep_updated": False, 392 | "message_data": "flag8_hb_3", 393 | }), 394 | "master_pump_control_allowed": BinarySensorSchema({ 395 | "description": "Configuration: Master pump control allowed", 396 | "message": "SConfigSMemberIDcode", 397 | "keep_updated": False, 398 | "message_data": "flag8_hb_4", 399 | }), 400 | "ch2_present": BinarySensorSchema({ 401 | "description": "Configuration: CH2 present", 402 | "message": "SConfigSMemberIDcode", 403 | "keep_updated": False, 404 | "message_data": "flag8_hb_5", 405 | }), 406 | "dhw_setpoint_transfer_enabled": BinarySensorSchema({ 407 | "description": "Remote boiler parameters: DHW setpoint transfer enabled", 408 | "message": "RBPflags", 409 | "keep_updated": False, 410 | "message_data": "flag8_hb_0", 411 | }), 412 | "max_ch_setpoint_transfer_enabled": BinarySensorSchema({ 413 | "description": "Remote boiler parameters: CH maximum setpoint transfer enabled", 414 | "message": "RBPflags", 415 | "keep_updated": False, 416 | "message_data": "flag8_hb_1", 417 | }), 418 | "dhw_setpoint_rw": BinarySensorSchema({ 419 | "description": "Remote boiler parameters: DHW setpoint read/write", 420 | "message": "RBPflags", 421 | "keep_updated": False, 422 | "message_data": "flag8_lb_0", 423 | }), 424 | "max_ch_setpoint_rw": BinarySensorSchema({ 425 | "description": "Remote boiler parameters: CH maximum setpoint read/write", 426 | "message": "RBPflags", 427 | "keep_updated": False, 428 | "message_data": "flag8_lb_1", 429 | }), 430 | }) 431 | 432 | class SwitchSchema(EntitySchema): 433 | default_mode: str 434 | 435 | SWITCHES: Schema[SwitchSchema] = Schema({ 436 | "ch_enable": SwitchSchema({ 437 | "description": "Central Heating enabled", 438 | "message": "Status", 439 | "keep_updated": True, 440 | "message_data": "flag8_hb_0", 441 | "default_mode": "restore_default_on" 442 | }), 443 | "dhw_enable": SwitchSchema({ 444 | "description": "Domestic Hot Water enabled", 445 | "message": "Status", 446 | "keep_updated": True, 447 | "message_data": "flag8_hb_1", 448 | "default_mode": "restore_default_on" 449 | }), 450 | "cooling_enable": SwitchSchema({ 451 | "description": "Cooling enabled", 452 | "message": "Status", 453 | "keep_updated": True, 454 | "message_data": "flag8_hb_2", 455 | "default_mode": "restore_default_off" 456 | }), 457 | "otc_active": SwitchSchema({ 458 | "description": "Outside temperature compensation active", 459 | "message": "Status", 460 | "keep_updated": True, 461 | "message_data": "flag8_hb_3", 462 | "default_mode": "restore_default_off" 463 | }), 464 | "ch2_active": SwitchSchema({ 465 | "description": "Central Heating 2 active", 466 | "message": "Status", 467 | "keep_updated": True, 468 | "message_data": "flag8_hb_4", 469 | "default_mode": "restore_default_off" 470 | }), 471 | }) 472 | 473 | class AutoConfigure(TypedDict): 474 | message: str 475 | message_data: str 476 | 477 | class InputSchema(EntitySchema): 478 | unit_of_measurement: str 479 | range: Tuple[int, int] 480 | auto_max_value: NotRequired[AutoConfigure] 481 | auto_min_value: NotRequired[AutoConfigure] 482 | 483 | INPUTS: Schema[InputSchema] = Schema({ 484 | "t_set": InputSchema({ 485 | "description": "Control setpoint: temperature setpoint for the boiler's supply water", 486 | "unit_of_measurement": UNIT_CELSIUS, 487 | "message": "TSet", 488 | "keep_updated": True, 489 | "message_data": "f88", 490 | "range": (0, 100), 491 | "auto_max_value": { "message": "MaxTSet", "message_data": "f88" }, 492 | }), 493 | "t_set_ch2": InputSchema({ 494 | "description": "Control setpoint 2: temperature setpoint for the boiler's supply water on the second heating circuit", 495 | "unit_of_measurement": UNIT_CELSIUS, 496 | "message": "TsetCH2", 497 | "keep_updated": True, 498 | "message_data": "f88", 499 | "range": (0, 100), 500 | "auto_max_value": { "message": "MaxTSet", "message_data": "f88" }, 501 | }), 502 | "cooling_control": InputSchema({ 503 | "description": "Cooling control signal", 504 | "unit_of_measurement": UNIT_PERCENT, 505 | "message": "CoolingControl", 506 | "keep_updated": True, 507 | "message_data": "f88", 508 | "range": (0, 100), 509 | }), 510 | "t_dhw_set": InputSchema({ 511 | "description": "Domestic hot water temperature setpoint", 512 | "unit_of_measurement": UNIT_CELSIUS, 513 | "message": "TdhwSet", 514 | "keep_updated": True, 515 | "message_data": "f88", 516 | "range": (0, 127), 517 | "auto_min_value": { "message": "TdhwSetUBTdhwSetLB", "message_data": "s8_lb" }, 518 | "auto_max_value": { "message": "TdhwSetUBTdhwSetLB", "message_data": "s8_hb" }, 519 | }), 520 | "max_t_set": InputSchema({ 521 | "description": "Maximum allowable CH water setpoint", 522 | "unit_of_measurement": UNIT_CELSIUS, 523 | "message": "MaxTSet", 524 | "keep_updated": True, 525 | "message_data": "f88", 526 | "range": (0, 127), 527 | "auto_min_value": { "message": "MaxTSetUBMaxTSetLB", "message_data": "s8_lb" }, 528 | "auto_max_value": { "message": "MaxTSetUBMaxTSetLB", "message_data": "s8_hb" }, 529 | }), 530 | "t_room_set": InputSchema({ 531 | "description": "Current room temperature setpoint (informational)", 532 | "unit_of_measurement": UNIT_CELSIUS, 533 | "message": "TrSet", 534 | "keep_updated": True, 535 | "message_data": "f88", 536 | "range": (-40, 127), 537 | }), 538 | "t_room_set_ch2": InputSchema({ 539 | "description": "Current room temperature setpoint on CH2 (informational)", 540 | "unit_of_measurement": UNIT_CELSIUS, 541 | "message": "TrSetCH2", 542 | "keep_updated": True, 543 | "message_data": "f88", 544 | "range": (-40, 127), 545 | }), 546 | "t_room": InputSchema({ 547 | "description": "Current sensed room temperature (informational)", 548 | "unit_of_measurement": UNIT_CELSIUS, 549 | "message": "Tr", 550 | "keep_updated": True, 551 | "message_data": "f88", 552 | "range": (-40, 127), 553 | }), 554 | }) 555 | -------------------------------------------------------------------------------- /components/opentherm/sensor.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.config_validation as cv 4 | from esphome.components import sensor 5 | 6 | from . import const, schema, validate, generate 7 | 8 | DEPENDENCIES = [ const.OPENTHERM ] 9 | COMPONENT_TYPE = const.SENSOR 10 | 11 | def get_entity_validation_schema(entity: schema.SensorSchema) -> cv.Schema: 12 | return sensor.sensor_schema( 13 | unit_of_measurement = entity["unit_of_measurement"] if "unit_of_measurement" in entity else sensor._UNDEF, 14 | accuracy_decimals = entity["accuracy_decimals"], 15 | device_class=entity["device_class"] if "device_class" in entity else sensor._UNDEF, 16 | icon = entity["icon"] if "icon" in entity else sensor._UNDEF, 17 | state_class = entity["state_class"] 18 | ) 19 | 20 | CONFIG_SCHEMA = validate.create_component_schema(schema.SENSORS, get_entity_validation_schema) 21 | 22 | async def to_code(config: Dict[str, Any]) -> None: 23 | await generate.component_to_code( 24 | COMPONENT_TYPE, 25 | schema.SENSORS, 26 | sensor.Sensor, 27 | generate.create_only_conf(sensor.new_sensor), 28 | config 29 | ) 30 | -------------------------------------------------------------------------------- /components/opentherm/switch.cpp: -------------------------------------------------------------------------------- 1 | #include "switch.h" 2 | 3 | namespace esphome { 4 | namespace opentherm { 5 | 6 | void OpenthermSwitch::write_state(bool state) { 7 | this->publish_state(state); 8 | } 9 | 10 | void OpenthermSwitch::setup() { 11 | bool initial_state = false; 12 | switch (this->mode) { 13 | case OPENTHERM_SWITCH_RESTORE_DEFAULT_ON: 14 | initial_state = this->get_initial_state().value_or(true); 15 | break; 16 | case OPENTHERM_SWITCH_RESTORE_DEFAULT_OFF: 17 | initial_state = this->get_initial_state().value_or(false); 18 | break; 19 | case OPENTHERM_SWITCH_START_ON: 20 | initial_state = true; 21 | break; 22 | case OPENTHERM_SWITCH_START_OFF: 23 | initial_state = false; 24 | break; 25 | } 26 | 27 | this->write_state(initial_state); 28 | } 29 | 30 | } // namespace opentherm 31 | } // namespace esphome 32 | -------------------------------------------------------------------------------- /components/opentherm/switch.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/components/switch/switch.h" 5 | 6 | namespace esphome { 7 | namespace opentherm { 8 | 9 | enum OpenthermSwitchMode { 10 | OPENTHERM_SWITCH_RESTORE_DEFAULT_ON, 11 | OPENTHERM_SWITCH_RESTORE_DEFAULT_OFF, 12 | OPENTHERM_SWITCH_START_ON, 13 | OPENTHERM_SWITCH_START_OFF 14 | }; 15 | 16 | class OpenthermSwitch : public switch_::Switch, public Component { 17 | protected: 18 | OpenthermSwitchMode mode; 19 | 20 | void write_state(bool state) override; 21 | 22 | public: 23 | void set_mode(OpenthermSwitchMode mode) { this->mode = mode; } 24 | 25 | void setup() override; 26 | }; 27 | 28 | } // namespace opentherm 29 | } // namespace esphome 30 | -------------------------------------------------------------------------------- /components/opentherm/switch.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import esphome.codegen as cg 4 | import esphome.config_validation as cv 5 | from esphome.components import switch 6 | from esphome.const import CONF_ID 7 | 8 | from . import const, schema, validate, generate 9 | 10 | DEPENDENCIES = [ const.OPENTHERM ] 11 | COMPONENT_TYPE = const.SWITCH 12 | 13 | OpenthermSwitch = generate.opentherm_ns.class_("OpenthermSwitch", switch.Switch, cg.Component) 14 | 15 | CONF_MODE = "mode" 16 | 17 | async def new_openthermswitch(config: Dict[str, Any]) -> cg.Pvariable: 18 | var = cg.new_Pvariable(config[CONF_ID]) 19 | await cg.register_component(var, config) 20 | await switch.register_switch(var, config) 21 | cg.add(getattr(var, "set_mode")(config[CONF_MODE])) 22 | return var 23 | 24 | def get_entity_validation_schema(entity: schema.SwitchSchema) -> cv.Schema: 25 | return switch.SWITCH_SCHEMA.extend({ 26 | cv.GenerateID(): cv.declare_id(OpenthermSwitch), 27 | cv.Optional(CONF_MODE, entity["default_mode"]): 28 | cv.enum({ 29 | "restore_default_on": cg.RawExpression("opentherm::OpenthermSwitchMode::OPENTHERM_SWITCH_RESTORE_DEFAULT_ON"), 30 | "restore_default_off": cg.RawExpression("opentherm::OpenthermSwitchMode::OPENTHERM_SWITCH_RESTORE_DEFAULT_OFF"), 31 | "start_on": cg.RawExpression("opentherm::OpenthermSwitchMode::OPENTHERM_SWITCH_START_ON"), 32 | "start_off": cg.RawExpression("opentherm::OpenthermSwitchMode::OPENTHERM_SWITCH_START_OFF") 33 | }) 34 | }).extend(cv.COMPONENT_SCHEMA) 35 | 36 | CONFIG_SCHEMA = validate.create_component_schema(schema.SWITCHES, get_entity_validation_schema) 37 | 38 | async def to_code(config: Dict[str, Any]) -> None: 39 | keys = await generate.component_to_code( 40 | COMPONENT_TYPE, 41 | schema.SWITCHES, 42 | OpenthermSwitch, 43 | generate.create_only_conf(new_openthermswitch), 44 | config 45 | ) 46 | generate.define_readers(COMPONENT_TYPE, keys) 47 | -------------------------------------------------------------------------------- /components/opentherm/validate.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import esphome.config_validation as cv 4 | 5 | from . import const, schema, generate 6 | 7 | def create_entities_schema(entities: schema.Schema[schema.T], get_entity_validation_schema: Callable[[schema.T], cv.Schema]) -> cv.Schema: 8 | schema = {} 9 | for key, entity in entities.items(): 10 | schema[cv.Optional(key)] = get_entity_validation_schema(entity) 11 | return cv.Schema(schema) 12 | 13 | def create_component_schema(entities: schema.Schema[schema.T], get_entity_validation_schema: Callable[[schema.T], cv.Schema]) -> cv.Schema: 14 | return cv.Schema({ cv.GenerateID(const.CONF_OPENTHERM_ID): cv.use_id(generate.OpenthermHub) }) \ 15 | .extend(create_entities_schema(entities, get_entity_validation_schema)) \ 16 | .extend(cv.COMPONENT_SCHEMA) 17 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # Gitignore settings for ESPHome 2 | # This is an example and may include too much for your use-case. 3 | # You can modify this file to suit your needs. 4 | /.esphome/ 5 | /secrets.yaml 6 | -------------------------------------------------------------------------------- /examples/thermostat-number-minimal.yaml: -------------------------------------------------------------------------------- 1 | # An extremely minimal configuration which only enables you to set the boiler's 2 | # water temperature setpoint as a number. 3 | 4 | esphome: 5 | name: thermostat-number-minimal 6 | 7 | external_components: 8 | # Replace with a direct reference to GitHub in your own configuration 9 | #source: github://arthurrump/esphome-opentherm@main 10 | source: 11 | type: local 12 | path: ../components 13 | 14 | esp8266: 15 | board: d1_mini 16 | 17 | logger: 18 | 19 | api: 20 | ota: 21 | wifi: 22 | ap: 23 | ssid: "Thermostat" 24 | password: "MySecretThemostat" 25 | captive_portal: 26 | 27 | opentherm: 28 | 29 | number: 30 | - platform: opentherm 31 | t_set: 32 | name: "Boiler Control setpoint" 33 | -------------------------------------------------------------------------------- /examples/thermostat-pid-basic.yaml: -------------------------------------------------------------------------------- 1 | # A basic thremostat for a boiler with a single central heating circuit and 2 | # domestic hot water. It reports the flame, CH and DHW status, similar to what 3 | # you would expect to see on a thermostat and also reports the internal boiler 4 | # temperatures and the current modulation level. The temperature is regulated 5 | # through a PID Climate controller and the current room temperature is retrieved 6 | # from a sensor in Home Asisstant. 7 | 8 | # This configuration should meet most needs and is the recommended starting 9 | # point if you just want a thermostat with an external temperature sensor. 10 | 11 | esphome: 12 | name: thermostat-pid-complete 13 | 14 | external_components: 15 | # Replace with a direct reference to GitHub in your own configuration 16 | #source: github://arthurrump/esphome-opentherm@main 17 | source: 18 | type: local 19 | path: ../components 20 | 21 | esp8266: 22 | board: d1_mini 23 | 24 | logger: 25 | 26 | api: 27 | ota: 28 | wifi: 29 | ap: 30 | ssid: "Thermostat" 31 | password: "MySecretThemostat" 32 | captive_portal: 33 | 34 | opentherm: 35 | ch_enable: true 36 | dhw_enable: true 37 | 38 | output: 39 | - platform: opentherm 40 | t_set: 41 | id: t_set 42 | min_value: 20 43 | max_value: 65 44 | zero_means_zero: true 45 | 46 | sensor: 47 | - platform: opentherm 48 | rel_mod_level: 49 | name: "Boiler Relative modulation level" 50 | t_boiler: 51 | name: "Boiler water temperature" 52 | t_ret: 53 | name: "Boiler Return water temperature" 54 | 55 | - platform: homeassistant 56 | id: ch_room_temperature 57 | entity_id: sensor.temperature 58 | filters: 59 | # Push room temperature every second to update PID parameters 60 | - heartbeat: 1s 61 | 62 | binary_sensor: 63 | - platform: opentherm 64 | ch_active: 65 | name: "Boiler Central Heating active" 66 | dhw_active: 67 | name: "Boiler Domestic Hot Water active" 68 | flame_on: 69 | name: "Boiler Flame on" 70 | fault_indication: 71 | name: "Boiler Fault indication" 72 | entity_category: diagnostic 73 | diagnostic_indication: 74 | name: "Boiler Diagnostic event" 75 | entity_category: diagnostic 76 | 77 | switch: 78 | - platform: opentherm 79 | ch_enable: 80 | name: "Boiler Central Heating enabled" 81 | mode: restore_default_on 82 | 83 | climate: 84 | - platform: pid 85 | name: "Central heating" 86 | heat_output: t_set 87 | default_target_temperature: 20 88 | sensor: ch_room_temperature 89 | control_parameters: 90 | kp: 0.4 91 | ki: 0.004 92 | -------------------------------------------------------------------------------- /examples/thermostat-pid-complete.yaml: -------------------------------------------------------------------------------- 1 | # A complete configuration with every sensor, binary sensor, switch and input 2 | # enabled. It uses a PID controller for the setpoints on the main and secondary 3 | # central heating circuits, and allows you to set other input values as a 4 | # number. 5 | 6 | esphome: 7 | name: thermostat-pid-complete 8 | 9 | external_components: 10 | # Replace with a direct reference to GitHub in your own configuration 11 | #source: github://arthurrump/esphome-opentherm@main 12 | source: 13 | type: local 14 | path: ../components 15 | 16 | esp8266: 17 | board: d1_mini 18 | 19 | logger: 20 | 21 | api: 22 | ota: 23 | wifi: 24 | ap: 25 | ssid: "Thermostat" 26 | password: "MySecretThemostat" 27 | captive_portal: 28 | 29 | opentherm: 30 | in_pin: 4 31 | out_pin: 5 32 | ch_enable: true 33 | dhw_enable: false 34 | cooling_enable: false 35 | otc_active: false 36 | ch2_active: true 37 | 38 | t_room: ch_room_temperature 39 | 40 | output: 41 | - platform: opentherm 42 | t_set: 43 | id: t_set 44 | min_value: 20 45 | auto_max_value: true 46 | zero_means_zero: true 47 | t_set_ch2: 48 | id: t_set_ch2 49 | min_value: 20 50 | max_value: 40 51 | zero_means_zero: true 52 | 53 | number: 54 | - platform: opentherm 55 | cooling_control: 56 | name: "Boiler Cooling control signal" 57 | t_dhw_set: 58 | name: "Boiler DHW Setpoint" 59 | max_t_set: 60 | name: "Boiler Max Setpoint" 61 | t_room_set: 62 | name: "Boiler Room Setpoint" 63 | t_room_set_ch2: 64 | name: "Boiler Room Setpoint CH2" 65 | 66 | sensor: 67 | - platform: opentherm 68 | rel_mod_level: 69 | name: "Boiler Relative modulation level" 70 | ch_pressure: 71 | name: "Boiler Water pressure in CH circuit" 72 | dhw_flow_rate: 73 | name: "Boiler Water flow rate in DHW circuit" 74 | t_boiler: 75 | name: "Boiler water temperature" 76 | t_dhw: 77 | name: "Boiler DHW temperature" 78 | t_outside: 79 | name: "Boiler Outside temperature" 80 | t_ret: 81 | name: "Boiler Return water temperature" 82 | t_storage: 83 | name: "Boiler Solar storage temperature" 84 | t_collector: 85 | name: "Boiler Solar collector temperature" 86 | t_flow_ch2: 87 | name: "Boiler Flow water temperature CH2 circuit" 88 | t_dhw2: 89 | name: "Boiler Domestic hot water temperature 2" 90 | t_exhaust: 91 | name: "Boiler Exhaust temperature" 92 | burner_starts: 93 | name: "Boiler Number of starts burner" 94 | ch_pump_starts: 95 | name: "Boiler Number of starts CH pump" 96 | dhw_pump_valve_starts: 97 | name: "Boiler Number of starts DHW pump/valve" 98 | dhw_burner_starts: 99 | name: "Boiler Number of starts burner during DHW mode" 100 | burner_operation_hours: 101 | name: "Boiler Number of hours that burner is in operation (i.e. flame on)" 102 | ch_pump_operation_hours: 103 | name: "Boiler Number of hours that CH pump has been running" 104 | dhw_pump_valve_operation_hours: 105 | name: "Boiler Number of hours that DHW pump has been running or DHW valve has been opened" 106 | dhw_burner_operation_hours: 107 | name: "Boiler Number of hours that burner is in operation during DHW mode" 108 | t_dhw_set_ub: 109 | name: "Boiler Upper bound for adjustement of DHW setpoint" 110 | t_dhw_set_lb: 111 | name: "Boiler Lower bound for adjustement of DHW setpoint" 112 | max_t_set_ub: 113 | name: "Boiler Upper bound for adjustement of max CH setpoint" 114 | max_t_set_lb: 115 | name: "Boiler Lower bound for adjustement of max CH setpoint" 116 | t_dhw_set: 117 | name: "Boiler Domestic hot water temperature setpoint" 118 | max_t_set: 119 | name: "Boiler Maximum allowable CH water setpoint" 120 | 121 | - platform: homeassistant 122 | id: ch_room_temperature 123 | entity_id: sensor.temperature 124 | filters: 125 | # Push room temperature every second to update PID parameters 126 | - heartbeat: 1s 127 | - platform: homeassistant 128 | id: ch2_room_temperature 129 | entity_id: sensor.other_temperature 130 | filters: 131 | # Push room temperature every second to update PID parameters 132 | - heartbeat: 1s 133 | 134 | binary_sensor: 135 | - platform: opentherm 136 | fault_indication: 137 | name: "Boiler Fault indication" 138 | ch_active: 139 | name: "Boiler Central Heating active" 140 | dhw_active: 141 | name: "Boiler Domestic Hot Water active" 142 | flame_on: 143 | name: "Boiler Flame on" 144 | cooling_active: 145 | name: "Boiler Cooling active" 146 | ch2_active: 147 | name: "Boiler Central Heating 2 active" 148 | diagnostic_indication: 149 | name: "Boiler Diagnostic event" 150 | dhw_present: 151 | name: "Boiler DHW present" 152 | control_type_on_off: 153 | name: "Boiler Control type is on/off" 154 | cooling_supported: 155 | name: "Boiler Cooling supported" 156 | dhw_storage_tank: 157 | name: "Boiler DHW storage tank" 158 | master_pump_control_allowed: 159 | name: "Boiler Master pump control allowed" 160 | ch2_present: 161 | name: "Boiler CH2 present" 162 | dhw_setpoint_transfer_enabled: 163 | name: "Boiler DHW setpoint transfer enabled" 164 | max_ch_setpoint_transfer_enabled: 165 | name: "Boiler CH maximum setpoint transfer enabled" 166 | dhw_setpoint_rw: 167 | name: "Boiler DHW setpoint read/write" 168 | max_ch_setpoint_rw: 169 | name: "Boiler CH maximum setpoint read/write" 170 | 171 | switch: 172 | - platform: opentherm 173 | ch_enable: 174 | name: "Boiler Central Heating enabled" 175 | mode: restore_default_on 176 | dhw_enable: 177 | name: "Boiler Domestic Hot Water enabled" 178 | cooling_enable: 179 | name: "Boiler Cooling enabled" 180 | mode: start_off 181 | otc_active: 182 | name: "Boiler Outside temperature compensation active" 183 | ch2_active: 184 | name: "Boiler Central Heating 2 active" 185 | 186 | climate: 187 | - platform: pid 188 | name: "Central heating" 189 | heat_output: t_set 190 | default_target_temperature: 20 191 | sensor: ch_room_temperature 192 | control_parameters: 193 | kp: 0.7 194 | ki: 0.003 195 | - platform: pid 196 | name: "Central heating (Circuit 2)" 197 | heat_output: t_set_ch2 198 | default_target_temperature: 18 199 | sensor: ch2_room_temperature 200 | control_parameters: 201 | kp: 0.4 202 | ki: 0.004 203 | -------------------------------------------------------------------------------- /generate_schema_docs.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import re 4 | 5 | import components.opentherm.schema as schema 6 | 7 | LINESEP = "\n" 8 | MD_LINEBREAK = " \n" 9 | 10 | README = "README.md" 11 | 12 | BEGIN_PATTERN = re.compile(r"") 13 | END_PATTERN = re.compile(r"") 14 | 15 | def begins_section(line: str) -> str | None: 16 | match = BEGIN_PATTERN.match(line) 17 | if match: 18 | return match.group(1) 19 | return None 20 | 21 | def ends_section(line: str) -> bool: 22 | match = END_PATTERN.match(line) 23 | return match != None 24 | 25 | def replace_docs(sections: Dict[str, str]) -> None: 26 | with open(README, "r") as f: 27 | lines = f.readlines() 28 | with open(README, "w", encoding = "utf-8") as f: 29 | in_section = False 30 | for line in lines: 31 | section = begins_section(line) 32 | if section: 33 | in_section = True 34 | f.write(line) 35 | f.write(sections[section]) 36 | continue 37 | if ends_section(line): 38 | in_section = False 39 | if not in_section: 40 | f.write(line) 41 | 42 | sections = { 43 | "input": LINESEP.join([ 44 | f"- `{key}`: {sch['description']} ({sch['unit_of_measurement']})" 45 | + MD_LINEBREAK + f" Default `min_value`: {sch['range'][0]}" 46 | + MD_LINEBREAK + f" Default `max_value`: {sch['range'][1]}" 47 | + (MD_LINEBREAK + f" Supports `auto_min_value`" if "auto_min_value" in sch else "") 48 | + (MD_LINEBREAK + f" Supports `auto_max_value`" if "auto_max_value" in sch else "") 49 | for key, sch in schema.INPUTS.items() 50 | ]) + LINESEP, 51 | "switch": LINESEP.join([ 52 | f"- `{key}`: {sch['description']}" 53 | + MD_LINEBREAK + f" Defaults to *{sch['default_mode'].endswith('on')}*" 54 | for key, sch in schema.SWITCHES.items() 55 | ]) + LINESEP, 56 | "binary_sensor": LINESEP.join([ 57 | f"- `{key}`: {sch['description']}" 58 | for key, sch in schema.BINARY_SENSORS.items() 59 | ]) + LINESEP, 60 | "sensor": LINESEP.join([ 61 | f"- `{key}`: {sch['description']}" 62 | + (f" ({sch['unit_of_measurement']})" if "unit_of_measurement" in sch else "") 63 | for key, sch in schema.SENSORS.items() 64 | ]) + LINESEP, 65 | } 66 | 67 | replace_docs(sections) 68 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = components/opentherm/**/*.py 3 | 4 | [mypy-esphome.*] 5 | ignore_missing_imports = True 6 | -------------------------------------------------------------------------------- /read_changelog.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from release import CHANGELOG_FILE, VERSION_HEADER_PATTERN 3 | 4 | if __name__ == "__main__": 5 | version = sys.argv[1] 6 | in_version = False 7 | 8 | with open(CHANGELOG_FILE, "r", encoding = "utf-8") as changelog: 9 | with open(CHANGELOG_FILE + ".tmp", "w", encoding = "utf-8") as changelog_tmp: 10 | for line in changelog.readlines(): 11 | if in_version: 12 | if line.startswith("##"): 13 | break 14 | changelog_tmp.write(line) 15 | if line.startswith("##"): 16 | match = VERSION_HEADER_PATTERN.match(line) 17 | in_version = match and ("v" + match.group(1)) == version 18 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, List, Optional, Tuple 2 | 3 | from datetime import date 4 | import os 5 | import re 6 | 7 | CHANGELOG_FILE = "CHANGELOG.md" 8 | CHANGELOG_HEADER = "# Changelog" 9 | VERSION_NEXT_HEADER = "## vNext" 10 | VERSION_HEADER_PATTERN = re.compile(r"## v(\d+\.\d+\.\d+) - (\d{4}-\d{2}-\d{2})") 11 | VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)") 12 | 13 | def read_changelog() -> List[str]: 14 | with open(CHANGELOG_FILE, "r", encoding = "utf-8") as f: 15 | return f.readlines() 16 | 17 | def get_current_version(changelog: List[str]) -> Optional[str]: 18 | for line in changelog: 19 | match = VERSION_HEADER_PATTERN.match(line) 20 | if match: 21 | return match.group(1) 22 | return None 23 | 24 | def get_next_changelog(changelog: List[str]) -> Iterator[str]: 25 | in_vNext = False 26 | for line in changelog: 27 | if in_vNext: 28 | if line.startswith("##"): 29 | break 30 | yield line 31 | if line.startswith(VERSION_NEXT_HEADER): 32 | in_vNext = True 33 | 34 | def parse_version(version: str) -> Tuple[int, int, int]: 35 | match = VERSION_PATTERN.match(version) 36 | if match and len(match.groups()) == 3: 37 | return (int(match.group(1)), int(match.group(2)), int(match.group(3))) 38 | raise ValueError(f"Invalid version number: {version}") 39 | 40 | def update_changelog(changelog: List[str], next_version: str) -> None: 41 | with open(CHANGELOG_FILE, "w", encoding = "utf-8") as f: 42 | for line in changelog: 43 | f.write(line) 44 | if line.startswith(VERSION_NEXT_HEADER): 45 | f.write("\n") 46 | f.write(f"## v{next_version} - {date.today().strftime('%Y-%m-%d')}\n") 47 | 48 | if __name__ == "__main__": 49 | changelog = read_changelog() 50 | current_version = get_current_version(changelog) 51 | 52 | if current_version: 53 | print(f"Current version is v{current_version}") 54 | else: 55 | print("No current version found...") 56 | 57 | print("Changelog for the next version:") 58 | for line in get_next_changelog(changelog): 59 | print(" " + line) 60 | 61 | next_version = input("Enter the version number for the new release: ") 62 | major, minor, patch = parse_version(next_version) 63 | 64 | update_changelog(changelog, next_version) 65 | 66 | os.system('git add CHANGELOG.md') 67 | os.system(f'git commit -m "Release v{next_version}"') 68 | os.system(f'git tag -a v{next_version} -m "v{next_version}"') 69 | os.system(f'git branch -f v{major}.{minor}') 70 | if major != 0: 71 | os.system(f'git branch -f v{major}') 72 | 73 | if input("Please verify the changes. Push to origin? (y/n) ").lower() in [ "y", "yes" ]: 74 | os.system('git push origin') 75 | os.system(f'git push origin v{next_version}') 76 | os.system(f'git push origin v{major}.{minor}') 77 | if major != 0: 78 | os.system(f'git push origin v{major}') 79 | --------------------------------------------------------------------------------