Commit 09538f8873de043d4a952152c4cd8c49e7cb8c23

Authored by Jeroen88
1 parent 2b5c768b

Completely reworked Advanced Thermostat example

examples/Advanced_Thermostat/Advanced_Thermostat.ino
... ... @@ -2,10 +2,70 @@
2 2 * https://github.com/Jeroen88/EasyOpenTherm
3 3 * https://www.tindie.com/products/Metriot/OpenTherm-adapter/
4 4 *
5   - * Advanced_Thermostat is a program to demonstrate a real working thermostat.
6   - * A temperature sensor is used to measure room temperature. A fixed room
7   - * setpoint is used. Ofcourse for a real life thermostat this should be
8   - * adjustable. Please adapt the program to your needs.
  5 + * This software needs an OpenTherm controller like
  6 + * https://www.tindie.com/products/Metriot/OpenTherm-adapter/ or
  7 + * https://www.tindie.com/products/Metriot/OpenTherm-adapter/ connected to a BOILER SUPPORTING
  8 + * THE OPENTHERM PROTOCOL, an ESP32-S2 mini, ESP32-C3 mini or ESP-D1 mini flashed with this
  9 + * software and Home Assistant or any other MQTT enabled home automation environment.
  10 + *
  11 + * MQTT_Advanced_Thermostat is a real working thermostat over MQTT. It offers a HIGH LEVEL API
  12 + * (OFF / HEAT / COOL / AUTO and room temperature setpoint) to control your boiler from any other
  13 + * system.
  14 + *
  15 + * It is SUPPOSED TO LOWER your energy bills, because it modulates the boiler's maximum power. This
  16 + * MAY lead to a LONGER PERIOD OF TIME before your room temperature setpoint is reached. If you are
  17 + * not happy with it's present behaviour, please change any of the settings or program your OWN
  18 + * heating or cooling STRATEGY!
  19 + *
  20 + * The easiest way to use this thermostat is from Home Assistant:
  21 + * - Set up a MQTT broker and integration in Home Assistant, if not already done
  22 + * (https://www.home-assistant.io/integrations/mqtt/). The MQTT broker should be reachable without
  23 + * using certificates (TLS).
  24 + * - Change the WiFi credentials and MQTT credentials in this software
  25 + * - Flash this software to a ESP mini, connect the ESP to the OpenTherm Adapter and connect the
  26 + * OpenTherm Adapter to the boiler with the two wires of your present thermostat (replace it)
  27 + * THAT'ALL, all integrations should automatically appear in Home Assistant and be active!
  28 +
  29 + * Although it is designed to work with Home Assistant and it's MQTT integration, it can fucntion
  30 + * in any home automation system as long as it has an MQTT integration.
  31 + * This program is developed and tested for a boiler, but could be easily adapted to work with HVAC
  32 + * systems too.
  33 + * Domestic Hot Water is supported if present (enabled by default and a sensor shows if the boiler
  34 + * is running for DHW). A secondary Heating cicuit (CH2) is not supported nor is Outside Temperature
  35 + * Compensation (OTC). If the boiler supports cooling too this is supported (though not tested).
  36 + * The Home Assistant MQTT Auto Discovery feature is used to automatically add a Climate (thermostat)
  37 + * entity to the user interface. Also several other sensors are added, like flame on / off, boiler
  38 + * flow and return water temperatures, WiFi RSSI, boiler water pressure, etc, depending on the
  39 + * boiler's capabilities.
  40 + *
  41 + * This thermostat subscribes to a MQTT topic, Metriot/EasyOpenTherm/112233445566/climate/state
  42 + * (with 112233445566 being replaced by an unique chip ID) to receive a (frequent) room temperature
  43 + * update. THIS THERMOSTAT CAN NOT FUNCTION WITHOUT A REGULAR TEMPERATURE UPDATE. Temperature updates
  44 + * should be send in the format {"temperature": 20,1}.
  45 + *
  46 + * This thermostat subscribes to a MQTT topic, Metriot/EasyOpenTherm/112233445566/mode/set
  47 + * to receive a mode (OFF, HEAT, COOL or AUTO). THIS THERMOSTAT CAN NOT FUNCTION WITHOUT A
  48 + * A MODE UPDATE, since it starts in 'OFF' state. Mode updates should be send as plain text without
  49 + * any leading or trailing spaces, in capitals, e.g. HEAT.
  50 + *
  51 + * This thermostat publishes to a MQTT topic, Metriot/EasyOpenTherm/112233445566/climate/state
  52 + * the local temperature measures by an onboard sensor. The value message is a JSON, e.g.:
  53 + * {"local_temperature": 20.1}. The software already has support for a Dallas Sensor that can be
  54 + * enabled by defining the one wire pin the sensor is connected to (const int oneWireBus). If you want
  55 + * to use this temerature as room temperature, you have to write an automationm that subscribes to the
  56 + * above topic, extracts the temperature and publishes it in the right format to the room temperature
  57 + * update topic.
  58 + *
  59 + * Unlike other examples on the internet, MQTT_Advanced_Thermostat already is a fully functional
  60 + * thermostat: just configure it once change the room temperature setpoint any time you want to.
  61 + *
  62 + * Use your home automation system, like Home Assistant or Domoticz, to create e.g. schedules
  63 + * and geo fencing, and have this program control your boiler by just setting the room
  64 + * temperature setpoint (and switching it on and off if the boiler supports cooling)
  65 + *
  66 + * Please note that after a reboot or a power cycle the thermostat starts in 'OFF' mode. This
  67 + * means that it will never activate the boiler after such an event. An 'ON' request is needed to
  68 + * (re-)activate the boiler.
9 69 *
10 70 * Copyright (C) 2022 Jeroen Döll <info@metriot.nl>
11 71 *
... ... @@ -28,52 +88,155 @@
28 88 * Connect the OpenTherm TXD pin to the microcontroller's pin defined by #define OT_RX_PIN.
29 89 * Connect the OpenTherm RXD pin to the microcontroller's pin defined by #define OT_TX_PIN.
30 90 *
31   - * Connect the BME280 temperature sensor SDA pin to the microcontroller's pin defined by #define I2C_SDA_PIN
32   - * Connect the BME280 temperature sensor SCL pin to the microcontroller's pin defined by #define I2C_SCL_PIN
33   - * Check your sensor's address, it may differ from the value defined by #define BME_ADDRESS (0x76)
34   - * Install the Adafruit BME280 library
35   - * Any other temperature sensor may be used, like a Dallas sensor, BME380, BMP380, BME680 if you adapt the program accordingly.
  91 + * You can also use this shield <https://www.tindie.com/products/jeroen88/opentherm-controller/> and an
  92 + * ESP32-S2 mini, an ESP32-C3 mini or an ESP D1 mini. With this shield all pins are already connected. The only thing you
  93 + * have to connect are the two OpenTherm thermostat wires.
36 94 *
37   - * Define the room temperature setpoint (desired room temperature) using #define ROOM_TEMPERATURE_SETPOINT. In a real application this should be settable.
38   - * Eventually define the maximum central heating boiler temperature setpoint using #define CH_MAX_SETPOINT.
  95 + * If needed, connect a Dallas Temperature Sensor to the right pin or, if you use the above shield, use the onboard sensor.
  96 + * Make sure though that the mesured temperature is not influenced too much by the heat produced by the processor. An
  97 + * ESP32-S2 e.g. becomes too hot.
  98 + * Any other temperature sensor may be used, like a BME280, BME380, BMP380, BME680 if you adapt the program accordingly.
39 99 *
40   - * Compile and upload the program as normal. If the temperature measured by your sensor is lower than the ROOM_TEMPERATURE_SETPOINT this thermostat program will actually begin to heat up your room
41   -*/
  100 + * I hope you enjoy working with this library, please share ideas in the Github Discussions sessions of this library.
  101 + */
42 102  
43 103  
  104 +static const char * TAG = __FILE__;
  105 +
  106 +#if !defined(ESP32) && !defined(ESP_LOGE)
  107 +#define ESP_LOGE(...)
  108 +#define ESP_LOGI(...)
  109 +#define ESP_LOGV(...)
  110 +#endif
  111 +
44 112 #include <Arduino.h>
45 113  
  114 +#include <WiFi.h>
  115 +
  116 +#include <time.h>
  117 +
  118 +#include <ArduinoJson.h>
  119 +
  120 +// This example does not use the more popular PubSubClient, because that has too many issues, like losing a connection without possibilities to signal it and correct it
  121 +// This MQTT client runs rock solid :)
  122 +// https://github.com/monstrenyatko/ArduinoMqtt
  123 +// Enable MqttClient logs
  124 +#define MQTT_LOG_ENABLED 1
  125 +// Include library
  126 +#include <MqttClient.h>
  127 +
  128 +
  129 +#include <OneWire.h>
  130 +#include <DallasTemperature.h>
  131 +
  132 +#include <PubSubClient.h>
  133 +
46 134 #include <EasyOpenTherm.h>
47 135  
48   -// ESP32-S2
  136 +#include "OpenThermHelpers.h"
  137 +#include "JSONs.h"
  138 +#include "MQTTHelpers.h"
  139 +#include "ThermoStateMachine.h"
  140 +
  141 +
  142 +// Update these with values suitable for your network.
  143 +const char * ssid = "H369A394602";
  144 +const char * password = "445396F996E9";
  145 +
  146 +
  147 +// Update these with values suitable for your MQTT broker, in this example TLS or certificates are not used
  148 +const char * mqtt_server = "homeassistant.local";
  149 +const char * mqtt_user = "mosquitto";
  150 +const char * mqtt_password = "M0squ1tt0";
  151 +
  152 +
  153 +// Your time zone, used to display times correctly (and needed for WiFiClientSecure TLS certificate validation, if used)
  154 +#define TZ_Europe_Amsterdam "CET-1CEST,M3.5.0,M10.5.0/3"
  155 +#define TIME_ZONE TZ_Europe_Amsterdam
  156 +
  157 +
  158 +// Define OT_RX_PIN, the GPIO pin used to read data from the boiler or HVAC. Must support interrupts
  159 +// Define OT_TX_PIN, the GPIO pin used to send data to the boiier or HVAC. Must not be a 'read only' GPIO
  160 +// Define DALLAS, the GPIO pin used for the Dallas sensor, if used
  161 +
  162 +#if defined(ARDUINO_LOLIN_S2_MINI)
49 163 #define OT_RX_PIN (35)
50 164 #define OT_TX_PIN (33)
  165 +#define DALLAS (11)
  166 +#elif defined(ARDUINO_LOLIN_C3_MINI)
  167 +#define OT_RX_PIN (10)
  168 +#define OT_TX_PIN (8)
  169 +#define DALLAS (4)
  170 +#else
  171 +#define OT_RX_PIN (35)
  172 +#define OT_TX_PIN (33)
  173 +#define DALLAS (-1)
  174 +#endif
  175 +
  176 +// The maximum room temperature
  177 +#define ROOM_TEMPERATURE_MAX_SETPOINT (30.0f)
  178 +// The minimum room temperature
  179 +#define ROOM_TEMPERATURE_MIN_SETPOINT (10.0f)
  180 +// The maximum Central Heating boiler temperature. If your house is well isolated and/or you have low temperature radiators this could be as low as 40.0f
  181 +#define CH_MAX_SETPOINT (60.0f)
  182 +// The minimum Central Heating boiler temperature. If the boiler can cool set this to a realistic minimum temperature. If the boiler can only heat, this value
  183 +// is also used as an extra way to switch the boiler OFF
  184 +#define CH_MIN_SETPOINT (10.0f)
  185 +
  186 +// Cheap sensor tend to be inaccurate. Accuracy can be increased by adding two temperatures as measured by the sensor and the 'real' temperature as measured with a calibrated thermometer
  187 +// If your sensor is accurate or if you do not want to use this feature, set measured and calibrated values to the same value
  188 +// Make sure that the lower temperatures and higher temperatures are a few degrees apart. Ideal would be to use temperatures around the minimum room temperature setpoint and the maximum room
  189 +// temperature setpoint
  190 +// Make sure that the difference between both lower temperatures and both higher temperatures is about the same, otherwise your temperature sensor is really bad and you might get strange results
  191 +// from recalculateTemperatures();
  192 +// Only used for (on board) temperature sensors, the room temperature coming in from MQTT is deemed to be correct
  193 +// https://www.letscontrolit.com/wiki/index.php?title=Basics:_Calibration_and_Accuracy
  194 +#define LOWER_MEASURED_TEMPERATURE (15.0)
  195 +#define LOWER_CALIBRATED_TEMPERATURE (15.0)
  196 +#define HIGHER_MEASURED_TEMPERATURE (20.0)
  197 +#define HIGHER_CALIBRATED_TEMPERATURE (20.0)
51 198  
52   -#define BME_ADDRESS (0x76)
53   -#define I2C_SDA_PIN (8)
54   -#define I2C_SCL_PIN (9)
55 199  
  200 +// Interval for publishing the thermostat state information (in seconds)
  201 +#define PUBLISH_STATE_UPDATE_INTERVAL (10)
56 202  
57   -#define ROOM_TEMPERATURE_SETPOINT (18.5f) // The desired room temperature
58   -#define CH_MAX_SETPOINT (60.0f) // If your house is well isolated and/or you have low temperature radiators this could be as low as 40.0f
59   -#define CH_MIN_SETPOINT (10.0f) // If the boiler starts it will warm up untill at least this temperature (unless the desired room temperature is reached before)
  203 +// Minimum interval for running the PID controller (in seconds)
  204 +#define PID_INTERVAL_S (5)
60 205  
61   -#define BOILER_ENABLE_HEATING (true) // If the boiler supports Central Heating (CH), use the boiler for heating
62   -#define BOILER_ENABLE_COOLING (true) // If the boiler supports cooling, use the boiler for cooling
63   -#define BOILER_ENABLE_DOMESTIC_HOT_WATER (true) // If the boiler supports Domestic Hot Water (DHW), use the boiler for DHW
64 206  
65   -#include <Wire.h>
66   -#include <Adafruit_Sensor.h>
67   -#include <Adafruit_BME280.h>
  207 +// MQTT client message buffer sizes, must be big enough to store a full message
  208 +const size_t MSG_BUFFER_SEND_SIZE = 2 * 1024;
  209 +const size_t MSG_BUFFER_RECV_SIZE = 256; // Too small a receive buffer will fail on receiving subscribed topics...
  210 +
  211 +// Define a global WiFiClient instance for WiFi connection
  212 +WiFiClient wiFiClient;
  213 +
  214 +#define MQTT_ID "EasyOpenTherm"
  215 +static MqttClient *mqtt = NULL;
  216 +
  217 +
  218 +// GPIO where the DS18B20 is connected to, set to '-1' if not used
  219 +//const int oneWireBus = DALLAS;
  220 +const int oneWireBus = -1;
  221 +
  222 +// Define a pointer to oneWire instance to communicate with any OneWire devices
  223 +OneWire * oneWire = nullptr;
  224 +
  225 +// Define a pointer to DallasTemperature instance to communicate with any OneWire devices
  226 +DallasTemperature * dallasSensors = nullptr;
  227 +
  228 +
  229 +// Create a global OpenTherm instance called 'thermostat' (i.e primary and boiler is secondary) with OT_RX_PIN to receive data from boiler and OT_TX_PIN to send data to boiler
  230 +// Only one OpenTherm object may be created!
  231 +OpenTherm thermostat(OT_RX_PIN, OT_TX_PIN);
68 232  
69   -Adafruit_BME280 bme; // I2C
70 233  
71 234 // Use a PID controller to calculate the CH setpoint. See e.g. https://en.wikipedia.org/wiki/PID_controller
72 235 float pid(float sp,
73 236 float pv,
74 237 float pv_last,
75 238 float & ierr,
76   - float dt) {
  239 + float dt) {
77 240 float KP = 30;
78 241 float KI = 0.02;
79 242  
... ... @@ -90,140 +253,592 @@ float pid(float sp,
90 253 }
91 254 ierr = I;
92 255  
  256 + op = roundf(op * 10.0f) / 10.0f; // Round to one decimal
  257 +
93 258 return op;
94 259 }
95 260  
96 261  
  262 +// Lineair recalculation of the temperature measured by the sensor using two calibrated temperatures. Round to two decimals
  263 +float recalculateTemperature(float temperature) {
  264 + // y = ax + b
  265 + if(LOWER_MEASURED_TEMPERATURE == HIGHER_MEASURED_TEMPERATURE) return temperature; // Lower and higher temperatures should be a few degrees apart, so this is wrong. Return the measured temperature
  266 + float a = float(HIGHER_CALIBRATED_TEMPERATURE - LOWER_CALIBRATED_TEMPERATURE) / float(HIGHER_MEASURED_TEMPERATURE - LOWER_MEASURED_TEMPERATURE);
  267 + float b = float(LOWER_CALIBRATED_TEMPERATURE) - a * float(LOWER_MEASURED_TEMPERATURE);
  268 + return roundf(100.0f * a * (temperature + b)) / 100.0f;
  269 +}
  270 +
  271 +
  272 +// Global variables that are set by incoming MQTT messages and used by loop()
  273 +// Global variable to store the room temperature setpoint.
  274 +float roomTemperatureSetpoint = ROOM_TEMPERATURE_MIN_SETPOINT;
  275 +// Global variable to store OFF/AUTO/HEAT/COOL requests to the boiler
  276 +ThermostatRequest thermostatRequest = ThermostatRequest::NONE;
  277 +// Global variable to keep track of the lastest room temperature update timestamp (in seconds)
  278 +uint32_t roomTemperatureTimestampS = 0;
  279 +// Global variable to store the room temperature
  280 +float roomTemperature;
  281 +
  282 +
  283 +// ============== Subscription callbacks ========================================
  284 +void processRoomTemperatureMessage(MqttClient::MessageData& md) {
  285 +Serial.println("processRoomTemperatureMessage");
  286 + const MqttClient::Message& msg = md.message;
  287 + char payload[msg.payloadLen + 1];
  288 + memcpy(payload, msg.payload, msg.payloadLen);
  289 + payload[msg.payloadLen] = '\0';
  290 +
  291 + StaticJsonDocument<32> roomTemperatureMsgDoc;
  292 + DeserializationError error = deserializeJson(roomTemperatureMsgDoc, payload);
  293 + if(error) {
  294 + ESP_LOGE(TAG, "Parsing JSON payload failed (%s): '%s'", error.f_str(), payload);
  295 +Serial.printf("Parsing JSON payload failed (%s): '%s'\n", error.f_str(), payload);
  296 +
  297 + return;
  298 + }
  299 +
  300 + if(!roomTemperatureMsgDoc.containsKey("temperature")) {
  301 + ESP_LOGE(TAG, "\"temperature\" tag is missing in JSON payload: '%s'", payload);
  302 +Serial.printf("\"temperature\" tag is missing in JSON payload: '%s'\n", payload);
  303 +
  304 + return;
  305 + }
  306 +
  307 +// What if {"temperature":15.3} a non float value is send? Value will evaluate to zero, which is also a valid temperature...
  308 +
  309 + ESP_LOGI(TAG, "Received room temperature '%s'", payload);
  310 + roomTemperatureTimestampS = time(nullptr);
  311 + roomTemperature = roomTemperatureMsgDoc["temperature"];
  312 +Serial.printf("Received room temperature %.01f ºC\n", roomTemperature);
  313 +}
  314 +
  315 +
  316 +void processSetpointTemperatureMessage(MqttClient::MessageData& md) {
  317 + const MqttClient::Message& msg = md.message;
  318 + char payload[msg.payloadLen + 1];
  319 + memcpy(payload, msg.payload, msg.payloadLen);
  320 + payload[msg.payloadLen] = '\0';
  321 +
  322 + float setpoint;
  323 + if(sscanf(payload, "%f", &setpoint) != 1) {
  324 + ESP_LOGE(TAG, "Payload is not a float: '%s'", payload);
  325 +
  326 + return;
  327 + }
  328 +
  329 + ESP_LOGI(TAG, "Received room temperature setpoint '%s'", payload);
  330 +Serial.printf("Received room temperature setpoint '%s'\n", payload);
  331 + roomTemperatureSetpoint = setpoint;
  332 +}
  333 +
  334 +
  335 +void processClimateMessage(MqttClient::MessageData& md) {
  336 + const MqttClient::Message& msg = md.message;
  337 + char payload[msg.payloadLen + 1];
  338 + memcpy(payload, msg.payload, msg.payloadLen);
  339 + payload[msg.payloadLen] = '\0';
  340 + ESP_LOGI(TAG,
  341 + "Message arrived: qos %d, retained %d, dup %d, packetid %d, payload:[%s]",
  342 + msg.qos, msg.retained, msg.dup, msg.id, payload
  343 + );
  344 +Serial.printf("processClimateMessage PAYLOAD: '%s'\n", payload);
  345 + ESP_LOGI(TAG, "processClimateMessage PAYLOAD: '%s'", payload);
  346 +
  347 + if(strcasecmp(payload, "off") == 0) {
  348 + thermostatRequest = ThermostatRequest::OFF;
  349 + } else if(strcasecmp(payload, "auto") == 0) {
  350 + thermostatRequest = ThermostatRequest::AUTO;
  351 + } else if(strcasecmp(payload, "heat") == 0) {
  352 + thermostatRequest = ThermostatRequest::HEAT;
  353 + } else if(strcasecmp(payload, "cool") == 0) {
  354 + thermostatRequest = ThermostatRequest::COOL;
  355 + }
  356 +}
  357 +
  358 +
97 359 void setup() {
98 360 Serial.begin(115200);
99 361 delay(5000); // For debug only: give the Serial Monitor some time to connect to the native USB of the MCU for output
100 362 Serial.println("\n\nStarted");
  363 + Serial.printf("Chip ID is %s\n", chipID());
  364 + ESP_LOGI(TAG, "Chip ID is %s", chipID());
  365 +
  366 + Serial.printf("OpenTherm RX pin is %d, TX pin is %d\n", OT_RX_PIN, OT_TX_PIN);
  367 +
  368 + pinMode(LED_BUILTIN, OUTPUT);
  369 +
  370 + // Connect WiFi
  371 + WiFi.mode(WIFI_STA);
  372 + WiFi.setAutoReconnect(true);
  373 + WiFi.begin(ssid, password);
  374 +
  375 + uint32_t startMillis = millis();
  376 + while (WiFi.status() != WL_CONNECTED) {
  377 + delay(500);
  378 + digitalWrite(LED_BUILTIN, digitalRead(LED_BUILTIN) == LOW ? HIGH : LOW);
101 379  
102   - Serial.println(F("\n-- BME280 test --"));
103   - Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  380 + if(millis() - startMillis > 15000) ESP.restart();
  381 + }
  382 +
  383 + ESP_LOGI(TAG, "WiFi connected to %s", ssid);
  384 + ESP_LOGI(TAG, "IP address: %s", WiFi.localIP().toString().c_str());
  385 +
  386 +// Set time and date, necessary for HTTPS certificate validation
  387 + configTzTime(TIME_ZONE, "pool.ntp.org", "time.nist.gov");
  388 + setenv("TZ", TIME_ZONE, 1); // Set environment variable with your time zone
  389 + tzset();
104 390  
105   - bool status = bme.begin(BME_ADDRESS);
106   - if (!status) {
107   - Serial.println("Could not find a valid BME280 sensor, check wiring and address!");
108   - while (1);
  391 + Serial.println("Waiting for NTP time sync: ");
  392 + time_t now = time(nullptr);
  393 + startMillis = millis();
  394 + while (now < 8 * 3600 * 2) {
  395 + delay(100);
  396 + digitalWrite(LED_BUILTIN, digitalRead(LED_BUILTIN) == LOW ? HIGH : LOW);
  397 + Serial.print(".");
  398 + now = time(nullptr);
  399 +
  400 + if(millis() - startMillis > 15000) ESP.restart();
109 401 }
110   - Serial.println("-- Temperature sensor present --");
  402 + digitalWrite(LED_BUILTIN, LOW);
  403 + Serial.println();
  404 +
  405 + const struct tm * timeinfo = localtime(&now);
  406 + Serial.printf("%s %s", tzname[0], asctime(timeinfo));
111 407  
112   - bme.setSampling();
  408 + if(oneWireBus != -1) {
  409 + oneWire = new OneWire(oneWireBus);
  410 + dallasSensors = new DallasTemperature(oneWire);
  411 +
  412 + // Start the DS18B20 sensor
  413 + dallasSensors->begin();
  414 + dallasSensors->setWaitForConversion(false);
  415 + dallasSensors->requestTemperatures();
  416 +
  417 + }
  418 +
  419 + // Setup MQTT client
  420 + MqttClient::System *mqttSystem = new System;
  421 +#if defined(ARDUINO_LOLIN_S2_MINI)
  422 + MqttClient::Logger *mqttLogger = new MqttClient::LoggerImpl<USBCDC>(Serial);
  423 +#elif defined(ARDUINO_LOLIN_C3_MINI)
  424 + MqttClient::Logger *mqttLogger = new MqttClient::LoggerImpl<HWCDC>(Serial);
  425 +#else
  426 + MqttClient::Logger *mqttLogger = new MqttClient::LoggerImpl<HardwareSerial>(Serial);
  427 +#endif
  428 + MqttClient::Network * mqttNetwork = new MqttClient::NetworkClientImpl<WiFiClient>(wiFiClient, *mqttSystem);
  429 + //// Make MSG_BUFFER_SIZE bytes send buffer
  430 + MqttClient::Buffer *mqttSendBuffer = new MqttClient::ArrayBuffer<MSG_BUFFER_SEND_SIZE>();
  431 + //// Make MSG_BUFFER_SIZE bytes receive buffer
  432 + MqttClient::Buffer *mqttRecvBuffer = new MqttClient::ArrayBuffer<MSG_BUFFER_RECV_SIZE>();
  433 + //// Allow up to 4 subscriptions simultaneously
  434 + MqttClient::MessageHandlers *mqttMessageHandlers = new MqttClient::MessageHandlersDynamicImpl<4>();
  435 + // Note: the MessageHandlersDynamicImpl does not copy the topic string. The second parameter to MessageHandlersStaticImpl is the maximum topic size
  436 + // NOT TRUE: https://github.com/monstrenyatko/ArduinoMqtt/blob/15091f0b8c05f843f93b73db9a98f7b59ffb4dfa/src/MqttClient.h#L390
  437 +// MqttClient::MessageHandlers *mqttMessageHandlers = new MqttClient::MessageHandlersStaticImpl<4, 128>();
  438 + //// Configure client options
  439 + MqttClient::Options mqttOptions;
  440 + ////// Set command timeout to 10 seconds
  441 + mqttOptions.commandTimeoutMs = 10000;
  442 + //// Make client object
  443 + mqtt = new MqttClient(mqttOptions, *mqttLogger, *mqttSystem, *mqttNetwork, *mqttSendBuffer, *mqttRecvBuffer, *mqttMessageHandlers);
  444 +
  445 + wiFiClient.connect(mqtt_server, 1883);
  446 + if(!wiFiClient.connected()) {
  447 + ESP_LOGE(TAG, "Can't establish the TCP connection");
  448 + delay(5000);
  449 + ESP.restart();
  450 + }
  451 +
  452 + Serial.println("Connect MQTT client...");
  453 +
  454 + bool MQTTConnected = connectMQTT(*mqtt, MQTT_ID, mqtt_user, mqtt_password);
  455 + if(MQTTConnected) {
  456 + ESP_LOGI(TAG, "MQTT connected");
  457 +Serial.println("MQTT connected");
  458 + } else {
  459 + ESP_LOGI(TAG, "MQTT NOT connected");
  460 +Serial.println("MQTT NOT connected");
  461 + delay(30000);
  462 +
  463 + return;
  464 + }
  465 +
  466 + // Set all IDs for all discovery messages
  467 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE);
  468 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR);
  469 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR);
  470 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR);
  471 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR);
  472 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR);
  473 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR);
  474 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR);
  475 + discoveryMessageSetIDs(EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR);
  476 +
  477 + // Check all JSONs by parsing the JSONs
  478 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE);
  479 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR);
  480 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR);
  481 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR);
  482 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR);
  483 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR);
  484 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR);
  485 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR);
  486 + if(!validJson(EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR)) Serial.printf("Invalid JSON '%s'\n", EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR);
  487 +
  488 + // Add all 'always present' entities (the other entities are added only if supported by the boiler)
  489 + addEntity(*mqtt, "climate", "climate", EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE);
  490 + addEntity(*mqtt, "sensor", "boiler_setpoint", EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR);
  491 + addEntity(*mqtt, "sensor", "thermostat_rssi", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR);
  492 + addEntity(*mqtt, "binary_sensor", "boiler_flame", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR);
  493 +
  494 + char payload[16];
  495 + snprintf(payload, sizeof payload, "%.01f", ROOM_TEMPERATURE_MIN_SETPOINT);
  496 + // Set all sensors, except those that are directly updated, to 'None', the binary sensor to 'Unknown'
  497 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR), "{\"ch_setpoint\":\"None\"}");
  498 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR), "{\"flow_temperature\":\"None\"}");
  499 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR), "{\"return_temperature\":\"None\"}");
  500 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR), "{\"water_pressure\":\"None\"}");
  501 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR), "{\"relative_modulation\":\"None\"}");
  502 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR), "{\"flame\":\"None\"}");
  503 + publish(*mqtt, topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR), "{\"dhw\":\"None\"}");
