NodeMCU – odbieranie danych przez protokół HTTP

W ostatnim artykule z serii dowiedzieliśmy się w jaki sposób odebrać dane konfiguracyjne za pomocą protokołu UDP. Dziś zaprezentuję alternatywną wersję konfiguracji – tym razem przez specjalną stronę hostowaną na ESP na której użytkownik może wprowadzić odpowiednie dane.

Na początku chciałbym zaznaczyć, że konfigurator, który stworzymy w tym poradniku będzie minimalnym minimum. Po lekturze artykułu każdy zainteresowany będzie mógł go sobie upiększyć dodając jakieś regułki CSS lub zwiększyć jego możliwości konfiguracyjne.

Na początek zobaczmy w oficjalnym repozytorium twórców bibliotek Arduino dla ESP8266 jak postawić prosty serwer: https://github.com/esp8266/Arduino/blob/master/doc/esp8266wifi/server-examples.rst

Wgrajmy ten przykład, otwórzmy monitor portu szeregowego i wejdźmy na adres IP naszego NodeMCU.

Jak widać każde żądanie rozpoczyna się od nazwy metody HTTP (w tym przypadku GET), lokalizacji, jaką klient odpytuje (tutaj '/’) oraz wersji protokołu HTTP (HTTP/1.1). Ta konkretna linijka na pewno przyda nam się później, gdyż będziemy musieli w różny sposób obsłużyć metodę GET oraz POST.

Żeby móc odebrać dane POST musimy nieco przerobić gotowy przykład z

while (client.connected())
{
  // read line by line what the client (web browser) is requesting
  if (client.available())
  {
    String line = client.readStringUntil('\r');
    Serial.print(line);
    // wait for end of client's request, that is marked with an empty line
    if (line.length() == 1 && line[0] == '\n')
    {
      client.println(prepareHtmlPage());
      break;
    }
  }
}

na

String line;
while(client.connected()) {
 for(int x = 0; client.available(); x++) {
 line = client.readStringUntil('\r');
 line.trim();
 Serial.println(line);
 }
}

client.println(prepareHtmlPage());
break;

Po wgraniu tak przerobionego szkicu możemy puścić testowe zapytanie np. Postmanem.

Jak widać w ostatniej linijce mojego posta znajdują się parametry, które przesłałem. Trzeba je będzie później jakoś rozkodować.

Kilka ważnych metod

Przed pisaniem klasy przyjrzyjmy się kilku metodom, które będą nam potrzebne:

  • server.available() – w przypadku oczekującego zapytania zwraca instancję klienta
  • client.connected() – zwraca prawdę, dopóki będzie istniało połączenie pomiędzy klientem, a serwerem
  • client.available() – zwraca prawdę, jeżeli w buforze klienta dostępne są jakieś dane (np. nagłówki HTTP)
  • client.readStringUntil() – odczytuje dane z bufora aż do napotkania konkretnego znaku

Nagłówek klasy

Tak jak i w poprzednich artykułach zaczniemy od ogólnego obrazu klasy, którą będziemy tworzyć:

WebConfig.h

#ifndef WEBCONFIG_H
#define WEBCONFIG_H

#include <ESP8266WiFi.h>

class WebConfig {
    private:
      WiFiServer server = WiFiServer(80);
      String prepareHtmlPage(String htmlContent);
      String prepareNetworksForm();
      String fetchNetworks();
      bool decodeAndSaveData(String data);
      void urlDecode(char* data);
    public:
      WebConfig();
      void listen();
};

#endif /* WEBCONFIG_H */

Z publicznie dostępnych metod mamy tutaj konstruktor oraz metodę listen(), która posłuży nam do nasłuchiwania żądań HTTP w odpowiednim momencie. Pozostałe metody są prywatne i służą do działań wykonywanych „pod maską”. Pierwsze 3 z nich (prepareHtmlPage, prepareNetworksForm, fetchNetworks) służą do generowania odpowiedniego kodu HTML.  decodeAndSaveData jest odpowiedzialne za odczytanie danych przesłanych POSTem i zapisanie ich w pamięci. urlDecode będzie metodą pomocniczą do rozkodowania znaków specjalnych.

Implementacja

Zajmijmy się teraz implementacją w pliku WebConfig.cpp. Na początek zróbmy najłatwiejsze, czyli konstruktor.

WebConfig::WebConfig() {
  server.begin();
}

Wystarczy, że wrzucimy do niego linijkę server.begin() z przykładu, który omawialiśmy.

Zaimplementujmy teraz funkcje odpowiedzialen za generowanie HTML:

String WebConfig::prepareHtmlPage(String htmlContent) {
  String htmlPage =
     String("HTTP/1.1 200 OK\r\n") +
            "Content-Type: text/html; charset=utf-8\r\n" +
            "Connection: close\r\n" +
            "\r\n" +
            "<!DOCTYPE HTML>" +
            "<html>" +
            htmlContent +
            "</html>" +
            "\r\n";
  return htmlPage;
}

String WebConfig::prepareNetworksForm() {
  String content =
    String("<p>Wybierz z poniższej listy nazwę sieci WiFi, do której urządzenie ma się podłączyć.</p>") +
          "<form action='.' method='POST'>" +
          "  <label for='ssid'><p>Nazwa sieci</p></label>" +
          "  <select id='ssid' name='ssid'>" +
                  fetchNetworks() +
          "  </select>" +
          "  <label for='password'><p>Hasło (opcjonalnie)</p></label>" +
          "  <p><input id='password' type='password' name='password'/></p>" +
          "  <input type='submit' value='Zapisz ustawienia'/>" +
          "</form>";
  return content;
}

Jak widać odwołujemy się tutaj pomiędzy linijkami HTML do metody o nazwie fetchNetworks(). Wygląda ona podobnie do tej, którą implementowaliśmy przy okazji UDP:

String WebConfig::fetchNetworks() {
  int networksCount = WiFi.scanNetworks();
  String result = "";
  for(int i = 0; i < networksCount; i++) {
    int32_t signalStrength = WiFi.RSSI(i);
    int32_t signalQuality;

    if(signalStrength <= -100) {
      signalQuality = 0;
    }
    else if(signalStrength >= -50) {
      signalQuality = 100;
    }
    else {
      signalQuality = 2 * (signalStrength + 100);
    }

    String encryptionType;
    switch(WiFi.encryptionType(i)) {
      case ENC_TYPE_WEP:
        encryptionType = "WEP";
        break;
      case ENC_TYPE_TKIP:
        encryptionType = "WPA/PSK";
        break;
      case ENC_TYPE_CCMP:
        encryptionType = "WPA2/PSK";
        break;
      case ENC_TYPE_AUTO:
        encryptionType = "WPA/WPA2/PSK";
        break;
      case ENC_TYPE_NONE:
        encryptionType = "Sieć otwarta";
        break;
      default:
        encryptionType = "?";
    }
    
    result += "<option value='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) + " (" + encryptionType + ", " + signalQuality + "%)</option>";
  }
  return result;
}

Skoro generowanie HTML mamy już za sobą to przejdźmy do metody oczekującej na nadejście zapytania:

void WebConfig::listen() {
  WiFiClient client = server.available();
  if(client) {
    bool isRequestProcessed = false;
    String line;
    int responseType = -1;
    while(client.connected()) {
      for(int x = 0; client.available(); x++) {
        isRequestProcessed = true;
        line = client.readStringUntil('\r');
        line.trim();
        if(x == 0) {
          if(line.startsWith("GET")) {
            responseType = 0;
          } else if(line.startsWith("POST")) {
            responseType = 1;
          }
        }
      }
      
      if(isRequestProcessed) {
        switch(responseType) {
          case 0:
            client.println(prepareHtmlPage(prepareNetworksForm()));
            break;
          case 1:
            if(decodeAndSaveData(line)) {
              client.println(prepareHtmlPage("Dane konfiguracyjne zostaly zapisane"));
            } else {
              client.println(prepareHtmlPage("Wystąpił błąd podczas zapisywania danych"));
            }
            break;
          default:
            client.println(prepareHtmlPage("Nie rozpoznano polecenia"));
        }
        break;
      }
    }
    delay(1);
    client.stop();
  }
}

Jest to mocno przerobiony kod z przykładu z githuba. Trzeba było wprowadzić kilka zmian, aby można było odebrać dane POST oraz rozróżnić typ zapytania (GET / POST). Następnie w zależności od przesłanych danych w zapytaniu ESP musi zwrócić konkretną odpowiedź. W przypadku zapytania GET (stosujemy tutaj bardzo duże uproszczenie – każdy GET będzie zwracał ten sam wynik, podobnie jak każdy POST) trzeba zwrócić formularz z dostępnymi sieciami (funkcja prepareNetworksForm()), a w przypadku POSTa zapisujemy dane, które otrzymaliśmy.

Aby zapisać dane zastosujemy funkcję decodeAndSaveData():

bool WebConfig::decodeAndSaveData(String data) {
  String ssid = "", pass = "";
  data.replace("+", " ");
  String field = "";
  for(int x = 0; x < data.length(); x++) {
    if(data[x] == '&' || x == data.length() - 1) {
      if(data[x] != '&') {
        field += data[x];
      }
      if(field.startsWith("ssid=")) {
        ssid = field.substring(5);
      } else if(field.startsWith("password=")) {
        pass = field.substring(9);
      }
      field = "";
    } else {
      field += data[x];
    }
  }

  if(ssid == "") {
    return false;
  }

  char ssidArr[32];
  char passwordArr[32];
  ssid.toCharArray(ssidArr, 32);
  pass.toCharArray(passwordArr, 32);

  urlDecode(ssidArr);
  urlDecode(passwordArr);

  ConfigManager configManager = ConfigManager::getInstance();
  configManager.load();
  configManager.setSSID(ssidArr);
  configManager.setPassword(passwordArr);

  return configManager.save();
}

Tutaj na samym początku zamieniamy wszystkie + na spacje (tak są one kodowane), a następnie rozbieramy ten długi ciąg na mniejsze podciągi i analizujemy ich treść. Jeżeli znajdziemy tam wartość dla klucza ssid lub password to zapisujemy je do odpowiednich zmiennych.

Po przeanalizowaniu całego ciągu sprawdzamy, czy znaleźliśmy interesujące nas wartości i jeżeli tak to trzeba je jeszcze rozkodować i zapisać. Jeżeli chodzi o dekodowanie tych stringów to posłużymy się bardzo dobrą implementacją stąd.

void WebConfig::urlDecode(char* data) {
  // Create two pointers that point to the start of the data
  char *leader = data;
  char *follower = leader;
  
  // While we're not at the end of the string (current character not NULL)
  while (*leader) {
      // Check to see if the current character is a %
      if (*leader == '%') {
  
          // Grab the next two characters and move leader forwards
          leader++;
          char high = *leader;
          leader++;
          char low = *leader;
  
          // Convert ASCII 0-9A-F to a value 0-15
          if (high > 0x39) high -= 7;
          high &= 0x0f;
  
          // Same again for the low byte:
          if (low > 0x39) low -= 7;
          low &= 0x0f;
  
          // Combine the two into a single byte and store in follower:
          *follower = (high << 4) | low;
      } else {
          // All other characters copy verbatim
          *follower = *leader;
      }
  
      // Move both pointers to the next character:
      leader++;
      follower++;
  }
  // Terminate the new string with a NULL character to trim it off
  *follower = 0;
}

Skoro mamy już całą klasę za sobą to wystarczy, że do głównego pliku programu dopiszemy klika linijek:

[...]
#include "WebConfig.h"

[...]
WebConfig webConfig;

void setup() {
  [...]

    while(true) {
      udpMessengerServie.listen();
      webConfig.listen();
    }
  
  [...]
}

Test

Po wgraniu szkicu do procesora i wejściu na adres 192.168.1.1 w sieci „Nettigo Config” powinniśmy ujrzeć podobną stronę do tej:

A po zapisaniu ustawień zobaczymy taki oto komunikat:

Teraz wystarczy, że uruchomimy ESP ponownie i powinno ono już połączyć się do naszej docelowej sieci :)