9. Oktober 2021

Google E-Ink Kalender - Der WiFi-Connector - Part 1

Hallo allerseits. Mein Name ist Florian Köhler und ich darf heute Gastauthor bei meinem Freund Matthias spielen. :D Heute möchte ich euch von meinem Abenteuer mit meinem ersten IoT (Internet of Things) Projekt erzählen. Der Titel dieses Projekts lautet „Operation E-Ink Kalender“.

 

Das Ziel

Das Ziel ist es, auf einem E-Ink Display die kommenden Ereignisse, das aktuelle Datum und eine Kalendertabelle darzustellen. In meinem Fall bestehen die Ereignisse aus den anstehenden Abfuhrterminen für die Mülltonnen. Mein ultimatives Ziel ist es nach und nach meine komplette Wohnung zu automatisieren und da darf ein über Google synchronisierender Kalender nicht fehlen. Warum ein E-Ink Panel und kein LCD? Die Vorteile als auch Nachteile eines E-Ink Panels liegen klar auf der Hand.

Vorteile:

  • Geringer Stromverbrauch
  • Paper-like Anzeige

Nachteile:

  • Geringe Aktualisierungsrate
  • Eine geringe Palette an Farben oder gar nur monochrome Darstellung (Schwarz/Weiß)
  • Wesentlich kostspieliger als ein LCD