113 504  
114 505 Serial.println("Setup done, start loop...");
115 506 }
116 507  
117 508  
118   -void loop() {
119   - // Create an OpenTherm thermostat (boiler is secondary) with OT_RX_PIN to receive data from boiler and OT_TX_PIN to send data to boiler
120   - // Only one OpenTherm object may be created!
121   - static OpenTherm thermostat(OT_RX_PIN, OT_TX_PIN);
122   -
123   - // static variables used by the PID controller
124   - static uint32_t previousTimestamp = millis(); // Previous timestamp
125   - static float previousTemperature = bme.readTemperature(); // Previous temperature
126   - static float ierr = 0; // Integral error
  509 +// updateClimateEntity()
  510 +// If the boikler can both heat and cool adds 'auto', 'off', 'cool' and 'heat' to the thermmostat modes
  511 +// If the boikler can only heat adds 'off' and 'heat' to the thermmostat modes
  512 +void updateClimateEntity(bool canCool) {
  513 + StaticJsonDocument<fullJsonDocSize> discoveryMsgDoc;
127 514  
128   -
129   - uint32_t timestamp = millis();
130   - if(timestamp - previousTimestamp >= 1000) {
131   - float roomTemperature = bme.readTemperature(); // Read the sensor to get the current room temperature
132   - Serial.printf("Room temperature is %.02f *C, room temperature setpoint is %.02f *C\n", roomTemperature, ROOM_TEMPERATURE_SETPOINT);
133   -
134   - float dt = (timestamp - previousTimestamp) / 1000.0; // Time between measurements in seconds
135   - float CHSetpoint = pid(ROOM_TEMPERATURE_SETPOINT, roomTemperature, previousTemperature, ierr, dt);
136   - previousTimestamp = timestamp;
137   - previousTemperature = roomTemperature;
138   -
139   - Serial.printf("New Central Heating (CH) setpoint computed by PID is %.02f\n", CHSetpoint);
140   -
141   -
142   - // First try to connect to the boiler to read it's capabilities. The boiler returns an 8 bit secondaryFlags and each bit has a meaning. The bits are defined in enum class OpenTherm::CONFIGURATION_FLAGS
143   - // The secondaryMemberIDCode identifies the manufacturer of the boiler
144   - uint8_t secondaryFlags;
145   - uint8_t secondaryMemberIDCode;
146   - if(thermostat.read(OpenTherm::READ_DATA_ID::SECONDARY_CONFIGURATION, secondaryFlags, secondaryMemberIDCode)) { // Mandatory support
147   - Serial.printf("Secondary configuration flags is 0x%02x, boiler manufacturer's ID is %d (0x%02x)\n", secondaryFlags, secondaryMemberIDCode, secondaryMemberIDCode);
148   - if(secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_DHW_PRESENT)) Serial.println("Domestic Hot Water (DHW) present"); else Serial.println("Domestic Hot Water (DHW) not present");
149   - if(secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_CONTROL_TYPE)) Serial.println("Control type on/off"); else Serial.println("Control type modulating");
150   - if(secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_COOLING)) Serial.println("Cooling supprted"); else Serial.println("Cooling not supported");
151   - if(secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_DHW)) Serial.println("Domestic Hot Water (DHW) storage tank"); else Serial.println("Domestic Hot Water (DHW) instantaneous or not-specified");
152   - if(secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_LOW_OFF_PUMP_CTRL)) Serial.println("Low off and pump control not allowed"); else Serial.println("Low off and pump control allowed");
153   - if(secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_CH2_PRESENT)) Serial.println("Second Centrel Heating system (CH2) present"); else Serial.println("Second Central Heating system (CH2) not present");
  515 + if(discoveryMsgToJsonDoc(discoveryMsgDoc, EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE)) {
  516 + JsonArray modesArray = discoveryMsgDoc["modes"];
  517 + if(canCool) {
  518 + modesArray.add("auto");
  519 + modesArray.add("off");
  520 + modesArray.add("cool");
154 521 } else {
155   - secondaryFlags = 0;
156   -
157   - if(thermostat.error() == OpenTherm::ERROR_CODES::UNKNOWN_DATA_ID) {
158   - // Valid data is received but the for boilers mandatory DATA-ID OpenTherm::READ_DATA_ID::SECONDARY_CONFIGURATION is not recognised. This is not a boiler but another device!
159   - Serial.println("Your remote device is not a boiler");
160   - } else {
161   - // No data or invalid data received
162   - Serial.println("Failed to get secondary configuration; is a boiler connected?");
163   - }
164   -
165   - // Wait 5 secs and try again
166   - delay(5000);
167   - return;
  522 + modesArray.add("off");
168 523 }
  524 + modesArray.add("heat");
  525 +
  526 + addEntity(*mqtt, "climate", "climate", discoveryMsgDoc);
  527 +
  528 + const char * availabilityTopic = topicByReference("availability_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE);
  529 + publish(*mqtt, availabilityTopic, "{\"climate_available\":\"ONLINE\"}");
  530 + }
  531 +}
  532 +
  533 +
  534 +// Adds a DHW binary sensor entity, only if the boiler can run for Domestic Hot Water
  535 +void updateDHWEntity(bool enableDHW) {
  536 + if(enableDHW) {
  537 + addEntity(*mqtt, "binary_sensor", "boiler_dhw", EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR);
169 538  
170   - // Tell the boiler the desired CH boiler water temperature
171   - // This is done by writing this value to DATA-ID OpenTherm::WRITE_DATA_ID::CONTROL_SETPOINT_CH
172   - if(thermostat.write(OpenTherm::WRITE_DATA_ID::CONTROL_SETPOINT_CH, CHSetpoint)) {
173   - Serial.printf("Central Heating (CH) temperature setpoint set to %.01f *C\n", CHSetpoint);
  539 + const char * topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR);
  540 + publish(*mqtt, topic, "{\"dhw\":\"OFF\"}");
  541 + } else {
  542 + addEntity(*mqtt, "binary_sensor", "boiler_dhw", "");
  543 + }
  544 +}
  545 +
  546 +
  547 +// updateFlameSensor
  548 +// Update the value of the flame sensor in Home Assistant. Usually this sensor reacts within one second
  549 +// If the sensor is 'ON' the boiler is running for Central Heating or Domestic Hot Water (see updateDHWSensor to see which is active)
  550 +// If the sensor is 'OFF' the boiler is idle (no heat demand)
  551 +void updateFlameSensor(uint8_t statusFlags) {
  552 + static bool flameSensorInitialised = false;
  553 + static uint8_t previousStatusFlags = 0;
  554 +Serial.printf("Secondary status flags is 0x%02x\n", statusFlags);
  555 +
  556 + if(!flameSensorInitialised || (statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_FLAME_STATUS)) != (previousStatusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_FLAME_STATUS))) {
  557 + const char * topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR);
  558 + if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_FLAME_STATUS)) {
  559 + publish(*mqtt, topic, "{\"flame\":\"ON\"}");
174 560 } else {
175   - Serial.printf("Failed to set Central Heating (CH) temperature setpoint to %.01f *C\n", CHSetpoint);
  561 + publish(*mqtt, topic, "{\"flame\":\"OFF\"}");
176 562 }
  563 + previousStatusFlags = statusFlags;
  564 + flameSensorInitialised = true;
  565 + }
  566 +}
  567 +
  568 +
  569 +// updateDHWSensor
  570 +// Update the value of the DHW sensor in Home Assistant. Usually this sensor reacts within one second
  571 +// If the sensor is 'ON' the boiler is running for Domestic Hot Water
  572 +// If the sensor is 'OFF' the boiler is either idle or running for Central Heating (this can be checked woth the flame sensor)
  573 +void updateDHWSensor(uint8_t statusFlags) {
  574 + static uint8_t previousStatusFlags = 0;
177 575  
178   - // Tell the boiler the current room temperature (optional?)
179   - // This is done by writing this value to DATA-ID OpenTherm::WRITE_DATA_ID::ROOM_TEMPERATURE
180   - if(thermostat.write(OpenTherm::WRITE_DATA_ID::ROOM_TEMPERATURE, roomTemperature)) {
181   - Serial.printf("Room temperature set to %.02f *C\n", roomTemperature);
  576 + bool changedDHW = (statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_DHW_MODE)) != (previousStatusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_DHW_MODE)) |
  577 + (statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_CH_MODE)) != (previousStatusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_CH_MODE));
  578 + if(changedDHW) {
  579 + const char * topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR);
  580 + if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_DHW_MODE)) {
  581 + publish(*mqtt, topic, "{\"dhw\":\"ON\"}");
182 582 } else {
183   - Serial.println("Failed to set room temperature to sensor value");
  583 + publish(*mqtt, topic, "{\"dhw\":\"OFF\"}");
184 584 }
  585 + previousStatusFlags = statusFlags;
  586 + }
  587 +}
