Commit 09538f8873de043d4a952152c4cd8c49e7cb8c23
1 parent
2b5c768b
Completely reworked Advanced Thermostat example
Showing
8 changed files
with
1997 additions
and
128 deletions
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 | +}; | ... | ... |