Da ich den E-Ink Kalender über Lithium Akku betreiben will, ist der geringe Stromverbrauch ein No-Brainer. Da der Kalender vielleicht 1x oder 2x pro Tag aktualisieren soll, ist die geringe Bildwiederholungsrate kein großes Thema. Die geringe Auswahl an Farben und der erhöhte Preis sind Mankos, die man einfach in Kauf nehmen muss. Es gibt zum Zeitpunkt des Schreibens allerdings schon E-Ink Panels mit bis zu 7(!) verschiedenen Farben. (z.B. bei Waveshare in den Größen 4.01“ und 5.65“ (nicht gesponsert) https://www.waveshare.com/catalogsearch/result/?q=7-color+E-paper) Damit kann man schon recht viel und schön darstellen. Es sei angemerkt, dass der Preis von E-Ink Panels mit der Größe schnell ansteigt. Während das 7.5“ Panel, das ich genommen habe, noch knapp 60€ kostet, liegt die nächsthöhere Größe von 7.8“ schon bei ca. 100€.

TL; DR: Ein E-Ink Kalender, der auf Akku läuft und mit meinem Google-Kalender synchronisiert.

 

Die Bauteile

Herzstücke:

Die komplementäre Bauteile für den Akku-Betrieb (bestellt von reichelt.at):


Datasheets

Waveshare ESP32 Driver Board: WS WikiWS Specification

Waveshare 7.5“ HD e-Paper (B) mit HAT:  WS WikiWS Specification

MCP 1703-3302MB: Reichelt Datasheet

 

Programmierumgebung und verwendete Software

Für die Programmierung verwende ich u.a.

Während ich die Arduino IDE recht ansehnlich finde, bin ich ein Langzeit Visual Studio Fan und finde es auch einfach schöner, alle Header- und Source-Files strukturiert in einem Projekt-Browser vorzufinden. Vor allem für größere Projekte würde ich dann das Atmel Studio oder eben Visual Studio mit Visual Micro Extension empfehlen. Für die Verwendung vom Visual Studio ist wichtig, dass ihr die C/C++ Komponenten mitinstalliert. Die Arduino IDE wird in beiden Fällen als Basis benötigt. Alternativ könnte man sich überlegen, den ESP32 mit der MicroPython-Firmware zu flashen und in Python zu programmieren. Da ich mir etwas Sorgen um den kleinen RAM des ESP32 (520kB) gemacht habe, dachte ich, es wäre unbedingt notwendig in C/C++ zu programmieren. Im Endeffekt hat sich diese Sorge als grundlos herausgestellt und mir ist eher der partitionierte Programmspeicher (~1.3MB) knapp geworden. 

Ein weiterer Nachteil von vMicro ist, dass ich das Debuggen absolut nicht hinbekommen habe. Wie sich später herausgestellt hat, benötigt man dafür einen externen Hardware Debugger, der an die richtigen Pins beim ESP32 angeschlossen werden muss. (https://www.visualmicro.com/page/ESP32-Debugging.aspx) Leider verwendet vMicro für das Debuggen genau die Pins 12,13,14,15, welche das Waveshare Driver Board auch für die SPI Schnittstelle verwendet. (https://www.visualmicro.com/page/Troubleshooting-Debugging.aspx) Was ich leider erst nach ein paar Stunden Troubleshooting festgestellt habe ist, dass dann nur der „Release“-Build funktioniert und der „Debug“-Build zum Blocken des Programms am ESP32 führt, da er sich sichtlich auf den Pins 12 bis 15 irgendein Signal erwartet.



3rd Party Libraries

  • GxEPD2: Eine Grafiklib für Dalian Good Display und Waveshare E-Ink Panels. Diese Lib basiert auf Adafruit_Gfx und erleichtert somit das Zeichnen am E-Ink Panel drastisch. Github
  • Adafruit GFX: Eine Kerngrafik-Lib für viele Displays. Github
  • ArduinoJson: Eine Lib für die einfache Handhabung von JSON-Objekten und -Arrays. Github

 

Das Programm

Das Programm besteht aus 3 fundamentalen Funktionsblöcken:

  1. Verbindung mit WiFi/Internet herstellen und Kalender-Daten von Google abrufen
  2. Die Batterie-Leistung über einen ADC-Pin auslesen und auswerten
  3. Den Kalender auf dem E-Ink Panel zeichnen

WiFi_Connector.h

 #ifndef _WiFi_CON_H THEN  
 #define _WiFi_CON_H  
 #include <WiFi.h>  
 #include <WiFiMulti.h>  
 #include <HTTPClient.h>  
 #include <WiFiClientSecure.h>  
 #include <ArduinoJson.h>  
 #include "WFSecrets.h"  
 #include "GSecrets.h"  
 #include "CalendarEntry.h"  
 #define CAP 16384  
 class Wifi_Connector  
 {  
 public:  
      Wifi_Connector(void);  
      ~Wifi_Connector(void);  
      void ConnectToWifi(void);  
      bool GetCalendarData(CalendarEntry**, int*);  
      struct tm GetCurrentDate(void);  
 private:  
      StaticJsonDocument<CAP> doc;  
      struct tm timeinfo;  
      WiFiMulti wifiMulti;  
      const char* googleRootCACertificate =  
           "-----BEGIN CERTIFICATE-----\n"  
           "MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n"  
           "A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n"  
           "b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n"  
           "MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n"  
           "YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n"  
           "aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n"  
           "jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n"  
           "xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n"  
           "1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n"  
           "snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n"  
           "U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n"  
           "9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n"  
           "BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n"  
           "AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n"  
           "yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n"  
           "38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n"  
           "AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n"  
           "DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n"  
           "HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n"  
           "-----END CERTIFICATE-----\n";  
      void SetCurrentDate(void);  
 };  
 #endif  

Fangen wir mit den Imports an.

WiFi.h, WiFiMulti.h und WiFiClientSecure.h dienen zum Verbinden mit dem lokalen WLan. WiFiMulti.h und WiFiClientSecure.h werden weiters für die HTTPS-Verbindung zu Google App Scripts benötigt.
HTTPClient.h übernimmt den eigentlichen HTTPS-POST Aufruf zu Google.
ArduinoJson.h zerlegt schlussendlich den Response-Body des POST in ein in C/C++ verwendbares Objekt.
WFSecrets.h und GSecrets.h beinhalten die jeweiligen Zugangsdaten zum lokalen WLan und die Google-Skript-Url bzw. -ID für den Webaufruf.
CalendarEntry.h enthält ein POJO-Objekt für einen einzelnen Kalendereintrag.

Das CAP Makro definiert die maximale Größe des Json-Arrays im RAM. Ist das Json-Objekt zu groß, werden die restlichen Felder auf „null“ gesetzt.

 

WiFi_Connector.cpp

 #include "Wifi_Connector.h"  
 Wifi_Connector::Wifi_Connector(void)  
 {  
   putenv("TZ=Europe/Vienna");  
   WiFi.mode(WIFI_STA); // Configures the ESP32 as Station (Client) for WiFi.  
   wifiMulti.addAP(WIFI_SSID, WIFI_PASSWORD);  
 }  
 Wifi_Connector::~Wifi_Connector(void)  
 {  
 }  
 /// <summary>  
 /// Tries to connected to the WiFi specified in the WFSecrets.h File.  
 /// </summary>  
 /// <param name="timeout">Maximum connection-time. 0 means the module tries endlessly or until active is set to false.</param>  
 void Wifi_Connector::ConnectToWifi()  
 {  
   Serial.print("Waiting for WiFi to connect...");  
   while ((wifiMulti.run() != WL_CONNECTED))  
   {  
     Serial.print(".");  
     delay(5000);  
   }  
   Serial.println(" connected");  
   SetCurrentDate();  
 }  
 bool Wifi_Connector::GetCalendarData(CalendarEntry** calEntriesPtr, int* lenPtr)  
 {  
   *lenPtr = 0;  
   bool succeeded = false;  
   CalendarEntry* entries = 0;  
   WiFiClientSecure* client = new WiFiClientSecure;  
   if (client)  
   {  
     client->setCACert(googleRootCACertificate);  
     {  
       HTTPClient https;  
       https.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS);  
       int httpCode = 0;  
       Serial.println(G_SCRIPT_ADR);  
       if (https.begin(*client, G_SCRIPT_ADR))  
       {  
         Serial.print("[HTTPS] POST...\n");  
         // start connection and send HTTP header  
         https.addHeader("Content-Type", "text/plain");  
         httpCode = https.POST(G_SCRIPT_PW);  
         // httpCode will be negative on error  
         if (httpCode > 0)  
         {  
           String response = https.getString(); //Get the response to the request  
           Serial.println("Code: ");  
           Serial.println(httpCode);  //Print return code  
           Serial.println("Response: ");  
           Serial.println(response);  
           Serial.println("JSON trying to deserialize");  
           DeserializationError err = deserializeJson(doc, response);  
           if (err)  
           {  
             Serial.print("Error: ");  
             Serial.println(err.c_str());  
           }  
           Serial.println("JSON Converting");  
           JsonArray arr = doc.as<JsonArray>();  
           *lenPtr = arr.size();  
           Serial.print("Amount of Json Elements: ");  
           Serial.println(*lenPtr);  
           entries = new CalendarEntry[*lenPtr]();  
           if (entries)  
           {  
             succeeded = true;  
             int i = 0;  
             int y, M, d, h, m;  
             float s;  
             for (JsonObject cal_entry : arr) {  
               String dateStr = cal_entry["StartTime"];  
               sscanf(dateStr.c_str(), "%d-%d-%dT%d:%d:%fZ", &y, &M, &d, &h, &m, &s);  
               entries[i].init(cal_entry["Title"], cal_entry["IsAllDayEvent"], y, M, d, h, m);  
               i++;  
             }  
             *calEntriesPtr = entries;  
           }  
         }  
       }  
     // End extra scoping block  
     }  
     delete client;  
   }  
   else {  
     Serial.println("Unable to create client");  
   }  
   return succeeded;  
 }  
 struct tm Wifi_Connector::GetCurrentDate(void)  
 {  
   GetCurrentDate();  
   return timeinfo;  
 }  
 void Wifi_Connector::SetCurrentDate(void)  
 {  
   configTime(0, 0, "pool.ntp.org");  
   //Serial.print(F("Waiting for NTP time sync: "));  
   time_t nowSecs = time(nullptr);  
   while (nowSecs < 8 * 3600 * 2) {  
   delay(500);  
   //Serial.print(F("."));  
   yield();  
   nowSecs = time(nullptr);  
   }  
   //Serial.println();  
   gmtime_r(&nowSecs, &timeinfo);  
   /*Serial.print(F("Current time: "));  
   */Serial.print(asctime(&timeinfo));  
 }  

Im Konstruktor wird die Zeitzone, der AP-Modus des ESP32 und die Zugangsdaten zum WLan gesetzt. Der AP-Modus muss als WLan-Client auf "Station" gesetzt werden. Die Zeitzone muss richtig konfiguriert werden, damit das TLS-Zertifikat von Google korrekt validiert wird.

Der Destruktor bleibt leer, da ich keine dynamischen Speicherreservierungen mache.

In "ConnectToWifi" wird die Verbindung zum WLan hergestellt und im Falle eines Misserfolgs 5 Sekunden gewartet, bis es erneut probiert wird. Nach dem Verbinden wird die Zeit über NTP-Server in der lokalen Variable "timeinfo" gespeichert. Der Code für "SetCurrentDate" und "GetCalenderData" wurde stark von Quelle abgeleitet bzw. übernommen.

In "GetCalenderData" wird ein neuer WiFiClientSecure alloziert. Nach erfolgreicher Speicherzuweisung wird das RootCA Zertifikat von Google App Scripts gesetzt. Danach wird ein HTTPClient initialisiert und setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS) auf diesem gesetzt. Das ist notwendig, da Google App Scripts die POST-Antwort nicht direkt zurückliefert, sondern auf eine einmalig verwendbare URL weiterleitet, wo die Daten dann abgeholt werden können. Dann wird der POST-Header und -Body gesetzt. Dieser enthält ein Passwort, damit nicht jeder einfach auf den Kalender zugreifen kann. Ich habe hier allerdings auf minimale Sicherheit gesetzt, da hier keine superwichtigen Daten drinstehen und vergleiche, wie man später noch sieht, mit einem einfachen salted Hash. Danach wird das JSON-Array im POST-Response-Body auf CalendarEntry Objekte geparsed. Am Ende wird der anfangs allozierte  WiFiClientSecure wieder aus dem Heap(RAM) gelöscht. Ich gebe hier außerdem einen boolean zurück, der darauf verweist, ob die Abfrage erfolgreich war und ein Array (wenn auch leeres) für die CalendarEntries alloziert wurde.