185 588  
186 589  
187   - // primaryFlags is used to tell the secondary device (boiler) what available services (Central heating, cooling, domestic hot water) it wants to make use of, if present
188   - // The meaning of each bit is defined in enum class OpenTherm::STATUS_FLAGS
189   - // BOILER_ENABLE_DOMESTIC_HOT_WATER is a #define. If defined 'true' then domestic hot water is enabled if it is available in the boiler. The previously read secondaryFlags are used to detect this capability
190   - uint8_t primaryFlags;
191   - if(BOILER_ENABLE_DOMESTIC_HOT_WATER && (secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_DHW))) {
192   - Serial.println("Enable Domestic Hot Water (DHW)");
193   - primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_DHW_ENABLE);
  590 +// updateSensors
  591 +// Update the values of all 'interval' sensors in Home Assistant by sending the value in the right format to the topic looked up in the discovery JSON. OpenTherm sensor can be read and do not have
  592 +// an entity yet in Home Assistant are created by sending the discovery JSON to the right '/config' topic.
  593 +void updateSensors(MqttClient & client,
  594 + float CHSetpoint,
  595 + uint32_t & previousOTCommunicationMs) {
  596 + if(client.isConnected()) {
  597 + char payload[64];
  598 + const char * topic;
  599 +
  600 + // Publish RSSI
  601 + snprintf(payload, sizeof payload, "{\"RSSI\":%d}", WiFi.RSSI());
  602 + topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR);
  603 + publish(client, topic, payload);
  604 +
  605 + // Publish the Dallas sensor value if such a sensor is present. This value can be used as room temperatur value using an automation in Home Assistant publishing it back to the same topic
  606 + // but with the JSON key 'temperature' instead
  607 + if(dallasSensors) {
  608 + snprintf(payload, sizeof payload, "{\"local_temperature\":%.01f}", recalculateTemperature(dallasSensors->getTempCByIndex(0)));
  609 + topic = topicByReference("current_temperature_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE);
  610 + publish(client, topic, payload);
194 611 }
195 612  
196   - // BOILER_ENABLE_HEATING is a #define. If defined 'true' then central heating is enabled.
197   -// if(roomTemperature < ROOM_TEMPERATURE_SETPOINT && BOILER_ENABLE_HEATING) {
198   - if(BOILER_ENABLE_HEATING) {
199   - Serial.println("Enable Central Heating (CH)");
200   - primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_CH_ENABLE);
  613 + // Publish the Central Heating setpoint temperature
  614 + snprintf(payload, sizeof payload, "{\"ch_setpoint\":%.01f}", CHSetpoint);
  615 + topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR);
  616 + publish(client, topic, payload);
  617 +
  618 + float value;
  619 + // Test if Relative Modulation Level can be read from the boiler
  620 + if(readSensor(thermostat, OpenTherm::READ_DATA_ID::RELATIVE_MODULATION_LEVEL, value, previousOTCommunicationMs)) {
  621 + Serial.printf("Relative Modulation level is %.01f %\n", value);
  622 + // Use a static variable to keep track of the entity already being created
  623 + static bool relativeModulationLevelDiscoveryPublished = false;
  624 + if(!relativeModulationLevelDiscoveryPublished) {
  625 + // Create the entity
  626 + addEntity(client, "sensor", "relative_modulation", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR);
  627 + relativeModulationLevelDiscoveryPublished = true;
  628 + }
  629 + // Publish the Relative Modulation Level
  630 + snprintf(payload, sizeof payload, "{\"relative_modulation\":%.01f}", value);
  631 + topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR);
  632 + publish(client, topic, payload);
  633 + }
  634 + // Test if Relative Central Heating Water Pressure can be read from the boiler
  635 + if(readSensor(thermostat, OpenTherm::READ_DATA_ID::CH_WATER_PRESSURE, value, previousOTCommunicationMs)) {
  636 + Serial.printf("Central Heating water pressure is %.01f bar\n", value);
  637 + // Use a static variable to keep track of the entity already being created
  638 + static bool waterPressureEntityAdded = false;
  639 + // Create the entity
  640 + if(!waterPressureEntityAdded) {
  641 + addEntity(client, "sensor", "water_pressure", EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR);
  642 + waterPressureEntityAdded = true;
  643 + }
  644 + // Publish the Central Heating Water Pressure
  645 + snprintf(payload, sizeof payload, "{\"water_pressure\":%.01f}", value);
  646 + topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR);
  647 + publish(client, topic, payload);
  648 + }
  649 + // Test if Flow Temperature can be read from the boiler
  650 + if(readSensor(thermostat, OpenTherm::READ_DATA_ID::BOILER_WATER_TEMP, value, previousOTCommunicationMs)) {
  651 + Serial.printf("Flow water temperature from boiler is %.01f %\n", value);
  652 + // Use a static variable to keep track of the entity already being created
  653 + static bool flowTemperatureEntityAdded = false;
  654 + // Create the entity
  655 + if(!flowTemperatureEntityAdded) {
  656 + addEntity(client, "sensor", "flow_temperature", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR);
  657 + flowTemperatureEntityAdded = true;
  658 + }
  659 + // Publish the Flow Temperature
  660 + snprintf(payload, sizeof payload, "{\"flow_temperature\":%.01f}", value);
  661 + topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR);
  662 + publish(client, topic, payload);
  663 + }
  664 + // Test if Return Temperature can be read from the boiler
  665 + if(readSensor(thermostat, OpenTherm::READ_DATA_ID::RETURN_WATER_TEMPERATURE, value, previousOTCommunicationMs)) {
  666 + Serial.printf("Return water temperature to boiler is %.01f %\n", value);
  667 + // Use a static variable to keep track of the entity already being created
  668 + static bool returnTemperatureEntityAdded = false;
  669 + // Create the entity
  670 + if(!returnTemperatureEntityAdded) {
  671 + addEntity(client, "sensor", "return_temperature", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR);
  672 + returnTemperatureEntityAdded = true;
  673 + }
  674 + // Publish the Return Temperature
  675 + snprintf(payload, sizeof payload, "{\"return_temperature\":%.01f}", value);
  676 + topic = topicByReference("state_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR);
  677 + publish(client, topic, payload);
201 678 }
  679 + // Any other OpenTherm sensor that returns a float (f8.8) can be added in the same way as above by using the correct OpenTherm::READ_DATA_ID::, adding a dicovery JSON in JSONs.h, calling
  680 + // addEntity with the according parameters and publishing the value to the right topic in the right format
  681 + }
  682 +}
  683 +
  684 +
  685 +// updateRoomTemperatureStale()
  686 +// If the room temperature becomes stale, after it has not been updated within the ROOM_TEMPERATURE_STALE_INTERVAL_S that defaults to 15 minutes,
  687 +// signal this to Home Assistant by setting the room temperature to None/
  688 +void updateRoomTemperatureStale(bool stale) {
  689 + static bool previousStale = false;
  690 +
  691 + if(stale && !previousStale) {
  692 + const char * topic = topicByReference("current_temperature_topic", EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE);
  693 + publish(*mqtt, topic, "{\"temperature\":\"None\"}");
  694 + }
  695 +
  696 + previousStale = stale;
  697 +}
  698 +
