mercredi 18 mars 2020

Serveur ESP32 : implémentation



Serveur ESP32 : implémentation


Cet article a pour but de présenter plusieurs méthodes d'implémentation pour un serveur HTTP sur ESP32 ou ESP8266.

Dans ce tutoriel nous allons aborder :
  • formulaire
  • objets divers :
    • bouton, bouton-radio
    • champ numérique avec boutons + -
    • champ texte, simple ou multiligne
  • un peu de JAVASCRIPT 
  • récupération de l'heure avec NTP
  • requêtes GET ou POST
  • envoi d'une réponse avec snprintf() 
  • envoi d'une réponse avec String
  • envoi d'une réponse à partir du SPIFFS avec une template

1. La classe WebServer

Cette classe fait partie de la librairie WebServer du package standard ESP32. Elle a déjà été utilisée dans l'article précédent :
https://riton-duino.blogspot.com/2020/03/arduino-ethernet-esp32-comparons.html

Elle permet d'implémenter un serveur très simplement. Nous n'allons pas revenir dessus.

2. La classe AsyncWebServer

Cette classe fait partie de la librairie ESPAsyncWebServer qui peut être trouvée ici :
https://github.com/me-no-dev/ESPAsyncWebServer
Comme dit sur la page la librairie AsyncTCP est nécessaire :
https://github.com/me-no-dev/AsyncTCP
Il existe une version ESP8266 :
https://github.com/me-no-dev/ESPAsyncTCP

Elle a l'énorme avantage de proposer, en autres, la gestion de pages HTML templates, ce qui va bien nous arranger.
Ceci sera expliqué plus loin.

3. Méthode GET ou POST

Comme je l'ai déjà expliqué deux méthode d'envoi de requête existent : GET ou POST.

En méthode GET les arguments suivent l'URL et on les voit dans la barre d'adresse du navigateur lorsque l'on valide le formulaire.
Les arguments font partie de l'URL, qui elle-même fait partie de l'entête HTTP.

En méthode POST les arguments ne sont pas visibles dans la barre d'adresse du navigateur lorsque l'on valide le formulaire.
Les arguments sont envoyés après l'entête HTTP. Un double caractère '\n' signale la fin de l'entête et donc le début des arguments.

On utilise en général la requête POST dans un formulaire. La requête GET est employée principalement dans la gestion de boutons ou de liens hypertexte.

4. JAVASCRIPT

Certains s'étonneront de voir du code JAVASCRIPT dans l'exemple qui va suivre. Comment se fait-il que l'on puisse exécuter du code JAVASCIPT dans une application ESP32 ?

Tout simplement l'ESP32 n'exécute pas le JAVASCRIPT. Les lignes JAVASCRIPT sont envoyées par l'ESP32 au sein du code HTML (ou dans des fichiers .js) et elles sont exécutées par le navigateur.

Pour l'ESP32 le code HTML et JAVASCRIPT ne sont que du texte. Il pourrait de la même façon envoyer un bout de code écrit en C++ ou une liste de courses, simplement le navigateur ne saurait pas quoi en faire, à part afficher le texte.

5. Affichage de pages à contenu variable

Il est assez rare, dans un logiciel serveur d'afficher une page statique, à moins qu'il ne s'agisse d'une page d'aide ou d'une page destinée à afficher la version du serveur, la liste des modifications, etc.
Dans la plupart des cas, nous aurons un mélange de code HTML fixe et de code HTML variable.

6. Les différentes méthodes d'envoi de réponse

Nous allons maintenant aborder la partie qui nous intéresse : quand le navigateur envoie une requête HTTP, comment faire pour lui répondre de manière correcte et efficace.

6.1. Utiliser un objet String

En général la solution utilisée est celle-ci :

  String msg = "<!DOCTYPE html>\n";
  msg += "<html>\n";
  msg += "<head>\n";
  msg += "<meta charset=\"UTF-8\">\n";
  msg += "<title>My Server</title>\n";
  msg += "</head>\n";
  msg += "<body>\n";
  msg += timeString;

  msg += "<br><br><h1>TITLE</h1>\n";
  // etc.
  request->send(200, "text/html", msg);


