ESP32 + serveur web : comment envoyer un fichier ?

un exemple de code avec un buffer de 3600 entiers et génération par chunks

je ne me suis pas cassé la tête pour les chunks, j'émets simplement un échantillon dans chaque chunk comme ça je ne m'ennuie pas à compter les octets qui rentrent dans le buffer; (Bien sûr, c'est plus long de générer le fichier comme cela car on n'optimise pas le buffer).

la subtilité vient du fait que sur l'ESP32 on a 2 processeurs et donc on ne peut pas se permettre que le callback web travaille sur le buffer qui sert aux acquisitions. Il y a donc une duplication protégée par un sémaphore (mutex) des 3600 valeurs et la fonction pour les chunks travaille sur la copie.

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>

const char*   ssid = "xxx";
const char*   password = "xxx";
AsyncWebServer server(80);
SemaphoreHandle_t mutex = NULL;


const char contenu[] PROGMEM = u8R"rawliteral(
  <html>
  <head>
    <meta charset="UTF-8">
    <title>Serveur de fichiers ESP32</title>
  </head>
  <body>
    <h1>ACQUISITION DE DONNEES</h1>
    <p><a href='/download'>Télécharger le fichier</a></p>
  </body>
  </html>
)rawliteral";


constexpr unsigned long nbMesures = 3600;
int mesures[nbMesures];
unsigned long indiceProchaineMesure = 0;
bool tableauPein = false;

int copieMesures[nbMesures];
unsigned long copieIndiceProchaineMesure = 0;
bool copieTableauPein = false;


void ajouterMesure(int valeur) {

  mesures[indiceProchaineMesure] = valeur;
  if (++indiceProchaineMesure >= nbMesures) {
    tableauPein = true;
    indiceProchaineMesure = 0;
  }
}

void acquisition() {
  static unsigned long chrono = -1000;

  if (millis() - chrono >= 10) {
    if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {

      // OK pour être en section critique
      // on rajoute un élément à 1Hz (ici un nombre aléatoire)
      ajouterMesure(random(4096));
      xSemaphoreGive(mutex);
      chrono = millis();
    }
  }
}

size_t chunkedCallback(uint8_t *buffer, unsigned long maxLen, unsigned long index) {
  // on ne se casse pas la tête on ajoute 1 seule ligne à la fois :)
  static unsigned long nbEchantillonsEnvoyes = 0;
  if (index == 0) {    // c'est le debut d'un chunk
    nbEchantillonsEnvoyes = 0;
    snprintf((char*) buffer, maxLen, "Le tableau contient %lu échantillons\r\n", tableauPein ? nbMesures : copieIndiceProchaineMesure);
    return strlen((const char*)buffer);
  }
  

  if (copieTableauPein) {
    // on doit emettre toutes les valeurs, la plus vieille est à indiceProchaineMesure
    if (nbEchantillonsEnvoyes >= nbMesures) return 0ul; // on a fini
  } else {
    // on emet de 0 à indiceProchaineMesure (non compris)
    if (nbEchantillonsEnvoyes >= copieIndiceProchaineMesure) return 0ul; // on a fini
  }

  // sinon on emet une ligne
  if (copieTableauPein) {
    snprintf((char*) buffer, maxLen, "%lu\t%d\r\n", nbEchantillonsEnvoyes + 1, copieMesures[(copieIndiceProchaineMesure + nbEchantillonsEnvoyes) % nbMesures]);
  } else {
    snprintf((char*) buffer, maxLen, "%lu\t%d\r\n", nbEchantillonsEnvoyes + 1, copieMesures[nbEchantillonsEnvoyes]);
  }
  nbEchantillonsEnvoyes++;
  return strlen((const char*)buffer);
}

void setup() {
  mutex = xSemaphoreCreateMutex();
  Serial.begin(115200);

  // Se connecter au Wi-Fi
  WiFi.begin(ssid, password);
  Serial.print("Connexion au Wi-Fi en cours...");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.write('.');
    delay(500);
  }

  Serial.println(" => Connecté");
  Serial.print("Joindre le serveur web sur http://");
  Serial.println(WiFi.localIP());

  server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) {
    request->send_P(200, "text/html", contenu);
  });

  server.on("/download", HTTP_GET, [](AsyncWebServerRequest * request) {
    // on attend le mutex et on fait une copie
    while (xSemaphoreTake(mutex, portMAX_DELAY) != pdTRUE);
    memcpy(copieMesures, mesures, sizeof mesures);
    copieIndiceProchaineMesure = indiceProchaineMesure;
    copieTableauPein = tableauPein;
    xSemaphoreGive(mutex);

    AsyncWebServerResponse *response = request->beginChunkedResponse("text/plain", chunkedCallback);
    response->addHeader("Content-Disposition", "attachment; filename=fichier.txt");
    request->send(response);

  });

  // Démarrer le serveur
  server.begin();
}