202 699  
203   - // BOILER_ENABLE_COOLING is a #define. If defined 'true' then cooling is enabled. secondaryFlags is used to detect the capability
204   - if(BOILER_ENABLE_COOLING && (secondaryFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_COOLING))) {
205   - Serial.println("Enable cooling");
206   - primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_COOLING_ENABLE);
  700 +// subscribeAll()
  701 +// Subsbribe to all needed topics: one for the room temperature setpoint, one for the mode (OFF / HEAT / COOL / AUTO) and one for
  702 +// the actual room temperature
  703 +bool subscribeAll(MqttClient & client) {
  704 + if(client.isConnected()) {
  705 + subscribe(client, processSetpointTemperatureMessage, EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE, "temperature_command_topic");
  706 + subscribe(client, processClimateMessage, EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE, "mode_command_topic");
  707 + subscribe(client, processRoomTemperatureMessage, EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE, "current_temperature_topic");
  708 +// subscribe(client, processClimateMessage, EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE, "preset_mode_command_topic"); // Not implemented yet
  709 +
  710 + return true;
  711 + }
  712 +
  713 + return false;
  714 +}
  715 +
  716 +
  717 +// The main loop, repeat over and over again:
  718 +// - Try to connect to the boiler if not connected yet
  719 +// - If connected, every second:
  720 +// - Inform the boiler of room temperature setpoint and actual room tenperature
  721 +// - Update the state (from OFF to IDLE to HEATING or COOLING to ANTI_HUNTING
  722 +// - Update the primary flags according to the state. The primary flags control if the boiler will statrt heating (or cooling) or not
  723 +// - Compute the new Central Heating Setpoint using the PID controller, if enough time (PID_INTERVAL_S) has lapsed
  724 +// - Inform the boiler of the Central Heating water temperature setpoint
  725 +// - read the boiler status (this also updates the primary falgs) and update all sensors
  726 +void loop() {
  727 + static ThermoStateMachine thermoStateMachine;
  728 +
  729 + // Static variable to (re-)subscribe to topics
  730 + static bool subscribed = false;
  731 + // static variables used by the PID controller
  732 + static uint32_t previousPIDTimestampS; // Save timestamp that the PID was previously run
  733 + static float previousPIDRoomTemperature; // Save previous room temperature
  734 + static float ierr; // Save PID integral error
  735 +
  736 + // static variable to store the CH setpoint
  737 + static float CHSetpoint = CH_MIN_SETPOINT;
  738 + // static variable used to comply to the OpenTherm specification to have a primary to secondary communication at least once every second
  739 + static uint32_t previousOTCommunicationMs = millis() - 1000; // Initialise to force the first communication
  740 + // static variables used to decide if the thermostat state should be published
  741 + static uint32_t publishedThermostatStateTimestamp = time(nullptr); // Initialise previous thermostat state publishing timestamp
  742 +
  743 +
  744 + // Check if we are still connected to the MQTT broker. If not, reconnect and resubscribe to the topics we are interested in
  745 + if(!mqtt->isConnected()) {
  746 + ESP_LOGI(TAG, "MQTT disconnected");
  747 +Serial.println("MQTT disconnected");
  748 + wiFiClient.stop();
  749 + wiFiClient.connect(mqtt_server, 1883);
  750 + connectMQTT(*mqtt, MQTT_ID, mqtt_user, mqtt_password);
  751 + subscribed = false;
  752 + }
  753 +
  754 +// Subscribe to all relevant topics after a new connection
  755 + if(!subscribed) {
  756 + if(subscribeAll(*mqtt)) {
  757 + subscribed = true;
207 758 }
  759 + }
208 760  
209   - // Enable Outside Temperature Compensation by default
210   - primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_OTC_ENABLE);
211   -
212   - // Send primaryFlags to the boiler to request services. Using statusFlags the boiler returns if it is in fault status, if it is in central heating status or domestic hot water, if it's flame is burning, etc.
213   - Serial.println("Request services from the boiler and check it's status...");
214   - uint8_t statusFlags;
215   - if(thermostat.status(primaryFlags, statusFlags)) { // Mandatory support
216   - Serial.printf("Status flags is 0x%02x\n", statusFlags);
217   -
218   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_FAULT_INDICATION)) Serial.println("FAULT NOTIFICATION");
219   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_CH_MODE)) Serial.println("Central Heating (CH) MODE");
220   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_DHW_MODE)) Serial.println("Domestc Hot Water (DHW) MODE");
221   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_FLAME_STATUS)) Serial.println("Flame is on");
222   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_COOLING_STATUS)) Serial.println("Cooling");
223   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_CH2_MODE)) Serial.println("Second Central Heating system (CH2) is active");
224   - if(statusFlags & uint8_t(OpenTherm::STATUS_FLAGS::SECONDARY_DIAGNOSTIC_IND)) Serial.println("DIAGNOSTICS INDICATION");
225   - } else {
226   - Serial.println("Failed to get status");
  761 + uint32_t thermostatStateTimestampAligned = ldiv(time(nullptr), PUBLISH_STATE_UPDATE_INTERVAL).quot * PUBLISH_STATE_UPDATE_INTERVAL; // Align to exact intervals
  762 + uint32_t thermostatStateTimestampMs = millis();
  763 +
  764 + if(millis() - previousOTCommunicationMs >= 1000) { // OpenTherm specifies that primary to secondary communication should take place at least every second
  765 + // connected() becomes 'true' after communication has taken place between thermostat and boiler and the boiler's configuration flags are read
  766 + if(!thermoStateMachine.connected()) {
  767 + // Try to contact the boiler and construct the thermostat's primary flags from the boiler's capabilities
  768 + OpenTherm::CONFIGURATION_FLAGS configurationFlags;
  769 + if(readSecondaryConfiguration(thermostat, configurationFlags, previousOTCommunicationMs)) {
  770 + thermoStateMachine.initPrimaryFlags(configurationFlags);
  771 +
  772 + updateClimateEntity((uint8_t(configurationFlags) & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_COOLING)) != 0);
  773 + updateDHWEntity((uint8_t(configurationFlags) & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_DHW_PRESENT)) != 0);
  774 + }
227 775 }
  776 +
  777 + if(thermoStateMachine.connected()) {
  778 + // Inform the boiler of the room temperature setpoint; is automatically ignored if setpoint did not change or if not supported by the boiler
  779 + roomTemperatureSetpointToBoiler(thermostat, roomTemperatureSetpoint, previousOTCommunicationMs);
  780 + // Check if roomTemperatureTimestampS is zero (No room temperature received yet)
  781 + if(roomTemperatureTimestampS != 0) {
  782 + // Inform the boiler of the room temperature; is automatically ignored if room temperature did not change or if not supported by the boiler
  783 + roomTemperatureToBoiler(thermostat, roomTemperature, previousOTCommunicationMs);
  784 +
  785 + uint32_t stateTimestampS = time(nullptr);
  786 + // State changes from IDLE -> HEATING or COOLING -> ANTI_HUNTING > IDLE, depending on the current state and changes in temperature setpoint and / or room temperature
  787 + // updateState returns true if the state has changed
  788 + if(thermoStateMachine.update(roomTemperature, roomTemperatureTimestampS, roomTemperatureSetpoint, thermostatRequest)) {
  789 + // state has changed, update the primary flags to the new state
  790 + thermoStateMachine.updatePrimaryFlags();
  791 + Serial.printf("State changed to '%s'\n", thermoStateMachine.c_str());
  792 + // Initialise the PID variables
  793 + ierr = 0;
  794 + previousPIDTimestampS = stateTimestampS;
  795 + previousPIDRoomTemperature = roomTemperature;
  796 + } else {
  797 + Serial.printf("State still is '%s'\n", thermoStateMachine.c_str());
  798 + // state did not change, update the CH setpoint using the PID if state is HEATING or COOLING
  799 + switch(thermoStateMachine.getState()) {
  800 + case ThermostatState::HEATING:
  801 + case ThermostatState::COOLING:
  802 + // If at least PID_INTERVAL_S seconds have elapsed, compute the new CH temperature setpoint using the PID
  803 + if(stateTimestampS - previousPIDTimestampS >= PID_INTERVAL_S) {
  804 + // Time between measurements in seconds
  805 + float dt = stateTimestampS - previousPIDTimestampS;
  806 + CHSetpoint = pid(roomTemperatureSetpoint, roomTemperature, previousPIDRoomTemperature, ierr, dt);
  807 + previousPIDTimestampS = stateTimestampS;
  808 + previousPIDRoomTemperature = roomTemperature;
  809 + Serial.printf("Computed CH setpoint using PID is %.01f\n", CHSetpoint);
  810 + }
  811 +
  812 + break;
  813 + default:
  814 + CHSetpoint = CH_MIN_SETPOINT; // Just to be sure, the boiler should alreay been disabled by the primary flags
  815 + break;
  816 + }
  817 + }
  818 + }
  819 +
  820 + // If room temperature gone stale, signal this to Home Assistant by setting the room temperature to None
  821 + updateRoomTemperatureStale(thermoStateMachine.getRoomTemperatureStale());
  822 +
  823 + // Inform the boiler of the boiler water setpoint; is automatically ignored if CH setpoint temperature did not change
  824 + CHSetpointToBoiler(thermostat, CHSetpoint, previousOTCommunicationMs);
  825 +
  826 + uint8_t primaryFlags = uint8_t(thermoStateMachine.getPrimaryFlags());
  827 + uint8_t statusFlags;
  828 + // Read status in every loop, to meet the 'communication each second' requirement
  829 + if(readStatus(thermostat, primaryFlags, statusFlags, previousOTCommunicationMs)) {
  830 + // Inform Home Assitant directly about the status; is automatically ignored if the flame status or CH / DHW status did not change
  831 + updateFlameSensor(statusFlags);
  832 + updateDHWSensor(statusFlags);
  833 + }
  834 + }
  835 + }
  836 +
  837 + // Publish the 'interval' sensors' at exact 'PUBLISH_STATE_UPDATE_INTERVAL' intervals
  838 + if(thermostatStateTimestampAligned - publishedThermostatStateTimestamp >= PUBLISH_STATE_UPDATE_INTERVAL) {
  839 + updateSensors(*mqtt, CHSetpoint, previousOTCommunicationMs);
  840 + publishedThermostatStateTimestamp = thermostatStateTimestampAligned;
228 841 }
  842 +
  843 + mqtt->yield(100);
