├── README.md ├── LICENSE └── opentherm-ha ├── config.h └── opentherm-ha.ino /README.md: -------------------------------------------------------------------------------- 1 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine) 2 | 3 | # Home Assistant OpentTherm Thermostat 4 | 5 | This repo contains a sketch to build a simple OpenTherm Wi-Fi thermostat controlled by Home Assistant using [ESP8266 Thermostat Shield](https://diyless.com/product/esp8266-thermostat-shield). 6 | More details on setup: 7 | https://diyless.com/blog/home-assistant-opentherm-thermostat 8 | 9 | ## Home Assistant OpentTherm Thermostat Mobile Application 10 | ![Home Assistant OpentTherm Thermostat Mobile Application](https://diyless.com/blog/home-assistant-opentherm-thermostat/home-assistant-opentherm-thermostat-app-s.webp) 11 | 12 | ## Home Assistant OpentTherm Thermostat Schematic 13 | ![Home Assistant OpentTherm Thermostat Schematic](https://diyless.com/blog/opentherm-sample/master-opentherm-shield-connection.webp) 14 | 15 | ## License 16 | Copyright (c) 2020 [DIYLESS](http://diyless.com/). Licensed under the [MIT license](/LICENSE?raw=true). 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 diyless 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /opentherm-ha/config.h: -------------------------------------------------------------------------------- 1 | /* 2 | Topic structure is built with get/set mechanizm for 3 | compatibility with home assistant and to allow external 4 | device control 5 | _GET_ topics are used to publish current thermostat state 6 | _SET_ topics are used to control the thermostat 7 | */ 8 | 9 | // Your WiFi credentials. 10 | // Set password to "" for open networks. 11 | const char* ssid = "xxxxxx"; 12 | const char* pass = "xxxxxx"; 13 | 14 | // Your MQTT broker address and credentials 15 | const char* mqtt_server = "xxx.xxx.xxx.xxx"; 16 | const char* mqtt_user = "xxxxxx"; 17 | const char* mqtt_password = "xxxxxx"; 18 | const int mqtt_port = 1883; 19 | 20 | // Master OpenTherm Shield pins configuration 21 | const int OT_IN_PIN = 4; //for Arduino, 4 for ESP8266 (D2), 21 for ESP32 22 | const int OT_OUT_PIN = 5; //for Arduino, 5 for ESP8266 (D1), 22 for ESP32 23 | 24 | // Temperature sensor pin 25 | const int ROOM_TEMP_SENSOR_PIN = 14; //for Arduino, 14 for ESP8266 (D5), 18 for ESP32 26 | 27 | /* 28 | current temperature topics 29 | if setter is used - thermostat works with external values, bypassing built-in sensor 30 | if no values on setter for more than 1 minute - thermostat falls back to built-in sensor 31 | */ 32 | 33 | const String BASE_TOPIC = "opentherm-thermostat/"; 34 | 35 | const String CURRENT_TEMP_GET_TOPIC = BASE_TOPIC + "current-temperature/get"; 36 | const String CURRENT_TEMP_SET_TOPIC = BASE_TOPIC + "current-temperature/set"; 37 | 38 | // current temperature topics 39 | const String TEMP_SETPOINT_GET_TOPIC = BASE_TOPIC + "setpoint-temperature/get"; 40 | const String TEMP_SETPOINT_SET_TOPIC = BASE_TOPIC + "setpoint-temperature/set"; 41 | 42 | // working mode topics 43 | const String MODE_GET_TOPIC = BASE_TOPIC + "mode/get"; 44 | const String MODE_SET_TOPIC = BASE_TOPIC + "mode/set"; 45 | 46 | // boiler water temperature topic 47 | const String TEMP_BOILER_GET_TOPIC = BASE_TOPIC + "boiler-temperature/get"; 48 | const String TEMP_BOILER_TARGET_GET_TOPIC = BASE_TOPIC + "boiler-target-temperature/get"; 49 | 50 | // debug data 51 | const String INTEGRAL_ERROR_GET_TOPIC = BASE_TOPIC + "integral-error/get"; 52 | const String FLAME_STATUS_GET_TOPIC = BASE_TOPIC + "flame-status/get"; 53 | const String FLAME_LEVEL_GET_TOPIC = BASE_TOPIC + "flame-level/get"; 54 | 55 | // domestic hot water temperature topic 56 | const String TEMP_DHW_GET_TOPIC = BASE_TOPIC + "dhw-temperature/get"; 57 | const String TEMP_DHW_SET_TOPIC = BASE_TOPIC + "dhw-temperature/set"; 58 | const String ACTUAL_TEMP_DHW_GET_TOPIC = BASE_TOPIC + "dhw-actual-temperature/get"; 59 | 60 | // domestic hot water enable/disable 61 | const String STATE_DHW_GET_TOPIC = BASE_TOPIC + "dhw-state/get"; 62 | const String STATE_DHW_SET_TOPIC = BASE_TOPIC + "dhw-state/set"; 63 | 64 | // setpoint topic 65 | const String SETPOINT_OVERRIDE_SET_TOPIC = BASE_TOPIC + "setpoint-override/set"; 66 | const String SETPOINT_OVERRIDE_RESET_TOPIC = BASE_TOPIC + "setpoint-override/reset"; 67 | 68 | // logs topic 69 | const String LOG_GET_TOPIC = BASE_TOPIC + "log"; 70 | -------------------------------------------------------------------------------- /opentherm-ha/opentherm-ha.ino: -------------------------------------------------------------------------------- 1 | /************************************************************* 2 | This example runs directly on ESP8266 chip. 3 | 4 | Please be sure to select the right ESP8266 module 5 | in the Tools -> Board -> WeMos D1 Mini 6 | 7 | Adjust settings in Config.h before run 8 | *************************************************************/ 9 | 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include "config.h" 16 | 17 | const unsigned long extTempTimeout_ms = 60 * 1000; 18 | const unsigned long statusUpdateInterval_ms = 1000; 19 | const unsigned long spOverrideTimeout_ms = 30 * 1000; 20 | 21 | float sp = 18, //set point 22 | t = 15, //current temperature 23 | t_last = 0, //prior temperature 24 | ierr = 25, //integral error 25 | dt = 0, //time between measurements 26 | op = 0; //PID controller output 27 | float op_override; 28 | 29 | unsigned long ts = 0, new_ts = 0; //timestamp 30 | unsigned long lastUpdate = 0; 31 | unsigned long lastTempSet = 0; 32 | 33 | float dhwTarget = 48; 34 | 35 | unsigned long lastSpSet = 0; 36 | 37 | bool heatingEnabled = true; 38 | bool enableHotWater = true; 39 | 40 | OneWire oneWire(ROOM_TEMP_SENSOR_PIN); 41 | DallasTemperature sensors(&oneWire); 42 | OpenTherm ot(OT_IN_PIN, OT_OUT_PIN); 43 | WiFiClient espClient; 44 | PubSubClient client(espClient); 45 | 46 | // upper and lower bounds on heater level 47 | float ophi = 63; 48 | float oplo = 35; 49 | 50 | // heating water temperature for fail mode (no external temp provided) 51 | const float noCommandSpOverride = 50; 52 | 53 | 54 | void IRAM_ATTR handleInterrupt() { 55 | ot.handleInterrupt(); 56 | } 57 | 58 | float getTemp() { 59 | unsigned long now = millis(); 60 | if (now - lastTempSet > extTempTimeout_ms) 61 | return sensors.getTempCByIndex(0); 62 | else 63 | return t; 64 | } 65 | 66 | float pid(float sp, float pv, float pv_last, float& ierr, float dt) { 67 | float KP = 10; 68 | float KI = 0.02; 69 | 70 | // calculate the error 71 | float error = sp - pv; 72 | // calculate the integral error 73 | ierr = ierr + KI * error * dt; 74 | // calculate the measurement derivative 75 | //float dpv = (pv - pv_last) / dt; 76 | // calculate the PID output 77 | float P = KP * error; //proportional contribution 78 | float I = ierr; //integral contribution 79 | float op = P + I; 80 | // implement anti-reset windup 81 | if ((op < oplo) || (op > ophi)) { 82 | I = I - KI * error * dt; 83 | // clip output 84 | op = max(oplo, min(ophi, op)); 85 | } 86 | ierr = I; 87 | 88 | Serial.println("sp=" + String(sp) + " pv=" + String(pv) + " dt=" + String(dt) + " op=" + String(op) + " P=" + String(P) + " I=" + String(I)); 89 | 90 | return op; 91 | } 92 | 93 | // This function calculates temperature and sends data to MQTT every second. 94 | void updateData() 95 | { 96 | //Set/Get Boiler Status 97 | bool enableCooling = false; 98 | unsigned long response = ot.setBoilerStatus(heatingEnabled, enableHotWater, enableCooling); 99 | OpenThermResponseStatus responseStatus = ot.getLastResponseStatus(); 100 | if (responseStatus != OpenThermResponseStatus::SUCCESS) { 101 | String msg = "Error: Invalid boiler response " + String(response, HEX); 102 | Serial.println(msg); 103 | client.publish(LOG_GET_TOPIC.c_str(), msg.c_str()); 104 | } 105 | 106 | ot.setDHWSetpoint(dhwTarget); 107 | 108 | if (responseStatus == OpenThermResponseStatus::SUCCESS) { 109 | 110 | unsigned long now = millis(); 111 | 112 | new_ts = millis(); 113 | dt = (new_ts - ts) / 1000.0; 114 | ts = new_ts; 115 | op = pid(sp, t, t_last, ierr, dt); 116 | 117 | if (now - lastSpSet <= spOverrideTimeout_ms) { 118 | op = op_override; 119 | } 120 | 121 | ot.setBoilerTemperature(op); 122 | } 123 | t_last = t; 124 | 125 | sensors.requestTemperatures(); //async temperature request 126 | 127 | float level = ot.getModulation(); 128 | float bt = ot.getBoilerTemperature(); 129 | float dhwTemp = ot.getDHWTemperature(); 130 | 131 | client.publish(TEMP_BOILER_TARGET_GET_TOPIC.c_str(), String(op).c_str()); 132 | client.publish(CURRENT_TEMP_GET_TOPIC.c_str(), String(t).c_str()); 133 | client.publish(TEMP_BOILER_GET_TOPIC.c_str(), String(bt).c_str()); 134 | client.publish(TEMP_SETPOINT_GET_TOPIC.c_str(), String(sp).c_str()); 135 | client.publish(INTEGRAL_ERROR_GET_TOPIC.c_str(), String(ierr).c_str()); 136 | client.publish(MODE_GET_TOPIC.c_str(), heatingEnabled ? "heat" : "off"); 137 | client.publish(FLAME_STATUS_GET_TOPIC.c_str(), ot.isFlameOn(response) ? "on" : "off"); 138 | client.publish(FLAME_LEVEL_GET_TOPIC.c_str(), String(level).c_str()); 139 | client.publish(STATE_DHW_GET_TOPIC.c_str(), enableHotWater ? "on" : "off"); 140 | client.publish(TEMP_DHW_GET_TOPIC.c_str(), String(dhwTarget).c_str()); 141 | client.publish(ACTUAL_TEMP_DHW_GET_TOPIC.c_str(), String(dhwTemp).c_str()); 142 | 143 | Serial.print("Current temperature: " + String(t) + " °C "); 144 | String tempSource = (millis() - lastTempSet > extTempTimeout_ms) 145 | ? "(internal sensor)" 146 | : "(external sensor)"; 147 | Serial.println(tempSource); 148 | } 149 | 150 | String convertPayloadToStr(byte* payload, unsigned int length) { 151 | char s[length + 1]; 152 | s[length] = 0; 153 | for (int i = 0; i < length; ++i) 154 | s[i] = payload[i]; 155 | String tempRequestStr(s); 156 | return tempRequestStr; 157 | } 158 | 159 | bool isValidNumber(String str) { 160 | bool valid = true; 161 | for (byte i = 0; i < str.length(); i++) 162 | { 163 | char ch = str.charAt(i); 164 | valid &= isDigit(ch) || 165 | ch == '+' || ch == '-' || ch == ',' || ch == '.' || 166 | ch == '\r' || ch == '\n'; 167 | } 168 | return valid; 169 | } 170 | 171 | void callback(char* topic, byte* payload, unsigned int length) { 172 | const String topicStr(topic); 173 | 174 | String payloadStr = convertPayloadToStr(payload, length); 175 | payloadStr.trim(); 176 | 177 | if (topicStr == TEMP_SETPOINT_SET_TOPIC) { 178 | Serial.println("Set target temperature: " + payloadStr); 179 | float sp1 = payloadStr.toFloat(); 180 | if (isnan(sp1) || !isValidNumber(payloadStr)) { 181 | Serial.println("Setpoint is not a valid number, ignoring..."); 182 | } 183 | else { 184 | sp = sp1; 185 | } 186 | } 187 | else if (topicStr == CURRENT_TEMP_SET_TOPIC) { 188 | float t1 = payloadStr.toFloat(); 189 | if (isnan(t1) || !isValidNumber(payloadStr)) { 190 | Serial.println("Current temp set is not a valid number, ignoring..."); 191 | } 192 | else { 193 | t = t1; 194 | lastTempSet = millis(); 195 | } 196 | } 197 | else if (topicStr == MODE_SET_TOPIC) { 198 | Serial.println("Set mode: " + payloadStr); 199 | if (payloadStr == "heat") 200 | heatingEnabled = true; 201 | else if (payloadStr == "off") 202 | heatingEnabled = false; 203 | else 204 | Serial.println("Unknown mode " + payloadStr); 205 | } 206 | else if (topicStr == TEMP_DHW_SET_TOPIC) { 207 | float dhwTarget1 = payloadStr.toFloat(); 208 | if (isnan(dhwTarget1) || !isValidNumber(payloadStr)) { 209 | Serial.println("DHW target is not a valid number, ignoring..."); 210 | } 211 | else { 212 | dhwTarget = dhwTarget1; 213 | } 214 | } 215 | else if (topicStr == STATE_DHW_SET_TOPIC) { 216 | if (payloadStr == "on") 217 | enableHotWater = true; 218 | else if (payloadStr == "off") 219 | enableHotWater = false; 220 | else 221 | Serial.println("Unknown domestic hot water state " + payloadStr); 222 | } 223 | else if (topicStr == SETPOINT_OVERRIDE_SET_TOPIC) { 224 | float op_override1 = payloadStr.toFloat(); 225 | if (isnan(op_override1) || !isValidNumber(payloadStr)) { 226 | Serial.println("Setpoint override is not a valid number, ignoring..."); 227 | } 228 | else { 229 | op_override = op_override1; 230 | lastSpSet = millis(); 231 | } 232 | } 233 | else if (topicStr == SETPOINT_OVERRIDE_RESET_TOPIC) { 234 | lastSpSet = 0; 235 | Serial.println("Setpoint override reset"); 236 | } 237 | else { 238 | Serial.printf("Unknown topic: %s\r\n", topic); 239 | return; 240 | } 241 | 242 | lastUpdate = 0; 243 | } 244 | 245 | void reconnect() { 246 | // Loop until we're reconnected 247 | while (!client.connected()) { 248 | Serial.print("Attempting MQTT connection..."); 249 | const char* clientId = "opentherm-thermostat-test"; 250 | if (client.connect(clientId, mqtt_user, mqtt_password)) { 251 | Serial.println("ok"); 252 | 253 | client.subscribe(TEMP_SETPOINT_SET_TOPIC.c_str()); 254 | client.subscribe(MODE_SET_TOPIC.c_str()); 255 | client.subscribe(CURRENT_TEMP_SET_TOPIC.c_str()); 256 | client.subscribe(TEMP_DHW_SET_TOPIC.c_str()); 257 | client.subscribe(STATE_DHW_SET_TOPIC.c_str()); 258 | client.subscribe(SETPOINT_OVERRIDE_SET_TOPIC.c_str()); 259 | client.subscribe(SETPOINT_OVERRIDE_RESET_TOPIC.c_str()); 260 | } else { 261 | Serial.print(" failed, rc="); 262 | Serial.print(client.state()); 263 | Serial.println(" try again in 5 seconds"); 264 | // Wait 5 seconds before retrying 265 | delay(5000); 266 | } 267 | } 268 | } 269 | 270 | void setup() 271 | { 272 | Serial.begin(115200); 273 | 274 | Serial.println("Connecting to " + String(ssid)); 275 | WiFi.mode(WIFI_STA); 276 | WiFi.begin(ssid, pass); 277 | 278 | int deadCounter = 20; 279 | while (WiFi.status() != WL_CONNECTED && deadCounter-- > 0) { 280 | delay(500); 281 | Serial.print("."); 282 | } 283 | 284 | if (WiFi.status() != WL_CONNECTED) { 285 | Serial.println("Failed to connect to " + String(ssid)); 286 | while (true); 287 | } 288 | else { 289 | Serial.println("ok"); 290 | } 291 | 292 | client.setServer(mqtt_server, mqtt_port); 293 | client.setCallback(callback); 294 | 295 | ot.begin(handleInterrupt); 296 | 297 | //Init DS18B20 sensor 298 | sensors.begin(); 299 | sensors.requestTemperatures(); 300 | sensors.setWaitForConversion(false); //switch to async mode 301 | t, t_last = sensors.getTempCByIndex(0); 302 | ts = millis(); 303 | lastTempSet = -extTempTimeout_ms; 304 | } 305 | 306 | 307 | void loop() 308 | { 309 | if (!client.connected()) { 310 | reconnect(); 311 | } 312 | client.loop(); 313 | 314 | unsigned long now = millis(); 315 | if (now - lastUpdate > statusUpdateInterval_ms) { 316 | lastUpdate = now; 317 | updateData(); 318 | } 319 | 320 | static bool failFlag = false; 321 | bool fail = now - lastTempSet > extTempTimeout_ms && now - lastSpSet > spOverrideTimeout_ms; 322 | if (fail) { 323 | if (!failFlag) { 324 | failFlag = true; 325 | Serial.printf("Neither temperature nor setpoint provided, setting heating water to %.1f\r\n", noCommandSpOverride); 326 | } 327 | 328 | lastSpSet = millis(); 329 | op_override = noCommandSpOverride; 330 | } 331 | else { 332 | failFlag = false; 333 | } 334 | } 335 | --------------------------------------------------------------------------------