void loop() {
  acquisition();
}

Si vous voulez améliorer les performances, il faudrait mettre un maximum d'enregistrements dans le buffer pour limiter le nombre de chunks ➜ laissé au lecteur :slight_smile:

Comme il voudrait un affichage en continue, il serait peut être intéressant de plutôt de passer par la websocket, plutôt que par du pooling.

Je précise alors un peu la démarche : je veux clairement séparer l'acquisition de l'exploitation. Cela correspond à la démarche didactique classique des manips scolaires, on ne fait pas les deux en même temps, c'est le meilleurs moyen de perdre tout le monde.

Donc deux temps séparés et même deux pages séparées : index.html où il y a l'affichage en continu des valeurs (ça permet de voir ce qui se passe et décider du moment où l'on clique sur le démarrage de l'acquisition. C'est pour toute cette partie que j’utilise la websocket pour la communication dans les deux sens :

  • serveur --> client : l'ESP32 pousse les valeurs vers le JS qui rafraîchit la valeur affichée toutes les secondes mais aussi le nombre de mesures enregistrées. Je pourrais aussi rajouter la durée de mesure etc.
  • client --> serveur : l'utilisateur clique sur le bouton pour démarrer et arrêter l'acquisition.

Dans les grandes lignes toute cette partie là fonctionne à quelques détails cosmétiques près.