229 844 }
... ...
examples/Advanced_Thermostat/JSONs.h 0 → 100644
  1 +#pragma once
  2 +
  3 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE
  4 +// Home Assistant Climate MQTT Discovery Message
  5 +// https://www.home-assistant.io/integrations/climate.mqtt/
  6 +//
  7 +// This JSON is sent to topic "homeassistant/climate/112233445566/climate/config" to automatically configurate a thermostat in Home Assistant
  8 +// Before sending this JSON all "112233445566" are replaced by the hexadecimal chip ID, i.e. the same as the MAC address in reverse
  9 +// order. Also all 11:22:33:44:55:66 are replaced by the MAC address and #### by the hex value of the least significant 4 bytes of the
  10 +// chip ID. Because this is done in place, the exact space in the character array is already reserved.
  11 +// All topics MAY begin with "~/". If so "~" must be defined by a key-value pair in this JSON. This is NOT CHECKED by the code.
  12 +// The following topics MUST be present. Again this is NOT CHECKED by the code:
  13 +// - "current_temperature_topic"
  14 +// - "temperature_command_topic"
  15 +// - "mode_command_topic"
  16 +// - "preset_mode_command_topic"
  17 +// - "outside_temperature_command_topic"
  18 +// Templates are used to pick out the right value. For the climate entity "current_temperature_topic" is the state topic, associated with the
  19 +// template "current_temperature_template".
  20 +// A subset of "modes":["auto","off","cool","heat"] is added to the ArduinoJson document depending on the capabilities of the boiler.
  21 +// If the boiler is for heating only, ["off","heat"] is added. If the boiler can also cool, the full set is added.
  22 +// If topic "mode_state_topic":"~/mode/state" or topic "temperature_state_topic":"~/setpoint/state" is defined the Home Assistant
  23 +// Thermostat is not properly updated, because of 'optimistic mode'
  24 +// For readability 'pretty' format is used, for saving a few bytes of memory it may also be minified.
  25 +
  26 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_CLIMATE[] = R"DISCMQTTCLIMATE(
  27 +{
  28 + "~":"Metriot/EasyOpenTherm/112233445566",
  29 + "name":"EasyOpenTherm #### Thermostat",
  30 + "unique_id":"Metriot-EasyOpenTherm-112233445566-thermostat",
  31 + "device":
  32 + {
  33 + "identifiers":["112233445566"],
  34 + "connections":[["mac","11:22:33:44:55:66"]],
  35 + "name":"EasyOpenTherm:boiler ####",
  36 + "suggested_area":"Living Room",
  37 + "manufacturer":"Metriot",
  38 + "model":"EasyOpenTherm:boiler",
  39 + "sw_version":"1.0"
  40 + },
  41 + "precision":0.1,
  42 + "temp_step":0.5,
  43 + "min_temp":10.0,
  44 + "max_temp": 30.0,
  45 + "initial":12,
  46 + "preset_modes":["eco","away","boost","comfort","home","sleep","activity"],
  47 + "preset_mode_command_topic":"~/preset_mode/set",
  48 + "current_temperature_topic":"~/climate/state",
  49 + "current_temperature_template":"{{value_json.temperature}}",
  50 + "temperature_command_topic":"~/setpoint/set",
  51 + "temperature_high_state_topic":"~/high_temperature/state",
  52 + "temperature_high_command_topic":"~/high_temperature/set",
  53 + "temperature_low_state_topic":"~/low_temperature/state",
  54 + "temperature_low_command_topic":"~/low_temperature/set",
  55 + "modes":[],
  56 + "mode_command_topic":"~/mode/set",
  57 + "availability_topic":"~/sensors/state",
  58 + "availability_template":"{{value_json.climate_available}}",
  59 + "payload_available":"ONLINE",
  60 + "payload_not_available":"OFFLINE",
  61 + "retain":false
  62 +}
  63 +)DISCMQTTCLIMATE";
  64 +
  65 +
  66 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR
  67 +// Discovery message for the central heating boiler temperature setpoint sensor. Since the setpoint is computed by the thermostat, this
  68 +// sensor is always present in Home Assistant
  69 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_SETPOINT_SENSOR[] = R"DISCMQTTSENSOR(
  70 +{
  71 + "~":"Metriot/EasyOpenTherm/112233445566",
  72 + "name":"EasyOpenTherm #### Boiler Setpoint",
  73 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_ch_setpoint",
  74 + "device":
  75 + {
  76 + "identifiers":["112233445566"],
  77 + "connections":[["mac","11:22:33:44:55:66"]],
  78 + "name":"EasyOpenTherm:boiler ####",
  79 + "suggested_area":"Living Room",
  80 + "manufacturer":"Metriot",
  81 + "model":"EasyOpenTherm:boiler",
  82 + "sw_version":"1.0"
  83 + },
  84 + "device_class":"temperature",
  85 + "state_topic":"~/ch_setpoint/state",
  86 + "unit_of_measurement":"°C",
  87 + "value_template":"{{value_json.ch_setpoint}}",
  88 + "retain":false
  89 +}
  90 +)DISCMQTTSENSOR";
  91 +
  92 +
  93 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR
  94 +// Discovery message for the boiler flow temperature sensor. The flow temperature is the temperature of
  95 +// the water leaving the boiler.
  96 +// This sensor is only added to Home Assistant if it can be read using the OpenTherm interface
  97 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLOW_TEMPERATURE_SENSOR[] = R"DISCMQTTSENSOR(
  98 +{
  99 + "~":"Metriot/EasyOpenTherm/112233445566",
  100 + "name":"EasyOpenTherm #### Boiler Flow Temperature",
  101 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_flow_temperature",
  102 + "device":
  103 + {
  104 + "identifiers":["112233445566"],
  105 + "connections":[["mac","11:22:33:44:55:66"]],
  106 + "name":"EasyOpenTherm:boiler ####",
  107 + "suggested_area":"Living Room",
  108 + "manufacturer":"Metriot",
  109 + "model":"EasyOpenTherm:boiler",
  110 + "sw_version":"1.0"
  111 + },
  112 + "device_class":"temperature",
  113 + "state_topic":"~/flow_temperature/state",
  114 + "unit_of_measurement":"°C",
  115 + "value_template":"{{value_json.flow_temperature}}",
  116 + "retain":false
  117 +}
  118 +)DISCMQTTSENSOR";
  119 +
  120 +
  121 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR
  122 +// Discovery message for the boiler return temperature sensor. The return temperature is the temperature of
  123 +// the water returning to the boiler.
  124 +// This sensor is only added to Home Assistant if it can be read using the OpenTherm interface
  125 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_RETURN_TEMPERATURE_SENSOR[] = R"DISCMQTTSENSOR(
  126 +{
  127 + "~":"Metriot/EasyOpenTherm/112233445566",
  128 + "name":"EasyOpenTherm #### Boiler Return Temperature",
  129 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_return_temperature",
  130 + "device":
  131 + {
  132 + "identifiers":["112233445566"],
  133 + "connections":[["mac","11:22:33:44:55:66"]],
  134 + "name":"EasyOpenTherm:boiler ####",
  135 + "suggested_area":"Living Room",
  136 + "manufacturer":"Metriot",
  137 + "model":"EasyOpenTherm:boiler",
  138 + "sw_version":"1.0"
  139 + },
  140 + "device_class":"temperature",
  141 + "state_topic":"~/return_temperature/state",
  142 + "unit_of_measurement":"°C",
  143 + "value_template":"{{value_json.return_temperature}}",
  144 + "retain":false
  145 +}
  146 +)DISCMQTTSENSOR";
  147 +
  148 +
  149 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR
  150 +// Discovery message for the boiler water pressure sensor.
  151 +// This sensor is only added to Home Assistant if it can be read using the OpenTherm interface
  152 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_WATER_PRESSURE_SENSOR[] = R"DISCMQTTSENSOR(
  153 +{
  154 + "~":"Metriot/EasyOpenTherm/112233445566",
  155 + "name":"EasyOpenTherm #### Boiler Water Pressure",
  156 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_water_pressure",
  157 + "device":
  158 + {
  159 + "identifiers":["112233445566"],
  160 + "connections":[["mac","11:22:33:44:55:66"]],
  161 + "name":"EasyOpenTherm:boiler ####",
  162 + "suggested_area":"Living Room",
  163 + "manufacturer":"Metriot",
  164 + "model":"EasyOpenTherm:boiler",
  165 + "sw_version":"1.0"
  166 + },
  167 + "device_class":"pressure",
  168 + "state_topic":"~/water_pressure/state",
  169 + "unit_of_measurement":"bar",
  170 + "value_template":"{{value_json.water_pressure}}",
  171 + "retain":false
  172 +}
  173 +)DISCMQTTSENSOR";
  174 +
  175 +
  176 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR
  177 +// Discovery message for the boiler relative modulation sensor.
  178 +// This sensor is only added to Home Assistant if it can be read using the OpenTherm interface
  179 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_RELATIVE_MODULATION_SENSOR[] = R"DISCMQTTSENSOR(
  180 +{
  181 + "~":"Metriot/EasyOpenTherm/112233445566",
  182 + "name":"EasyOpenTherm #### Boiler Relative Modulation",
  183 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_relative_modulation",
  184 + "device":
  185 + {
  186 + "identifiers":["112233445566"],
  187 + "connections":[["mac","11:22:33:44:55:66"]],
  188 + "name":"EasyOpenTherm:boiler ####",
  189 + "suggested_area":"Living Room",
  190 + "manufacturer":"Metriot",
  191 + "model":"EasyOpenTherm:boiler",
  192 + "sw_version":"1.0"
  193 + },
  194 + "icon":"mdi:percent-outline",
  195 + "state_topic":"~/relative_modulation/state",
  196 + "unit_of_measurement":"%",
  197 + "value_template":"{{value_json.relative_modulation}}",
  198 + "retain":false
  199 +}
  200 +)DISCMQTTSENSOR";
  201 +
  202 +
  203 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR
  204 +// Discovery message for the RSSI signal strength sensor.
  205 +// This sensor is always added to Home Assistant
  206 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_RSSI_SENSOR[] = R"DISCMQTTSENSOR(
  207 +{
  208 + "~":"Metriot/EasyOpenTherm/112233445566",
  209 + "name":"EasyOpenTherm #### Thermostat RSSI",
  210 + "unique_id":"Metriot-EasyOpenTherm-112233445566-thermostat_rssi",
  211 + "device":
  212 + {
  213 + "identifiers":["112233445566"],
  214 + "connections":[["mac","11:22:33:44:55:66"]],
  215 + "name":"EasyOpenTherm:boiler ####",
  216 + "suggested_area":"Living Room",
  217 + "manufacturer":"Metriot",
  218 + "model":"EasyOpenTherm:boiler",
  219 + "sw_version":"1.0"
  220 + },
  221 + "device_class":"signal_strength",
  222 + "state_topic":"~/RSSI/state",
  223 + "unit_of_measurement":"dBm",
  224 + "value_template":"{{value_json.RSSI}}",
  225 + "retain":false
  226 +}
  227 +)DISCMQTTSENSOR";
  228 +
  229 +
  230 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR
  231 +// Discovery message for the boiler flame on / off binary sensor.
  232 +// This sensor is always added to Home Assistant
  233 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_FLAME_BINARY_SENSOR[] = R"DISCMQTTSENSOR(
  234 +{
  235 + "~":"Metriot/EasyOpenTherm/112233445566",
  236 + "name":"EasyOpenTherm #### Boiler Flame",
  237 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_flame",
  238 + "device":
  239 + {
  240 + "identifiers":["112233445566"],
  241 + "connections":[["mac","11:22:33:44:55:66"]],
  242 + "name":"EasyOpenTherm:boiler ####",
  243 + "suggested_area":"Living Room",
  244 + "manufacturer":"Metriot",
  245 + "model":"EasyOpenTherm:boiler",
  246 + "sw_version":"1.0"
  247 + },
  248 + "icon":"mdi:fire",
  249 + "state_topic":"~/flame/state",
  250 + "value_template":"{{value_json.flame}}",
  251 + "payload_on":"ON",
  252 + "payload_off":"OFF",
  253 + "retain":false
  254 +}
  255 +)DISCMQTTSENSOR";
  256 +
  257 +
  258 +// EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR
  259 +// Discovery message for the boiler Domestic Hot Water on / off binary sensor.
  260 +// This sensor is 'on' if the boiler is running for Domestoc Hot Wtaer and 'off' if
  261 +// the boiler is running for Central Heating or is not running.
  262 +// This sensor is only added to Home Assistant if the boiler supports DHW
  263 +char EASYOPENTHERM_MQTT_DISCOVERY_MSG_DHW_BINARY_SENSOR[] = R"DISCMQTTSENSOR(
  264 +{
  265 + "~":"Metriot/EasyOpenTherm/112233445566",
  266 + "name":"EasyOpenTherm #### Boiler Domestic Hot Water",
  267 + "unique_id":"Metriot-EasyOpenTherm-112233445566-boiler_DHW",
  268 + "device":
  269 + {
  270 + "identifiers":["112233445566"],
  271 + "connections":[["mac","11:22:33:44:55:66"]],
  272 + "name":"EasyOpenTherm:boiler ####",
  273 + "suggested_area":"Living Room",
  274 + "manufacturer":"Metriot",
  275 + "model":"EasyOpenTherm:boiler",
  276 + "sw_version":"1.0"
  277 + },
  278 + "icon":"mdi:shower",
  279 + "state_topic":"~/dhw/state",
  280 + "payload_on":"ON",
  281 + "payload_off":"OFF",
  282 + "value_template":"{{value_json.dhw}}",
  283 + "retain":false
  284 +}
  285 +)DISCMQTTSENSOR";
