Gib mir den REST - Teil 2: Der Client

Wie kommuniziert ein Arduino-Board serverseitig mit einer REST-API, um Sensor-Messungen zu speichern? Nach dem Server in Teil 1 steht die Client-Seite an.

In Pocket speichern vorlesen Druckansicht

Die Verbindung von eingebetteten Systemen und Servern kann über REST-Kommunikation erfolgen

(Bild: pixabay.com)

Lesezeit: 10 Min.
Von
  • Dr. Michael Stal
Inhaltsverzeichnis

Arduino-Boards oder andere Microcontroller-Boards beziehungsweise eingebettete Systeme oder Einplatinencomputer können mit einer REST-Anwendung kommunizieren, wenn sie einen WiFi- oder Ethernet-Anschluss integrieren. Da ein typischer Anwendungsfall wie der hier vorgestellte überwiegend Messungen mit Sensoren durchführt, erweist eine Echtzeituhr (RTC) als Vorteil. Dadurch lassen sich zusätzlich zu den Messdaten Zeitstempel (Datum und Zeit) hinzufügen, die später eine historische Analyse ermöglichen, etwa den Verlauf der Temperatur über einen bestimmten Zeitraum.

Der Pragmatische Architekt – Michael Stal

Prof. Dr. Michael Stal arbeitet seit 1991 bei Siemens Technology. Seine Forschungsschwerpunkte umfassen Softwarearchitekturen für große komplexe Systeme (Verteilte Systeme, Cloud Computing, IIoT), Eingebettte Systeme, und Künstliche Intelligenz. Er berät Geschäftsbereiche in Softwarearchitekturfragen und ist für die Architekturausbildung der Senior-Software-Architekten bei Siemens verantwortlich.

Auch wenn hier vom Arduino Giga die Rede ist, beschränkt sich die Anwendung keineswegs nur auf dieses Board, sondern lässt sich relativ leicht für andere Arduino-, STM- oder ESP-Boards anpassen, für die funktional ähnliche Bibliotheken wie WiFi, WiFiUdp und HTTPClient in der Arduino IDE oder in Visual Studio Code mit PlatformIO-Plug-in bereitstehen.

Notwendig ist ein Sensor von Bosch Sensortec aus der BME-Familie (BME68x, BME58x, BME38x, BME28x), den Maker über I2C oder SPI anschließen. Für das Beispiel kam ein BME688 zum Einsatz, den es als Breakout-Board von verschiedenen Herstellern wie Pomoroni oder Adafruit gibt. Wer möchte, kann auch ein Bosch Development Kit kaufen. Dieses Board besteht aus einem BME688-Adapter, der huckepack auf einem Adafruit Huzzah32 Feather Board (ESP32) sitzt. Das schlägt mit rund 100 Euro zu Buche, hat aber den Charme, sich mittels der kostenlosen BME-AI-Studio-Software trainieren zu lassen. Der Sensor lernt dabei, über ein neuronales Netz verschiedene Gasmoleküle in der Luft zu erkennen, wie Kaffee, CO₂ oder Chlor. Er ermittelt für die Moleküle in der Luft Widerstände in Ohm und kann über die elektrische Charakteristik Gase klassifizieren. Diese spezifischen ML-Trainings sind mit Breakout-Boards freilich nicht möglich – der Anwender erfährt dort nur den Widerstand des Gases ohne Zuordnung zu einem konkreten Molekül. Dafür kosten die einfachen Boards auch nur zwischen 20 und 30 Euro.

Wie bereits im ersten Teil erwähnt, findet sich der Quellcode für die Beispiele in dem GitHub Repository.

Ein Hinweis vorab: Als mögliche Alternative für die hier vorgestellte Integration über eine RESTful-Architektur käme beispielsweise der Einsatz von MQTT infrage, das eine ereignis- beziehungsweise nachrichtenorientierte Kommunikation zwischen Client und Server ermöglicht. In vielen Fällen, vielleicht auch für den hier vorliegenden, ist MQTT eine sehr gute Option. Allerdings fokussieren sich die beiden Artikelteile speziell darauf, Kommunikation und Backend-Integration über REST-APIs zu veranschaulichen. Ein vom Backend betriebener Microservice stellt schließlich typischerweise eine REST-API bereit.