L'autre partie c'est l'exploitation, donc sur une autre page (donnees.html) et c'est cette partie que je veux travailler maintenant. L’utilisateur arrive sur la page et il a un lien pour télécharger les données (et idéalement la courbe qui s'affiche, ça serait trop la classe). Il faut que je bloque l'acquisition quand l'utilisateur accède à la page ou qu'elle ne soit pas accessible lors de l'acquisition. Sinon, ça n'a pas vraiment de sens "pédagogique" et je suppose que ça complique de toute façon le code.

Pour cette partie là, je n'ai pas besoin d'affichage en temps (plus ou moins) réel.

En fait c'est toujours pareil il y a plusieurs façon de faire la même chose :slight_smile:

Effectivement si on prends ce que du décris au pied de la lettre, le plus simple c'est de faire un point de terminaison qui renvois tes données dans un tableau json assez élémentaire [1.56, 1.58, 1.55, 1.51, 1.52, 1.53, 1.54], si tu as une seul courbe.

Comme normalement l'acquisition est fini, tu pourrais te contenter de lire dans ton tableau de valeur.

Comme tu ne sais pas vraiment ce qui te reste de mémoire pour faire ta chaine à renvoyer, l'idée de @J-M-L de passer par le transfert par fragment est intéressante, sauf(et encore :slight_smile: ) si AsyncWebServerRequest permettrais d'envoyer les données par bloque, mais il ne me semble pas.

il ne reste plus qu'a :face_with_hand_over_mouth:

Un très grand merci :pray:, ça marche et je commence à comprendre le principe, je devrais arriver à adapter au code existant.

Je pense qu'on peut effectivement booster un peu les choses, même si c'est tout à fait utilisable tel que, le transfert du fichier se fait à 3 - 4 ko/s

Une (dernière ?) question : si je pars de l'idée de dissocier (et interdire) l'acquisition et l'exploitation, je peux me passer de la copie des données et du mutex. Je devrais pouvoir utiliser les données originales, non ?

Tu remarqueras que comme tu veux faire du "statistique", ta page HTML donnees.html, peut contenir directement le tableau de valeur et que tu n'a pas besoin de le faire en deux étapes.

« statique » plutôt non ?

C’est à dire ?

oui, j'ai cliqué un peu vite sur le correcteur.

ba, il me semble que tu était partis de charger les données, a afficher via une requête AJAX ou un lien.
Mais peut être que ce n'est pas le cas.
Donc si tu veux afficher les données dans un graphique charJS, dans la page HTML tu va insérer les tag HTML et le javascript du composant chartJS.

Effectivement il faudrait idéalement éviter de transmettre deux fois les mêmes données (csv et chart.js) mais elles ne sont pas formatées de la même manière alors je ne vois pas trop comment faire

Bonjour,

j'ai été un peu présomptueux :

donc quelques questions quand même :wink:

  1. à quoi correspond maxLen ? J'ai ajouté un Serial.println(maxLen) de débug pour voir et je ne comprends pas trop à quoi correspond la valeur (5000 et quelques) J'imagine que c'est la taille du buffer mais comment est-elle définie et pourquoi cette valeur ?

  2. index est la position, en octet, dans l'ensemble des données, ce n'est pas le numéro de l'enregistrement ou de ligne. C'est bien ça ?

Donc

if (index == 0) { // première ligne avant les données

doit se comprendre « tout premier octet des données (et donc juste avant le début du premier enregistrement) »
index ne compte pas les lignes mais les caractères du buffer. C'est bien ça ?

C'est un buffer fourni pas la bibliothèque réseau sous jacente qui sert à communiquer avec le client. Sa taille dépend de votre architecture (j'ai vu entre 1500 et 5000 en effet suivant les cas). Il n'est même pas forcément constant lors des appels consécutifs.

Comme je le disais, ne mettre qu'une dizaine de caractères dans un buffer de 5000 octets c'est pas génial - idéalement il faut le remplir au max.

index est le nombre d'octets déjà envoyés dans les réponses précédentes. Quand il vaut 0 c'est ce que c'est la première fois que le callback qui fournit les "chunks" a été appelé.

Merci, c’est bien ce que j'avais compris (et je suppose que ça accélérera fortement le truc) et pourquoi je cherchais à comprendre comment le remplir au mieux.

OK merci !

(je reviens plus tard pour les autres questions que je ne me suis pas encore posées, en attendant ma petite dame me fait remarquer que je n'ai pas fini les découpes de carrelage)

Dans l'exemple que je donnais, la loop crée un nouvel enregistrement toutes les 10ms (même si le commentaire dit 1Hz, j'ai changé 1000 et 10 dans le test pour que le fichier se remplisse plus vite).

Comme son nom l'indique ESPAsyncWebServer est asynchrone et les réponses envoyées au client web sont traitées sur le second core de l'ESP, ce qui fait que le tableau et les indices continuent d'être modifiés pendant que les chunks sont générés. Cela bien sûr conduirait à des erreurs sur le contenu envoyé si on n'y prête pas garde.

C'est pour cela que j'avais le mutex : lors d'une demande de génération de fichier, j'acquiers le jeton qui me garantit que le tableau ne sera pas touché pendant ce temps là et je fais la copie des éléments puis je relâche le jeton et traite l'exportation depuis la copie. Ce qui fait que pendant ce temps là l'enregistrement peut continuer toutes les 10ms.

Si vous êtes 100% sûr qu'il n'y a plus d'acquisition pendant l'exportation alors le mutex et la copie deviennent inutiles, vous pouvez travailler directement sur le tableau d'origine.

il faut gérer les priorités !

Tu as vraiment besoin de ce CSV ?
Si oui tu as deux cas qui se valent à mon avis, sois tu fais un point de terminaison avec un paramètre qui indique le type de retour, tu pourrais faire deux poins de terminaison, mais c'est moi beau :slight_smile:
Tu utilises les données du chartJS qui serait fournis avec la page HTML, pour remettre en CVS, via du script javascript.
comme tu véhicules des données assez simple, récupérer le tableau de donnée, pour générer un texte avec des valeurs séparée par des retour chariot, devrait être assez simple.

Bonjour,

mes devoirs du week-end : ça compile mais ça plante :frowning:

J'ai repris le code de @J-M-L avec une modification de la génération du tableau :

  • un peu moins rapide pour avoir le temps de voir ce qui se passe avant le remplissage complet
  • une fois le tableau plein, je ne repasse plus dessus. La vraie-fausse acquisition s'arrête.

Ce que j'ai essayé de faire : remplir le buffer au mieux (jusqu'à maxLen) avec les lignes les unes après les autres... Mais j'ai un plantage à la fin du remplissage

