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:
- Waveshare 7.5“ HD e-Paper (B) mit HAT Amazon (nicht gesponsert)
- Waveshare Universal e-Paper Driver Board ESP32 Amazon (nicht gesponsert)
Die komplementäre Bauteile für den Akku-Betrieb (bestellt von reichelt.at):
Datasheets
Waveshare ESP32
Driver Board: WS Wiki, WS Specification
Waveshare 7.5“ HD e-Paper (B) mit HAT: WS Wiki, WS Specification
MCP
1703-3302MB: Reichelt Datasheet
Programmierumgebung und verwendete Software
Für die Programmierung verwende ich u.a.
- Arduino IDE (kostenlos)
- Visual Studio 2019 – Community Edition (kostenlos)
- Visual Micro Extension (45 Tage kostenlos, dann 12$/Jahr oder einmalig 49$ für 1 PC – Hobby Lizenz)
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:
- Verbindung mit WiFi/Internet herstellen und Kalender-Daten von Google abrufen
- Die Batterie-Leistung über einen ADC-Pin auslesen und auswerten
- 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
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.