Google Apps Script

Google erlaubt inzwischen nicht mehr, dass man seinen Google Kalendar als xml anfordert. Dadurch wird der Zwischenschritt über ein Google Apps Script notwendig. Dieses könnt ihr ganz einfach auf Google Apps Script erstellen und dann als Web-App bereitstellen. (Notiz: Wenn ihr eine neue Version eures Scripts bereitstellen wollt, dann müsst ihr zwangsläufig die Version hochziehen, da sie sonst nicht aktualisiert wird.) Aus der Web-App bekommt ihr dann eine Url, die oben als G_SCRIPT_ADR angedeutet wurde. Der salted Hash wird per G_SCRIPT_PW definiert.
 function doPost(e) {  
  if(!e)  
   return undefined;  
  var x = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, 'SALT' + e.postData.contents, Utilities.Charset.US_ASCII);  
  // Oder
  // var x = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, e.postData.contents + 'SALT', Utilities.Charset.US_ASCII);  
  var txtHash = '';  
  for (i = 0; i < x.length; i++) {  
   var hashVal = x[i];  
   if (hashVal < 0) {  
    hashVal += 256;  
   }  
   if (hashVal.toString(16).length == 1) {  
    txtHash += '0';  
   }  
   txtHash += hashVal.toString(16);  
  }  
  Logger.log(txtHash);  
  if(txtHash.toUpperCase() !== "EUER SHA-256 HASH")  
   return undefined;  
  var cal = CalendarApp.getCalendarById('EURE KALENDAR-ID');  
  if (cal == undefined) {  
   return ContentService.createTextOutput('no access to calendar');  
  }  
  const now = new Date();  
  var start = new Date(); start.setHours(0, 0, 0); // start at midnight  
  const oneday = 24* 60 * 60 * 1000; // [msec]  
  const stop = new Date(start.getTime() + 31 * oneday);  
  var events = cal.getEvents(start, stop);  
  var data = [];  
  for (var ii = 0; ii < events.length && ii < 8; ii++) {  
   var event=events[ii];   
   var myStatus = event.getMyStatus();   
   switch(myStatus) {  
    case CalendarApp.GuestStatus.OWNER:  
    case CalendarApp.GuestStatus.YES:  
    case CalendarApp.GuestStatus.MAYBE:  
     data.push({  
      "StartTime": event.getStartTime(),  
      "IsAllDayEvent": event.isAllDayEvent(),  
      "Title": event.getTitle()  
     });  
     break;  
    default:  
     break;  
   }  
  }  
  return ContentService.createTextOutput(JSON.stringify(data));  
 }  

