Mikrokontróler ESP32 je prepojený s PHY Ethernet modulom LAN8720 cez rozhranie RMII (Reduced Media Independent Interface). ESP32 cez WiFi kontróler zabezpečuje MAC vrstvu pre PHY Ethernet. Webserver implementovaný v Arduino Core a umožňuje riadiť termostat cez prehliadač v LAN sieti, kde je ESP32 dostupné na pridelenej IPv4 adrese z DHCP servera v LAN sieti. Logika termostatu sa vykonáva nezávisle na otvorenej webaplikácii termostatu. Zmena logiky, prahových teplôt sa vykonáva prostredníctom HTTP požiadaviek od klientov v sieti cez HTML formulár, prípadne je možné požiadavku zaslať aj priamo prostredníctvom HTTP POST požiadavky s predvolenými argumentami fname a fname2. Na základe požiadavky na konkrétnu podstránku je možné ovládať výstup, napríklad manuálne / automaticky, alebo je možné argumentom nastaveným na určitú hodnotu prepísať riadiace dáta (cieľová teplota, hysteréza - dátový typ float). Riadiace dáta sú uložené v emulovanej EEPROM pamäti na ofsete flash pamäte, pričom je životnosť tohto sektora na úrovni 10-tisíc prepisov. Webserver beží na štandardnom HTTP porte - 80.
Termostat dokáže automatizovane prostredníctvom GPIO výstupu (invertovaná logika) ovládať signalizačné relé pre zapnutie / vypnutie kotla. Dokáže tak nahradiť existujúci izbový termostat a sprístupniť ho v sieti všetkým klientom. Termostat dokáže obsluhovať akékoľvek zariadenie s prehliadačom - počítač / smartfón / tablet / Smart TV a podobne. Ako rozhodovací algoritmus sa využíva cieľová teplota s hysterézou, ktorá sa porovnáva s nameranou teplotou z digitálneho senzora teploty Dallas DS18B20. Cieľová teplota a hysteréza je načítaná z EEPROM pamäte, kde je uchovaná aj v prípade výpadku napájania permanentne a pri zápise nových dát sa prepíše. Rozlíšenie senzora DS18B20 pri meraní je 12-bitov, čomu rozpovedá rozlíšenie na teploty 0.0625 °C, čo je minimálny rozlišovací krok medzi rozlišnými meraniami. Dáta po OneWire zbernici môžu prísť do mikrokontroléru po vyžiadaní za 500 až 1000 ms v závislosti od počtu senzorov na OneWire zbernici, dĺžke zbernice a podobne... Rozhodovacia logika termostatu sa vykonáva každých 10 sekúnd nezávisle na webaplikácii, nevyžaduje sa keep-alive spojenie pre vykonanie logiky, systém tak funguje autonómne a nevyžaduje pozornosť používateľa.
Po hardvérovej stránke projekt využíva:ESP32 | Dallas DS18B20 |
---|---|
3V3 | Vcc |
GND | GND |
D5 | DATA |
ESP32 | Relé (OMRON G3MB-202P / SRD-05VDC-SL-C) |
---|---|
5V | Vcc |
GND | GND |
D4 | IN |
ESP32 | PHY Ethernet LAN8720 |
---|---|
3V3 | Vcc |
GND | GND / RBIAS |
D18 | MDIO |
D19 | TXD0 |
D21 | TXEN |
D22 | TXD1 |
D23 | MDC |
D25 | RXD0 |
D26 | RXD1 |
D27 | CRS_DV |
HTML stránky bežiace na ESP32:
Webové rozhranie je navrhnuté pre prispôsobenie sa väčším i menším obrazovkám. Je reponzívne, podporuje širokouhlé obrazovky s vysokým rozlíšením, ale aj mobilné zariadenia. Rozhranie využíva importované CSS štýly Bootstrap frameworku z externého CDN servera, ktorý načíta client-side zariadenie pri otvorení stránky bežiacej na ESP32. Aby ostali nastavené hodnoty termostatu zachované aj po výpadku napájania, sú uložené do EEPROM pamäte ESP, ktorá je emulovaná vo flash pamäti, nakoľko platforma nemá fyzicky EEPROM čip (pamäť). Referenčná teplota na offset 10, hysteréza na offset 100. Každá z hodnôt zaberá maximálne 5B v EEPROM pamäti + ukončovací znak. Dáta sa prepisujú iba pri odoslaní HTML formulára, prevádzka termostatu je tak maximálne šetrná k EEPROM pamäti pre jej maximálnu trvácnosť. Stav výstupu existuje iba v RAM pamäti, kde sa pri zmene prepisuje. Hodnota sa neukladá do emulovanej EEPROM pamäte vo flash pamäti.
Prostredníctvom meta tagu Refresh vykonáva webserver obnovu celej stránky každých 30 sekúnd a prostredníctvom Javascriptu sa vypisuje do HTML stránky aj orientačný čas do refreshu. Do tohto času je potrebné stihnúť zapísať zmenu pre termostat, inak sa input okná pre číselné vstupy do formulára resetujú pri obnovení stránky. Na základe spätnej väzby od používateľov Android zariadení bol čas pre Refresh predĺžený z 10 na 30 sekúnd. Dynamický údaj, ktorý sa predovšetkým mení je aktuálna hodnota výstupu - Zapnutý / Vypnutý, ktorý informuje prevádzkovateľa o skutočnom stave výstupu spoločne aj s farebným označením. Nakoľko sa logika systému vykonáva nezávisle na webserveri, do refreshu môže už byť výstup v inom stave, ako aktuálne vypísanom vo webaplikácii. Zmena výstupu je ihneď vypísaná napríklad na UART monitor (115200 baud/s). Na webovej stránke termostatu nájde používateľ aj informácie o uptime zariadenia (ako dlho beží), t.j. čas v dňoch, hodinách, minútach a sekundách.
Termostat vykuruje od nameranej teploty 22.49 °C a nižšej. V prípade dosiahnutia teploty 23.01 °C a vyššej sa výstup vypne, signalizačné relé sa rozpojí a plynový kotol prestáva kúriť. Nastáva dobeh vykurovania a chladnutie miestnosti v ktorej sa vykonávajú merania. Termostat sa opäť aktivuje až pri dosiahnutí teploty 22.49 °C, alebo nižšej.
/*|-----------------------------------------------------------|*/
/*|HTTP webserver - Ethernet thermostat - ESP32 + PHY LAN8720 |*/
/*|Project webpage: |*/
/*|https://martinius96.github.io/termostat-ethernet/ |*/
/*|AUTHOR: Martin Chlebovec |*/
/*|EMAIL: martinius96@gmail.com |*/
/*|DONATE: paypal.me/chlebovec |*/
/*|Arduino Core 2.0.7 (August 2022) |*/
/*|-----------------------------------------------------------|*/
#include <ETH.h>
#include <WebServer.h>
WebServer server(80);
#include <EEPROM.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#define ONE_WIRE_BUS 5 //D5
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensorsA(&oneWire);
const int rele = 4; //D4
unsigned long cas = 0;
String stav = "VYP";
float teplota;
long day = 86400000; // 86400000 milliseconds in a day
long hour = 3600000; // 3600000 milliseconds in an hour
long minute = 60000; // 60000 milliseconds in a minute
long second = 1000; // 1000 milliseconds in a second
float rezim;
#ifdef ETH_CLK_MODE
#undef ETH_CLK_MODE
#endif
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
// Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source)
#define ETH_POWER_PIN -1
// Type of the Ethernet PHY (LAN8720 or TLK110)
#define ETH_TYPE ETH_PHY_LAN8720
// I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110)
#define ETH_ADDR 1
// Pin# of the I²C clock signal for the Ethernet PHY, DONT USE THIS PIN FOR ultrasonic sensor in this sketch
#define ETH_MDC_PIN 23
// Pin# of the I²C IO signal for the Ethernet PHY
#define ETH_MDIO_PIN 18
void WiFiEvent(WiFiEvent_t event)
{
switch (event) {
case ARDUINO_EVENT_ETH_START:
Serial.println("ETH Started");
//set eth hostname here
ETH.setHostname("esp32-ethernet");
break;
case ARDUINO_EVENT_ETH_CONNECTED:
Serial.println("ETH Connected");
break;
case ARDUINO_EVENT_ETH_GOT_IP:
Serial.print("ETH MAC: ");
Serial.print(ETH.macAddress());
Serial.print(", IPv4: ");
Serial.print(ETH.localIP());
if (ETH.fullDuplex()) {
Serial.print(", FULL_DUPLEX");
}
Serial.print(", ");
Serial.print(ETH.linkSpeed());
Serial.println("Mbps");
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
Serial.println("ETH Disconnected");
break;
case ARDUINO_EVENT_ETH_STOP:
Serial.println("ETH Stopped");
break;
default:
break;
}
}
boolean isFloat(String tString) {
String tBuf;
boolean decPt = false;
if (tString.charAt(0) == '+' || tString.charAt(0) == '-') tBuf = &tString[1];
else tBuf = tString;
for (int x = 0; x < tBuf.length(); x++)
{
if (tBuf.charAt(x) == '.' || tBuf.charAt(x) == ',') {
if (decPt) return false;
else decPt = true;
}
else if (tBuf.charAt(x) < '0' || tBuf.charAt(x) > '9') return false;
}
return true;
}
void writeString(char add, float data)
{
EEPROM.put(add, (data * 1000));
EEPROM.commit();
}
float read_String(char add)
{
float payload = 0;
float data = EEPROM.get(add, payload);
return (data / 1000);
}
void handleRoot() {
int days = millis() / day ; //number of days
unsigned int hours = (millis() % day) / hour; //the remainder from days division (in milliseconds) divided by hours, this gives the full hours
unsigned int minutes = ((millis() % day) % hour) / minute ; //and so on...
unsigned int seconds = (((millis() % day) % hour) % minute) / second;
String stranka = F("<!DOCTYPE html>");
stranka += F("<html>");
stranka += F("<head>");
stranka += F("<meta charset='utf-8'>");
stranka += F("<meta name='author' content='Martin Chlebovec'>");
stranka += F("<meta http-equiv='Refresh' content='30'; />");
stranka += F("<meta name='viewport' content='width=device-width, initial-scale=1'>");
stranka += F("<link rel='stylesheet' href='https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css'>");
stranka += F("<script type='text/javascript'>");
stranka += F("var timeleft = 30;");
stranka += F("var downloadTimer = setInterval(function(){");
stranka += F("if(timeleft <= 0){");
stranka += F("clearInterval(downloadTimer);");
stranka += F("document.getElementById(\"countdown\").innerHTML = \"Reštart...\";");
stranka += F("} else {");
stranka += F("document.getElementById(\"countdown\").innerHTML = timeleft + \" sekúnd do reštartu\";");
stranka += F("}");
stranka += F("timeleft -= 1;");
stranka += F("}, 1000);");
stranka += F("</script>");
stranka += F("<title>Ethernet termostat - ESP32</title>");
stranka += F("</head>");
stranka += F("<body>");
stranka += F("<center><h3>Ethernet termostat - ESP32:</h3>");
if (rezim == 0.00) {
stranka += F("<form action='/action.html' method='post'>");
stranka += "<b>Referenčná teplota:</b><br><input type='text' id='fname' name='fname' min='5' max='50' step='0.25' value=" + String(read_String(10)) + "><br>";
stranka += "<b>Hysteréza:</b><br><input type='text' id='fname2' name='fname2' min='0' max='10' step='0.25' value=" + String(read_String(100)) + "><br>";
stranka += F("<input type='submit' class='btn btn-success' value='Zapísať'>");
stranka += F("</form>");
stranka += F("<a href='manual.html' class='btn btn-primary' role='button'>Manuálny režim</a><hr>");
} else if (rezim == 1.00) {
if (stav == "ZAP") {
stranka += F("<a href='vyp.html' class='btn btn-danger' role='button'>Vypnúť</a><br>");
}
if (stav == "VYP") {
stranka += F("<a href='zap.html' class='btn btn-success' role='button'>Zapnúť</a><br>");
}
stranka += F("<a href='automat.html' class='btn btn-primary' role='button'>Automatický režim</a><hr>");
}
if (stav == "ZAP") {
stranka += F("<b><font color='green'>Výstup: Zapnutý</font></b>");
}
if (stav == "VYP") {
stranka += F("<b><font color='red'>Výstup: Vypnutý</font></b>");
}
stranka += F("<div id=\"countdown\"></div>");
stranka += F("<b>Aktuálna teplota senzora DS18B20:</b> ");
stranka += String(teplota);
stranka += F(" °C");
stranka += F("<hr>");
stranka += F("<b>Uptime: </b>");
stranka += String(days);
stranka += F("d");
stranka += F(" ");
stranka += String(hours);
stranka += F("h");
stranka += F(" ");
stranka += String(minutes);
stranka += F("m");
stranka += F(" ");
stranka += String(seconds);
stranka += F("s");
stranka += F("<h3>Autor: Martin Chlebovec - martinius96@gmail.com - https://martinius96.github.io/termostat-ethernet/phy_ethernet.html</h3>");
stranka += F("<h4>Verzia free - 1.0.4 build: 22. Aug. 2022</h4>");
stranka += F("</center>");
stranka += F("<div class='alert alert-info'>");
stranka += F("Finálny build projektu Ethernet termostat. Ďakujem za vyskúšanie webaplikácie.<br><strong>Rozšírenie o platený obsah:</strong><li>Async Webserver - AJAX update</li><li>Režim chladenia</li><li>mDNS záznam</li><li>OTA aktualizácie</li><li>Ovládanie hlasom cez Amazon Alexa</li><li>Ovládanie cez UDP callbacky</li><li>Možnosť publikácie dát na MQTT Broker (Loxone, IoT Industries Slovakia, Blynk...),</li><li>Dostupné senzory Bosch, Sensirion, DHT</li><li>Watchdog Timer</li><li>Zdrojový kód (.ino) pre aplikáciu.</li><li>Auto-test periférii, fail-safe riešenie</li><li>JSON output rozšírený o systémové dáta (Ethernet sieť, RSSI, uptime, napájacie napätie...)</li>");
stranka += F("</div>");
stranka += F("</body>");
stranka += F("</html>");
server.send(200, "text/html", stranka);
}
void handleBody() {
if (server.hasArg("fname")) {
String target_temp = server.arg("fname");
// float cielova_teplota = target_temp.toFloat();
if (isFloat(target_temp)) {
float cielova_teplota = target_temp.toFloat();
writeString(10, cielova_teplota);
} else {
Serial.println(F("Do input pola cielovej teploty nebolo vlozene cislo!"));
Serial.println(F("Zapis zakazany!"));
}
}
if (server.hasArg("fname2")) {
String hysteresis = server.arg("fname2");
if (isFloat(hysteresis)) {
float hystereza = hysteresis.toFloat();
writeString(100, hystereza);
} else {
Serial.println(F("Do input pola hysterezy nebolo vlozene cislo!"));
Serial.println(F("Zapis zakazany!"));
}
}
String stranka = F("<!DOCTYPE html>");
stranka += F("<html>");
stranka += F("<head>");
stranka += F("<meta charset='utf-8'>");
stranka += F("<meta http-equiv='Refresh' content='5; url=/' />");
stranka += F("<title>Ethernet termostat - ESP32 - spracovanie riadiach dát</title>");
stranka += F("</head>");
stranka += F("<body>");
stranka += F("<center><h3>Server prijal data z formulára:</h3>");
stranka += "<li><b>Referenčná teplota: </b>" + String(read_String(10)) + " °C</li>";
stranka += "<li><b>Hysteréza: </b>" + String(read_String(100)) + " °C</li>";
stranka += F("<b>Presmerovanie... Prosím čakajte</b></center>");
stranka += F("</body>");
stranka += F("</html>");
server.send(200, "text/html", stranka);
}
void handleGet() {
String stranka = "{\n";
stranka += F("\"Hysteresis\":");
stranka += String(read_String(100));
stranka += F(",\n");
stranka += F("\"Target_Temperature\":");
stranka += String(read_String(10));
stranka += F(",\n");
stranka += F("\"Actual_Temperature\":");
stranka += String(teplota) + "\n";
stranka += F("}\n");
server.send(200, "application/json", stranka);
}
void handleZAP() {
stav = "ZAP";
digitalWrite(rele, LOW);
String stranka = F("<!DOCTYPE html>");
stranka += F("<html>");
stranka += F("<head>");
stranka += F("<meta charset='utf-8'>");
stranka += F("<meta http-equiv='Refresh' content='0; url=/' />");
stranka += F("</head>");
stranka += F("</html>");
server.send(200, "text/html", stranka);
}
void handleAuto() {
writeString(150, 0.00);
rezim = read_String(150);
String stranka = F("<!DOCTYPE html>");
stranka += F("<html>");
stranka += F("<head>");
stranka += F("<meta charset='utf-8'>");
stranka += F("<meta http-equiv='Refresh' content='0; url=/' />");
stranka += F("</head>");
stranka += F("</html>");
server.send(200, "text/html", stranka);
}
void handleManual() {
writeString(150, 1.00);
rezim = read_String(150);
String stranka = F("<!DOCTYPE html>");
stranka += F("<html>");
stranka += F("<head>");
stranka += F("<meta charset='utf-8'>");
stranka += F("<meta http-equiv='Refresh' content='0; url=/' />");
stranka += F("</head>");
stranka += F("</html>");
server.send(200, "text/html", stranka);
}
void handleVYP() {
stav = "VYP";
digitalWrite(rele, HIGH);
String stranka = F("<!DOCTYPE html>");
stranka += F("<html>");
stranka += F("<head>");
stranka += F("<meta charset='utf-8'>");
stranka += F("<meta http-equiv='Refresh' content='0; url=/' />");
stranka += F("</head>");
stranka += F("</html>");
server.send(200, "text/html", stranka);
}
void setup() {
Serial.begin(115200);
WiFi.onEvent(WiFiEvent);
ETH.begin(ETH_ADDR, ETH_POWER_PIN, ETH_MDC_PIN, ETH_MDIO_PIN, ETH_TYPE, ETH_CLK_MODE);
EEPROM.begin(512); //Initialize EEPROM
float a = read_String(10);
float b = read_String(100);
float c = read_String(150);
if (isnan(a)) {
writeString(10, 20.25);
}
if (isnan(b)) {
writeString(100, 0.25);
}
if (isnan(c)) {
writeString(150, 0.00);
}
sensorsA.begin();
pinMode(rele, OUTPUT);
digitalWrite(rele, HIGH);
sensorsA.requestTemperatures();
delay(750);
Serial.println(F("Ethernet termostat - Author: Martin Chlebovec"));
server.on("/", handleRoot);
server.on("/get_data.json", handleGet);
server.on("/automat.html", handleAuto);
server.on("/manual.html", handleManual);
server.on("/zap.html", handleZAP);
server.on("/vyp.html", handleVYP);
server.on("/action.html", HTTP_POST, handleBody);
server.begin();
}
void loop() {
if ((millis() - cas) >= 10000 || cas == 0) {
cas = millis();
teplota = sensorsA.getTempCByIndex(0);
Serial.println();
Serial.println(F("----------------------------------------------"));
Serial.print(F("IP addresa ESP32 termostat: "));
Serial.print(ETH.localIP());
Serial.print(F(", pre pristup k termostatu navstivte http://"));
Serial.print(ETH.localIP());
Serial.println(F("/"));
Serial.print(F("Free HEAP: "));
Serial.print(ESP.getFreeHeap());
Serial.println(F(" B"));
Serial.print(F("Aktuálna teplota: "));
Serial.print(String(teplota));
Serial.println(F(" °C"));
sensorsA.requestTemperatures();
rezim = read_String(150);
if (rezim == 0.00) {
float cielova_teplota = read_String(10);
float hystereza = read_String(100);
float minus_hystereza_teplota = (-1 * hystereza);
float rozdiel = cielova_teplota - teplota; //21 - 20
if (rozdiel > hystereza) {
Serial.println(F("Kotol zapnuty"));
stav = "ZAP";
digitalWrite(rele, LOW);
} else if (rozdiel < minus_hystereza_teplota) {
Serial.println(F("Kotol vypnuty"));
stav = "VYP";
digitalWrite(rele, HIGH);
} else {
Serial.println(F("Rozdiel cielovej a aktuálnej teploty nie je nad, ani pod hysterezou. Stav vystupu sa nemeni."));
Serial.print(F("Aktualny stav vystupu pre kotol: "));
Serial.println(stav);
}
} else {
Serial.print(F("Manualny rezim, stav vystupu: "));
Serial.println(stav);
}
}
server.handleClient();
yield();
}
Implementácia obsahuje program pre dynamickú IPv4 adresu priradenú k Ethernet PHY termostatu z DHCP servera v sieti. Termostat je určený iba pre interiérové teploty! (nad 0°C), čomu je prispôsobená aj logika systému! Termostatom je možné nahradiť už existujúci izbový termostat, možno dočasne nahradiť ohrievač v akváriu / teráriu pre udržiavanie stálej teploty.
Na konci roka 2024 bola pridaná programová implementácia aj pre ESPlan od Laskakitu. Použité najnovšie verzie Bootstrap (5.3.3), Arduino Core (3.0.10), pozor, iný PINOUT pre relé a DS18B20! Programovú implementáciu nájdete na: ESPLAN implementácia.