Arduino Webserver: POST Daten empfangen
Wenige Beispiele findet man zum Thema: "Empfang von Daten die mittels POST gesendet wurden", da die meisten Arduino Beispiele auf GET (also dem Anhängen von Parametern mittels ? an den URI) behandeln. GET ist aber eigentlich die falsche Methode zur Übertragung von Daten. Eigentlich erfolgt das Übertragen von Daten mittels POST.
Nach einer kurzen Einleitung zu den HTTP Methoden POST und GET, analysieren wir das IDE Beispiel, erstellen einen hoffentlich besseren Sketch und statten diesen noch mit ein paar Besonderheiten aus. Für das durcharbeiten wirst du etwa 45 Minuten benötigen, dafür erhältst du aber einen wirklich sauber funktionierenden Arduino webserver.
Eins noch vorausgeschickt: Der Webserver funktioniert in dieser Form gut für Arduino UNO/Nano/Pro Mini und Arduino MEGA. Für den ESP8266/ESP32 gibt es entsprechende Libraries und man muss das nicht alles zu Fuß machen.
Unterschied POST vs GET
Wenn du diese Seite gefunden hast, sind dir die Unterschiede zwischen POST und GET wahrscheinlich bekannt, daher nur das Wesentliche kurz zusammengefasst.
Bei GET werden Parameter nach einem ? direkt nach dem aufzurufenden URI angehängt (und das sieht auch der Anwender in seinem Browser-Adressfeld)
Der http-header request beginnt beispielsweise mit
GET /form.php?variable=1&nochein=Text HTTP/1.1 HOST: 172.18.67.93 Content-Length: 42 <!doctype html> <html>
Bei POST werden die übermittelten Daten im HTTP-Body übertragen. HTTP-Header ist vom HTTP-Body durch eine Leerzeile getrennt.
POST /form.php HTTP/1.1 HOST: 172.18.67.93 Content-Length: 24 variable=1&nochein=Text
Die Daten im HTTP-Body sind gleich aufgebaut, nur das ? entfällt bei POST.
Für den Sketch bedeutet dies, du musst nicht nur die erste Zeile lesen, sondern den ganzen HTTP-Header durchgehen. Du musst erkennen wann eine Leerzeile kommt und erst danach die folgenden Daten interpretieren.
Das Beispiel Ethernet | Webserver
... und warum es suboptimal ist
Das in der IDE mitgelieferte Beispiel Ethernet | Webserver ist nur eine Minimal-Variante eines Webservers. Jede Methode, jede Eingabe wird akzeptiert und führt zur Ausgabe einer Webseite. Viele Arduino Webserver Beispiele im Internet verwenden daher auch (fälschlicherweise) die GET Methode zur Datenübernahme.
Weiters empfehle ich, z.B. im Firefox unter Tools | Web Developer | Netzwerk den verursachten Traffic anzusehen. Firefox (und andere Browser), fordern nicht nur die Seite aus der Adresszeile an, sondern auch ein favicon.ico - eine kleine Grafik die - wenn vorhanden - auf der Seitenlasche dargestellt wird.
Der originale Webserver interpretiert die Anforderung nach dem favicon.ico als weitere Seitenanforderung und schickt somit jedes mal die Seite zweimal an den Browser. Die hereinkommenden Requests sieht man auch im Serial Monitor:
Ziele für unseren Webserver mit POST Funktionalität
Für unseren eigenen POST fähigen Webserver stecken wir uns folgende Ziele
- Daten lesen die mittels POST übermittelt wurden
- Response abhängig vom Client-Request
- falsche Seiten sollen zu einem HTTP 404 führen
- Das favicon.ico soll behandelt werden
- Keine Verwendung von Arduino-Strings
- den loop() sauber halten
Umsetzung eines Arduino Webservers mit POST
Zum Versenden von POST Daten verwenden wir gleich den Arduino selbst und hosten darauf ein Eingabeformular das Daten an den Arduino mittels POST sendet. Theoretisch könnte aber auch ein anderer Client unserem Arduino Webserver Daten mittels POST übermitteln.
Zur konkreten Umsetzung machen wir daher einen einfachen Arduino Webserver, mit einem POST Formular, das einige LEDs mittels Buttons ein- und ausschalten kann.
Das Setup() kann man im wesentlich vom IDE Beispiel übernehmen. Der loop() beinhaltet nur den laufenden Funktionsaufruf der für unseren webserver verantwortlich ist:
#include <SPI.h> #include <Ethernet.h> byte mac[] { 0x52, 0x64, 0x75, 0x69, 0x6E, 0x6F }; IPAddress ip( 172, 18, 67, 93 ); EthernetServer server(80); // Port 80 is HTTP port const byte ledPin[] {2, 3, 4, 5, 6, 7, 8, 9}; void setup() { Serial.begin(115200); Serial.println(F("\nsimple webserver")); for (auto &pin : ledPin) { pinMode(pin, OUTPUT); // Set the digital pins ready to write to } Ethernet.begin(mac, ip); // Start the Ethernet shield: printHardwareInfo(); server.begin(); // Start the webserver: Serial.print(F("server available at http://")); Serial.println(Ethernet.localIP()); } void loop() { checkForClient(); }
Das ist eigentlich schon alles. MAC und IP Adresse festlegen, ein Array mit unseren GPIOs für die LEDs, das Festlegen derselbigen als Output, und das Netzwerk-Shield starten.
Nun legen wir in der IDE einen neuen Tab mit dem Namen "server" an. In diesen Tab kommt alles aus dem loop() des IDE Beispiels in unsere neue Funktion
void checkForClient(); { // alles mal aus dem Beispiel übernehmen }
Testweise kann man das kompilieren und hochladen - soll noch immer alles funktionieren.
Unsere eigene HTML Seite am Arduino UNO/MEGA
Unsere eigene HTML Seite - ganz schnörkellos - geben wir in eine eigene Funktion. Vor dem HTTP-Body brauchen wir noch den HTTP-Header mit Returncode 200. Da wir keine Content-Length senden, verwenden wir HTTP/1.0. Nach der Leerzeile kommt der HTTP Body mit dem HTML. Es ist eine schlichte HTML5 Seite mit einem Eingabeformular und zwei Buttons für jeden PIN aus dem ledPIN Array.
void sendPage(EthernetClient &client) { // Serial.println("[server] 200 response send"); client.println( ("HTTP/1.0 200 OK\r\n" "Content-Type: text/html\r\n" "\r\n" // a blank line to split HTTP header and HTTP body "<!doctype html>\n" // the HTTP Page itself "<html lang='en'>\n" "<head>\n" "<meta charset='utf-8'>\n" "<meta name='viewport' content='width=device-width'>\n" "<title>Webserver as pin controller</title>\n" "</head>\n" "<body style='font-family:Helvetica, sans-serif'>\n" "<h1>Webserver as pin controller</h1>\n" "<p>Buttons turn pins on or off</p>\n" "<form method='post' action='/' name='pins'>")); for (auto &pin : ledPin) { //client.print( ("<p>")); client.print(pin); client.print( (" <input name='pinD")); client.print(pin); client.print( ("' type='submit' value='On'>")); client.print( ("<input name='pinD")); client.print(pin); client.print( ("' type='submit' value='Off'>")); if (digitalRead(pin)) client.print(F(" active")); client.print( ( "<br>\n")); } client.print ( ("</form>\n")); client.print ( ("</body>\n</html>")); client.stop(); }
(Der body style wäre nicht notwendig, aber ich finde Schriften ohne Serifen im browser besser lesbar).
Aussehen wird das ganze dann ca so:
Zum Auslesen des HTTP-Headers und Bodies setzen wir eine state machine ein. Um magic number zu vermeiden verwendest du am besten ein enum class mit diesen 4 Status:
- REQUEST ... lesen der Methode, URI und evtl. GET Parameter
- CONTENT_LENGTH ... lesen der Request-Länge
- EMPTY_LINE ... warten bis die Leerzeile erkannt wird
- BODY ... lesen der Daten aus dem HTTP body
umgesetzt so:
enum class Status {REQUEST, CONTENT_LENGTH, EMPTY_LINE, BODY}; Status status = Status::REQUEST;
Damit liest du Zeichen für Zeichen in den lineBuffer ein. Nach jeder Zeilenschaltung behandelst du die übernommene Zeile gem. Status.
while (client.connected()) { while (client.available()) { char c = client.read(); Serial.print(c); if ( c == '\n' ) { if (status == Status::REQUEST) { status = Status::EMPTY_LINE; } else if (status == Status::CONTENT_LENGTH) { status = Status::EMPTY_LINE; } else if (status > Status::REQUEST && i <= 2) // check if we have an empty line { status = Status::BODY; } else if (status == Status::BODY) { strlcpy(postParameter, lineBuffer, smallbuffersize); break; // we have received one line payload and break out } i = 0; strcpy(lineBuffer, ""); } else { if (i < buffersize) { lineBuffer[i] = c; i++; lineBuffer[i] = '\0'; } // MISSING wenn status 3 und content-length --> abbrechen. } } if (status == Status::BODY) // status 3 could end without linefeed, therefore we takeover here also { strlcpy(postParameter, lineBuffer, smallbuffersize); }
Etwas komplexer ist es, die erste Zeile zu interpretieren. Die HTTP Request beginnt mit der Methode, ein Blank trennt den URI mit eventuellen Parametern und abschließend wieder ein blank und die Unterscheidung ob es sich um einen HTTP/1.0 oder HTTP/1.1 Request handelt. Zunächst interessiert nur die Methode, der URI und eventuelle Parameter. Zum Zerteilen verwendest du die C++ Funktion strtok:
if (status == Status::REQUEST) // read the first line { //Serial.print(F("lineBuffer="));Serial.println(lineBuffer); // now split the input char *ptr; ptr = strtok(lineBuffer, " "); // strtok willdestroy the newRequest strlcpy(method, ptr, smallbuffersize); Serial.print(F("method=")); Serial.println(method); ptr = strtok(NULL, " "); strlcpy(uri, ptr, smallbuffersize); // enthält noch evtl. parameter if (strchr(uri, '?') != NULL) { ptr = strtok(uri, "?"); // split URI from parameters strcpy(uri, ptr); ptr = strtok(NULL, " "); strcpy(requestParameter, ptr); Serial.print(F("requestParameter=")); Serial.println(requestParameter); } Serial.print(F("uri=")); Serial.println(uri);
Die Leerzeile erkennst du an einem LF an den ersten 2 Stellen. HTTP trennt per Definition die Header-Request Felder tatsächlich mit CR+LF (!)
else if (status > Status::REQUEST && i < 2) // check if we have an empty line { status = Status::BODY; }
Die Parameter aus dem Body kannst du wieder mit strtok teilen oder wenn sie eindeutig genug sind mit strncmp durchsuchen.
if ( strncmp( postParameter, "pinD", 4) == 0 ) { // check the first 4 characters (= length of needle) byte pin = atoi(postParameter + 4); // Convert ASCII to byte from position 4 onwards //Serial.print(("pin=")); Serial.println(pin); const char * ptr = strchr(postParameter, '='); // get a pointer to the first occurance of '=' if (ptr != NULL) // only continue when postParameter contains '=' { size_t pos = ptr - postParameter +1; // calculate from the pointer adress to the absolute position within postParameter if ( strncmp( postParameter + pos, "On", 2) == 0 ) { digitalWrite(pin, 1); } else if ( strncmp( postParameter + pos, "Off", 3) == 0 ) { digitalWrite(pin, 0); } } }
Das war schon die ganze Datenübernahme mit HTTP POST: je nach dem welcher Button gedrückt wurde, wird der Pin ein oder ausgeschaltet.
Der Browser erwartet aber noch eine Antwort vom Server. In diesem konkreten Beispiel sendet der Server die Seite retour. Da sich möglicherweise einzelne Pins geändert haben, macht das auch Sinn. Im Regelfall reicht der Vergleich auf "/", aber hier liese sich auch auf eine andere Seiten prüfen (z.B. index.htm):
// send back a response if (!strcmp(uri, "/") || !strcmp(uri, "/index.htm")) sendPage(client);
Fein. Aber wenn der User eine falsche URI eingibt, läuft der Browser in ein Timeout. Daher behandelst du das besser ordnungsgemäß mit einem HTTP Fehler 404:
else send404(client);
und eine ganz einfache Seite dazu:
void send404(EthernetClient &client) { client.println( ("HTTP/1.0 404 Not Found\r\n" "Content-Type: text/plain\r\n" "\r\n" "File Fot Found\n")); client.stop(); }
Beispielhaft sendet der Server eine reine Textseite (ohne HTML Formierung) zurück. Alles kein Problem, solange du den Content-Type (text/plain) richtig setzt.
Das favicon.ico mit dem Arduino webserver behandeln
Ja da war doch noch etwas. Genau das favicon.ico. Aktuell würde der Browser statt dem favicon.ico einen HTTP Fehler 404 bekommen:
Das ist schon besser als die ganze Seite
noch einmal, aber das geht sicher schöner.
Vor dem else für die Seite 404 ergänzt du noch ein else if
und einen neuen Funktionsaufruf:
else if (!strcmp(uri, "/favicon.ico")) // a favicon send204(client);
Eine Seite 204 ist eine leere Seite ohne Content. Das sagt dem Browser "ich habe deinen (GET) Request verarbeitet - Ende und aus". Die Funktion ist daher nur ein Zweizeiler:
void send204(EthernetClient &client) { client.println( ("HTTP/1.0 204 no content\r\n")); client.stop(); }
Jetzt sieht es im Network Monitor auch wieder OK aus:
Meiner Meinung nach ist Response Code 204 für das favicon.ico ein guter Kompromis für den Arduino Uno webserver - wir haben ja nicht endlos Speicher zur Verfügung.
Exkurs: Arduino mit einem andere Webserver im eigenen Netzwerk ansteuern
Wenn du den Arduino mit einem anderen Webserver ansteuern möchtest, so benötigst du auf diesen Webserver ein Formular, dass an die IP des Arduino die Daten übermittelt. Im Beispiel erwartet der Arduino die Parameter direkt bei der Startseite "/" und hat die IP 172.18.67.93 daher musst du auf dem anderen Webserver als Form action="172.18.67.93/" eintragen. Das funktioniert natürlich nur, wenn der andere Webserver und der Arduino im gleichen Netzwerk stehen. Hast du einen Webserver bei einem Hoster im Internet und den Arduino bei dir zu Hause muss zunächst der Arduino mittels DynDNS oder statischer Public IP und Portweiterleitung am Router aus dem Internet erreichbar gemacht werden und das Formular am Webserver so angepasst werden, dass der Arduino aus dem internet (also mit der DynDNS Adresse/static IP und Port) angesprochen wird.
Kauftipp: Generelles zum Ethernet am Arduino UNO/MEGA
Beim Kauf von "billigen" Ethernet Shields für den UNO oder MEGA achte bittet darauf, welcher Chip verbaut ist. Der IC sollte ein Wiznet W5100 oder W5500 sein. Meide die Boards mit dem ENC28J60 da bei diesen der ganze TCP/IP Stack in Software aufgebracht werden muss und somit der UNO noch mehr zu rechnen hat. Bei den Ethernet Shields zum Aufstecken verwende ich nur neuere Modelle, diese sind erkennbar an der schräg angeordneten POE Pins quer über das Board. Ältere Shields (ohne POE Vorbereitung) können einen Reset Fehler haben. Schau dir einfach die Bilder aus den Links genauer an - dann siehst du den Unterschied.
Weitere Optimierungen
Der hier dargestellte Webserver läuft stabil, dennoch kann man noch weitere Optimierungen vornehmen. Auch die Ausgabe eines echten Favicon.ico am Arduion webserver (en) ist möglich.
Zusammenfassung
Wir haben nicht nur Daten mittels POST am Arduino empfangen, sondern auch gezeigt, wie man einen einfachen Webserver am Arduino UNO programmieren kann. Der Server schickt nur Daten die angefragt wurden, wir behandeln 404 Fehler und behandeln das Favicon für den Browser korrekt.
Fragen zum Webserver stellt man am Besten im deutschsprachigen Forum von arduino.cc.