Wann kommt eigentlich die nächste Straßenbahn – oder U-Bahn, Bus, etc.? Zwar hat der ÖPNV in der Regel einen festen Takt, den du für deine favorisierte Haltestelle sicherlich im Kopf hast, aber was ist mit Verspätungen oder Ausfällen?
Hierfür gibt es Info-Displays an den Haltestellen oder in den Apps der Verkehrsbetriebe. Aber Maker wie du können sich auch einfach einen eigenen, auf die persönlichen Bedürfnisse zugeschnittenen Abfahrtsmonitor bauen und diesen z.B. prominent an die Wohnungstür hängen.
In diesem Projekt machst du genau das: Ein ESP32 lädt auf Knopfdruck relevante Live-Daten von der API der Verkehrsbetriebe an deinem Ort (vorausgesetzt sie bieten eine solche API an) und zeigt sie aufbereitet auf einem TFT-Display an. So siehst du sofort, ob deine nächste Verbindung pünktlich an der Haltestelle eintreffen wird – oder du noch etwas mehr Zeit hast, da die Straßenbahn ohnehin zu spät kommt.
Diese Bauteile benötigst du:
ESP32 (DevKit C* oder wie hier einen S3-Zero*)
TFT Farb-Display (ST7735)*
Button
Breadboard und Kabel
So bekommst du Live-Daten von deinen Verkehrsbetrieben
Für deinen Abfahrtsmonitor benötigst du aktuelle Daten, klar. Diese müssen mindestens die Linie, planmäßige Abfahrt an der Haltestelle sowie das Ziel bzw. die Richtung beinhalten. Ideal wären zusätzlich noch die prognostizierte Abfahrtszeit (also die Verspätung) sowie ein Hinweis, falls die Fahrt ausfällt.
Leider sind diese Daten in Deutschland genaus versprengt wie die Verkehrsbetriebe selbst – viele „kochen ihr eigenes Süppchen“ und haben ihre eigene Schnittstellen entwickelt. Ich habe aber auch einen Zusammenschluss von mehreren Verkehrsbetrieben gefunden: https://www.opendata-oepnv.de/ht/de/willkommen
Am schnellsten wirst du jedoch vorankommen, wenn du nach dem Namen deiner Verkehrsbetriebe plus „API“ suchst und die Infos deines lokalen Anbieters konsultierst. In meinem Fall ist das der Karlsruher Verkehrsverbund (KVV). Dieser hat sein eigenes Open-Data-Konzept entwickelt und bietet interessierten Privatpersonen Zugriff auf die Daten. Falls du also zufällig im (recht großen) Einzugsgebiet des KVV lebst, findest du hier weitere Infos zur Anmeldung und Verwendung der API: https://www.kvv.de/fahrplan/fahrplaene/open-data.html
Wichtig: Da es keine standardisierte Lösung gibt, gibt es leider auch keinen „Standard-Sketch“, den du nur mit ein paar persönlichen Daten anpassen musst. Hier bietet sich die künstliche Intelligenz an: Schnelle (und ausreichend gute) Lösungen bekommst du, wenn du die Dokumentation deiner Verkehrsbetriebe zusammen mit ChatGPT, Claude oder Gemini bearbeitest und deinen persönlichen Sketch daraus gießt.
Welche ID hat deine Haltestelle?
Was du auf jeden Fall benötigen wirst, sind eine oder mehrere Haltestellen, die du abfragst. Und höchstwahrscheinlich benötigst du die eindeutige ID dieser Haltestelle. Diese sind praktischerweise bundeseinheitlich vergeben – so hat die Haltestelle Barbarossaplatz in Karlsruhe die ID „de:08212:5003“. Diese ID besteht aus dem Land (de), dem Bundesland (08, Baden-Württemberg), der Stadt (212, Karlsruhe) und der Nummer der Haltestelle (5003).
Den kompletten Datensatz für ganz Deutschland kannst du hier herunterladen – du brauchst hierfür allerdings einen kostenlosen Account: https://mobilithek.info/offers/631185048592105472
Aufbau der Hardware
Für deinen Abfahrtsmonitor musst das TFT-Display und den Button mit deinem ESP32 verbinden. Wie immer eignet sich hier zunächst der Aufbau auf einem Breadboard. Wenn du zufrieden bist, kannst du später zum Lötkolben greifen. Das Ergebnis könnte dann folgendermaßen aussehen:
Hier verwende ich einen ESP32 S3-Zero. Schließe das Display und den Button wie folgt an:
___STEADY_PAYWALL___
TFT-Display
| TFT-Pin | ESP32 | Hinweis |
| VCC | 3V3 | nicht 5V ! |
| GND | GND | |
| CS | GP5 | |
| RESET | GP4 | |
| A0 / DC | GP3 | |
| SDA / MOSI | GP7 | |
| SCK | GP6 | |
| LED / BL | GP10 |
Button
| Button-Pin | ESP32 |
| Pin 1 | GP9 |
| Pin 2 | GND |
Den Button wirst du später verwenden, um Abfragen zu starten: Sobald du drückst, geht das Display an und dein ESP32 führt eine Abfrage aktueller Daten ab. Noch einmal drücken schaltet das Display wieder aus. Der ESP32 selbst läuft hier jedoch weiter, damit er sich beim Starten nicht erst wieder mit deinem WLAN verbinden muss.
Der Sketch für den Abfahrtsmonitor
Hier nun der fertige Sketch für deinen Abfahrtsmonitor – für den Karlsruher Verkehrsverbund. Dieser Sketch ist sogar noch weiter personalisiert: Er fragt nur die Tram-Linien 2 und 3 sowie die Stadtbahnlinien S1 und S11 ab – an den Haltestellen Barbarosssaplatz, Kolpingplatz und Albtalbahnhof.
Wenn du also im Landkreis Karlsruhe wohnst, kannst du diesen Sketch verwenden und an deine Bedürfnisse anpassen – nachdem du vom KVV freigeschaltet wurdest, wie oben beschrieben. Da die Chancen allerdings viel größer sind, dass das bei dir nicht der Fall ist, solltest du einfach eine KI deiner Wahl konsultieren. Kopiere hierfür einfach den folgenden Sketch und bitte ChatGPT und Co. darum, das Konzept auf deinen Verkehrsverbund zu übertragen. Das Ergebnis wird zwar vermutlich nicht sofort einsatzbereit sein, aber du kannst du dich so Schritt für Schritt zum fertigen Abfahrtsmonitor voranarbeiten.
Hier ein Beispiel für den Anfang des Umbaus für den Rhein-Main-Verkehrsverbund (RMV).
Hier also der vollständige KVV-Sketch – zum Kopieren, Weiterverwenden und -bearbeiten:
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <time.h>
// ===== TFT-Pins =====
// Board wird anhand des gewählten ESP32-Target erkannt.
#if CONFIG_IDF_TARGET_ESP32S3
// ESP32-S3 Zero (Waveshare)
#define TFT_CS 5
#define TFT_DC 3
#define TFT_RST 4
#define TFT_MOSI 7
#define TFT_SCK 6
#define BL_PIN 10
#define BTN_PIN 9
#else
// ESP32 WROOM-32 (VSPI-Defaults)
#define TFT_CS 5
#define TFT_DC 16
#define TFT_RST 4
#define TFT_MOSI 23
#define TFT_SCK 18
#define BL_PIN 17
#define BTN_PIN 27
#endif
const unsigned long AUTO_OFF_MS = 30000; // Display geht nach 30 s wieder aus
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
// ===== Konfiguration =====
const char* WIFI_SSID = "DEIN WLAN-NETZ";
const char* WIFI_PASS = "DEIN PASSWORT";
const char* TRIAS_URL = "DIE URL DER API";
const char* REQUESTOR_REF = "DEIN ZUGANGSCODE";
const unsigned long REFRESH_MS = 30000; // alle 30 s neu abfragen
const int SERVER_LIMIT = 20; // vom Server max. Abfahrten holen
const int PER_STOP = 2; // wie viele Treffer pro Linie anzeigen
struct Stop {
const char* id;
const char* name;
const char* lines; // kommasepariert, z.B. "S1,S11"
const char* destinations; // kommasepariert; leer = alle Ziele erlaubt
};
Stop stops[] = {
{ "de:08212:5003", "Barbarossaplatz", "2", "" },
{ "de:08212:63", "Kolpingplatz", "3", "" },
{ "de:08212:1201", "Albtalbahnhof", "S1,S11",
"Ittersbach Rathaus,Bad Herrenalb,Ettlingen Albgaubad,Ettlingen Stadt" },
};
const int NUM_STOPS = sizeof(stops) / sizeof(stops[0]);
unsigned long lastFetch = 0;
// ===== Display-State =====
bool screenOn = false;
unsigned long screenOnSince = 0;
int lastBtnRead = HIGH;
unsigned long lastBtnChange = 0;
bool readButtonPressed() {
int r = digitalRead(BTN_PIN);
unsigned long now = millis();
if (r != lastBtnRead && (now - lastBtnChange) > 30) {
lastBtnChange = now;
bool pressed = (lastBtnRead == HIGH && r == LOW); // fallende Flanke
lastBtnRead = r;
return pressed;
}
return false;
}
void setBacklight(bool on) {
digitalWrite(BL_PIN, on ? HIGH : LOW);
}
// ===== Hilfsfunktionen =====
String nowIsoUtc() {
time_t now = time(nullptr);
struct tm tm;
gmtime_r(&now, &tm);
char buf[25];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
return String(buf);
}
// mktime, aber interpretiert struct tm als UTC (ESP32 hat kein timegm).
// Trick: TZ kurz auf UTC setzen, mktime, TZ zurück.
time_t mktimeUtc(struct tm* tm) {
const char* saved = getenv("TZ");
String savedStr = saved ? saved : "";
setenv("TZ", "UTC0", 1);
tzset();
time_t t = mktime(tm);
if (savedStr.length()) setenv("TZ", savedStr.c_str(), 1);
else unsetenv("TZ");
tzset();
return t;
}
time_t parseUtc(const String& iso) {
struct tm tm = {};
strptime(iso.c_str(), "%Y-%m-%dT%H:%M:%SZ", &tm);
return mktimeUtc(&tm);
}
String fmtLocalHHMM(const String& iso) {
if (iso.length() < 19) return "--:--";
time_t t = parseUtc(iso);
struct tm local;
localtime_r(&t, &local);
char buf[8];
snprintf(buf, sizeof(buf), "%02d:%02d", local.tm_hour, local.tm_min);
return String(buf);
}
// Extrahiert den Inhalt zwischen zwei Tags ab Position `from`. Aktualisiert
// `from` auf die Position nach dem schließenden Tag. Leer, wenn nicht gefunden.
// Prüft, ob ab Position `lt` (muss auf '<' zeigen) ein Öffnungs-Tag mit
// Namen `tagName` beginnt — auch mit XML-Namespace-Prefix (z.B. <trias:Tag).
// Return: Position direkt hinter dem Namen (vor Attributen/'>'), oder -1.
int matchOpenTagAt(const String& src, int lt, const String& tagName) {
int n = src.length();
if (lt + 1 >= n || src.charAt(lt) != '<' || src.charAt(lt + 1) == '/') return -1;
int p = lt + 1;
// Optionalen Prefix (Buchstaben/Ziffern/_/-) gefolgt von ':' überspringen.
int prefixStart = p;
while (p < n) {
char c = src.charAt(p);
if (c == ':') { p++; break; }
if (isalnum(c) || c == '_' || c == '-') { p++; continue; }
p = prefixStart; break;
}
if (p + (int)tagName.length() > n) return -1;
for (int k = 0; k < (int)tagName.length(); k++) {
if (src.charAt(p + k) != tagName.charAt(k)) return -1;
}
int after = p + tagName.length();
char c = src.charAt(after);
if (c == '>' || c == ' ' || c == '/' || c == '\t' || c == '\n' || c == '\r') return after;
return -1;
}
// Prefix-toleranter Content-Extractor: liefert den Inhalt zwischen <tagName>
// und </tagName>, egal ob mit oder ohne Namespace-Prefix, mit oder ohne
// Attribute. Ignoriert Selbstschachtelung (TRIAS-Felder verschachteln sich
// nicht mit gleichem Namen auf der Ebene, die wir anfassen).
String betweenTag(const String& src, const String& tagName, int& from) {
int n = src.length();
int pos = from;
int openEnd = -1;
while (pos < n) {
int lt = src.indexOf('<', pos);
if (lt < 0) { from = n; return ""; }
int nameEnd = matchOpenTagAt(src, lt, tagName);
if (nameEnd > 0) {
openEnd = src.indexOf('>', nameEnd);
if (openEnd < 0) { from = n; return ""; }
if (src.charAt(openEnd - 1) == '/') { from = openEnd + 1; return ""; }
int contentStart = openEnd + 1;
// Matching Close-Tag suchen — auch Prefix-tolerant.
int scan = contentStart;
while (scan < n) {
int next = src.indexOf("</", scan);
if (next < 0) { from = n; return ""; }
int p = next + 2;
int prefixStart = p;
while (p < n) {
char c = src.charAt(p);
if (c == ':') { p++; break; }
if (isalnum(c) || c == '_' || c == '-') { p++; continue; }
p = prefixStart; break;
}
if (p + (int)tagName.length() <= n) {
bool match = true;
for (int k = 0; k < (int)tagName.length(); k++) {
if (src.charAt(p + k) != tagName.charAt(k)) { match = false; break; }
}
if (match && src.charAt(p + tagName.length()) == '>') {
from = p + tagName.length() + 1;
return src.substring(contentStart, next);
}
}
scan = next + 2;
}
from = n;
return "";
}
pos = lt + 1;
}
from = n;
return "";
}
// InternationalText-Wert lesen: <Name><Text>X</Text></Name> oder <Name>X</Name>.
String intlText(const String& src, const String& tagName) {
int p = 0;
String container = betweenTag(src, tagName, p);
if (container.length() == 0) return "";
int q = 0;
String t = betweenTag(container, "Text", q);
t.trim();
if (t.length()) return t;
container.trim();
return container;
}
String buildRequest(const char* stopId) {
String ts = nowIsoUtc();
String xml;
xml.reserve(800);
xml = F("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
xml += F("<Trias version=\"1.2\" xmlns=\"http://www.vdv.de/trias\" xmlns:siri=\"http://www.siri.org.uk/siri\">");
xml += F("<ServiceRequest>");
xml += "<siri:RequestTimestamp>" + ts + "</siri:RequestTimestamp>";
xml += "<siri:RequestorRef>" + String(REQUESTOR_REF) + "</siri:RequestorRef>";
xml += F("<RequestPayload><StopEventRequest>");
xml += "<Location><LocationRef><StopPointRef>" + String(stopId) + "</StopPointRef></LocationRef>";
xml += "<DepArrTime>" + ts + "</DepArrTime></Location>";
xml += "<Params><NumberOfResults>" + String(SERVER_LIMIT) + "</NumberOfResults>";
xml += F("<StopEventType>departure</StopEventType>");
xml += F("<IncludeRealtimeData>true</IncludeRealtimeData></Params>");
xml += F("</StopEventRequest></RequestPayload></ServiceRequest></Trias>");
return xml;
}
// ===== TFT-Layout / Farben =====
const int TFT_W = 160;
const int TFT_H = 128;
const int TFT_ROW_H = 9;
const int TFT_STOP_GAP = 8; // Leerzeile zwischen Stops
const int TFT_HEADER_H = 18; // Kopfzeile + Leerzeile danach
// Brown existiert nicht als Konstante — wir mischen's selbst (RGB565).
uint16_t COLOR_BROWN;
uint16_t COLOR_GREY;
int tftY = 0;
// UTF-8 -> CP437: Umlaute und ß auf die Positionen im Default-Font mappen.
// Voraussetzung: tft.cp437(true) in setup().
String toCp437(const String& utf8) {
String out;
out.reserve(utf8.length());
int n = utf8.length();
for (int i = 0; i < n; i++) {
uint8_t c = (uint8_t)utf8[i];
if (c < 0x80) { out += (char)c; continue; }
if ((c & 0xE0) == 0xC0 && i + 1 < n) {
uint8_t c2 = (uint8_t)utf8[i + 1];
uint16_t cp = ((c & 0x1F) << 6) | (c2 & 0x3F);
i++;
char m;
switch (cp) {
case 0x00E4: m = (char)0x84; break; // ä
case 0x00F6: m = (char)0x94; break; // ö
case 0x00FC: m = (char)0x81; break; // ü
case 0x00DF: m = (char)0xE1; break; // ß
case 0x00C4: m = (char)0x8E; break; // Ä
case 0x00D6: m = (char)0x99; break; // Ö
case 0x00DC: m = (char)0x9A; break; // Ü
default: m = '?'; break;
}
out += m;
} else if ((c & 0xF0) == 0xE0 && i + 2 < n) {
i += 2; out += '?'; // 3-Byte UTF-8 -> Platzhalter
} else if ((c & 0xF8) == 0xF0 && i + 3 < n) {
i += 3; out += '?'; // 4-Byte UTF-8 -> Platzhalter
}
}
return out;
}
uint16_t colorForLine(const String& line) {
String l = line; l.trim();
if (l == "2") return ST77XX_BLUE;
if (l == "3") return COLOR_BROWN;
if (l.length() >= 1 && (l[0] == 'S' || l[0] == 's')) return ST77XX_GREEN;
return ST77XX_WHITE;
}
uint16_t colorForDelay(int delayMin, bool realtime) {
if (!realtime) return COLOR_GREY;
if (delayMin <= 0) return ST77XX_GREEN;
if (delayMin < 5) return ST77XX_YELLOW;
return ST77XX_RED;
}
void tftHeader() {
tft.fillScreen(ST77XX_BLACK);
tftY = 6;
tft.setTextSize(1);
tft.setTextColor(ST77XX_WHITE);
tft.setCursor(2, tftY);
time_t now = time(nullptr);
struct tm lt;
localtime_r(&now, <);
char buf[32];
snprintf(buf, sizeof(buf), "Abfahrten %02d:%02d", lt.tm_hour, lt.tm_min);
tft.print(buf);
tftY += TFT_HEADER_H;
}
void tftStopName(const char* name) {
tft.setCursor(2, tftY);
tft.setTextColor(ST77XX_YELLOW);
tft.print(toCp437(String(name)));
tftY += TFT_ROW_H;
}
void tftRow(const String& timeStr, int delayMin, bool realtime,
const String& line, const String& dest) {
tft.setTextSize(1);
// Spalten (in 6-Pixel-Char-Schritten): time(0..5) delay(6..10) line(11..14) dest(15..25)
tft.setCursor(2 + 6 * 0, tftY);
tft.setTextColor(ST77XX_WHITE);
tft.print(timeStr);
char delayBuf[6];
if (!realtime) snprintf(delayBuf, sizeof(delayBuf), "---");
else if (delayMin == 0) snprintf(delayBuf, sizeof(delayBuf), "pkt");
else if (delayMin > 0) snprintf(delayBuf, sizeof(delayBuf), "+%dm", delayMin);
else snprintf(delayBuf, sizeof(delayBuf), "%dm", delayMin);
tft.setCursor(2 + 6 * 6, tftY);
tft.setTextColor(colorForDelay(delayMin, realtime));
tft.print(delayBuf);
tft.setCursor(2 + 6 * 11, tftY);
tft.setTextColor(colorForLine(line));
tft.print(line);
String d = toCp437(dest);
if (d.length() > 11) d = d.substring(0, 10) + ".";
tft.setCursor(2 + 6 * 15, tftY);
tft.setTextColor(ST77XX_WHITE);
tft.print(d);
tftY += TFT_ROW_H;
}
void tftNote(const char* msg, uint16_t color) {
tft.setCursor(10, tftY);
tft.setTextColor(color);
tft.print(toCp437(String(msg)));
tftY += TFT_ROW_H;
}
void tftStopGap() { tftY += TFT_STOP_GAP; }
bool iequals(const String& a, const String& b) {
if (a.length() != b.length()) return false;
for (size_t i = 0; i < a.length(); i++) {
if (tolower(a[i]) != tolower(b[i])) return false;
}
return true;
}
// Kommasepariertes `csv` durchgehen. Für jeden (getrimmten) Eintrag wird
// entweder ein Exact-Match (prefix=false) oder ein Prefix-Match
// (prefix=true) gegen `value` geprüft, jeweils case-insensitive.
// Leeres csv -> Rückgabe `emptyMeans`.
bool csvMatch(const char* csv, const String& value, bool prefix, bool emptyMeans) {
if (!csv || !*csv) return emptyMeans;
String v = value; v.trim();
const char* p = csv;
while (*p) {
const char* q = p;
while (*q && *q != ',') q++;
int s = 0, e = (int)(q - p);
while (s < e && isspace((unsigned char)p[s])) s++;
while (e > s && isspace((unsigned char)p[e - 1])) e--;
int len = e - s;
if (len > 0 && len <= (int)v.length()) {
bool ok = prefix ? true : (len == (int)v.length());
for (int k = 0; ok && k < len; k++) {
if (tolower(p[s + k]) != tolower(v[k])) ok = false;
}
if (ok) return true;
}
if (*q == 0) break;
p = q + 1;
}
return false;
}
void printDeparture(const String& scheduled, const String& estimated, const String& line, const String& dest) {
String schedStr = fmtLocalHHMM(scheduled);
bool realtime = estimated.length() > 0;
String estStr = realtime ? fmtLocalHHMM(estimated) : "";
String timeCell;
if (realtime && estStr != schedStr) timeCell = schedStr + " -> " + estStr;
else timeCell = realtime ? estStr : schedStr;
String delayCell;
if (!realtime) {
delayCell = "(Plan)";
} else {
long delaySec = (long)parseUtc(estimated) - (long)parseUtc(scheduled);
int delayMin = (int)(delaySec / 60);
if (delayMin == 0) delayCell = "puenktl.";
else { char b[12]; snprintf(b, sizeof(b), "%+d min", delayMin); delayCell = b; }
}
Serial.printf(" %-18s %-10s %-4s -> %s\n",
timeCell.c_str(), delayCell.c_str(), line.c_str(), dest.c_str());
// TFT: zeigt die "beste" Zeit (Echtzeit wenn vorhanden, sonst Plan)
int delayMin = 0;
if (realtime) {
long delaySec = (long)parseUtc(estimated) - (long)parseUtc(scheduled);
delayMin = (int)(delaySec / 60);
}
tftRow(realtime ? estStr : schedStr, delayMin, realtime, line, dest);
}
void queryStop(const Stop& stop) {
if (stop.destinations && *stop.destinations) {
Serial.printf("\n> %s (Linien %s; Ziele %s)\n",
stop.name, stop.lines, stop.destinations);
} else {
Serial.printf("\n> %s (Linien %s)\n", stop.name, stop.lines);
}
tftStopName(stop.name);
WiFiClientSecure client;
client.setInsecure(); // Cert-Prüfung aus — für Demo ok; später Root-Cert hinzufügen
HTTPClient http;
if (!http.begin(client, TRIAS_URL)) {
Serial.println(" x HTTP begin fehlgeschlagen");
tftNote("HTTP begin fehlgeschlagen", ST77XX_RED);
tftStopGap();
return;
}
http.addHeader("Content-Type", "text/xml; charset=utf-8");
int code = http.POST(buildRequest(stop.id));
if (code != 200) {
Serial.printf(" x HTTP %d\n", code);
char err[24];
snprintf(err, sizeof(err), "HTTP %d", code);
tftNote(err, ST77XX_RED);
http.end();
tftStopGap();
return;
}
String response = http.getString();
http.end();
int pos = 0;
int shown = 0;
int seenBlocks = 0;
while (true) {
String block = betweenTag(response, "StopEventResult", pos);
if (block.length() == 0 && pos >= (int)response.length()) break;
if (block.length() == 0) continue;
seenBlocks++;
String line = intlText(block, "PublishedLineName");
if (!csvMatch(stop.lines, line, false, false)) continue;
String dest = intlText(block, "DestinationText");
if (!csvMatch(stop.destinations, dest, true, true)) continue;
if (shown >= PER_STOP) { shown++; continue; }
int p = 0;
String tt = betweenTag(block, "TimetabledTime", p);
p = 0;
String et = betweenTag(block, "EstimatedTime", p);
printDeparture(tt, et, line, dest);
shown++;
}
if (shown == 0) {
Serial.printf(" (keine passende Abfahrt)\n");
tftNote("keine passende Abfahrt", COLOR_GREY);
if (seenBlocks == 0) {
Serial.println(" (keine StopEventResult-Bloecke in der Antwort)");
Serial.printf(" Response-Laenge: %d Bytes\n", response.length());
}
}
tftStopGap();
}
// ===== setup / loop =====
void setup() {
Serial.begin(115200);
delay(200);
Serial.println();
// Backlight + Taster
pinMode(BL_PIN, OUTPUT);
setBacklight(false); // Display startet AUS
pinMode(BTN_PIN, INPUT_PULLUP);
// TFT init
SPI.begin(TFT_SCK, -1, TFT_MOSI); // SPI-Pins explizit binden (S3 hat keine festen Defaults)
tft.initR(INITR_BLACKTAB); // ggf. GREENTAB / REDTAB probieren
tft.setRotation(1); // Querformat 160x128
tft.fillScreen(ST77XX_BLACK);
tft.setTextSize(1);
tft.setTextWrap(false);
tft.cp437(true); // aktiviert Codepage 437 -> Umlaute im Default-Font
COLOR_BROWN = tft.color565(139, 69, 19);
COLOR_GREY = tft.color565(128, 128, 128);
tft.setTextColor(ST77XX_WHITE);
tft.setCursor(2, 0);
tft.print("Booting...");
Serial.print("WLAN: ");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print("."); }
Serial.printf(" verbunden (%s)\n", WiFi.localIP().toString().c_str());
tft.fillScreen(ST77XX_BLACK);
tft.setCursor(2, 0);
tft.print("WLAN ok");
// NTP: Uhrzeit holen (UTC), dann lokale TZ Europe/Berlin setzen
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
Serial.print("Zeit-Sync");
while (time(nullptr) < 1700000000) { delay(300); Serial.print("."); }
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
Serial.println(" ok");
}
void loop() {
// Taster: toggeln zwischen an und aus
if (readButtonPressed()) {
screenOn = !screenOn;
setBacklight(screenOn);
if (screenOn) {
screenOnSince = millis();
lastFetch = 0; // sofort neu abfragen
tft.fillScreen(ST77XX_BLACK);
tft.setCursor(2, 0);
tft.setTextColor(ST77XX_WHITE);
tft.setTextSize(1);
tft.print("Lade...");
Serial.println("[Display AN]");
} else {
Serial.println("[Display AUS]");
}
}
// Auto-Aus nach AUTO_OFF_MS
if (screenOn && millis() - screenOnSince > AUTO_OFF_MS) {
screenOn = false;
setBacklight(false);
Serial.println("[Display AUS (Timeout)]");
}
// Daten nur abfragen + zeichnen, wenn das Display gerade an ist
if (screenOn && (lastFetch == 0 || millis() - lastFetch > REFRESH_MS)) {
lastFetch = millis();
Serial.println();
Serial.println("=== Abfahrtstafel ===");
tftHeader();
for (int i = 0; i < NUM_STOPS; i++) queryStop(stops[i]);
}
delay(10);
}