... ...
examples/Advanced_Thermostat/MQTTHelpers.cpp 0 → 100644
  1 +#include "MQTTHelpers.h"
  2 +
  3 +#if defined(ESP32)
  4 +#include <WiFi.h>
  5 +#elif defined(ESP8266)
  6 +#include <ESP8266WiFi.h>
  7 +#else
  8 +#error Unsupported or unknown board
  9 +#endif
  10 +
  11 +static const char * TAG = "EasyOpenTherm MQTTHelpers";
  12 +
  13 +#if !defined(ESP32) && !defined(ESP_LOGE)
  14 +#define ESP_LOGE(...)
  15 +#define ESP_LOGI(...)
  16 +#define ESP_LOGV(...)
  17 +#endif
  18 +
  19 +
  20 +const size_t smallJsonDocSize = 256;
  21 +
  22 +
  23 +/*
  24 + * chipID()
  25 + * Store the ESP32 unique ID once. Return a pointer to the stored chip ID. The ID is the same as the MAC but in reversed byte order
  26 + */
  27 +const char * chipID() {
  28 + static char serialNumber[13] = "";
  29 +
  30 + if(*serialNumber == '\0') {
  31 +#if defined(ESP32)
  32 + sprintf(serialNumber, "%012llx", ESP.getEfuseMac());
  33 +#elif defined(ESP8266)
  34 + sprintf(serialNumber, "%012llx", ESP.getChipId());
  35 +#else
  36 +#error Unsupported or unknown board
  37 +#endif
  38 + ESP_LOGI(TAG, "Serial number is %s", serialNumber);
  39 + }
  40 +
  41 + return serialNumber;
  42 +}
  43 +
  44 +#include <machine/types.h>
  45 +/*
  46 + * shortID()
  47 + * Store the least significant two bytes of the ESP32 unique ID once. Return a pointer to the stored short ID. The ID is the same as the last two MAC bytes, in the same order
  48 + */
  49 +const char * shortID() {
  50 + static char shortNumber[5] = "";
  51 +
  52 + if(*shortNumber == '\0') {
  53 +#if defined(ESP32)
  54 + uint64_t mac = ESP.getEfuseMac();
  55 +#elif defined(ESP8266)
  56 + uint64_t mac = ESP.getChipId();
  57 +#else
  58 +#error Unsupported or unknown board
  59 +#endif
  60 +
  61 + uint8_t * MAC = (uint8_t *) &mac;
  62 + sprintf(shortNumber, "%02X%02X", MAC[4], MAC[5]);
  63 + ESP_LOGI(TAG, "Short ID is %s", shortNumber);
  64 + }
  65 +
  66 + return shortNumber;
  67 +}
  68 +
  69 +
  70 +/*
  71 + * replaceAll()
  72 + * Do an 'in place' replacement into 'destination' of all occurances of the 'search' by 'replace'
  73 + */
  74 +bool replaceAll(char * destination,
  75 + const char * search,
  76 + const char * replace) {
  77 + size_t searchLength = strlen(search);
  78 + if(strlen(replace) != searchLength) {
  79 + ESP_LOGE(TAG, "Length of search string (%s) does not match replace string (%s)\n", search, replace);
  80 +
  81 + return false;
  82 + }
  83 +
  84 + size_t length = strlen(destination);
  85 + char * searchFrom = destination;
  86 + bool replaced = false;
  87 + for(;;) {
  88 + char * found = strstr(searchFrom, search);
  89 + if(found == nullptr) break;
  90 + memcpy(found, replace, searchLength);
  91 + replaced = true;
  92 +
  93 + searchFrom = found + searchLength;
  94 + if(searchFrom >= destination + length) break;
  95 + }
  96 +
  97 + return replaced;
  98 +}
  99 +
  100 +
  101 +/*
  102 + * discoveryMessageSetIDs()
  103 + * Replace all placeholders '112233445566' with the chip ID, all placeholders '11:22:33:44:55:66' with the MAC and all placeholder '####' with the short ID.
  104 + */
  105 +void discoveryMessageSetIDs(char * discoveryMsgJson) {
  106 + replaceAll(discoveryMsgJson, "112233445566", chipID());
  107 + replaceAll(discoveryMsgJson, "11:22:33:44:55:66", WiFi.macAddress().c_str());
  108 + replaceAll(discoveryMsgJson, "####", shortID());
  109 +}
  110 +
  111 +
  112 +const char * topicByReference(const char * key,
  113 + const char * discoveryMsgJson) {
  114 + static char * topicBuffer = nullptr;
  115 + static size_t topicBufferSize = 0; // Includes the trailing `\0'
  116 +
  117 + StaticJsonDocument<smallJsonDocSize> discoveryJsonPartDoc;
  118 +
  119 + StaticJsonDocument<smallJsonDocSize> filter;
  120 + filter["~"] = true;
  121 + filter[key] = true;
  122 +
  123 + DeserializationError error = deserializeJson(discoveryJsonPartDoc, discoveryMsgJson, DeserializationOption::Filter(filter));
  124 + if(error) {
  125 + ESP_LOGE(TAG, "Deserialize error %s for JSON '%s'", error.f_str(), discoveryMsgJson);
  126 +
  127 + return nullptr;
  128 + }
  129 +
  130 + const char * baseTopic = discoveryJsonPartDoc["~"];
  131 + const char * topic = discoveryJsonPartDoc[key];
  132 + if(topic == nullptr) {
  133 + ESP_LOGE(TAG, "Key '%s' not found in JSON '%s'", key, discoveryMsgJson);
  134 +
  135 + return nullptr;
  136 + }
  137 +
  138 + if(*topic == '~' && baseTopic == nullptr) {
  139 + ESP_LOGE(TAG, "Base topic '~' not found in JSON '%s'", discoveryMsgJson);
  140 +
  141 + return nullptr;
  142 + }
  143 +
  144 + size_t newSize = strlen(topic) + 1;
  145 + if(*topic == '~') newSize += strlen(baseTopic);
  146 + if(topicBufferSize < newSize) {
  147 + free(topicBuffer);
  148 + topicBuffer = (char *) malloc(newSize);
  149 + if(topicBuffer == nullptr) {
  150 + topicBufferSize = 0;
  151 + ESP_LOGE(TAG, "topicByReference out of memory");
  152 +
  153 + return nullptr;
  154 + }
  155 + topicBufferSize = newSize;
  156 + }
  157 +
  158 + if(*topic == '~') {
  159 + sprintf(topicBuffer, "%s%s", baseTopic, topic + 1);
  160 + } else {
  161 + sprintf(topicBuffer, "%s", topic);
  162 + }
  163 +
  164 + return topicBuffer;
  165 +}
  166 +
  167 +
  168 +bool validJson(const char * discoveryMsgJson) {
  169 + StaticJsonDocument<0> emptyDoc;
  170 +
  171 + StaticJsonDocument<0> filter;
  172 +
  173 + DeserializationError error = deserializeJson(emptyDoc, discoveryMsgJson, DeserializationOption::Filter(filter));
  174 + if(error) {
  175 + ESP_LOGE(TAG, "Deserialize error %s for JSON '%s'", error.f_str(), discoveryMsgJson);
  176 + }
  177 +
  178 + return error == DeserializationError::Ok;
  179 +}
  180 +
  181 +
  182 +bool connectMQTT(MqttClient & client,
  183 + const char * clientID,
  184 + const char * user,
  185 + const char * password) {
  186 + // Start new MQTT connection
  187 + ESP_LOGI(TAG, "Connecting");
  188 + MqttClient::ConnectResult connectResult;
  189 + // Connect
  190 + MQTTPacket_connectData options = MQTTPacket_connectData_initializer;
  191 + options.MQTTVersion = 4;
  192 + options.clientID.cstring = (char *)clientID;
  193 + options.username.cstring = (char *)user;
  194 + options.password.cstring = (char *)password;
  195 + options.cleansession = true;
  196 + options.keepAliveInterval = 15; // 15 seconds
  197 + MqttClient::Error::type rc = client.connect(options, connectResult);
  198 + if (rc != MqttClient::Error::SUCCESS) {
  199 + ESP_LOGE(TAG, "Connection error: %i", rc);
  200 +
  201 + return false;
  202 + }
  203 +
  204 + return true;
  205 +}
  206 +
  207 +
  208 +bool publish(MqttClient & client,
  209 + const char * topic,
  210 + const char * payload,
  211 + bool retained) {
  212 + if(!client.isConnected()) return false;
  213 +
  214 + MqttClient::Message message;
  215 + message.qos = MqttClient::QOS0;
  216 + message.retained = retained;
  217 + message.dup = true;
  218 + message.payload = (void *)payload;
  219 + message.payloadLen = strlen(payload);
  220 + client.publish(topic, message);
  221 +Serial.printf("PUBLISH topic is '%s' message is '%s'\n", topic, message.payload);
  222 +
  223 + return true;
  224 +}
  225 +
  226 +
  227 +bool discoveryMsgToJsonDoc(JsonDocument & discoveryMsgDoc,
  228 + const char * discoveryMsgJson) {
  229 + DeserializationError error = deserializeJson(discoveryMsgDoc, discoveryMsgJson);
  230 + if(error) {
  231 + ESP_LOGE(TAG, "Deserialize error %s for JSON '%s'", error.f_str(), discoveryMsgJson);
  232 +
  233 + return false;
  234 + }
  235 +
  236 + return true;
  237 +}
  238 +
  239 +
  240 +/*
  241 + * addEntity()
  242 + * Publish the prepared and deserialized discovery message to topic 'homeassistant/[component]/[chip ID]/[object]/config', with component being one of the
  243 + * Home Assistant MQTT components like 'climate', 'sensor' or 'binary_sensor' and object a unique ID to differentiate between e.g. two sensors in the same
  244 + * device.
  245 + */
  246 +bool addEntity(MqttClient & client,
  247 + const char * component,
  248 + const char * object,
  249 + JsonDocument & discoveryMsgDoc) {
  250 + if(!client.isConnected()) return false;
  251 +
  252 + size_t jsonSize = measureJson(discoveryMsgDoc);
  253 + char discoveryMsgJson[jsonSize + 1];
  254 + serializeJson(discoveryMsgDoc, discoveryMsgJson, sizeof discoveryMsgJson);
  255 +
  256 + ESP_LOGI(TAG, "Discovery topic is '%s'\n", (String("homeassistant/") + String(component) + String("/") + String(chipID()) + String("/") + String(object) + String("/config")).c_str());
  257 + ESP_LOGI(TAG, "Discovery message is '%s'", discoveryMsgJson);
  258 +
  259 + MqttClient::Message message;
  260 + message.qos = MqttClient::QOS0;
  261 + message.retained = false;
  262 + message.dup = false;
  263 + message.payload = (void*) discoveryMsgJson;
  264 + message.payloadLen = jsonSize;
  265 + client.publish((String("homeassistant/") + String(component) + String("/") + String(chipID()) + String("/") + String(object) + String("/config")).c_str(), message);
  266 +Serial.printf("PUBLISH topic is '%s' message is '%s'\n", (String("homeassistant/") + String(component) + String("/") + String(chipID()) + String("/") + String(object) + String("/config")).c_str(), message.payload);
  267 +
  268 + return true;
  269 +}
  270 +
  271 +
  272 +// addEntity()
  273 +// Same as above but providing the discovery message as a const char * JSON
  274 +bool addEntity(MqttClient & client,
  275 + const char * component,
  276 + const char * object,
  277 + const char * discoveryMsgJson) {
  278 + DynamicJsonDocument discoveryMsgDoc(fullJsonDocSize);
  279 +
  280 + if(!discoveryMsgToJsonDoc(discoveryMsgDoc, discoveryMsgJson)) return false;
  281 +
  282 + return addEntity(client, component, object, discoveryMsgDoc);
  283 +}
  284 +
  285 +/*
  286 + * subscribe()
  287 + * Subscribe to the topic at at the given key in the discovery message. Before subscription the topic in the discovery
  288 + * message is expanded to the full topic by prefixing the "~" path.
  289 + */
  290 +bool subscribe(MqttClient & client,
  291 + MqttClient::MessageHandlerCbk cbk,
  292 + const char * discoveryMessage,
  293 + const char * key) {
  294 + const char * topic = topicByReference(key, discoveryMessage);
  295 + if(topic == nullptr) return false;
  296 +
  297 + MqttClient::Error::type rc = client.subscribe(topic, MqttClient::QOS0, cbk);
  298 + if (rc != MqttClient::Error::SUCCESS) {
  299 + ESP_LOGE(TAG, "Subscribe error: %i for topic '%s'", rc, topic);
  300 + ESP_LOGE(TAG, "Drop connection");
  301 + client.disconnect();
  302 +
  303 + return false;
  304 + }
  305 + ESP_LOGI(TAG, "Subscribed to '%s'", topic);
  306 +Serial.printf("Subscribed to '%s'\n", topic);
  307 +
  308 + return true;
  309 +}
0 310 \ No newline at end of file
... ...
examples/Advanced_Thermostat/MQTTHelpers.h 0 → 100644
  1 +#pragma once
  2 +
  3 +
  4 +#include <Arduino.h>
  5 +#include <MqttClient.h>
  6 +#include <ArduinoJson.h>
  7 +
  8 +
  9 +const size_t fullJsonDocSize = 2 * 1024;
  10 +
  11 +const char * chipID();
  12 +
  13 +const char * shortID();
  14 +
  15 +
  16 +// ============== Object to supply system functions ============================
  17 +class System: public MqttClient::System {
  18 +public:
  19 +
  20 + unsigned long millis() const {
  21 + return ::millis();
  22 + }
  23 +
  24 + void yield(void) {
  25 + ::yield();
  26 + }
  27 +};
  28 +
  29 +void discoveryMessageSetIDs(char * discoveryMsgJson);
  30 +
  31 +const char * topicByReference(const char * key,
  32 + const char * discoveryMsgJson);
  33 +
  34 +bool validJson(const char * discoveryMsgJson);
  35 +
  36 +bool connectMQTT(MqttClient & client,
  37 + const char * clientID,
  38 + const char * user,
  39 + const char * password);
  40 +
  41 +bool publish(MqttClient & client,
  42 + const char * topic,
  43 + const char * payload,
  44 + bool retained = false);
  45 +
  46 +bool discoveryMsgToJsonDoc(JsonDocument & discoveryMsgDoc,
  47 + const char * discoveryMsgJson);
  48 +
  49 +bool addEntity(MqttClient & client,
  50 + const char * component,
  51 + const char * object,
  52 + JsonDocument & discoveryMsgDoc);
  53 +
  54 +bool addEntity(MqttClient & client,
  55 + const char * component,
  56 + const char * object,
  57 + const char * discoveryMsgJson);
  58 +
  59 +bool subscribe(MqttClient & client,
  60 + MqttClient::MessageHandlerCbk cbk,
  61 + const char * discoveryMessage,
  62 + const char * key);