Dans la réponse à la requête du navigateur on vient insérer une variable timeString (la date et l'heure courante).

On voit des caratères quotes " échapés à l'aide de backslash : \"
Cet échappement est obligatoire en C si l'on veut insérer une quote dans une chaîne de caractères.
Résultat : fastidieux et assez illisible.

6.2. Utiliser snprintf

On peut également utiliser la vénérable fonction snprintf() (elle devrait bientôt fêter son demi-siècle d'existence) :

#define RESPONSE_SIZE     1000
char msg[RESPONSE_SIZE];
 

const char *page = "<!DOCTYPE html>\n"
                              "<html>\n"
                              "<head>\n"
                              "<meta charset=\"UTF-8\">\n"
                              "<title>Subscribers</title>\n"
                              "</head>\n"
                              "<body>\n"
                              "%s<br><br><h1>SUBSCRIBERS</h1>\n"

                              // etc
  snprintf(msg, RESPONSE_SIZE, page, timeString, etc.
  request->send(200, "text/html", msg);

Nous remarquons dans l'utilisation de la fonction snprintf() qu'un large buffer est utilisé est que nous passons son adresse et sa taille à la fonction, ceci afin d'éviter les débordements qui pourraient arriver si l'on utilisait simplement sprintf().
snprintf() apporte une sécurité supplémentaire, et c'est important.

La variable timeString est également passée en argument et sera formatée selon une chaîne de format %s (c'est à dire une chaîne de caractère).

snprintf() est une fonction à nombre d'arguments variables. Il est donc possible de passer plusieurs arguments à formater si la page HTML comporte plusieurs champs variables, donc plusieurs chaînes de format :
  • %s : chaîne de caractère
  • %d : variable entière (int)
  • %x : variable entière (int) à formater en hexadécimal
  • %l : variable entière (long)
  • %f : variable flotante (float)
  • la liste est longue.
Le nombre d'arguments variables doit être le même que le nombre de chaînes de format. Le format doit être cohérent avec le type de variable. On ne formate pas une variable entière avec une chaîne de format convenant à une chaîne de caractères, et vice-versa.

Par rapport à la solution précédente, il est assez difficile de maintenir une cohérence lorsqu'un nombre important de chaînes de format sont noyées au sein d'une longue page HTML. Il faut être rigoureux et contrôler que le bon nombre d'arguments est passé à snprintf() et que le format convient.

L'utilisation des quotes avec échappement est identique à la méthode précédente.
Résultat : fastidieux et à peine plus lisible.

Par contre cette méthode offre une rapidité d'exécution sans équivalent.

6.3. Utiliser une template

La troisième méthode passe par un fichier HTML stocké dans la partition SPIFFS de l'ESP32 :

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Subscribers</title>
</head>
<body>
%PLACEHOLDER DATE%<br><br><h1>SUBSCRIBERS</h1>

etc.

On voit dans ce fichier un identifiant %PLACEHOLDER DATE% qui sera remplacé par la valeur d'une variable lors de l'envoi.

Le fichier HTML doit être créé dans un répertoire data du projet et sera chargé dans la partition SPIFFS de l'ESP32 grâce au chargeur :

Menu "Outils / ESP32 Sketch Data Upload"
Menu "Outils / ESP8266 Sketch Data Upload"

Il est nécessaire d'installer un plugin dans l'IDE ARDUINO :
https://github.com/me-no-dev/arduino-esp32fs-plugin
https://github.com/esp8266/arduino-esp8266fs-plugin

Voici le template processor :

const char *var_timeString;
const char *var_statusColor;

String templateProcessor(const String& var)
{
  if (var == "PLACEHOLDER DATE") {
    return var_timeString;
  }

  // etc.
  else if (var == "PLACEHOLDER STATUS_COLOR") {
    return var_statusColor;
  }
  return "?????";

}

Ensuite dans le code du handler on passe le nom du fichier HTML et l'adresse d'une fonction de traitement des PLACEHOLDERs :

  server.on("/index.html", HTTP_GET, [](AsyncWebServerRequest * request) {
    request->send(SPIFFS, "/index.html", "text/html", false, templateProcessor);
  });

A chaque fois qu'un identifiant encadré par deux caractères % sera rencontré la fonction templateProcessor sera appelée. Elle renvoie la valeur à insérer dans la page HTML envoyée.
Bien entendu si la variable est un nombre entier ou flottant il faudra la convertir en String :

int var_subscriberId;
    return String(var_subscriberId);


Cette méthode est largement utilisée dans le monde du développement WEB (Cheetah par exemple en PYTHON).

Résultat : le fichier HTML est clair, et l'on n'est même pas perturbé par la présence des identifiants PLACEHOLDER.

Même si cette solution est coûteuse en temps d'exécution, elle soulage grandement le développeur.

7. Exemple

Un serveur exemple est récupérable ici :
https://bitbucket.org/henri_bachetti/webserver-form/src/v1.0/esp32-subscriber/

Une directive de compilation permet de tester l'une des trois méthodes :

#define USE_SPRINTF       1
#define USE_STRING        2
#define USE_SPIFFS        3
#define METHOD            USE_SPIFFS


Il suffit de changer :

#define METHOD            USE_SPRINTF
#define METHOD            USE_STRING

Il s'agit d'un petit serveur de gestion d'abonnés :


Le formulaire comporte un certain nombre d'objets :
  • date et heure courante (obtenue par NTP)
  • bouton "Read media"
  • champ champ N° de l'abonné
  • champ identifiant de l'abonné
  • champ nom de l'abonné
  • champ crédits de l'abonné
  • 6 boutons radio
    • ajouter 1, 5 ou 10 crédits
    • mettre les crédits à zéro
    • interdire l'abonné
    • réautoriser l'abonné
  • champ historique des actions
  • champ status 
  • bouton SUBMIT
Le bouton "Read media" permet de lire le média de l'abonné. Celui-ci peut de divers types :
  • carte magnétique
  • carte à puce
  • RFID
  • code barre
  • carte SD
  • etc.
Le champ "Subscriber Number" permet de parcourir la liste des abonnés.

esp32-subscriber.ino
Le skectch est assez classique. Il gère une seule URL : /
La callback handleRoot fait tout le travail, qu'il s'agisse de requêtes GET ou POST.

subscriber.cpp
Comme on peut le voir dans ce source, tout est virtuel bien entendu.
Les données sont en RAM, et le bouton "Read media" se contente d'appeler un fonction qui tire aléatoirement un numéro.
Aucun besoin de matériel. Un ESP32 suffit.

Dans une vraie application il faudrait ajouter un lecteur de média et enregistrer les données historiques dans un fichier.
Ceci sera probablement le sujet d'un prochain tutoriel.

index.html
Le fichier HTML comporte deux petites fonction JAVASCRIPT qui permettent de gérer les boutons "Read media" et "N° de l'abonné" :
<body>
<script type="text/javascript">
function subscriberChanged(value){
document.location ="/?subscriber=" + value;
};
function readMedia(){
document.location ="/?action=read_media"
};
</script>

La fonction subscriberChanged() est associée au champ "N° de l'abonné" :
<input type="number" id="subscriber" name="subscriber" value="%PLACEHOLDER SUBSCRIBER%"
min="1" max="%PLACEHOLDER NSUBSCRIBERS%" onchange="subscriberChanged(this.value)">
A chaque changement de valeur la page est réaffichée :

  document.location ="/?subscriber=" + value;

L'URL / est donc demandée avec l'argument subscriber=numéro. Cet argument est traité par la callback handleRoot du sketch.

La fonction readMedia() est associée au bouton "ReadMedia" :

<button onclick="readMedia()">Read media</button>

A chaque click la page est réaffichée :

document.location ="/?action=read_media"

L'URL / est donc demandée avec l'argument action=read_media. Cet argument est traité par la callback handleRoot du sketch.

8. Le petit plus : NTP

L'application demande l'heure au démarrage au serveur NTP (Network Time Protocol) pool.ntp.org :

#include <time.h>

const char* ntpServer = "pool.ntp.org";

  configTime(3600, 0, ntpServer);

Ensuite il suffit de lire l'heure et de la formater selon le besoin :

  char timeString[50];
  struct tm timeinfo;
 

  if (!getLocalTime(&timeinfo)) {
    Serial.println("Failed to obtain time");
    return;
  }
  else {
    strftime(timeString, sizeof(timeString), "%d/%m/%Y %H:%M", &timeinfo);
  }


C'est tout ? Oui
Pas la moindre librairie ? Non.

Pour une application sérieuse il serait souhaitable de demander l'heure périodiquement, ceci afin de mettre à jour l'heure de l'ESP32, car son oscillateur n'est pas d'une précision extraordinaire.

Une période d'une heure par exemple, ou même une journée, est suffisante. On peut utiliser un timer.

Vous trouvez cela manque de sérieux d'équiper un ESP32 d'un oscillateur qui dérive tellement qu'il faille réajuster l'heure aussi souvent ?
Votre montre est plus précise ?

Sachez-le : un ARDUINO dérive encore plus.

Avouez que grâce au NTP un circuit RTC (Real Time Clock) paraît absolument superflu.

9. Petits défauts

9.1. snprintf

L'utilisation de snprintf() impose quelques précautions quand à la taille du buffer d'émission. Dans cet exemple elle est de 1950 octets.
Si la taille était insuffisante, la réponse serait tronquée. Dans le code la taille est testée :

#define RESPONSE_SIZE     1950
char msg[RESPONSE_SIZE];
 

  size_t sz = strlen(msg);
  if (sz > RESPONSE_SIZE - 50) {
    Serial.println("WARNING");
    Serial.print("response size: "); Serial.println(sz);
    Serial.print("maximal size: "); Serial.println(RESPONSE_SIZE);
  }


Si le buffer est presque plein, ou tout à fait plein, un message est affiché sur le terminal :

WARNING
response size: 1912
maximal size: 1950


Les risques de plantage dû à un débordement du buffer sont donc inexistants.

9.2. String

L'utilisation de String est lente. Pourquoi ?
A chaque fois que l'on ajoute une chaîne de caractère à un objet String, cela provoque une ré-allocation de mémoire. Cela prend du temps.

9.3. AsyncWebServer

La classe AsyncWebServer n'est pas tout à fait aussi pratique que la classe WebServer dans la manipulation des arguments de la requête.
Dans cette petite application j'ai choisi de traiter les requêtes GET et POST dans la même fonction callback handleRoot (j'ai fait ce choix car cela m'arrange d'un point de vue factorisation du code) :

  int subscriberIndex = 1;
  if (request->hasArg("subscriber")) {                      // GET
    subscriberIndex = request->arg("subscriber").toInt();
  }
  if (request->hasParam("subscriber", true)) {         // POST
    subscriberIndex = request->getParam("subscriber", true)->value().toInt();
  }


Dans cet exemple, suivant qu'il s'agisse d'une requête GET ou POST, l'accès aux arguments diffère. Pourquoi les dévloppeurs de AsyncWebServer ont-ils fait ce choix ? mystère.

L'utilisation de la classe WebServer est plus simple :

  int subscriberIndex = 1;
  if (server->hasArg("subscriber")) {                      // GET ou POST
    subscriberIndex = server->arg("subscriber").toInt();
  }


Mais ce petit défaut de la classe AsyncWebServer est minime. Il suffit de connaître le problème.

10. Performances

Le temps d'envoi de la réponse est le suivant :
  • version snprintf() : 2ms
  • version String : 4ms
  • version SPIFFS : 5ms
La version SPIFFS n'est pas beaucoup plus pénalisante que la version String. Autant utiliser le SPIFFS et les templates.
La version snprintf() est la plus rapide.

11. Empreinte mémoire

En utilisant la classe WebServer la quantité de mémoire utilisée par l'exemple de l'article cité plus haut et la suivante :

Code: 754574 octets (57%) 
Variables: 45264 octets (13%)

La classe AsyncWebServer est un peu plus gourmande en code :

Code: 794582 octets (60%)
Variables: 43544 octets (13%)

Les deux codes sont assez comparables. On peut donc estimer que la différence est négligeable.

La version SPIFFS utilisant les templates est à peine moins économe en FLASH. Elle est même plus économe en mémoire RAM :

Code : 800790 octets (61%)
Variables: 41648 octets (12%)

11. Téléchargement

Cette version 1.0 est disponible ici :
https://bitbucket.org/henri_bachetti/webserver-form/src/v1.0/esp32-subscriber/

12. Liens utiles

La suite :
Serveur ESP32 : implémentation (2ème partie)
Serveur ESP32 : implémentation (3eme-partie)
Serveur ESP32 : implémentation (4eme-partie)
Serveur ESP32 : implémentation (5eme-partie)
Serveur ESP32 : tests automatisés
Serveur ESP32 : tests automatisés (2ème partie)
Serveur ESP32 : implémentation (6eme-partie)

13. Conclusion

La classe AsyncWebServer ouvre de belles perspectives pour l'écriture de pages formulaires évoluées.
Les templates apportent un gain de temps appréciable. J'irais même jusqu'à dire que sans elles cette classe AsyncWebServer a peu d'intérêt.
J'espère que ce petit divertissement vous aura plu.


Cordialement
Henri

3 commentaires:

  1. Bonjour avec arduino 2.0 la fonction SPIFFS ne fonctionne plus il faut créer 1 fichier HTML par exemple "index.html" et le mettre dans le même dossier que le fichier "INO" en le renommant "index.h" et en entête du fichier ino mettre << #include "index.h">> salutations

    RépondreSupprimer
    Réponses
    1. Pour l'instant il n'y a pas de support pour des extensions dans l'IDE 2.0, mais cela n'empêche pas d'utiliser l'IDE 1.8 pour charger des fichiers dans SPIFFS, tout en développant le code avec la 2.0.
      Quant à écrire les fichiers HTML dans un fichier .h à inclure dans le .ino, cela revient à faire un grand bond en arrière, chose que je n'envisage pas.

      Supprimer