CORRUPT HEAP: Bad tail at 0x3fcb4d9c. Expected 0xbaad5678 got 0xbaad0a0d
06:24:24.375 -> 
06:24:24.375 -> assert failed: multi_heap_free multi_heap_poisoning.c:259 (head != NULL)
06:24:24.375 -> Core  0 register dump:

Je mets le code avec mes commentaires et les affichages de debug...

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>

const char*   ssid = "maboxàmoi";
const char*   password = "j'aipenséàl'enlever";

AsyncWebServer server(80);
SemaphoreHandle_t mutex = NULL;


const char contenu[] PROGMEM = u8R"rawliteral(
  <html>
  <head>
    <meta charset="UTF-8">
    <title>Serveur de fichiers ESP32</title>
  </head>
  <body>
    <h1>ACQUISITION DE DONNEES</h1>
    <p><a href='/download'>Télécharger le fichier</a></p>
  </body>
  </html>
)rawliteral";


constexpr unsigned long nbMaxMesures = 3600;
int mesures[nbMaxMesures];
unsigned long indiceProchaineMesure = 0;
bool TableauPlein = false;

int copieMesures[nbMaxMesures];
unsigned long copieIndiceProchaineMesure = 0;
bool copieTableauPlein = false;



void acquisition() {
  static unsigned long chrono = -1000; // unsigned avec -1000 ??

  if ((millis() - chrono >= 100) & (indiceProchaineMesure < nbMaxMesures)) { // ne pas refaire le tour du tableau
    if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {

      // OK pour être en section critique
      // on rajoute un élément à 10 Hz en 6 minutes c'est plein
      //mesures[indiceProchaineMesure] = random(4096);
      mesures[indiceProchaineMesure] = millis()/100; // pas aléatoire pour pouvoir les compter et vérifier

      indiceProchaineMesure++;
      xSemaphoreGive(mutex);
      chrono = millis();
    }
  }
}

size_t chunkedCallback(uint8_t *buffer, unsigned long maxLen, unsigned long index) {
  //La fin de l'envoi est marquée par return 0ul;
  // sinon on renvoie la longueur du buffer : return strlen((const char*)buffer);

Serial.print("maxLen = ");
Serial.println(maxLen);

  char ligne[50];
  unsigned long longueurTotale = 0;
  static unsigned long nbEchantillonsEnvoyes = 0;

  if (index == 0) {    // première ligne avant les données
Serial.println("--- 1ere ligne ");
    nbEchantillonsEnvoyes = 0;
    snprintf((char*) buffer, maxLen, "Le tableau contient %lu échantillon%s\r\n", copieIndiceProchaineMesure, (copieIndiceProchaineMesure > 1) ? "s" : "");
    return strlen((const char*)buffer);
Serial.println("OK"); // jamais lue : normal
  } 
  
  for (int i = nbEchantillonsEnvoyes ; i < nbMaxMesures ; i++) {

Serial.print("----------- ligne ");
Serial.print(i);
Serial.print("  ");

    snprintf((char*) ligne, maxLen, "%lu;%d\r\n", i + 1, copieMesures[i]);
Serial.println(ligne);

    longueurTotale = longueurTotale + strlen((const char*)ligne);

Serial.print(longueurTotale);
Serial.print("/");
Serial.println(strlen((const char*)buffer));
    
    if (longueurTotale >= maxLen - 2) {   // le -2 c'était pour essayer de couper avant le plantage, mais pareil avec -1 ou rien... 
Serial.print("mouchard 1 -------------------------------------------------------- 1");
      return strlen((const char*)buffer);   // ça plante ici !
Serial.print("mouchard 2 -------------------------------------------------------- 2");
      break;
    } else {
      strcat((char*) buffer, ligne);
    }
  }
  
  return strlen((const char*)buffer);
}