0 63 \ No newline at end of file
... ...
examples/Advanced_Thermostat/OpenThermHelpers.cpp 0 → 100644
  1 +#include "OpenThermHelpers.h"
  2 +
  3 +
  4 +#include <unordered_map>
  5 +
  6 +
  7 +static const char * TAG = "EasyOpenTherm OpenThermHelpers";
  8 +
  9 +#if !defined(ESP32) && !defined(ESP_LOGE)
  10 +#define ESP_LOGE(...)
  11 +#define ESP_LOGI(...)
  12 +#define ESP_LOGV(...)
  13 +#endif
  14 +
  15 +
  16 +// If CH setpoint has changed write it to the boiler
  17 +// If writing CH setpoint to the boiler is successful, update the static previous CH setpoint for the next round
  18 +// Since OpenTherm::WRITE_DATA_ID::CONTROL_SETPOINT_CH is mandatory no need for tracking if the command is supported
  19 +// Returns false if writing to the boiler failed
  20 +// This function is robust: if writing fails it will write again in de next loop
  21 +bool CHSetpointToBoiler(OpenTherm & thermostat,
  22 + float CHSetpoint,
  23 + uint32_t & previousOTCommunicationMs) {
  24 + static float previousCHSetpoint = -1000.0f;
  25 +
  26 + if(CHSetpoint == previousCHSetpoint) return true;
  27 +
  28 + uint32_t timestampMs = millis();
  29 + if(thermostat.write(OpenTherm::WRITE_DATA_ID::CONTROL_SETPOINT_CH, CHSetpoint)) {
  30 + previousOTCommunicationMs = timestampMs;
  31 + Serial.printf("Central Heating (CH) temperature setpoint set to %.01f ºC\n", CHSetpoint);
  32 + previousCHSetpoint = CHSetpoint;
  33 +
  34 + return true;
  35 + } else {
  36 + Serial.printf("Failed to set Central Heating (CH) temperature setpoint to %.01f ºC\n", CHSetpoint);
  37 +
  38 + return false;
  39 + }
  40 +}
  41 +
  42 +
  43 +// If room temperature setpoint has changed write it to the boiler
  44 +// If writing room temperature setpoint to the boiler is successful, update the static previous room temperature setpoint for the next round
  45 +// Since OpenTherm::WRITE_DATA_ID::ROOM_SETPOINT is optional, so track if the command is supported
  46 +// Returns false if writing to the boiler failed
  47 +// This function is robust: if writing fails it will write again in de next loop
  48 +bool roomTemperatureSetpointToBoiler(OpenTherm & thermostat,
  49 + float roomTemperatureSetpoint,
  50 + uint32_t & previousOTCommunicationMs) {
  51 + static float previousRoomTemperatureSetpoint = -1000.0f;
  52 + static bool dataIDSupported = true; // until proven otherwise
  53 +
  54 + roomTemperatureSetpoint = roundf(roomTemperatureSetpoint * 10.0f) / 10.0f; // Round to one decimal
  55 +
  56 + if(roomTemperatureSetpoint == previousRoomTemperatureSetpoint) return true;
  57 +
  58 + if(!dataIDSupported) return false;
  59 +
  60 + uint32_t timestampMs = millis();
  61 + if(thermostat.write(OpenTherm::WRITE_DATA_ID::ROOM_SETPOINT, roomTemperatureSetpoint)) {
  62 + previousOTCommunicationMs = timestampMs;
  63 + if(thermostat.error() == OpenTherm::ERROR_CODES::UNKNOWN_DATA_ID) {
  64 + Serial.println("ROOM_SETPOINT not supported");
  65 + dataIDSupported = false; // Remember for the next round that this data ID is not supported
  66 +
  67 + return false;
  68 + }
  69 +
  70 + Serial.printf("Room temperature setpoint set to %.01f ºC\n", roomTemperatureSetpoint);
  71 + previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  72 +
  73 + return true;
  74 + } else {
  75 + Serial.printf("Failed to set room temperature setpoint to %.01f ºC\n", roomTemperatureSetpoint);
  76 +
  77 + return false;
  78 + }
  79 +}
  80 +
  81 +
  82 +// If room temperature has changed write it to the boiler
  83 +// If writing room temperature to the boiler is successful, update the static previous room temperature for the next round
  84 +// Since OpenTherm::WRITE_DATA_ID::ROOM_TEMPERATURE is optional, so track if the command is supported
  85 +// Returns false if writing to the boiler failed
  86 +// This function is robust: if writing fails it will write again in de next loop
  87 +bool roomTemperatureToBoiler(OpenTherm & thermostat,
  88 + float roomTemperature,
  89 + uint32_t & previousOTCommunicationMs) {
  90 + static float previousRoomTemperature = -1000.0f;
  91 + static bool dataIDSupported = true; // until proven otherwise
  92 +
  93 + roomTemperature = roundf(roomTemperature * 10.0f) / 10.0f; // Round to one decimal
  94 +
  95 + if(roomTemperature == previousRoomTemperature) return true;
  96 +
  97 + if(!dataIDSupported) return false;
  98 +
  99 + uint32_t timestampMs = millis();
  100 + if(thermostat.write(OpenTherm::WRITE_DATA_ID::ROOM_TEMPERATURE, roomTemperature)) {
  101 + previousOTCommunicationMs = timestampMs;
  102 + if(thermostat.error() == OpenTherm::ERROR_CODES::UNKNOWN_DATA_ID) {
  103 + Serial.println("ROOM_TEMPERATURE not supported");
  104 + dataIDSupported = false; // Remember for the next round that this data ID is not supported
  105 +
  106 + return false;
  107 + }
  108 +
  109 + Serial.printf("Room temperature set to %.01f ºC\n", roomTemperature);
  110 + previousRoomTemperature = roomTemperature;
  111 +
  112 + return true;
  113 + } else {
  114 + Serial.printf("Failed to set room temperature to %.01f ºC\n", roomTemperature);
  115 +
  116 + return false;
  117 + }
  118 +}
  119 +
  120 +
  121 +// Read the secondary configuration. Upon success return true
  122 +bool readSecondaryConfiguration(OpenTherm & thermostat,
  123 + OpenTherm::CONFIGURATION_FLAGS & configurationFlags,
  124 + uint32_t & previousOTCommunicationMs) {
  125 + uint8_t secondaryMemberIDCode;
  126 + uint8_t flags;
  127 + uint32_t timestampMs = millis();
  128 + if(thermostat.read(OpenTherm::READ_DATA_ID::SECONDARY_CONFIGURATION, flags, secondaryMemberIDCode)) { // It is mandatory for a boiler to suppport SECONDARY_CONFIGURATION
  129 + previousOTCommunicationMs = timestampMs;
  130 + if(thermostat.error() == OpenTherm::ERROR_CODES::UNKNOWN_DATA_ID) {
  131 + // Valid data is received but the for boilers mandatory DATA-ID OpenTherm::READ_DATA_ID::SECONDARY_CONFIGURATION is not recognised. This is not a boiler but another device!
  132 + ESP_LOGE(TAG, "Your remote device is not a boiler");
  133 +
  134 + return true;
  135 + }
  136 +
  137 + configurationFlags = OpenTherm::CONFIGURATION_FLAGS(flags);
  138 +
  139 + return true;
  140 + } else {
  141 + // No data or invalid data received
  142 + ESP_LOGI(TAG, "Failed to get secondary configuration; is a boiler connected?");
  143 +
  144 + return false;
  145 + }
  146 +}
  147 +
  148 +
  149 +// Read the secondary status flags. Upon success set the status flags and return true
  150 +bool readStatus(OpenTherm & thermostat,
  151 + uint8_t primaryFlags,
  152 + uint8_t & statusFlags,
  153 + uint32_t & previousOTCommunicationMs) {
  154 + static uint8_t previousStatusFlags = 0;
  155 +
  156 + uint32_t timestampMs = millis();
  157 + if(thermostat.status(primaryFlags, statusFlags)) { // It is mandatory for the boiler to support it's status
  158 + previousOTCommunicationMs = timestampMs;
  159 + if(statusFlags != previousStatusFlags) {
  160 +// showSecondaryStatus(statusFlags); // UPDATE STATE JSON INSTEAD!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  161 + previousStatusFlags = statusFlags;
  162 + } else {
  163 + ESP_LOGV(TAG, "Secondary status checked; unchanged");
  164 + }
  165 +
  166 + return true;
  167 + } else {
  168 + ESP_LOGI(TAG, "Failed to get secondary status, error code is %d\n", thermostat.error());
  169 +
  170 + return false;
  171 + }
  172 +}
  173 +
  174 +
  175 +// Read any sensor from the boiler over the OpenTherm interface that returns a float (f8.8) by using the DATA ID
  176 +// If the readSensor is called for the first time for a particular DATA ID, the sensor is read over the OpenTherm interface
  177 +// If the sensor can be read succesfully, this is stored as true into the unordered map
  178 +// If the instead an INVALID DATA ID response is read from the boiler, this is flagged as false into the unordered map, so
  179 +// the bext time this function is called it inmediately returns false without trying to read the sensor again.
  180 +bool readSensor(OpenTherm & thermostat,
  181 + OpenTherm::READ_DATA_ID dataID,
  182 + float & value,
  183 + uint32_t & previousOTCommunicationMs) {
  184 + static std::unordered_map<OpenTherm::READ_DATA_ID, bool> sensorPresent;
  185 +
  186 + auto got = sensorPresent.find(dataID);
  187 + if(got != sensorPresent.end() && !got->second) {
  188 +
  189 + return false;
  190 + }
  191 +
  192 + uint32_t timestampMs = millis();
  193 + if(thermostat.read(dataID, value)) {
  194 + previousOTCommunicationMs = timestampMs;
  195 + if(thermostat.error() == OpenTherm::ERROR_CODES::UNKNOWN_DATA_ID) {
  196 + // Valid data is received for the dataID, however the boiler does not support it. Add an entry in the sensorPresent unordered_map to prevent another read out
  197 + ESP_LOGI(TAG, "Sensor with DATA ID %d not present", dataID);
  198 + sensorPresent[dataID] = false;
  199 +
  200 + return false;
  201 + } else {
  202 + // Valid data is received for the dataID and the boiler supports it. Add an entry in the sensorPresent unordered_map, if not already present, to enable another read out
  203 + if(got == sensorPresent.end()) sensorPresent[dataID] = true;
  204 +
  205 + return true;
  206 + }
  207 + } else {
  208 + // No data or invalid data received
  209 +
  210 + return false;
  211 + }
  212 +}
0 213 \ No newline at end of file
... ...
examples/Advanced_Thermostat/OpenThermHelpers.h 0 → 100644
  1 +#pragma once
  2 +
  3 +#include <Arduino.h>
  4 +
  5 +#include <EasyOpenTherm.h>
  6 +
  7 +
  8 +bool CHSetpointToBoiler(OpenTherm & thermostat,
  9 + float CHSetpoint,
  10 + uint32_t & previousOTCommunicationMs);
  11 +
  12 +bool roomTemperatureSetpointToBoiler(OpenTherm & thermostat,
  13 + float roomTemperatureSetpoint,
  14 + uint32_t & previousOTCommunicationMs);
  15 +
  16 +bool roomTemperatureToBoiler(OpenTherm & thermostat,
  17 + float roomTemperature,
  18 + uint32_t & previousOTCommunicationMs);
  19 +
  20 +bool readSecondaryConfiguration(OpenTherm & thermostat,
  21 + OpenTherm::CONFIGURATION_FLAGS & configurationFlags,
  22 + uint32_t & previousOTCommunicationMs);
  23 +
  24 +bool readStatus(OpenTherm & thermostat,
  25 + uint8_t primaryFlags,
  26 + uint8_t & statusFlags,
  27 + uint32_t & previousOTCommunicationMs);
  28 +
  29 +bool readSensor(OpenTherm & thermostat,
  30 + OpenTherm::READ_DATA_ID dataID,
  31 + float & value,
  32 + uint32_t & previousOTCommunicationMs);
