/* * Boiler Project * Paul Derbyshire - May 2018 - https://github.com/proddy/EMS-ESP-Boiler * * See Readme for Acknowledgments */ // local libraries #include "ESPHelper.h" #include "ems.h" #include "emsuart.h" // public libraries #include #include // https://github.com/esp8266/Arduino/tree/master/libraries/Ticker // these are set as -D build flags. If you're not using PlatformIO hard code them //#define WIFI_SSID "" //#define WIFI_PASSWORD "" //#define MQTT_IP "" //#define MQTT_USER "" //#define MQTT_PASS "" // private function prototypes void heartbeat(); void systemCheck(); void publishValues(); void _showerColdShotStart(); void _showerColdShotStop(); void _toggleHeartbeat(); // timers Ticker publishValuesTimer; Ticker systemCheckTimer; Ticker heartbeatTimer; Ticker showerResetTimer; #define publishValuesTime 300 // every 5 mins post HA values #define systemCheckTime 10 // every 10 seconds check if Boiler is online #define heartbeatTime 1 // every second blink heartbeat LED // hostname is also used as the MQTT topic identifier (home/) #define HOSTNAME "boiler" // Project commands for telnet // Note: ?, *, $, ! and & are reserved #define PROJECT_CMDS \ "s=show statistics\n\r" \ "* q=toggle Verbose telegram logging\n\r" \ "* P=publish stats to MQTT\n\r" \ "* p=toggle Poll response (for debugging)\n\r" \ "* T=toggle Thermostat suport on/off\n\r" \ "* S=toggle Shower Timer on/off\n\r" \ "* r [n] to request for data from EMS, some examples:\n\r" \ "* from Boiler: 33=UBAParameterWW, 18=UBAMonitorFast, 19=UBAMonitorSlow, 34=UBAMonitorWWMessage\n\r" \ "* from Thermostat: 91=RC20StatusMessage, A8=RC20Temperature, 6=RC20Time, 2=Version\n\r" \ "* t [n] set thermostat temperature to n\n\r" \ "* m [n] set thermostat mode (0=low, 1=manual, 2=clock)\n\r" \ "* w [n] set boiler warm water temperature to n (min 30)\n\r" \ "* a [n] activate boiler warm water on (n=1) or off (n=0)" // GPIOs #define LED_RX D1 // (GPIO5 on nodemcu) #define LED_TX D2 // (GPIO4 on nodemcu) #define LED_ERR D3 // (GPIO0 on nodemcu) // app specific - do not change #define MQTT_BOILER MQTT_BASE HOSTNAME "/" #define TOPIC_START MQTT_BOILER MQTT_TOPIC_START #define TOPIC_THERMOSTAT_TEMP MQTT_BOILER "thermostat_temp" // for received thermostat temp changes #define TOPIC_THERMOSTAT_CURRTEMP MQTT_BOILER "thermostat_currtemp" // current temperature #define TOPIC_THERMOSTAT_SELTEMP MQTT_BOILER "thermostat_seltemp" // selected temperature #define TOPIC_BOILER_DATA MQTT_BOILER "boiler_data" // for sending boiler values #define TOPIC_SHOWERTIME MQTT_BOILER "showertime" // for sending shower time results // thermostat support, shower timing and shower alert all enabled #define BOILER_THERMOSTAT_ENABLED 1 #define BOILER_SHOWER_ENABLED 1 #define BOILER_SHOWER_TIMER 0 // shower settings const unsigned long SHOWER_PAUSE_TIME = 15000; // 15 seconds, max time if water is switched off & on during a shower const unsigned long SHOWER_MIN_DURATION = 120000; // 2 minutes, before recognizing its a shower const unsigned long SHOWER_MAX_DURATION = 420000; // 7 minutes, before trigger a shot of cold water const unsigned long SHOWER_OFF_DURATION = 3000; // 3 seconds long for cold water const uint8_t SHOWER_BURNPOWER_MIN = 80; typedef struct { bool wifi_connected; bool boiler_online; bool shower_enabled; // true if we want to report back on shower times bool shower_timer; // true if we want the cold water reminder } _Boiler_Status; typedef struct { bool showerOn; unsigned long timerStart; // ms unsigned long timerPause; // ms unsigned long duration; // ms bool isColdShot; // true if we've just sent a jolt of cold water } _Boiler_Shower; // ESPHelper netInfo homeNet = {.mqttHost = MQTT_IP, .mqttUser = MQTT_USER, .mqttPass = MQTT_PASS, .mqttPort = 1883, // this is the default, change if using another port .ssid = WIFI_SSID, .pass = WIFI_PASSWORD }; ESPHelper myESP(&homeNet); // store for overall system status _Boiler_Status Boiler_Status; _Boiler_Shower Boiler_Shower; // Debugger to telnet #define myDebug(x, ...) myESP.printf(x, ##__VA_ARGS__); // Timers const unsigned long POLL_TIMEOUT_ERR = 10000; // if no signal from boiler for last 10 seconds, assume its offline bool heartbeatEnabled = false; const unsigned long TX_HOLD_LED_TIME = 2000; // how long to hold the Tx LED because its so quick unsigned long timestamp; // for internal timings, via millis() static int connectionStatus = NO_CONNECTION; bool startMQTTsent = false; // Show command - display stats on an 's' command void showInfo() { char s[10]; // for formatting floats using the _float_to_char() function // General stats from EMS bus myDebug("EMS Bus stats:\n"); myDebug(" Poll is %s, Shower is %s, Shower timer is %s, RxPgks=%d, TxPkgs=%d, #CrcErrors=%d", ((EMS_Sys_Status.emsPollEnabled) ? "enabled" : "disabled"), ((Boiler_Status.shower_enabled) ? "enabled" : "disabled"), ((Boiler_Status.shower_timer) ? "enabled" : "disabled"), EMS_Sys_Status.emsRxPgks, EMS_Sys_Status.emsTxPkgs, EMS_Sys_Status.emxCrcErr); myDebug(", RxStatus="); switch (EMS_Sys_Status.emsRxStatus) { case EMS_RX_IDLE: myDebug("idle"); break; case EMS_RX_ACTIVE: myDebug("active"); break; } myDebug(", TxStatus="); switch (EMS_Sys_Status.emsTxStatus) { case EMS_TX_IDLE: myDebug("idle"); break; case EMS_TX_PENDING: myDebug("pending"); break; case EMS_TX_ACTIVE: myDebug("active"); break; } myDebug(", TxAction="); switch (EMS_TxTelegram.action) { case EMS_TX_READ: myDebug("read"); break; case EMS_TX_WRITE: myDebug("write"); break; case EMS_TX_VALIDATE: myDebug("validate"); break; case EMS_TX_NONE: myDebug("none"); break; } myDebug("\nBoiler stats:\n"); // UBAMonitorWWMessage & UBAParameterWW myDebug(" Warm Water activated: %s\n", (EMS_Boiler.wWActivated ? "yes" : "no")); myDebug(" Warm Water selected temperature: %d C\n", EMS_Boiler.wWSelTemp); myDebug(" Warm Water circulation pump available: %s\n", (EMS_Boiler.wWCircPump ? "yes" : "no")); myDebug(" Warm Water desired temperature: %d C\n", EMS_Boiler.wWDesiredTemp); myDebug(" Warm Water current temperature: %s C\n", _float_to_char(s, EMS_Boiler.wWCurTmp)); myDebug(" Warm Water # starts: %d times\n", EMS_Boiler.wWStarts); myDebug(" Warm Water active time: %d days %d hours %d minutes\n", EMS_Boiler.wWWorkM / 1440, (EMS_Boiler.wWWorkM % 1440) / 60, EMS_Boiler.wWWorkM % 60); myDebug(" Warm Water 3-way valve: %s\n", EMS_Boiler.wWHeat ? "on" : "off"); // UBAMonitorFast myDebug(" Selected flow temperature: %d C\n", EMS_Boiler.selFlowTemp); myDebug(" Current flow temperature: %s C\n", _float_to_char(s, EMS_Boiler.curFlowTemp)); myDebug(" Return temperature: %s C\n", _float_to_char(s, EMS_Boiler.retTemp)); myDebug(" Gas: %s\n", EMS_Boiler.burnGas ? "on" : "off"); myDebug(" Boiler pump: %s\n", EMS_Boiler.heatPmp ? "on" : "off"); myDebug(" Fan: %s\n", EMS_Boiler.fanWork ? "on" : "off"); myDebug(" Ignition: %s\n", EMS_Boiler.ignWork ? "on" : "off"); myDebug(" Circulation pump: %s\n", EMS_Boiler.wWCirc ? "on" : "off"); myDebug(" Burner max power: %d %%\n", EMS_Boiler.selBurnPow); myDebug(" Burner current power: %d %%\n", EMS_Boiler.curBurnPow); myDebug(" Flame current: %s uA\n", _float_to_char(s, EMS_Boiler.flameCurr)); myDebug(" System pressure: %s bar\n", _float_to_char(s, EMS_Boiler.sysPress)); // UBAMonitorSlow myDebug(" Outside temperature: %s C\n", _float_to_char(s, EMS_Boiler.extTemp)); myDebug(" Boiler temperature: %s C\n", _float_to_char(s, EMS_Boiler.boilTemp)); myDebug(" Pump modulation: %d %%\n", EMS_Boiler.pumpMod); myDebug(" # burner restarts: %d\n", EMS_Boiler.burnStarts); myDebug(" Total burner operating time: %d days %d hours %d minutes\n", EMS_Boiler.burnWorkMin / 1440, (EMS_Boiler.burnWorkMin % 1440) / 60, EMS_Boiler.burnWorkMin % 60); myDebug(" Total heat operating time: %d days %d hours %d minutes\n", EMS_Boiler.heatWorkMin / 1440, (EMS_Boiler.heatWorkMin % 1440) / 60, EMS_Boiler.heatWorkMin % 60); // Thermostat stats if (EMS_Sys_Status.emsThermostatEnabled) { myDebug("Thermostat stats:\n Thermostat time is %02d:%02d:%02d %d/%d/%d\n", EMS_Thermostat.hour, EMS_Thermostat.minute, EMS_Thermostat.second, EMS_Thermostat.day, EMS_Thermostat.month, EMS_Thermostat.year + 2000); myDebug(" Setpoint room temperature is %s C\n", _float_to_char(s, EMS_Thermostat.setpoint_roomTemp)); myDebug(" Current room temperature is %s C\n", _float_to_char(s, EMS_Thermostat.curr_roomTemp)); myDebug(" Mode is set to "); if (EMS_Thermostat.mode == 0) { myDebug("low\n"); } else if (EMS_Thermostat.mode == 1) { myDebug("manual\n"); } else if (EMS_Thermostat.mode == 2) { myDebug("clock/auto\n"); } else { myDebug("\n"); } } // show the Shower Info if (Boiler_Status.shower_enabled) { myDebug("Shower stats:\n Shower is %s\n", (Boiler_Shower.showerOn ? "on" : "off")); char s[70]; uint8_t sec = (uint8_t)((Boiler_Shower.duration / 1000) % 60); uint8_t min = (uint8_t)((Boiler_Shower.duration / (1000 * 60)) % 60); sprintf(s, " Last shower duration was %d minutes and %d %s\n", min, sec, (sec == 1) ? "second" : "seconds"); myDebug(s); } myDebug("\n"); } // send values to HA via MQTT void publishValues() { myDebug("Publishing data to MQTT topics\n"); char s[20]; // for formatting strings // Boiler values as one JSON object StaticJsonBuffer<512> jsonBuffer; char data[512]; JsonObject & root = jsonBuffer.createObject(); root["wWCurTmp"] = _float_to_char(s, EMS_Boiler.wWCurTmp); root["wWHeat"] = EMS_Boiler.wWHeat ? "on" : "off"; root["curFlowTemp"] = _float_to_char(s, EMS_Boiler.curFlowTemp); root["retTemp"] = _float_to_char(s, EMS_Boiler.retTemp); root["burnGas"] = EMS_Boiler.burnGas ? "on" : "off"; root["heatPmp"] = EMS_Boiler.heatPmp ? "on" : "off"; root["fanWork"] = EMS_Boiler.fanWork ? "on" : "off"; root["ignWork"] = EMS_Boiler.ignWork ? "on" : "off"; root["wWCirc"] = EMS_Boiler.wWCirc ? "on" : "off"; root["selBurnPow"] = (String)EMS_Boiler.selBurnPow; root["curBurnPow"] = (String)EMS_Boiler.curBurnPow; root["sysPress"] = _float_to_char(s, EMS_Boiler.sysPress); root["boilTemp"] = _float_to_char(s, EMS_Boiler.boilTemp); root["pumpMod"] = (String)EMS_Boiler.pumpMod; root.printTo(data, root.measureLength() + 1); myESP.publish(TOPIC_BOILER_DATA, data); if (EMS_Sys_Status.emsThermostatEnabled) { // only send thermostat values if we actually have them if (((int)EMS_Thermostat.curr_roomTemp == (int)0) || ((int)EMS_Thermostat.setpoint_roomTemp == (int)0)) { return; } myESP.publish(TOPIC_THERMOSTAT_CURRTEMP, _float_to_char(s, EMS_Thermostat.curr_roomTemp)); myESP.publish(TOPIC_THERMOSTAT_SELTEMP, _float_to_char(s, EMS_Thermostat.setpoint_roomTemp)); } } // extra commands options for telnet debug window void myDebugCallback() { char * cmd = myESP.consoleGetLastCommand(); bool b; switch (cmd[0]) { case 's': showInfo(); break; case 'p': b = !ems_getPoll(); ems_setPoll(b); break; case 'P': publishValues(); break; case 'r': // read command for Boiler or Thermostat ems_doReadCommand((uint8_t)strtol(&cmd[2], 0, 16)); break; case 't': // set thermostat temp ems_setThermostatTemp(strtof(&cmd[2], 0)); break; case 'm': // set thermostat mode ems_setThermostatMode(cmd[2] - '0'); break; case 'w': // set warm water temp ems_setWarmWaterTemp((uint8_t)strtol(&cmd[2], 0, 10)); break; case 'q': // quiet _toggleHeartbeat(); break; case 'a': // set ww activate on or off if ((cmd[2] - '0') == 1) ems_setWarmWaterActivated(true); else if ((cmd[2] - '0') == 0) ems_setWarmWaterActivated(false); break; case 'T': // toggle Thermostat b = !ems_getThermostatEnabled(); ems_setThermostatEnabled(b); break; case 'S': // toggle Shower timer support Boiler_Status.shower_enabled = !Boiler_Status.shower_enabled; myDebug("Shower timer is %s\n", Boiler_Status.shower_enabled ? "enabled" : "disabled"); break; default: myDebug("Unknown command '%c'. Use ? for help.\n", cmd[0]); break; } } // toggle heartbeat LED void _toggleHeartbeat() { bool b = !ems_getLogVerbose(); ems_setLogVerbose(b); heartbeatEnabled = b; digitalWrite(LED_BUILTIN, (b) ? LOW : HIGH); // set the LED } // MQTT Callback to handle incoming/outgoing changes void MQTTcallback(char * topic, byte * payload, uint8_t length) { // check if start is received, if so return boottime - defined in ESPHelper.h if (strcmp(topic, TOPIC_START) == 0) { payload[length] = '\0'; // add null terminator myDebug("MQTT topic boottime: %s\n", payload); myESP.setBoottime((char *)payload); return; } // thermostat_temp if (strcmp(topic, TOPIC_THERMOSTAT_TEMP) == 0) { float f = strtof((char *)payload, 0); char s[10]; myDebug("MQTT topic: thermostat_temp value %s\n", _float_to_char(s, f)); ems_setThermostatTemp(f); return; } } // WifiCallback, called when a WiFi connect has successfully been established void WIFIcallback() { Boiler_Status.wifi_connected = true; // turn off the LEDs since we've finished the boot loading digitalWrite(LED_RX, LOW); digitalWrite(LED_TX, LOW); digitalWrite(LED_ERR, LOW); // when finally we're all set up, we can fire up the uart (this will enable the interrupts) emsuart_init(); } // Initialize the boiler settings void _initBoiler() { // default settings ems_setThermostatEnabled(BOILER_THERMOSTAT_ENABLED); Boiler_Status.shower_enabled = BOILER_SHOWER_ENABLED; Boiler_Status.shower_timer = BOILER_SHOWER_TIMER; // init boiler Boiler_Status.wifi_connected = false; Boiler_Status.boiler_online = true; // assume we have a connection, it will be checked in the loop() anyway // init shower Boiler_Shower.timerStart = 0; Boiler_Shower.timerPause = 0; Boiler_Shower.duration = 0; Boiler_Shower.isColdShot = false; } // // SETUP // Note: we don't init the UART here as we should wait until everything is loaded first. It's done in loop() // void setup() { // set pin for LEDs - start up with all lit up while we sort stuff out pinMode(LED_RX, OUTPUT); pinMode(LED_TX, OUTPUT); pinMode(LED_ERR, OUTPUT); pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_RX, HIGH); digitalWrite(LED_TX, HIGH); digitalWrite(LED_ERR, HIGH); // Timers publishValuesTimer.attach(publishValuesTime, publishValues); // every 5 mins (300000) post HA values systemCheckTimer.attach(systemCheckTime, systemCheck); // every 10 seconds check if Boiler is online heartbeatTimer.attach(heartbeatTime, heartbeat); // every second blink heartbeat LED // set up Wifi, MQTT, Telnet myESP.setWifiCallback(WIFIcallback); myESP.setMQTTCallback(MQTTcallback); myESP.addSubscription(TOPIC_START); myESP.addSubscription(TOPIC_THERMOSTAT_TEMP); myESP.consoleSetHelpProjectsCmds(PROJECT_CMDS); myESP.consoleSetCallBackProjectCmds(myDebugCallback); myESP.begin(HOSTNAME); // init ems stats ems_init(); // init Boiler specific params _initBoiler(); // heartbeat, only if setting is enabled heartbeatEnabled = ems_getLogVerbose(); } // flash LEDs // Using a faster way to write to pins as digitalWrite does a lot of overhead like pin checking & disabling interrupts void showLEDs() { if (ems_getLogVerbose()) { // ERR LED if (!Boiler_Status.boiler_online) { WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + 4, (1 << LED_ERR)); // turn on EMS_Sys_Status.emsRxStatus = EMS_RX_IDLE; EMS_Sys_Status.emsTxStatus = EMS_TX_IDLE; } else { WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + 8, (1 << LED_ERR)); // turn off } // Rx LED WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + ((EMS_Sys_Status.emsRxStatus == EMS_RX_IDLE) ? 8 : 4), (1 << LED_RX)); // Tx LED // because sends are quick, if we did a recent send show the LED for a short while uint64_t t = (timestamp - EMS_Sys_Status.emsLastTx); WRITE_PERI_REG(PERIPHS_GPIO_BASEADDR + ((t < TX_HOLD_LED_TIME) ? 4 : 8), (1 << LED_TX)); } } // heartbeat callback to light up the LED, called via Ticker void heartbeat() { if (heartbeatEnabled) { int state = digitalRead(LED_BUILTIN); digitalWrite(LED_BUILTIN, !state); } } // do a healthcheck every now and then to see if we connections void systemCheck() { Boiler_Status.boiler_online = ((timestamp - EMS_Sys_Status.emsLastPoll) < POLL_TIMEOUT_ERR); if (!Boiler_Status.boiler_online) { myDebug("Error! Boiler unreachable. Please check connection. Retrying in 10 seconds.\n"); } } // turn off hot water to send a shot of cold void _showerColdShotStart() { myDebug("Shower: exceeded max shower time, doing a shot of cold...\n"); ems_setWarmWaterActivated(false); Boiler_Shower.isColdShot = true; } // turn back on the hot water for the shower void _showerColdShotStop() { if (Boiler_Shower.isColdShot) { myDebug("Shower: turning back hot shower water.\n"); ems_setWarmWaterActivated(true); Boiler_Shower.isColdShot = false; showerResetTimer.detach(); } } // // Main loop // void loop() { connectionStatus = myESP.loop(); timestamp = millis(); // update the Rx Tx and ERR LEDs showLEDs(); // do not continue unless we have a wifi connection if (connectionStatus < WIFI_ONLY) { myDebug("Waiting to connect to wifi...\n"); return; } // if first time connected to MQTT, send welcome start message // which will send all the state values from HA back to the clock via MQTT and return the boottime if ((!startMQTTsent) && (connectionStatus == FULL_CONNECTION)) { myESP.sendStart(); startMQTTsent = true; } // if we received new data and flagged for pushing, do it if (EMS_Sys_Status.emsRefreshed) { EMS_Sys_Status.emsRefreshed = false; publishValues(); } /* * Shower Logic */ if (Boiler_Status.shower_enabled) { // if already in cold mode, ignore all this logic until we're out of the cold blast if (!Boiler_Shower.isColdShot) { // these values come from UBAMonitorFast - type 0x18) which is broadcasted every second so our timings are accurate enough // and no need to fetch the values from the boiler Boiler_Shower.showerOn = ((EMS_Boiler.selBurnPow >= SHOWER_BURNPOWER_MIN) && (EMS_Boiler.selFlowTemp == 0) && EMS_Boiler.burnGas); // is the shower on? if (Boiler_Shower.showerOn) { // if heater was off, start the timer if (Boiler_Shower.timerStart == 0) { Boiler_Shower.timerStart = timestamp; Boiler_Shower.timerPause = 0; // remove any last pauses Boiler_Shower.isColdShot = false; Boiler_Shower.duration = 0; myDebug("Shower: starting timer...\n"); } else { // check if the shower has been on too long if ((((timestamp - Boiler_Shower.timerStart) > SHOWER_MAX_DURATION) && !Boiler_Shower.isColdShot) && Boiler_Status.shower_timer) { _showerColdShotStart(); // start the timer for n seconds which will reset the water back to hot showerResetTimer.attach(SHOWER_OFF_DURATION, _showerColdShotStop); } } } else { // shower is off // if it just turned off, record the time as it could be a pause if ((Boiler_Shower.timerStart != 0) && (Boiler_Shower.timerPause == 0)) { Boiler_Shower.timerPause = timestamp; myDebug("Shower: water has just turned off...\n"); } else { // if shower has been off for longer than the wait time if ((Boiler_Shower.timerPause != 0) && ((timestamp - Boiler_Shower.timerPause) > SHOWER_PAUSE_TIME)) { // its over the wait period, so assume that the shower has finished and calculate the total time and publish Boiler_Shower.duration = (Boiler_Shower.timerPause - Boiler_Shower.timerStart); if (Boiler_Shower.duration > SHOWER_MIN_DURATION) { char s[50]; sprintf(s, "%d minutes and %d seconds", (uint8_t)((Boiler_Shower.duration / (1000 * 60)) % 60), (uint8_t)((Boiler_Shower.duration / 1000) % 60)); myDebug("Shower: finished, duration was %s\n", s); myESP.publish(TOPIC_SHOWERTIME, s); // publish to HA } // reset myDebug("Shower: resetting timers.\n"); Boiler_Shower.timerStart = 0; Boiler_Shower.timerPause = 0; _showerColdShotStop(); // turn heat back on in case its off } } } } } // yield to prevent watchdog from timing out yield(); }