Ihr müsst die Texte "SALT", "EUER SHA-256 HASH" und "EURE KALENDAR-ID" mit euren eigenen Daten ersetzen.

Bei diesem Script ist es außerdem wichtig, dass eure Funktion entweder doGet(e) oder doPost(e) heißt. Es sei angemerkt, dass man doGet(e) auch über den Browser testen kann, während doPost(e) nicht direkt angesprochen werden kann. Dafür empfiehlt sich ein Tool wie "Postman", wo man HTML-Requests bauen kann, zu verwenden. Bei doGet(e) enthält e die nachgesetzen Parameter in der Url, während die Variable bei doPost(e) den POST-Header und -Body enthält.

Im ersten Schritt des Scripts wird das gesendete Passwort mit dem vordefinierten Hash verglichen. Im zweiten Schritt werden die Kalendarevents über 1 Monat ausgelesen und schlussendlich im dritten Schritt zu einem JSON-Array geparsed, welches allerdings nur die ersten 8 Ereignisse umfasst. Das ist nötig, da das E-Ink Display nur eine gewisse Anzahl an Events anzeigen kann und so dem JsonDocument Buffer eine gewisse Speicher-Obergrenze gesetzt wird.

Im nächsten Blog-Post werde ich euch zeigen, wie man das Ganze dann mittels GxEPD2 auf dem E-Ink Panel rendert. 

Hierzu möchte ich allerdings noch etwas vorgreifen. Im Demo-Beispiel von GxEPD2 hat sich nämlich ein Fehler eingeschlichen, der mein Programm immer ohne Fehlermeldung zum Abstürzen gebracht hat. Es wird nämlich einmal in der setup() Funktion Serial.begin() aufgerufen und innerhalb der GxEPD2 Library dann nochmal. Das hat meinem ESP32 nicht geschmeckt und hat einfach gestreikt. Darum könnt ihr einfach in eurer setup() Funktion Serial.begin(115200) machen und dann statt display.init(115200) den Bug mit display.init(0) verhindern. Alternativ könnt ihr auch einfach euer Serial.begin() weglassen, aber dann besteht die Gefahr, dass der Stream noch nicht geöffnet wurde. Ihr könnt dann erst nach display.init(115200) auf den Serial-Stream schreiben.