ESP32 & UNO & DHT11

Acum 2 ani mi-am cumpărat o plăcuță NodeMCU care e bazată pe un ESP8266 și am început să lucrez la acest proiect din curiozitatea de a o conecta la internet.

Anul trecut, la una din materiile pe care le predau la laborator, s-a decis să fie înlocuite lucrarea de laborator în care se face comunicația prin bluetooth folosind un modul HC-05 cu două lucrări în care se face o comunicație prin WiFi folosind ESP32 pe post de access point și pe post de client. Uitându-mă peste lucrările respective și văzând că placa este dotată atât cu WiFi cât și cu Bluetooth, mă tot bătea gândul să îmi comand una și să mă joc cu ea. Au trecut cele două lucrări, studenții la proiecte au lucrat pe alte plăcuțe și fiind prins cu alte probleme de rezolvat în perioada aceea nu am mai achiziționat-o.

Acum două săptămâni am făcut cu studenții prima lucrare iar una din cerințe a fost aprinderea și stingerea LED-ului plăcii pe baza de GET request către serverul care rula pe placă. Soluția pe care o găsisem pe baza lucrării de laborator era parcurgerea șirului de caractere, identificarea șirului de caractere GET urmat de comandă(ex. GET /LedOn). Mi-am amintit că atunci când am lucrat cu NodeMCU am folosit o bibliotecă care îmi permitea să definesc în funcția setup() endpointurile.

Am plecat de la laborator cu gândul să îmi comand o placă și să fac un demo folosind o bibliotecă ca și în cazul lui NodeMCU. Am comandat 2 plăci, una ca și cele de la laborator respectiv una pe type C. Placa pe microUSB a ajuns vineri seara și fiind răcit am testat un sketch rapid care să conțină și un afișor LCD pe I²C să văd dacă merge pentru a da informația mai departe către studenții mei dacă este în regulă sau nu de cumpărat acea placă.

Duminică mai spre seară mi-am revenit și am făcut un mic demo care implica aprinderea și stingerea LED-ului pe care l-am partajat cu studenții mei.

Luni înainte să plec spre la facultate am primit mesaj că mi-a ajuns și a doua plăcuță și am zis în că e ziua norocoasă și când ajung acasă să le leg între ele ca în cea de-a doua lucrare de laborator pe care urma să o fac în 30 minute.

Am început ora de laborator, discut cu studenții mei dacă sunt nelămuriri legate de demo și pe finalul orei studenții vin cu rugămintea dacă aș putea să fac un demo care să conțină requesturi POST/GET făcute de plăcută către un server extern. Mi-am amintit că am făcut ceva asemnător când am folosit NodeMCU pentru astfel de requesturi când primea comenzi de la Macro-KeyBoard(despre proiectul acesta nu am discutat aici încă, ce pot să zic este faptul că este o versiune îmbunătățită a acestuia și o să apară în perioada următoare după o mică refactorizare). Am acceptat acest să fac acest demo și pe această cale doresc să le mulțumesc de provocare 😊.

În acea seară mi-am bătut capul 2 ore cu placa pe type C dar nu am reușit să o programez sub nici o formă așa că am returnat-o. Nemaiavând două plăcuțe, nu mai puteam să fac al doilea laborator așa că m-am tot gândit ce aș putea astfel încât să înglobez un exemplu cu requesturi dar să ating și alte subiecte în contextul în care mai mulți studenți de ai mei folosesc la proiect 2 plăcuțe(un ESP și un Arduino UNO) și diferiți senzori.

Zilele au trecut și joi înainte să dorm mi-a venit o idee de proiect completă: o plăcuță Ardunino UNO citește date de la unu sau mai mulți senzori, trimite mai departe pe serial aceste date către ESP, ESP-ul o să facă un GET către un server public pentru a oține data și ora iar apoi va face POST către un server local pentru a salva datele într-o bază de date MySQL.

Vineri, având și cu studenții de anul 4, mi-au m-ai venit niște idei și am ajuns la o versiune finală a proiectului: pe lângă cele menționate anterior, voi folosi ca și senzor DHT11 pentru că îmi furnizează atât temperatura cât și umiditatea iar serverul web și baza de date le voi ține în containere de Docker(WAMP-ul nu mai e disponibil cum trebuie pe Win11 iar XAMP-ul moare când îți este lumea mai dragă).

Acestea fiind zise, vineri mai spre seară m-am apucat de lucru iar duminică după-masă l-am cam terminat.

În continuare o trecem prin partea mai tehnică a acestui articol și o să vă prezint schema de montaj împreună cu codul aferent.

În schema de mai sus se poate observa faptul că pentru comunicarea serială dintre cele 2 plăci am avut nevoie de un convertor logic 5V-3.3V. Acest lucru se datorează faptului că plăcuța Arduino UNO folosește o tensiune de referință de 5V în schimb ESP32 folosește 3.3V.