In der konkreten Schaltung sind die folgenden Verbindungen notwendig:

Arduino ........ BME688

3.3V ............ Vin

GND ............. GND

SCL ............. SCK

SDA ............. SDI

Folgendes Fritzing-Diagramm veranschaulicht dies:

Die Verbindung des BME688-Sensors und dem Arduino läuft über I2C oder SPI. Im vorliegenden Fall ist I2C das Mittel der Wahl

Beim Entwickeln der Arduino-Software sind folgende Verantwortlichkeiten zu beachten:

Initiales Setup:

  • Verbindung zum WiFi-Netz aufbauen
  • Über NTP-Server die aktuelle Zeit einlesen und die Echtzeituhr (RTC) einstellen
  • Verbindung zum I2C-Sensor BME688 aufbauen

Kontinuierliche Loop:

  • Messergebnisse vom BME688 auslesen
  • Lokale Zeit über RTC ermitteln
  • JSON-Nachricht zusammenbauen
  • Mit REST-Server über WiFi/HTTP verbinden
  • POST-Nachricht schicken
  • Prüfen, ob die Paketsendung erfolgreich war

Nebenbei soll der Sketch Informationen über den seriellen Monitor zu Test- und Beobachtungszwecken ausgeben.

Der Arduino-Sketch erwartet im selben Verzeichnis eine Datei arduino_secrets.h, die Definitionen für das gewünschte WLAN enthalten, konkret die SSID und den WLAN-Key. Für die Nutzung des Arduino-WiFis bedarf es einer entsprechenden WiFi-Bibliothek, deren Header wir inkludieren, zum Beispiel: #include <WiFi.h>. Zusätzlich benötigen wir die Bibliothek ArduinoHttpClient, die wir über den Library Manager der Arduino IDE oder VS Code & PlatformIO installieren.

Den Verbindungsaufbau zum WLAN übernimmt nachfolgender Code:


void setup() {
  // Seriellen Stream starten
  Serial.begin(9600);

  // Verbindungsaufbau WiFi
  while (status != WL_CONNECTED) {
    Serial.print("Versuche Verbindungsaufbau zum WLAN-Netz: ");
    Serial.println(ssid);                   // print the network name (SSID);

    // Verbinden mit WPA/WPA2 Netzwerk:
    status = WiFi.begin(ssid, pass);
  }

  // Name des WLANs nach erfolgtem Verbindungsaufbau:
  Serial.print("SSID: ");
  Serial.println(WiFi.SSID());

Anschließend soll der Zugriff auf einen NTP-Server zum Einstellen der Echtzeituhr verhelfen.

Beim Giga R1 Board sind dazu folgende Header-Dateien notwendig:

#include <WiFiUdp.h>      // zum Versenden von NTP-Paketen notwendig
#include <mbed_mktime.h>

Das schaut auf anderen Boards natürlich anders aus und wäre diesbezüglich zu ändern.

Der eigentliche Code für das Setzen der Echtzeituhr befindet sich in der Methode setNtpTime(), die ihrerseits eine UDP-Verbindung mit einem lokalen Port öffnet, ein Anfragepaket an den gewünschten Zeitserver verschickt (sendNTPacket()), um danach die Antwort über parseNtpPacket() zu empfangen, zu verarbeiten und die Echtzeituhr mit der aktuellen Zeit einzustellen. Hat dies erfolgreich geklappt, lässt sich fortan über die Methode getLocalTime() stets die aktuelle Zeit aus der Echtzeituhr auslesen. Zurückgeliefert wird jeweils ein Datetime-String, der sich aus Datum und Zeit zusammensetzt: „2023-06-05 12:31:45“.

// NTP-Anfrage senden, Antwort erhalten und parsen
void setNtpTime()
{
    status = WL_IDLE_STATUS;
    Udp.begin(localPort);
    sendNTPpacket(timeServer);
    delay(1000);
    parseNtpPacket();
}

// Rufe einen Zeitserver auf 
unsigned long sendNTPpacket(const char * address)
{
    memset(packetBuffer, 0, NTP_PACKET_SIZE);
    packetBuffer[0] = 0b11100011; // LI, Version, Modus
    packetBuffer[1] = 0; // Stratum, Art der Uhr
    packetBuffer[2] = 6; // Abfrageintervall
    packetBuffer[3] = 0xEC; // Präzision der Uhr
    // 8 Bytes von Nullen für Root Delay & Root Dispersion
    packetBuffer[12] = 49;
    packetBuffer[13] = 0x4E;
    packetBuffer[14] = 49;
    packetBuffer[15] = 52;

    Udp.beginPacket(address, 123); // NTP Aufrufe erfolgen über Port 123
    Udp.write(packetBuffer, NTP_PACKET_SIZE);
    Udp.endPacket();
}

// Hier wird das NTP-Antwortobjekt empfangen und geparst:
unsigned long parseNtpPacket()
{
    if (!Udp.parsePacket())
        return 0;

    Udp.read(packetBuffer, NTP_PACKET_SIZE); // Paket vom NTP-Server empfangen
    const unsigned long highWord = word(packetBuffer[40], packetBuffer[41]);
    const unsigned long lowWord = word(packetBuffer[42], packetBuffer[43]);
    const unsigned long secsSince1900 = highWord << 16 | lowWord;
    constexpr unsigned long seventyYears = 2208988800UL;
    const unsigned long epoch = secsSince1900 - seventyYears;
    set_time(epoch);

// Folgende ausführliche Beschreibung lässt sich ausgeben,
// sobald man DETAILS definiert
#if defined(DETAILS)
    Serial.print("Sekunden seit Jan 1 1900 = ");
    Serial.println(secsSince1900);

    // NTP time in "echte" Zeit umwandeln:
    Serial.print("Unix Zeit = ");
    // Ausgabe der Unix time:
    Serial.println(epoch);

    // Stunde, Minute, Sekunde ausgeben:
    Serial.print("Die UTC Zeit ist "); // UTC entspricht Greenwich Meridian (GMT)
    Serial.print((epoch % 86400L) / 3600); // Stunde ausgeben (86400 sind die Sekunden pro Tag)
    Serial.print(':');
    if (((epoch % 3600) / 60) < 10) {
        // In den ersten 10 Minuten einer Stunde brauchen wir eine führende Null
        Serial.print('0');
    }
    Serial.print((epoch % 3600) / 60); // Minute ausgeben (3600 = Sekunden pro Stunde)
    Serial.print(':');
    if ((epoch % 60) < 10) {
        // In den ersten 10 Minuten einer Stunde brauchen wir eine führende Null
        Serial.print('0');
    }
    Serial.println(epoch % 60); // Sekunde ausgeben
#endif

    return epoch;
}

// Lokale Zeit mittels RTC (Real Time Clock) ermitteln:
String getLocaltime()
{
    char buffer[32];
    tm t;
    _rtc_localtime(time(NULL), &t, RTC_FULL_LEAP_YEAR_SUPPORT);
    strftime(buffer, 32, "%Y-%m-%d %k:%M:%S", &t);
    return String(buffer);
}

Für Interessierte, die mehr über das NTP (Network Time Protocol) erfahren möchten, eine Abbildung mit anschließenden Erläuterungen, die den Aufbau eines NTP-Pakets veranschaulichen:

Mit dem NTP-Protokoll können eingebettete Systeme ihre Echtzeituhr setzen

Die Header-Einträge eines NTP-Pakets gestalten sich oben wie folgt:

LI

Leap Indicator (2 bits)

Dieses Attribut zeigt an, ob die letzte Minute des aktuellen Tages eine Zusatzsekunde benötigt.

0: Keine Sekundenanpassung nötig

1: Letzte Minute soll 61 sec haben

2: Letzte Minute soll 59 sec haben

3: Uhr wird nicht synchronisiert

VN

NTP-Versionsnummer (3 Bits) (etwa Version 4).

Mode

NTP-Paketmodus (3 Bits)

0: Reserviert

1: Symmetrisch aktiv

2: Symmetrisch passiv

3: Client

4: Server

5: Broadcast

6: NTP-Kontrollnachricht

7: Für private Nutzung reserviert

Stratum

Stratum-Ebene der Zeitquelle (8 bits)

0: Unspezifiziert oder ungültig

1: Primärer Server

2–15: Sekundärer Server

16: Unsynchronisiert

17–255: Reserviert

Poll

Poll Interval (8-Bit Ganzzahl mit Vorzeichen), die in Sekunden definiert, was das maximale Zeitintervall zwischen aufeinander folgenden NTP-Nachrichten sein soll.

Precision

Präzision der Uhr (8-Bit Ganzzahl mit Vorzeichen)

Root Delay

Die Round-Trip-Verzögerung vom Server zur primären Zeitquelle. Es handelt sich um eine 32-Bit Gleitpunktzahl, die Sekunden festlegt. Der Dezimalpunkt befindet sich zwischen Bits 15 und 16. Sie ist nur für Server-Nachrichten relevant.

Root Dispersion

Der maximale Fehler aufgrund der Toleranz bezüglich der Uhr-Frequenz. Es handelt sich um eine 32-Bit Gleitpunktzahl, die Sekunden festlegt. Der Dezimalpunkt befindet sich zwischen Bits 15 und 16. Sie ist nur für Servernachrichten relevant

Reference Identifier

Bei Stratum-1-Servern beschreibt dieser Wert (ein ASCII-Wert mit 4 Bytes) die externen Referenzquellen. Für sekundäre Server beschreibt der Wert (4 Bytes) die IPv4-Adresse der Synchronisationsquelle, oder die ersten 32 Bit des MDA-Hashes (MDA = Message Digest Algorithm 5) der IPv6-Adresse des Synchronisationsservers.

Um den BME688-Sensor über I2C anzusteuern, müssen wir Bibliotheken von Adafruit über den Bibliotheksmanager laden - der Code lässt sich einfach umstellen, um stattdessen SPI zu verwenden. Importieren muss man die Bibliotheken Adafruit BME680 und Adafruit Unified Sensor. Es kann natürlich auch die Bibliothek eines anderen Sensors aus der BME-Familie von Bosch sein, etwa die eines BME280.

Die Initialisierung des Sensors ist in setup() integriert. Hier die Vereinbarung des BME-Proxies:

Adafruit_BME680 bme; // Vereinbarung der Variablen bme

Und hier die eigentliche Initialisierung:

  // Verbindung mit Sensor BME680 etablieren
  if (!bme.begin()) {
    Serial.println(F("Konnte keinen Sensor finden. Bitte Schaltung überprüfen!"));
    while (1);
  }

  // Oversampling-Werte setzen und IIR-Filter initialisieren
  bme.setTemperatureOversampling(BME680_OS_8X);
  bme.setHumidityOversampling(BME680_OS_2X);
  bme.setPressureOversampling(BME680_OS_4X);
  bme.setIIRFilterSize(BME680_FILTER_SIZE_3);
  bme.setGasHeater(320, 150); // 320*C für 150 ms
}