0 33 \ No newline at end of file
... ...
examples/Advanced_Thermostat/ThermoStateMachine.cpp 0 → 100644
  1 +#include "ThermoStateMachine.h"
  2 +
  3 +static const char * TAG = "EasyOpenTherm ThermoStateMachine";
  4 +
  5 +// Define a dead zone: the boiler will start heating if room temperature is below room setpoint minus lower dead zone to prevent oscillation or 'hunting'
  6 +#define ROOM_TEMPERATURE_SETPOINT_LOWER_DEAD_ZONE (0.1f)
  7 +// Define a dead zone: the boiler will start cooling if room temperature is aboce room setpoint plus higher dead zone (if the boiler can cool) to prevent oscillation or 'hunting'
  8 +#define ROOM_TEMPERATURE_SETPOINT_HIGHER_DEAD_ZONE (0.1f)
  9 + // Anti hunting tim einterval in seconds
  10 +#define ANTI_HUNTING_TIME_INTERVAL_S (600)
  11 +
  12 +#define ROOM_TEMPERATURE_STALE_INTERVAL_S (900)
  13 +
  14 + ThermoStateMachine::ThermoStateMachine() {
  15 +}
  16 +
  17 +
  18 + ThermoStateMachine::~ThermoStateMachine() {
  19 +}
  20 +
  21 +
  22 +bool ThermoStateMachine::connected() {
  23 + return _state != ThermostatState::WAITING_FOR_SECONDARY_CONFIGURATION;
  24 +}
  25 +
  26 +
  27 +void ThermoStateMachine::initPrimaryFlags(OpenTherm::CONFIGURATION_FLAGS configurationFlags) {
  28 + _configurationFlags = uint8_t(configurationFlags);
  29 + _primaryFlags = 0;
  30 + // Enable DHW if boiler is capable of DHW
  31 + if(_configurationFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_DHW_PRESENT)) {
  32 + _primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_DHW_ENABLE);
  33 + }
  34 + // If boiler is capable of cooling, signal this by setting 'canCool'
  35 + _canCool = (_configurationFlags & uint8_t(OpenTherm::CONFIGURATION_FLAGS::SECONDARY_COOLING)) != 0;
  36 +
  37 + // Do not enable OTC
  38 + // _primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_OTC_ENABLE);
  39 +
  40 + _state = ThermostatState::OFF;
  41 +}
  42 +
  43 +
  44 +// Observe a dead zone around the room temperature setpoint of ROOM_TEMPERATURE_SETPOINT_LOWER_DEAD_ZONE below the setpoint and of ROOM_TEMPERATURE_SETPOINT_HIGHER_DEAD_ZONE above the setpoint
  45 +// If state is IDLE stay IDLE if the room temperature is in between roomTemperatureSetpoint - ROOM_TEMPERATURE_SETPOINT_LOWER_DEAD_ZONE and roomTemperatureSetpoint + ROOM_TEMPERATURE_SETPOINT_HIGHER_DEAD_ZONE
  46 +// UNLESS the room temperature setpoint is changed (indicating a user request for a change in temperature), change the state to HEATING or COOLING depending on the difference between actual room
  47 +// temperature and room temperature setpoint
  48 +// If state is OFF and either an 'ON'-request is set, or the room temperature setpoint is set change to IDLE
  49 +// in any state but OFF, if an 'OFF'-request is set change to OFF
  50 +// If state is IDLE and the room temperature drops below roomTemperatureSetpoint - ROOM_TEMPERATURE_SETPOINT_LOWER_DEAD_ZONE change the state to HEATING
  51 +// If state is IDLE and the room temperature rises above roomTemperatureSetpoint - ROOM_TEMPERATURE_SETPOINT_HIGHER_DEAD_ZONE change the state to COOLING
  52 +// If state is HEATING and the room temperature is equal or above the room temperature setpoint change state to ANTI_HUNTING
  53 +// If state is COOLING and the room temperature is equal or below the room temperature setpoint change state to ANTI_HUNTING
  54 +// If state is COOLING and 'canCool' is false change state to IDLE since there is nothing the boiler can do
  55 +// If state is ANTI_HUNTING for ANTI_HUNTING_INTERVAL_S seconds change state to IDLE
  56 +// If state is WAITING_FOR_SECONDARY_CONFIGURATION keep that state, no communcation has yet taken place between thermostat (primary) and boiler (secondary)
  57 +// Return true if the state has changed, false otherwise. Any state change MUST return true, because otherwise the caller of this function does not take any action
  58 +bool ThermoStateMachine::update(float roomTemperature,
  59 + uint32_t roomTemperatureTimestampS,
  60 + float roomTemperatureSetpoint,
  61 + ThermostatRequest request) {
  62 + _roomTemperatureStale = (_previousRoomTemperatureTimestampS != 0 && roomTemperatureTimestampS - _previousRoomTemperatureTimestampS > ROOM_TEMPERATURE_STALE_INTERVAL_S);
  63 + _previousRoomTemperatureTimestampS = roomTemperatureTimestampS;
  64 +
  65 +
  66 + roomTemperature = roundf(roomTemperature * 10.0f) / 10.0f; // Round to one decimal
  67 + roomTemperatureSetpoint = roundf(roomTemperatureSetpoint * 10.0f) / 10.0f; // Round to one decimal
  68 +
  69 + // Record room temperature for use by other member functions
  70 + _roomTemperature = roomTemperature;
  71 +
  72 + Serial.printf("updateState room temperature is %.01f, room temperature setpoint is %.01f, previous is %.01f\n", roomTemperature, roomTemperatureSetpoint, _previousRoomTemperatureSetpoint);
  73 +
  74 + if(request == ThermostatRequest::OFF) {
  75 + // If an 'OFF' request is received and the thermoostat state is NOT OFF and NOT waiting for secondary configuration, change state to OFF
  76 + // If an 'OFF' request is received and the thermostat state was already off, the state does not change
  77 + // In both cases there is nothing more to do
  78 + if(_state != ThermostatState::OFF && _state != ThermostatState::WAITING_FOR_SECONDARY_CONFIGURATION) {
  79 + _state = ThermostatState::OFF;
  80 +
  81 + return true;
  82 + }
  83 +
  84 + return false;
  85 + }
  86 +
  87 + // If an 'ON' request is received and the thermostate state is OFF, change state to IDLE (IDLE implies that the boiler is NOT switched on,
  88 + // unless in te NEXT run room temperature and room temperature setpoint imply to turn on heating or cooling)
  89 + // In all other cases the thermostat was already 'on' (any state but OFF), so the request is ignored
  90 + if(request == ThermostatRequest::AUTO || request == ThermostatRequest::HEAT || request == ThermostatRequest::COOL) {
  91 + if(_state == ThermostatState::OFF) {
  92 + _state = ThermostatState::IDLE;
  93 +
  94 + return true;
  95 + }
  96 + }
  97 +
  98 + // A change in roomtemperature setpoint is received in 'OFF' state; just record the new setpoint
  99 + if(_state == ThermostatState::OFF && roomTemperatureSetpoint != _previousRoomTemperatureSetpoint) {
  100 + _previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  101 +
  102 + return false;
  103 + }
  104 +
  105 + if(_state == ThermostatState::IDLE) {
  106 + // A change in setpoint is directly honoured, without taking deadzones into regard unless the room temperature timestamp is stale
  107 + if(_roomTemperatureStale) {
  108 + _previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  109 +
  110 + return false;
  111 + }
  112 +
  113 + if(roomTemperatureSetpoint != _previousRoomTemperatureSetpoint) {
  114 + _previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  115 + if(roomTemperature < roomTemperatureSetpoint) {
  116 + if(request == ThermostatRequest::AUTO || request == ThermostatRequest::HEAT) {
  117 + _state = ThermostatState::HEATING;
  118 +
  119 + return true;
  120 + } else {
  121 +
  122 + // It is colder than the setpoint but heating is requested, so stay IDLE
  123 + return false;
  124 + }
  125 + } else if(roomTemperature > roomTemperatureSetpoint) {
  126 + if(_canCool && (request == ThermostatRequest::AUTO || request == ThermostatRequest::COOL)) {
  127 + _state = ThermostatState::COOLING;
  128 +
  129 + return true;
  130 + } else {
  131 +
  132 + // It is hotter than the setpoint but either cooling is not enabled or not requested, so stay IDLE
  133 + return false;
  134 + }
  135 + } else {
  136 + // Room temperature is exactly at setpoint, stay IDLE
  137 +
  138 + return false;
  139 + }
  140 + }
  141 +
  142 + if(roomTemperature >= roomTemperatureSetpoint - ROOM_TEMPERATURE_SETPOINT_LOWER_DEAD_ZONE && roomTemperature <= roomTemperatureSetpoint + ROOM_TEMPERATURE_SETPOINT_HIGHER_DEAD_ZONE) {
  143 + // Room temperature at setpoint plus or minus the dead zones, so stay idle
  144 +
  145 + return false;
  146 + }
  147 +
  148 + if(roomTemperature < roomTemperatureSetpoint - ROOM_TEMPERATURE_SETPOINT_LOWER_DEAD_ZONE) {
  149 + _state = ThermostatState::HEATING;
  150 +
  151 + return true;
  152 + } else {
  153 + if(_canCool) {
  154 + _state = ThermostatState::COOLING;
  155 +
  156 + return true;
  157 + } else {
  158 +
  159 + return false; // It is too hot but cooling is not enabled. Stay IDLE
  160 + }
  161 + }
  162 +
  163 + return false; // Not reached
  164 + } else if(_state == ThermostatState::HEATING) {
  165 + _previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  166 + if(roomTemperature < roomTemperatureSetpoint && !_roomTemperatureStale) {
  167 +
  168 + return false; // Keep on heating
  169 + } else {
  170 + _state = ThermostatState::ANTI_HUNTING;
  171 + _antiHuntingStartTimestampS = time(nullptr);
  172 +
  173 + return true;
  174 + }
  175 + } else if(_state == ThermostatState::COOLING) {
  176 + _previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  177 + if(roomTemperature > roomTemperatureSetpoint && !_roomTemperatureStale) {
  178 +
  179 + return false; // Keep on cooling
  180 + } else {
  181 + _state = ThermostatState::ANTI_HUNTING;
  182 + _antiHuntingStartTimestampS = time(nullptr);
  183 +
  184 + return true;
  185 + }
  186 + } else if(_state == ThermostatState::ANTI_HUNTING) {
  187 + if(time(nullptr) - _antiHuntingStartTimestampS > ANTI_HUNTING_TIME_INTERVAL_S) {
  188 + _state = ThermostatState::IDLE;
  189 +
  190 + return true;
  191 + }
  192 +
  193 + return false; // Stay in anti hunting state until enough time has passed
  194 + } else if(_state == ThermostatState::WAITING_FOR_SECONDARY_CONFIGURATION) {
  195 + _previousRoomTemperatureSetpoint = roomTemperatureSetpoint;
  196 +
  197 + return false;
  198 + } else {
  199 + Serial.println("Unknown state");
  200 +
  201 + return false;
  202 + }
  203 +}
  204 +
  205 +
  206 +void ThermoStateMachine::updatePrimaryFlags() {
  207 + switch(_state) {
  208 + case ThermostatState::OFF:
  209 + _state_c_str = "Off";
  210 + // Switch off the boiler by disabling both heating and cooling
  211 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_CH_ENABLE);
  212 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_COOLING_ENABLE);
  213 + break;
  214 + case ThermostatState::IDLE:
  215 + _state_c_str = "Idle";
  216 + // Switch off the boiler by disabling both heating and cooling (may already be done by state change to anti hunting)
  217 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_CH_ENABLE);
  218 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_COOLING_ENABLE);
  219 + break;
  220 + case ThermostatState::HEATING:
  221 + _state_c_str = "Heating";
  222 + // Switch on the boiler by enabling heating and disabling heating and cooling
  223 + _primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_CH_ENABLE);
  224 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_COOLING_ENABLE);
  225 + break;
  226 + case ThermostatState::COOLING:
  227 + _state_c_str = "Cooling";
  228 + // Switch on the cooling capabolities of the boiler by disabling heating and enabling heating and cooling
  229 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_CH_ENABLE);
  230 + _primaryFlags |= uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_COOLING_ENABLE);
  231 + break;
  232 + case ThermostatState::ANTI_HUNTING:
  233 + _state_c_str = "Anti hunting";
  234 + // Switch off the boiler by disabling both heating and cooling
  235 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_CH_ENABLE);
  236 + _primaryFlags &= ~uint8_t(OpenTherm::STATUS_FLAGS::PRIMARY_COOLING_ENABLE);
  237 + break;
  238 + case ThermostatState::WAITING_FOR_SECONDARY_CONFIGURATION:
  239 + _state_c_str = "Waiting for seconday configuration";
  240 + break;
  241 + default:
  242 + _state_c_str = "Unknown";
  243 + break;
  244 + }
  245 +}
  246 +
  247 +
  248 +OpenTherm::STATUS_FLAGS ThermoStateMachine::getPrimaryFlags() {
  249 + return OpenTherm::STATUS_FLAGS(_primaryFlags);
  250 +}
  251 +
  252 +
  253 +ThermostatState ThermoStateMachine::getState() {
  254 + return _state;
  255 +}
  256 +
  257 +bool ThermoStateMachine::getRoomTemperatureStale() {
  258 + return _roomTemperatureStale;
  259 +}
  260 +
  261 +
  262 +
  263 +const char * ThermoStateMachine::c_str() {
  264 + return _state_c_str;
  265 +}
  266 +
  267 +
... ...
examples/Advanced_Thermostat/ThermoStateMachine.h 0 → 100644
  1 +#pragma once
  2 +
  3 +#include <Arduino.h>
  4 +#include <EasyOpenTherm.h>
  5 +
  6 +
  7 +// ThermostatState
  8 +// The current state the thermostat is in
  9 +// WAITING_FOR_SECONDARY_CONFIGURATION: waiting for a valid response from the boiler over the OpenTherm interface
  10 +// OFF: a connection with the boiler over the OpenTherm interface has been made, the thermostat is waiting for a request to become active (AUTO, HEAT or COOL)
  11 +// IDLE: the boiler is idle but the thermostat is repeatingly comparing the setpoint room temperature with the actual room temperature to decide what actin should be taken (either HEATING or COOLING)
  12 +// HEATING: the thermostat is computing the CH setpoint and sends it to the boiler. The boiler decides upon the CH setpoint to start heating up
  13 +// COOLING: the thermostat is computing the CH setpoint and sends it to the boiler. The boiler decides upon the CH setpoint to start cooling (only if the boiler has this capability)
  14 +// ANTI_HUNTING: after the boiler has been active, the boiler stays deactivated for a couple of minutes to prevent the boiler from repeatedly switching om and off
  15 +enum class ThermostatState {
  16 + WAITING_FOR_SECONDARY_CONFIGURATION,
  17 + OFF,
  18 + IDLE,
  19 + HEATING,
  20 + COOLING,
  21 + ANTI_HUNTING,
  22 +};
  23 +
  24 +
  25 +// Different requests from the Climate entity to the thermostat can come in over MQTT in text. These text messages requests are translated into these values
  26 +// NONE: no request is yet received
  27 +// OFF: request to put the thermostat into 'OFF' position
  28 +// AUTO (only exists if the boiler can both heat and cool): keep the room at the room temperature setpoint by both heating and cooling
  29 +// HEAT: keep the room at or above the room temperature setpoint by heating if necessary
  30 +// COOL (only exists if the boiler can cool): keep the room at or below the room temperature setpoint by cooling if necessary
  31 +enum class ThermostatRequest {
  32 + NONE,
  33 + OFF,
  34 + AUTO,
  35 + HEAT,
  36 + COOL,
  37 +};
  38 +
  39 +
  40 +// ThermoStateMachine
  41 +// Record and change the thermostat state reacting to changes in the room temperature, room temperature setpoint and user requests all coming in
  42 +// over MQTT. Also change the state to IDLE if no room temperature is received for too long a period of time.
  43 +// update(): changes the state depending on the input parameters provided. Returns true if the state was changed, false otherwise
  44 +// updatePrimaryFlags(): updates the primary flags (enabling and disabling heating and / or cooling) depending on the state
  45 +// getPrimaryFlags() returns the primary flags to be send to the boiler over the OpenTherm interface
  46 +// getState() returns the current state as an enum value
  47 +// getRoomTemperatureStale() returns true if no room temperature update was received within the defined interval
  48 +// c_str(): returns the state as a zero terminated const char * pointer, i.e. a 'c'-string. Pointer stays accesible during the life time of the ThermostatState instance
  49 +class ThermoStateMachine {
  50 +public:
  51 + ThermoStateMachine();
  52 +
  53 + ~ThermoStateMachine();
  54 +
  55 + bool connected();
  56 +
  57 + void initPrimaryFlags(OpenTherm::CONFIGURATION_FLAGS configurationFlags);
  58 +
  59 + bool update(float roomTemperature,
  60 + uint32_t roomTemperatureTimestampS,
  61 + float roomTemperatureSetpoint,
  62 + ThermostatRequest request = ThermostatRequest::NONE);
  63 +
  64 + void updatePrimaryFlags();
  65 +
  66 + OpenTherm::STATUS_FLAGS getPrimaryFlags();
  67 +
  68 + ThermostatState getState();
  69 +
  70 + bool getRoomTemperatureStale();
  71 +
  72 + const char * c_str();
  73 +
  74 +private:
  75 + ThermostatState _state = ThermostatState::WAITING_FOR_SECONDARY_CONFIGURATION;
  76 + char * _state_c_str = "Waiting for seconday configuration";
  77 +
  78 + uint8_t _configurationFlags = 0;
  79 + uint8_t _primaryFlags = 0;
  80 +
  81 + bool _canCool = false;
  82 + float _previousRoomTemperatureSetpoint = -1000.0f;
  83 + float _roomTemperature;
  84 + uint32_t _previousRoomTemperatureTimestampS = 0;
  85 + bool _roomTemperatureStale = false;
  86 + uint32_t _antiHuntingStartTimestampS;
  87 +};
... ...