Codul sursă are următoarea structură:

ESP32_UNO_DHT11
├───ESP32
│       arduino_secrets.h
│       ESP32.ino
│
├───UNO_DHT11
│       UNO_DHT11.ino
│
└───WebServer
    │   docker-compose.yml
    │
    ├───app
    │       addSensorData.php
    │       addSensorDataGUI.php
    │       index.php
    │
    └───build
        ├───mysql
        │       Dockerfile
        │       operations.sql
        │       privileges.sql
        │
        └───php
                Dockerfile

Codul care rulează pe Arduino UNO se regăsește în folderul UNO_DHT11.

Pentru a citi temperatura și umiditatea am folosit biblioteca DHT11 iar citirea se face în funcția getTemperatureHumidity().

Citirea temperaturii și umidității se face atunci când variabila reading este egală cu 1. Pentru a seta această variabilă la 1 avem 2 cazuri:

  1. A fost apăsat butonul conectat la placă la pinul 2 care are atașat o întrerupere externă.
    Inițial am vrut să apelez funcția getTemperatureHumidity() direct în întrerupere dar investigând biblioteca DHT11 am descoperit că folosește în spate întreruperi interne.
  2. S-a primit pe interfața serială(mai exact Software Serial) mesajul ”GET data”.

Odată ce datele au fost citite cu succes, variabila reading este resetată la 0.

După ce datele au fost citite, ele sunt transmise mai departe pe interfața software serială către ESP32 în functia sendSensorData() și send().

Pentru a nu trimite variabilele citite în 2 grupări de câte 4 bytes am apelat la o împachetare într-o structură de date.

typedef struct { //If you use other then char or char[], specify in format mentioned below.
    uint32_t temperature;
    uint32_t humidity;
    uint32_t result;
} SensorData;

Inițial nu am folosit ca și tip de date uint32_t pentru variabile dar în momentul în care trimiteam și recepționam bytes, funcția sizeof(SensorData) returna valori diferite pentru tipul de date int pe cele două plăci. Acest lucru se datoreaza arhitecturii diferite a celor două plăcuțe dar folosind uint32_t, am garația că variabilele de acest tip sunt pe 4 bytes.

void sendSensorData(){
  sensorData.temperature=temperature;
  sensorData.humidity=humidity;
  sensorData.result=result;
  int sizeSensorData = sizeof(SensorData);
  send(&sensorData,sizeSensorData);
}

void send(const SensorData* data, int sizeSensorData){
  mySerial.write((const char*)data, sizeSensorData); 
}

După cum se poate vedea mai sus, înainte de a fi trimise, datele sunt împachetate într-o variabilă iar mai apoi se face cast la un vector de caractere deoarece trimiterea pe interfața serială se face la nivel de byte.

Codul pentru ESP32 se regăsește în folderul ESP32. În interiorul acestui folder, pe lângă fișierul cu codul propriu zis se regăsește și fișierul arduino_secrets.h. Idee fișierului arduino_secrets.h mi-a venit de aici și are următoarea structură:

#define SECRET_SSID "MyWIFIName"
#define SECRET_PASSWORD "password1234"
#define SERVER_NAME "192.168.1.175"

Pentru implementarea codului care rulează pe ESP32, am folosit următoarele biblioteci:

  • WiFi – pentru a folosi plăcuța ca si client și a o conecta la o rețea Wifi disponibilă
  • HTTPClient – pentru a face requesturi GET/POST către un server
  • ESPAsyncWebServer – pentru a porni un server și a defini endpointuri
  • StringSplitter – pentru a împărți șirurile de caractere având un caracter delimitator. Recunosc că am căutat soluția aceasta pentru că îmi lipseau funcțiile din Python pentru manipularea șirurilor de caractere și nu voiam să le implementez de la 0. Am modificat în această bibliotecă în fișierul StringSplitter.h MAX din 5 în 15.
  • LiquidCrystal_I2C pentru a lucra cu afișorul 16×2. De menționat este faptul că atunci când programezi placa, IDE-ul o să arunce un warning că biblioteca este pentru avr dar funcționează.

Placa ESP32 dispune de 3 porturi seriale dar pot fi folosite fără modificări doar UART0 și UART2. Pentru comnicarea cu Arduino UNO am folosit UART2.

Serial2.begin(9600,SERIAL_8N1,RX2_PIN,TX2_PIN);

În ceea ce privește serverul web care va rula pe plăcuță avem 3 etape:

1. Declararea serverului web iar ca și parametru se specifică portul pe care să pornească:

AsyncWebServer server(80);

2. Declarea endpointurilor:

server.on("/getSensorData", HTTP_GET, [](AsyncWebServerRequest* request) {
    getSensorData();
    request->send(200, "text/html", "<html><body><h1>You will receive new data!</h1></body></html>");
  });

Endpointul declarat mai sus este de tipul GET și în urma apelării sale, pe lângă pagina web pe care o returnează în imaginea de mai jos, mai este apelată funcția getSensorData().

Funcția getSensorData() trimite pe interfața Serial2 către Arduino UNO mesajul ”GET data”. Acesta este modul în care este declanșată o nou citire a temperaturii și umidității.

3. Pornirea propriu zisă a serverului web.

 server.begin();

Am optat pentru un endpoint în ceea ce privește declanșarea unei noi citiri de catre Arduino UNO deoarece în viitor aș vrea să fac o integrare cu IFTTT și indiferent de trigger, să se execute aceeași funcție.

ESP32 primește datele primite pe interfața serială de la Arduino UNO iar aceste date sunt citite ca și bytes și reinterpretate ca și o variabilă de tipul SensorData.

bool receive(SensorData* sd){
  return (Serial2.readBytes((char*)sd, sizeof(SensorData)) == sizeof(SensorData));
}

Pentru a obține data și ora curentă am făcut un request de tipul GET către un server public.

String dateTimeServer = "https://timeapi.io/api/time/current/zone?timeZone=Europe%2FBucharest";

String getCurrentDate(){
  HTTPClient http;
  String date="";
  String urlPath=dateTimeServer;
  http.begin(urlPath);
  int httpResponseCode = http.GET();
  if (httpResponseCode>0) {
      String payload = http.getString();
      date=parsePayload(payload);
    }
  http.end();
  return date;
}

Iar rezultatul obținut în urma apelului este următorul șir de caractere:

{"year":2024,"month":11,"day":30,"hour":14,"minute":8,"seconds":36,"milliSeconds":40,"dateTime":"2024-11-30T14:08:36.0403397","date":"11/30/2024","time":"14:08","timeZone":"Europe/Bucharest","dayOfWeek":"Saturday","dstActive":false}

Pentru a obține data și ora în formatul necesar pentru baza de date, am prelucrat șirul de caractere în funcția parsePayload() folosind StringSplitter.

Având cele 3 informații necesare: temperatura, umiditate, data și ora le-am salvat într-o bază de date pe baza unui request de tipul POST – sendToDatabase() și le-am afișat pe LCD – printDataOnLCD().

String serverName = SERVER_NAME;

void sendToDatabase(int temperature, int humidity, String date){
  HTTPClient http;
  String urlPath="http://"+serverName+"/addSensorData.php";
  String httpRequestData ="temperature="+String(temperature)+"&humidity="+String(humidity)+"&date="+date;
  http.begin(urlPath);
  http.addHeader("Content-Type", "application/x-www-form-urlencoded"); 
  int httpResponseCode = http.POST(httpRequestData);
  if (httpResponseCode>0) {
      Serial.print("HTTP Response code: ");
      Serial.println(httpResponseCode);
    }
  http.end();
}

Împachetarea body-ului pentru POST request se face după următorul format:

String httpRequestData="var1=val1&var2=val2&var3&val3"

De asemenea este foarte important să fie specificat headerul:

http.addHeader("Content-Type", "application/x-www-form-urlencoded"); 

De partea cealaltă a requestului POST stă un server web Apache împreună cu o bază de date MySQL.

Pentru a evita să folosesc un server hostat direct pe mașină am mers pe o variantă cu containere folosind Docker.

WebServer
│   docker-compose.yml
│   
├───app
│       addSensorData.php
│       addSensorDataGUI.php
│       index.php
│       
└───build
    ├───mysql
    │       Dockerfile
    │       operations.sql
    │       privileges.sql
    │       
    └───php
            Dockerfile

Aplicația PHP este se regăsește în folderul app și este distribuită fișiere de mai jos:

  • index.php – pagină principală care afișează valorile din baza de date. Pagina mai conține și un formular pentru introducerea manuală a datelor în baza de date.
<?php
    if ($_SERVER["REQUEST_METHOD"] == "GET") {
        $sql_query="SELECT * from dht";
        $connect= mysqli_connect("mysql", "user", "password", "sensors");
        $search_result = mysqli_query($connect,$sql_query);
        
    }
?>
<!DOCTYPE html>
<html>
    <head>
        <title>Sensor page</title> 
    </head>
    <body>
        <h1>Sensor page</h1>
        <form action="addSensorDataGUI.php" method="POST">
            Temperature: <input type="text" name="temperature"><br>
            Humidity: <input type="text" name="humidity"><br>
            <br>
            <input type="submit" name="insert">
        </form>
        <br>
        <br>
        <table border="1" align="left">
            <tr align="center">
                <td>ID</td>
                <td>TEMPERATURE</td>
                <td>HUMIDITY</td>
                <td>DATE</td>
            </tr>
                
            <?php
            while($row=mysqli_fetch_array($search_result)):?>
            <tr align="center">
                <td><?php echo $row['id'];?></td>
                <td><?php echo $row['temperature'];?>°C</td>
                <td><?php echo $row['humidity'];?>%</td>
                <td><?php echo $row['date'];?></td>
            </tr>
            <?php endwhile;?>
		</table>
    </body>