void setup() {
  mutex = xSemaphoreCreateMutex();
  Serial.begin(115200);

  // Se connecter au Wi-Fi
  WiFi.begin(ssid, password);
  Serial.print("Connexion au Wi-Fi en cours...");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.write('.');
    delay(500);
  }

  Serial.println(" => Connecté");
  Serial.print("Joindre le serveur web sur http://");
  Serial.println(WiFi.localIP());

  server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) {
    request->send_P(200, "text/html", contenu);
  });

  server.on("/download", HTTP_GET, [](AsyncWebServerRequest * request) {
    // copie des données pour l'asynchronisme
    while (xSemaphoreTake(mutex, portMAX_DELAY) != pdTRUE);
    memcpy(copieMesures, mesures, sizeof mesures);
    copieIndiceProchaineMesure = indiceProchaineMesure;
    copieTableauPlein = TableauPlein;
    
    Serial.println("download");

    xSemaphoreGive(mutex);

    AsyncWebServerResponse *response = request->beginChunkedResponse("text/plain", chunkedCallback); // mime text/csv plutôt
    response->addHeader("Content-Disposition", "attachment; filename=fichier.txt");
    request->send(response);

  });

  // Démarrer le serveur
  server.begin();
}

void loop() {
  acquisition();
}

Est-ce que qq'un voit mon erreur ?

Merci d'avance

vous faites des strcat() mais pour le premier vous n'avez pas vérifié que le buffer était vide.

rajoutez au début (avant la boucle for) un

*buffer = '\0'; // on commence avec un buffer vide

pour être sûr que vous commenciez à ajouter au tout début du buffer.

il faut aussi mettre à jour nbEchantillonsEnvoyes en tenant compte du nombre d'échantillons que vous avez pu ajouter

Merci, je vais rajouter ça...

OK mais pris d'un gros doute, j'ai vérifé que peux rajouter nbEchantillonsEnvoyes++ dans la boucle sans que ça perturbe... Encore un truc appris aujourd'hui !

J'essayerai alors ceci :

for (int i = nbEchantillonsEnvoyes ; i < nbMaxMesures ; i++) {
                     ......blabla
                     .....

   nbEchantillonsEnvoyes++;
}

Verdict ce soir, là je n'ai pas le matos pour faire le test

Encore une question, dans le code il y a

static unsigned long chrono = -1000;

Comment ça se fait qu'on puisse initialiser un unsigned long avec une valeur négative ? Où alors ça correspond à 4 294 966 295 et il faut attendre d'avoir ajouté 1000 pour retomber sur 0 ?

En tout cas un grand merci pour l'aide !

Même pas besoin d'attendre, j'ai un ESP32-C3 supplémentaire dans mon sac :laughing: :heart_eyes:

Ça marche presque parfaitement (reste à tronquer le fichier s'il est incomplet) et surtout c'est super rapide ! Sans chronométrer mais la manipulation du navigateur est largement plus longue que le traitement du fichier...

Merci beaucoup !

Oui c'est ça, la valeur binaire, ne prends pas en compte l'interprétation du dernier bit, indiquant le signe.
C'est comme pour les nombres à virgules flottants, sauf que l'a cela n'a plus de sens :slight_smile:

En général on fait ça, pour éviter d'avoir une variable pour savoir si le compteur est lancé.
du coup "(millis() - chrono >= 100" qui avec chrono commençant à 0 serait rapidement vrai.

Non tu ne rajoute rien à chrono, tu l'initialise avec la valeur retourner par "millis".
"millis" retournant le nombre de milliseconde depuis le démarrage du µC, il faudra un certains temps pour que celui-ci soit supérieur de 100 à 4 294 966295.
Après c'est moche, mais très pratique et parlant, car la tu sais que tu as -1000 par rapport au nombre maximal codé sur ta plateforme, surtout qu'entre Uno et ESP32, je crois que c'est différent.
et donc que 1000 est supérieur à 100 :slight_smile:

Je ne sais pas si je suis claire ?

attention, il ne faut l'incrémenter que si vous avez pu faire le strcat() sinon c'est que ça ne rentrait pas et ce sera pour la prochaine fois.

L'objectif c'est que lorsque je fais ce test pour savoir si c'est le moment de capturer un échantillon

je commence tout de suite au lancement de l'arduino (quand millis vaut 0) et je n'attends pas puisque automatiquement plus de 100ms se seront écoulées depuis le dernier chrono (puisqu'il a été établi 1000ms avant 0)

Sur l'usage d'un nombre négatif pour l'initialisation, c'est autorisé par la norme (donc ce n'est pas moche :slight_smile: ).

Quand vous affectez à une variable non signée un entier signé, le compilateur stocke la représentation binaire exactement telle qu'elle est c'est à dire 0xFFFFFC18, et quand on utilise la variable bien sûr c'est le nombre non signé correspondant qui est pris en compte soit 4294966296