Ab geht die POST

Die Methode createJSONString() konstruiert aus Messdaten und Zeitstempel ein JSON-Objekt. Ich habe hier bewusst ein manuelles Erstellen gewählt, statt ArduinoJson zu verwenden, weil der Aufwand sehr übersichtlich bleibt:

// Hier wird ein String generiert, der ein JSON-Objekt enthält
String createJSONString(double temp, double humi, double pres, double resi, String date, String time) {
  String result = "{";
  const String up = "\"";
  const String delim = ",";
  const String colon = ":";

  result += up + "temperature" + up + colon + String(temp,2) + delim;
  result += up + "humidity"    + up + colon + String(humi,2) + delim;
  result += up + "pressure"    + up + colon + String(pres,2) + delim;
  result += up + "resistance"  + up + colon + String(resi,2) + delim;
  result += up + "date"        + up + colon + up + date + up + delim;
  result += up + "time"        + up + colon + up + time + up;
  result += "}";
  return result;
}

Wer einen Sensor besitzt, der einige dieser Messdaten nicht enthält, stutzt das JSON-Objekt entsprechend. In der Server-Datenbank wird für jedes nicht übergebene Attribut einfach ein null eingetragen.

Für das Versenden der Messdaten über HTTP-POST ist callAPIPost() verantwortlich, das anschließend auch die Antwort des Servers ausgibt, der im Erfolgsfall mit Status-Code 200 antwortet:

// Hier erfolgt die Vorbereitung und die eigentliche Durchführung des POST-Aufrufes / REST-PUSH 
void callAPIPost(double temperature, double humidity, double pressure, double resistance, const String& date, const String& time){
  Serial.println("Durchführung eines neuen REST API POST Aufrufs:");
  String contentType = "application/json"; // Wir übergeben ein JSON-Objekt

  // JSON Nutzlast (Body) kreieren
  String postData = createJSONString(temperature, humidity, pressure, resistance, date, time);
  Serial.println(postData); // ... und ausgeben

  // Per WiFi-Verbindung POST aufrufen und dabei "application/json" und das JSON-Objekt übergeben
  client.post("/measurements/api", contentType, postData);

  // Status und Body aus Antwort extrahieren
  int statusCode = client.responseStatusCode();
  String response = client.responseBody();

  // Ausgabe der Ergebnisse:
  Serial.print("Status code: ");
  Serial.println(statusCode);
  Serial.print("Antwort: ");
  Serial.println(response);
}

Aufrufe der vorgenannten Methoden finden in der loop()-Methode statt, außer natürlich die Initialisierungen beim Programmstart.

Läuft der Sketch, verbindet er sich über WiFi mit dem WLAN, misst kontinuierlich Daten vom BME688 und übermittelt sie und einen Zeitstempel als JSON-Objekt per POST an den Server

Damit ist der Arduino-Sketch fertig, der den REST-Server aus Teil 1 mit neuen Messungen versorgt. Natürlich gibt es wie immer Erweiterungsmöglichkeiten:

  • So ließe sich ein SSD1306-OLED-Display hinzufügen, das die jeweiligen Messwerte ausgibt. Oder ein SD-Karten-basierter Datenlogger. Ein Beispiel für die zusätzliche Integration eines OLED-Displays mit IIC-Anschluss findet sich ebenfalls auf dem oben erwähnten GitHub Repository. Der Sensor und das I2C-Display sind per Daisy-Chain hintereinander geschaltet. Gegebenenfalls muss man die IIC-Adresse (default: 0x3C) und die Auflösung (default: 128x64) für die eigene Konfiguration ändern.

Das am IIC-Bus seriell angeschlossene SSD1306-Display (128 x 64) gibt die Daten der aktuellen Messung aus

Hier werden der Anschluss für 3.3V, GND, SDA, SCL vom Arduino auf ein Breadboard geführt, von wo sie IIC-Display und IIC-Sensor abgreifen können.

  • Denkbar ist das Nutzen anderer Klimasensoren wie BME280, BME580 oder sogar anderer Typen von Sensoren. Das erfordert Änderungen sowohl am Client- als auch am Server-Code.
  • Wer verschiedene Boards mit Sensoren besitzt, könnte dem Datenschema Attribute wie Board-Kennung und GPS-Daten mitgeben.
  • Es könnten alternative Boards wie Raspberry Pi Picos, ESP32-Boards zum Einsatz kommen. Sogar das direkte Anschließen eines BME688 an den Mac OS/Windows/Linux-Computer ist über ein GPIO-Erweiterungsboard möglich.
  • Eine App fürs Handy oder Tablet könnte den aktuellen Messverlauf grafisch visualisieren.

Und das ist immer noch nicht das Ende der Fahnenstange. Die Möglichkeiten sind fast unendlich. Client- und Server-Anwendungen des Beispiels sollten das Vorgehen dabei gut veranschaulichen und zu eigenen Experimenten anreizen. Die Implementierungen für Client und Server lassen sich entsprechend anpassen.

Ich hoffe jedenfalls, der hier vorgestellte Showcase bietet viel Nutzen und macht Spaß.

(rme)