</html>
index.php
  • addSensorDataGUI.php – reprezintă endpointul de tip POST în care datele primite prin formularul din pagina principală sunt inserate în baza de date. După ce datele au fost inserate se face o redirecționare către pagina principală.
<?php
    date_default_timezone_set("Europe/Bucharest");
    function valid_user_data() { 
        if ($_SERVER["REQUEST_METHOD"] == "POST") {
            $temperature = $_POST['temperature'];
            $humidity = $_POST['humidity'];
            $date = date("Y-m-d H:i:s");
            $sql_query="INSERT INTO dht (temperature, humidity, date) VALUES ('$temperature','$humidity','$date')";
            $connect= mysqli_connect("mysql", "user", "password", "sensors");
            mysqli_query($connect,$sql_query);
        }
        else {
            echo 'GET request';
        }
        return TRUE; 
    } 
    
    if(valid_user_data()) { 
        header('Location: index.php'); 
    } 
?>
  • addSensorData.php – reprezintă un endpoint de tip POST în care datele primite de la diferiți clienți sunt inserate în baza de date. Spre deosebire de celalalt endpoint, acesta nu face redirecționare către pagina principală. Acest endpoint este apelat de către ESP32.
<?php
if ($_SERVER["REQUEST_METHOD"] == "POST") {
    $temperature = $_POST['temperature'];
    $humidity = $_POST['humidity'];
    $date = $_POST['date'];
    $sql_query="INSERT INTO dht (temperature, humidity, date) VALUES ('$temperature','$humidity','$date')";
    $connect= mysqli_connect("mysql", "user", "password", "sensors");
    mysqli_query($connect,$sql_query);
}
?>

Pentru a gestiona mai ușor parmetrii sub care construiesc și rulez containerele de Docker am mers pe abordarea de a folosi docker-compose iar fișierul docker-compose.yml are următoarea structură:

services:
  php-apache:
    ports:
      - "80:80"
    build: './build/php'
    volumes:
      - ./app:/var/www/html
  mysql:
    ports:
      - "3306:3306"
    build: './build/mysql'
    environment:
      MYSQL_ROOT_PASSWORD: "root"
      MYSQL_DATABASE: "sensors"
    volumes:
      - dbData:/var/lib/mysql
volumes:
  app:
  dbData:

După cum se poate vedea mai sus folderul app care conține aplicația PHP este un volum pentru containerul php-apache. Dockerfile-ul pentru containerul php-apache este următorul :

FROM php:8.1-apache

RUN apt-get update && \
    docker-php-ext-install mysqli pdo pdo_mysql

Pentru containerul care conține baza de date MySQL am definit baza de date sensors iar pentru persistență am folosit un volum. Dockerfile-ul pentru baza de date este următorul:

FROM mysql:latest
USER root
RUN chmod 755 /var/lib/mysql
COPY ./privileges.sql /docker-entrypoint-initdb.d/
COPY ./operations.sql /docker-entrypoint-initdb.d/

Scripturile care se regăsesc în folderul /docker-entrypoint-initdb.d sunt executate o singură dată la prima pornire a containerului așa cum este precizat aici.

Scriptul privileges.sql l-am folosit pentru a crea un nou utilizator cu o parolă și a-i da drepturi pe baza de date. Am făcut lucrul acesta deoarece uneori în trecut aveam probleme cu userul root când încercam să rulez operații de INSERT sau SELECT pe baza de date din php.

CREATE USER 'user'@'%' IDENTIFIED BY 'password';
GRANT ALL ON *.* TO 'user'@'%';
FLUSH PRIVILEGES;

Scriptul operations.sql l-am folosit pentru a crea tabela ”DHT” în care voi salva datele.

CREATE TABLE `sensors`.`dht` (
   `id` int NOT NULL AUTO_INCREMENT,
   `temperature` int DEFAULT NULL,
   `humidity` int DEFAULT NULL,
   `date` datetime DEFAULT NULL,
   PRIMARY KEY (`id`),
   UNIQUE KEY `id_UNIQUE` (`id` ASC) VISIBLE);

Codul sursă se găsește aici.

Un demo se regăsește în videoul de mai jos.

Lasă un comentariu

Acest site folosește Akismet pentru a reduce spamul. Află cum sunt procesate datele comentariilor tale.