Initial commit

This commit is contained in:
2026-08-01 23:38:09 +02:00
parent 0d9cca3668
commit a99b986544
28 changed files with 1476 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
.pio/
.vscode/
include/secrets.h
*.o
*.bin
+122
View File
@@ -0,0 +1,122 @@
# PLAN.md Umsetzungsplan M5Stack Dashboard
_Letztes Update: 2026-08-01 23:18 CEST_
Dieses Dokument beschreibt, in welcher Reihenfolge und mit welchem Ansatz das Projekt aus SPEC-final.md umgesetzt wird. Ziel ist ein iteratives Vorgehen mit kleinen, in sich funktionierenden Schritten, sodass nach jedem Schritt ein sichtbares Ergebnis auf dem M5Stack Core zu sehen ist.
## Leitprinzip
Jeder Schritt wird als eigener, eng gefasster Prompt an OpenCode gegeben. Kein Schritt soll mehr als 20-30 Minuten inkl. Flash und Test auf dem Gerät dauern. Erst wenn ein Schritt lauffähig ist, wird der nächste angegangen.
## Phase 0 Projekt-Grundgerüst
Ziel: Ein leeres, aber lauffähiges PlatformIO-Projekt, das auf dem M5Stack Core bootet und einen Testtext anzeigt.
- PlatformIO-Projekt für M5Stack Core anlegen (`platformio.ini` mit passendem Board, Framework Arduino, M5Stack-Library als Dependency)
- Ordnerstruktur gemäß SPEC-final.md Abschnitt 10 anlegen (`include/`, `src/screens/`, `src/api/`, `src/icons/`, `scripts/`)
- `secrets.h.example` mit Platzhaltern für WLAN SSID/Passwort anlegen, `.gitignore` vorbereiten (auch wenn Git noch nicht initialisiert wird)
- Minimaler `main.cpp`, der nur "Hello Dashboard" auf dem Display zeigt
- `scripts/deploy.sh` erstellen: Build + Flash via PlatformIO CLI, automatische Port-Erkennung mit optionalem Port-Parameter
**Abnahmekriterium**: `./scripts/deploy.sh` baut und flasht erfolgreich, Display zeigt Testtext.
## Phase 1 WLAN & HTTP-Grundlage
Ziel: Das Gerät verbindet sich mit dem WLAN und kann eine der drei APIs erfolgreich abrufen und als Rohtext auf dem Display anzeigen.
- WLAN-Verbindung mit `secrets.h`-Werten aufbauen, Verbindungsstatus auf Display anzeigen
- Generischen HTTP-GET-Client (`src/api/`) bauen, der eine URL abruft und den Response-Body zurückgibt
- JSON-Parsing-Library einbinden (z.B. ArduinoJson)
- Testweise Wetter-API abrufen und Rohwerte (Temperatur, Beschreibung) als Text anzeigen
**Abnahmekriterium**: Aktuelle Temperatur von der echten Wetter-API wird nach Boot auf dem Display angezeigt.
## Phase 2 Screen-Grundgerüst & Navigation
Ziel: Die Screen-Struktur und Button-Navigation stehen, noch ohne fertiges Design.
- Screen-Abstraktion bauen (z.B. einfaches State-Machine-Pattern: aktueller Screen als Enum, Render-Funktion pro Screen)
- Vier Screens als leere Platzhalter anlegen: Home, WeatherDetail, CalendarDetail, MVG
- Button-Logik gemäß SPEC-final.md Abschnitt 6 umsetzen (A: Toggle Wetter/Kalender-Detail, B: zurück zu Home, C: MVG-Screen)
- 5-Minuten-Inaktivitäts-Timeout implementieren, der zurück zu Home springt
- Grundlegendes Farbschema (schwarz/weiß, iOS-Dark-Mode-Look) als globale Konstanten/Theme-Datei anlegen
**Abnahmekriterium**: Mit den drei Buttons kann zuverlässig zwischen allen vier Screens (Platzhaltertexte reichen) navigiert werden, Timeout funktioniert.
## Phase 3 Home-Screen (final)
Ziel: Der wichtigste Screen ist fertig gestaltet und mit echten Daten befüllt.
- Wetter-API und Kalender-API in den Home-Screen integrieren
- Weicher Regen-Hinweis über die nächsten 8 Vorhersage-Stunden implementieren (Banner/Icon)
- Anzeige der nächsten 2 Kalendertermine inkl. Sonderbehandlung für `all_day`
- 10-Minuten-Refresh-Timer implementieren
- Layout und Typografie gemäß UI-Konzept (Abschnitt 7) verfeinern: große Temperatur, klare Hierarchie
**Abnahmekriterium**: Home-Screen zeigt aktuelles Wetter, Regen-Hinweis (wenn zutreffend) und die nächsten 2 Termine an, aktualisiert sich automatisch alle 10 Minuten.
## Phase 4 Icon-Set (farbig, RGB565)
Ziel: Text-Platzhalter werden durch die im UI-Konzept beschriebenen farbigen Bitmap-Icons ersetzt.
- Icon-Liste finalisieren (Sonne/klar, bewölkt, Regen, Nacht-Varianten, U-Bahn, S-Bahn, Kalender, Regen-Warnung, Retry/Fehler)
- Icons als RGB565-Bitmap-Arrays erzeugen (z.B. per Konvertierungsskript aus PNG-Vorlagen) und in `src/icons/` ablegen
- Icons in Home-Screen integrieren (Wettericon, Regen-Warnsymbol)
- Mapping von `symbol`-Feld (z.B. `mo____`, `mb____`, `wb____`) auf das jeweilige Icon implementieren
**Abnahmekriterium**: Home-Screen nutzt farbige Icons statt reinem Text für Wetterzustand und Regen-Hinweis.
## Phase 5 Wetter-Detailseite
Ziel: Vollständige stundenweise Vorhersage mit Icons.
- Abruf und Darstellung der vollständigen `forecast`-Liste (bis zu 8 Stunden)
- Pro Stunde: Zeit, Temperatur, Icon, Regenwahrscheinlichkeit
- Scroll- oder Paginierungslogik falls nicht alle Einträge auf einen Screen passen
**Abnahmekriterium**: Über Button A von Home aus erreichbar, zeigt stündliche Vorhersage mit Icons korrekt an.
## Phase 6 Kalender-Detailseite
Ziel: Vollständige Terminliste.
- Abruf und Darstellung aller 10 Termine aus der Kalender-API
- Gleiche Sonderbehandlung für `all_day` wie auf der Hauptseite
- Scroll-/Paginierungslogik analog zu Phase 5
**Abnahmekriterium**: Über Button A (Toggle von Wetter-Detail) erreichbar, zeigt alle 10 Termine übersichtlich an.
## Phase 7 MVG-Abfahrtsseite
Ziel: Vollständige, ungefilterte Abfahrtenliste mit 1-Minuten-Refresh.
- Abruf und Darstellung aller Einträge aus `departures` (beide Stationen, U- und S-Bahn gemischt)
- Farbliche Kennzeichnung U-Bahn vs. S-Bahn (dezente Akzentfarben gemäß Theme)
- Darstellung von Verspätung (`delay_min`) und Ausfall (`cancelled`)
- 1-Minuten-Refresh-Timer implementieren
**Abnahmekriterium**: Über Button C von jedem Screen aus erreichbar, zeigt aktuelle Abfahrten an, aktualisiert sich jede Minute.
## Phase 8 Fehlerbehandlung
Ziel: Robustheit bei nicht erreichbaren APIs.
- Timeout- und Fehlerbehandlung für alle drei API-Clients implementieren
- Einheitliche Fehleranzeige (Retry-Icon + Text) pro betroffenem Screen
- Retry-Logik: automatisch beim nächsten regulären Refresh-Intervall, zusätzlich manuell durch erneuten Tastendruck auf den jeweiligen Screen-Button
**Abnahmekriterium**: Bei simuliertem API-Ausfall (z.B. Docker-Service kurz stoppen) zeigt das Gerät eine saubere Fehlermeldung statt abzustürzen oder hängen zu bleiben, und erholt sich automatisch nach Wiederverfügbarkeit.
## Phase 9 Politur & Feinschliff
Ziel: Letzter Schliff für ein rundes Gesamtbild.
- Konsistenzprüfung aller Screens gegen das UI-Konzept (Kontrast, Schriftgrößen, Abstände)
- Performance-Check (Speicherverbrauch, Rendering-Geschwindigkeit bei Refresh)
- Code-Aufräumen, Kommentare, README.md mit Setup-Anleitung (WLAN-Konfiguration, Flash-Vorgang) schreiben
**Abnahmekriterium**: Projekt läuft stabil im Dauerbetrieb, README erklärt Setup für zukünftiges Ich.
## Spätere Schritte (nicht Teil dieser Phasen)
- Git-Repository initialisieren und optional mit GitHub/GitLab-Remote verknüpfen (bewusst erst nach funktionierendem Code, siehe SPEC-final.md Abschnitt 3)
+77 -1
View File
@@ -1 +1,77 @@
# m5stack-dashboard
# M5Stack Core Dashboard
Wetter-, Kalender- und MVG-Abfahrten-Dashboard für den M5Stack Core (ESP32),
gesteuert über die 3 eingebauten Buttons (A, B, C).
Details zu Funktionsumfang, API-Formaten und Design-Entscheidungen stehen in
`SPEC-final.md` im Space. Umsetzungsschritte stehen in `PLAN.md` und `TODO.md`.
## Setup
1. PlatformIO CLI installieren:
```
pip install -U platformio
```
2. WLAN-Zugangsdaten eintragen:
```
cp include/secrets.h.example include/secrets.h
```
Dann in `include/secrets.h` `WIFI_SSID` und `WIFI_PASSWORD` anpassen.
Die drei API-URLs sind bereits vorbefüllt.
3. Gerät per USB anschließen.
## Build & Flash
```
./scripts/deploy.sh
```
Falls mehrere serielle Geräte angeschlossen sind und die automatische
Port-Erkennung fehlschlägt, kann der Port explizit übergeben werden:
```
./scripts/deploy.sh /dev/ttyUSB0
```
Das Skript baut nur und flasht, es öffnet keinen seriellen Monitor.
Falls du den Monitor separat sehen willst:
```
pio device monitor
```
## Bedienung
| Button | Funktion |
|---|---|
| A | Wechselt zwischen Wetter-Detail und Kalender-Detail |
| B | Zurück zur Hauptseite |
| C | MVG-Abfahrtsseite |
Nach 5 Minuten ohne Tastendruck springt das Gerät automatisch zurück zur
Hauptseite. Die Hauptseite aktualisiert sich alle 10 Minuten, die
MVG-Seite jede Minute.
## Icons
Die Icons (Sonne, Wolke, Regen, U-/S-Bahn-Badges, Kalender, Fehler-Symbol)
werden aktuell prozedural mit M5Stack-Grafikprimitiven gezeichnet
(`src/icons/icons.h`), um den Flash-Speicher zu schonen. Für echte
Pixel-Art-Bitmaps können die Funktionskörper später durch
`M5.Lcd.drawBitmap(...)`-Aufrufe mit RGB565-Arrays ersetzt werden.
## Git
Dieses Projekt ist bewusst noch nicht als Git-Repository initialisiert.
Sobald gewünscht:
```
git init
git add .
git commit -m "Initial M5Stack dashboard"
```
`include/secrets.h` ist bereits in `.gitignore` ausgeschlossen.
+239
View File
@@ -0,0 +1,239 @@
# M5Stack Core Wetter/Kalender/MVG Dashboard
_Letztes Update: 2026-08-01 23:16 CEST_
## 1. Ziel
Ein M5Stack Core (ESP32, 2.0" IPS Display, 3 physische Buttons: A, B, C) zeigt Wetter, Kalendertermine und MVG-Abfahrten an. Die Navigation erfolgt ausschließlich über die drei Buttons, es gibt keine Touch-Bedienung. Besonderer Fokus liegt auf einer visuell ansprechenden, **farbigen** UI mit eigenen Bitmap-Icons und einem **dunklen Farbschema**, die trotz des kleinen 2.0" Displays hochwertig aussieht.
## 2. Hardware
- **Gerät**: M5Stack Core (Basic), ESP32-basiert
- **Display**: 2.0" IPS, 320x240 px
- **Eingabe**: 3 Buttons (A, B, C)
- **Netzwerk**: WLAN (Heimnetz, Zugriff auf `*.fritz.box` Hosts)
## 3. Toolchain
- **Build-System**: PlatformIO (kein Arduino IDE)
- **Deployment**: Eigenes Deploy-Skript, das ausschließlich **Build + Flash** durchführt (kein automatisches Öffnen des seriellen Monitors, kein zusätzlicher Schritt danach). USB-Port wird standardmäßig automatisch erkannt (PlatformIO-Standardverhalten), kann aber optional als Parameter/Umgebungsvariable an das Skript übergeben werden, um einen festen Port zu erzwingen (z.B. `./deploy.sh /dev/ttyUSB0`).
- **WLAN-Zugangsdaten**: Fest im Code hinterlegt, aber ausgelagert in eine eigene Datei (z.B. `include/secrets.h` oder `src/secrets.cpp`), die per `.gitignore` vom Git-Repo ausgeschlossen wird. Ein `secrets.h.example` mit Platzhaltern wird stattdessen eingecheckt.
- **Zeitsynchronisation**: Keine eigene NTP-Sync im Gerät. Alle Zeitangaben werden 1:1 so übernommen und dargestellt, wie sie von den APIs geliefert werden (keine relative Umrechnung wie "in 20 Minuten").
- **Versionskontrolle**: Für den ersten Wurf wird noch kein Git-Repository angelegt bzw. initialisiert (kein `git init`, kein Remote). Die Projektstruktur inkl. `.gitignore` und `secrets.h.example` wird trotzdem von Anfang an sauber vorbereitet, damit später jederzeit unkompliziert `git init` + Remote-Verknüpfung nachgeholt werden kann.
## 4. Datenquellen (APIs)
Alle APIs liegen im lokalen Netz und liefern JSON per HTTP GET.
### 4.1 Wetter-API
- **Endpoint**: `http://<host>:<port>/api/weather`
- **Methode**: GET
- **Beispiel-Response**:
```json
{
"current": {
"temperature": 20,
"symbol": "mo____",
"description": "Klar",
"emoji": "🌙"
},
"forecast": [
{
"time": "2026-08-01T23:00:00+02:00",
"temperature": 20,
"symbol": "mb____",
"description": "Bewölkt",
"emoji": "🌙",
"precipitation": { "probability": 0.2, "type": "rain" }
},
{
"time": "2026-08-02T00:00:00+02:00",
"temperature": 20,
"symbol": "mb____",
"description": "Bewölkt",
"emoji": "🌙",
"precipitation": { "probability": 0.2, "type": "rain" }
}
]
}
```
- `current`: aktuelles Wetter
- `forecast`: stündliche Vorhersage (im Beispiel 8 Einträge), jeder Eintrag enthält u.a. `precipitation.probability` (0.01.0) und `precipitation.type` (z.B. `"rain"`)
- `symbol`: interner Wettercode (z.B. `mo____` = klar/Mond, `mb____` = bewölkt/Mond, `wb____` = bewölkt/Tag). Wird als Grundlage für die Auswahl des passenden Bitmap-Icons verwendet (siehe Abschnitt 7)
### 4.2 Kalender-API
- **Endpoint**: `http://<host>:<port>/api/events`
- **Methode**: GET
- **Liefert**: die nächsten 10 Termine (bereits chronologisch sortiert, serverseitig limitiert)
- **Beispiel-Response**:
```json
{
"events": [
{
"id": 205,
"summary": "Sommerferien",
"description": "",
"location": "",
"start_at": "2026-08-03T00:00:00",
"end_at": "2026-09-15T00:00:00",
"all_day": true,
"status": "CONFIRMED"
},
{
"id": 113,
"summary": "Meeting",
"description": "",
"location": "",
"start_at": "2026-08-03T08:00:00",
"end_at": "2026-08-03T09:00:00",
"all_day": false,
"status": "CONFIRMED"
}
],
"count": 10,
"query_time": "2026-08-01T20:58:37.858934Z"
}
```
- Für die Hauptseite werden die ersten 2 Einträge aus `events` verwendet (nächste 2 Termine)
- `all_day` Termine werden anders dargestellt als Termine mit konkreter Uhrzeit (nur Datum statt Uhrzeit)
- Zeiten (`start_at`, `end_at`) werden unverändert übernommen, keine Umrechnung/Lokalisierung
### 4.3 MVG-Abfahrten-API
- **Endpoint**: `http://<host>:<port>/api/departures`
- **Methode**: GET
- **Beispiel-Response** (gekürzt):
```json
{
"departures": [
{
"station": "Josephsburg, München",
"type": "UBAHN",
"icon": "U",
"line": "U2",
"destination": "Feldmoching",
"time_epoch": 1785618120,
"time_str": "23:02",
"delay_min": -1,
"cancelled": false,
"messages": []
},
{
"station": "Berg am Laim, München",
"type": "SBAHN",
"icon": "S",
"line": "S2",
"destination": "Pasing",
"time_epoch": 1785618840,
"time_str": "23:14",
"delay_min": 4,
"cancelled": false,
"messages": []
}
]
}
```
- **Kein Filter**: Es werden alle zurückgelieferten Abfahrten (beide Stationen, U-Bahn und S-Bahn gemischt) angezeigt, in der Reihenfolge wie von der API geliefert
- `delay_min`: Verspätung in Minuten (kann negativ sein = früher), `cancelled`: Ausfall-Flag
- `time_str` wird direkt übernommen (keine eigene Zeitberechnung)
## 5. Screens
### 5.1 Hauptseite (Home)
Wird nach Boot standardmäßig angezeigt und ist der "Ruhezustand" des Geräts.
Inhalt:
- Aktuelle Temperatur + Icon + Beschreibung (aus `current`)
- Regen-Hinweis (weicher Schwellwert): Sobald irgendein Eintrag der nächsten 8 Vorhersage-Stunden `precipitation.type == "rain"` mit `probability > 0` enthält, wird ein Regen-Hinweis-Icon/Banner angezeigt. Es wird also lieber zu früh als zu spät gewarnt.
- Nächste 2 Kalendertermine (Summary + Datum/Uhrzeit, `all_day` gesondert markiert)
Refresh: alle 10 Minuten (Wetter + Kalender neu abrufen)
### 5.2 Wetter-Detailseite
Inhalt:
- Aktuelles Wetter (ausführlicher als Home)
- Stundenweise Vorhersage aus `forecast` (Zeit, Temperatur, Icon, Regenwahrscheinlichkeit) mit Icons statt Text wo sinnvoll
### 5.3 Kalender-Detailseite
Inhalt:
- Liste aller 10 Termine aus `events` (nicht nur die ersten 2 wie auf der Hauptseite)
### 5.4 MVG-Abfahrtsseite
Inhalt:
- Liste aller Abfahrten aus `departures`, ohne Filterung nach Station oder Linie (Linie, Ziel, Zeit, Verspätung, ggf. Ausfall-Hinweis)
Refresh: jede Minute
## 6. Navigation (Buttons)
| Button | Funktion |
|---|---|
| A | Wechselt zwischen den Detailseiten Wetter und Kalender (Toggle) |
| B | Springt zurück zur Hauptseite |
| C | Öffnet die MVG-Abfahrtsseite |
- Automatischer Rücksprung zur Hauptseite nach 5 Minuten Inaktivität (kein Button-Druck), unabhängig davon, auf welcher Seite man sich gerade befindet
## 7. UI- und Icon-Konzept
Ein zentraler Bestandteil des Projekts ist eine hochwertige, **farbige** und für das kleine Display optimierte Oberfläche, kein reiner Text-Dump.
- **Farbschema "iPhone Dark Mode"-Look**: Primär reines/sehr dunkles Schwarz (`#000000` bzw. `#0B0B0D`-ähnlich) als Hintergrund mit weißem bzw. sehr hellem Text (`#FFFFFF` / `#F2F2F7`) als Basis, ganz im Stil von iOS Dark Mode. Farbe wird bewusst zurückhaltend und dezent als Akzent eingesetzt (z.B. gedämpfte Blau-/Grüntöne für Wetter, eigene dezente Akzentfarbe für Kalender, an echte MVV-Linienfarben angelehnte, aber nicht grelle Töne für U-Bahn/S-Bahn), nicht als große flächige Buntheit
- **Eigene farbige Bitmap-Icons** statt Unicode-Emojis (M5Stack-Displays unterstützen keine nativen Emoji-Fonts). Icons werden als eingebettete Bitmaps (RGB565-Arrays) im Code hinterlegt, nicht als Dateien auf SD-Karte, um Ladezeiten zu vermeiden
- **Icon-Set mindestens für**: Sonne/klar, bewölkt, Regen, Nacht-Varianten (basierend auf dem `symbol`-Feld, z.B. `mo____`, `mb____`, `wb____`), U-Bahn-Symbol, S-Bahn-Symbol, Kalender-Symbol, Warn-/Regen-Hinweis-Symbol, Retry-/Fehler-Symbol alle farbig statt monochrom
- **Layout-Prinzipien**: Klare visuelle Hierarchie (große Temperatur, kleinere Nebeninfos), hoher Kontrast durch schwarz/weiß als Basis, moderne, aufgeräumte, iOS-inspirierte Optik ohne überladene Screens, dezente Akzentfarben statt vieler bunter Flächen
- **Typografie**: Angepasste, gut lesbare, weiße Schriftgrößen für das 320x240 Display vor schwarzem Hintergrund, wichtige Werte (Temperatur, Abfahrtszeit) deutlich größer und ggf. fett gegenüber Nebeninfos, ganz im Stil moderner iOS-Widgets
## 8. Fehlerbehandlung
- Bei nicht erreichbarer API: Einfache Fehleranzeige auf dem betroffenen Screen (z.B. Retry-Icon + kurzer Text wie "Keine Verbindung")
- **Retry-Auslöser**: Automatisch beim nächsten regulären Refresh-Intervall der jeweiligen Seite (10 Minuten bzw. 1 Minute), zusätzlich manuell durch erneuten Tastendruck auf den Button, der die aktuelle Seite aufruft
- Kein Vorhalten "letzter bekannter Werte" über den Fehlerzustand hinaus gefordert, es genügt die einfache Fehleranzeige bis zum nächsten erfolgreichen Refresh
## 9. Refresh-Intervalle
| Seite/Datenquelle | Intervall |
|---|---|
| Hauptseite (Wetter + Kalender) | 10 Minuten |
| MVG-Abfahrtsseite | 1 Minute |
| Wetter-Detailseite | folgt Hauptseiten-Intervall (10 Minuten), da gleiche Datenquelle |
| Kalender-Detailseite | folgt Hauptseiten-Intervall (10 Minuten), da gleiche Datenquelle |
## 10. Projektstruktur (PlatformIO)
Git-Initialisierung und Remote-Verknüpfung erfolgen bewusst zu einem späteren Zeitpunkt, nicht in diesem ersten Schritt. Die Ordnerstruktur wird aber von Anfang an git-freundlich vorbereitet:
```
/
├── .gitignore (schließt u.a. include/secrets.h, .pio/ aus bereits vorbereitet für späteres git init)
├── platformio.ini
├── include/
│ ├── secrets.h.example (Platzhalter für WLAN, später einzuchecken)
│ └── secrets.h (lokal, später nicht einzuchecken)
├── src/
│ ├── main.cpp
│ ├── screens/ (Home, WeatherDetail, CalendarDetail, MVG)
│ ├── api/ (HTTP-Clients für Weather, Calendar, MVG)
│ └── icons/ (farbige Bitmap-Icon-Definitionen, RGB565)
├── scripts/
│ └── deploy.sh (Build + Flash via PlatformIO CLI, USB-Port automatisch erkannt oder optional als Parameter übergeben, kein Monitor)
└── README.md
```
- `deploy.sh` ruft im Kern `pio run --target upload` auf; ohne Parameter wird der Port automatisch erkannt, mit Parameter (z.B. `./deploy.sh /dev/ttyUSB0`) wird ein fester Port erzwungen. Kein automatisches Starten des seriellen Monitors oder weiterer Schritte danach.
## 11. Offene Punkte / Rückfragen
Aktuell keine offenen Punkte mehr, alle wesentlichen Entscheidungen (Toolchain, UI-Stil, Farbschema, Git-Timing, USB-Port-Handling) sind getroffen. Git-Initialisierung und Remote-Repo werden bewusst erst in einem späteren Schritt nachgeholt, sobald der Code lokal funktioniert.
+98
View File
@@ -0,0 +1,98 @@
# TODO.md Einzelne Tasks
_Letztes Update: 2026-08-01 23:18 CEST_
Checkliste abgeleitet aus PLAN.md. Reihenfolge entspricht der empfohlenen Umsetzungsreihenfolge.
## Phase 0 Projekt-Grundgerüst
- [ ] PlatformIO-Projekt für M5Stack Core initialisieren (`platformio.ini`: Board, Framework Arduino, M5Stack-Library)
- [ ] Ordnerstruktur anlegen: `include/`, `src/screens/`, `src/api/`, `src/icons/`, `scripts/`
- [ ] `include/secrets.h.example` mit Platzhaltern für WLAN SSID/Passwort erstellen
- [ ] `.gitignore` vorbereiten (schließt `include/secrets.h`, `.pio/` aus)
- [ ] Minimalen `src/main.cpp` schreiben, der "Hello Dashboard" anzeigt
- [ ] `scripts/deploy.sh` schreiben (Build + Flash, automatische Port-Erkennung, optionaler Port-Parameter)
- [ ] Deploy-Skript testen, Testtext auf echtem Gerät prüfen
## Phase 1 WLAN & HTTP-Grundlage
- [ ] WLAN-Verbindungsaufbau mit `secrets.h`-Werten implementieren
- [ ] Verbindungsstatus (verbindet/verbunden/Fehler) auf Display anzeigen
- [ ] Generischen HTTP-GET-Client in `src/api/` implementieren
- [ ] ArduinoJson (oder Alternative) als Dependency einbinden
- [ ] Wetter-API testweise abrufen und Rohdaten (Temperatur, Beschreibung) als Text anzeigen
- [ ] Auf echtem Gerät gegen echte API testen
## Phase 2 Screen-Grundgerüst & Navigation
- [ ] Screen-State-Machine implementieren (Enum + Render-Dispatch)
- [ ] Vier Platzhalter-Screens anlegen: Home, WeatherDetail, CalendarDetail, MVG
- [ ] Button-A-Logik: Toggle zwischen WeatherDetail und CalendarDetail
- [ ] Button-B-Logik: zurück zu Home
- [ ] Button-C-Logik: wechsle zu MVG-Screen
- [ ] 5-Minuten-Inaktivitäts-Timeout implementieren (zurück zu Home)
- [ ] Theme-Datei mit Basisfarben (Schwarz/Weiß, iOS-Dark-Mode-Look) anlegen
- [ ] Navigation zwischen allen vier Screens auf echtem Gerät testen
## Phase 3 Home-Screen (final)
- [ ] Wetter-API-Aufruf in Home-Screen integrieren
- [ ] Kalender-API-Aufruf in Home-Screen integrieren
- [ ] Regen-Hinweis-Logik implementieren (irgendein `precipitation.type == "rain"` mit `probability > 0` in den nächsten 8 Stunden)
- [ ] Anzeige der nächsten 2 Kalendertermine inkl. `all_day`-Sonderbehandlung
- [ ] 10-Minuten-Refresh-Timer implementieren
- [ ] Layout verfeinern: große Temperaturanzeige, klare visuelle Hierarchie
- [ ] Auf echtem Gerät über längeren Zeitraum (mind. 1 Refresh-Zyklus) testen
## Phase 4 Icon-Set (farbig, RGB565)
- [ ] Icon-Liste finalisieren (Sonne, bewölkt, Regen, Nacht-Varianten, U-Bahn, S-Bahn, Kalender, Regen-Warnung, Retry/Fehler)
- [ ] Icon-Vorlagen erstellen/beschaffen (farbig, für kleines Display optimiert)
- [ ] Konvertierung zu RGB565-Bitmap-Arrays einrichten (Skript oder Tool)
- [ ] Icons in `src/icons/` ablegen und einbinden
- [ ] Mapping `symbol`-Feld → Icon implementieren (`mo____`, `mb____`, `wb____`, etc.)
- [ ] Icons im Home-Screen integrieren und visuell prüfen
## Phase 5 Wetter-Detailseite
- [ ] Vollständige `forecast`-Liste abrufen und verarbeiten
- [ ] Darstellung pro Stunde: Zeit, Temperatur, Icon, Regenwahrscheinlichkeit
- [ ] Scroll- oder Paginierungslogik implementieren, falls nötig
- [ ] Auf echtem Gerät testen (Navigation über Button A von Home)
## Phase 6 Kalender-Detailseite
- [ ] Vollständige `events`-Liste (10 Termine) abrufen und anzeigen
- [ ] `all_day`-Sonderbehandlung analog zur Hauptseite
- [ ] Scroll-/Paginierungslogik implementieren
- [ ] Auf echtem Gerät testen (Navigation über Button A Toggle)
## Phase 7 MVG-Abfahrtsseite
- [ ] Vollständige `departures`-Liste abrufen und anzeigen (ungefiltert)
- [ ] Farbliche Kennzeichnung U-Bahn vs. S-Bahn
- [ ] Anzeige von Verspätung (`delay_min`) und Ausfall (`cancelled`)
- [ ] 1-Minuten-Refresh-Timer implementieren
- [ ] Auf echtem Gerät testen (Navigation über Button C)
## Phase 8 Fehlerbehandlung
- [ ] Timeout-Handling für alle drei API-Clients implementieren
- [ ] Einheitliche Fehleranzeige (Retry-Icon + Text) pro Screen implementieren
- [ ] Automatischen Retry beim nächsten Refresh-Intervall sicherstellen
- [ ] Manuellen Retry per erneutem Tastendruck implementieren
- [ ] Fehlerfall simulieren (z.B. Docker-Service stoppen) und Verhalten prüfen
## Phase 9 Politur & Feinschliff
- [ ] Alle Screens gegen UI-Konzept prüfen (Kontrast, Schriftgrößen, Abstände)
- [ ] Speicherverbrauch und Rendering-Performance prüfen
- [ ] Code aufräumen und kommentieren
- [ ] README.md mit Setup-Anleitung (WLAN-Konfiguration, Flash-Vorgang) schreiben
- [ ] Finalen Dauertest durchführen (mehrere Stunden Betrieb ohne Absturz)
## Später (nicht Teil der Phasen)
- [ ] Git-Repository initialisieren (`git init`)
- [ ] `secrets.h.example` und übrige Struktur committen, `secrets.h` bleibt ausgeschlossen
- [ ] Optional: Remote-Repository (GitHub/GitLab) verknüpfen
+11
View File
@@ -0,0 +1,11 @@
#pragma once
// Copy this file to secrets.h and fill in your real values.
// secrets.h is git-ignored and must never be committed.
#define WIFI_SSID "YOUR_WIFI_SSID"
#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD"
#define WEATHER_API_URL "http://<host>:<port>/api/weather"
#define CALENDAR_API_URL "http://<host>:<port>/api/events"
#define DEPARTURES_API_URL "http://<host>:<port>/api/departures"
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <M5Stack.h>
// "iPhone Dark Mode" inspired theme: near-black background, white text,
// desaturated accent colors used sparingly.
namespace Theme {
constexpr uint16_t BG = 0x0000; // near-black background
constexpr uint16_t BG_CARD = 0x1082; // slightly lighter card background (dark gray)
constexpr uint16_t TEXT = 0xFFFF; // white
constexpr uint16_t TEXT_DIM = 0x8410; // light gray, secondary text
constexpr uint16_t ACCENT_WEATHER = 0x051D; // muted blue
constexpr uint16_t ACCENT_SUN = 0xFEA0; // warm yellow
constexpr uint16_t ACCENT_RAIN = 0x03BF; // muted cyan/blue
constexpr uint16_t ACCENT_CALENDAR= 0x7BEF; // muted lavender/gray
constexpr uint16_t ACCENT_UBAHN = 0x0410; // U-Bahn blue (muted)
constexpr uint16_t ACCENT_SBAHN = 0x0540; // S-Bahn green (muted)
constexpr uint16_t ACCENT_WARN = 0xFB00; // muted orange for delays/warnings
constexpr uint16_t ACCENT_ERROR = 0xF800; // red for errors/cancellations
constexpr int SCREEN_W = 320;
constexpr int SCREEN_H = 240;
}
+10
View File
@@ -0,0 +1,10 @@
[env:m5stack-core-esp32]
platform = espressif32
board = m5stack-core-esp32
framework = arduino
monitor_speed = 115200
lib_deps =
m5stack/M5Stack@^0.4.5
bblanchon/ArduinoJson@^6.21.5
build_flags =
-DCORE_DEBUG_LEVEL=1
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# Build + flash the M5Stack Dashboard project via PlatformIO.
#
# Usage:
# ./scripts/deploy.sh # auto-detect USB port
# ./scripts/deploy.sh /dev/ttyUSB0 # force a specific port
#
# This script intentionally does NOT open the serial monitor afterwards.
cd "$(dirname "$0")/.."
if ! command -v pio &> /dev/null; then
echo "Error: PlatformIO CLI ('pio') not found in PATH."
echo "Install it via: pip install -U platformio"
exit 1
fi
if [ "$#" -ge 1 ]; then
PORT="$1"
echo "Using forced upload port: $PORT"
pio run --target upload --upload-port "$PORT"
else
echo "Auto-detecting upload port..."
pio run --target upload
fi
echo "Build + flash complete."
+36
View File
@@ -0,0 +1,36 @@
#include "calendar_api.h"
#include "http_client.h"
#include "../../include/secrets.h"
#include <ArduinoJson.h>
CalendarData fetchCalendar() {
CalendarData data;
ApiResult res = httpGet(CALENDAR_API_URL);
if (!res.success) {
data.valid = false;
return data;
}
DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, res.body);
if (err) {
data.valid = false;
return data;
}
JsonArray events = doc["events"];
for (JsonObject e : events) {
CalendarEvent ev;
ev.id = e["id"] | 0;
ev.summary = e["summary"] | "";
ev.startAt = e["start_at"] | "";
ev.endAt = e["end_at"] | "";
ev.allDay = e["all_day"] | false;
ev.status = e["status"] | "";
data.events.push_back(ev);
}
data.valid = true;
return data;
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <Arduino.h>
#include <vector>
struct CalendarEvent {
long id = 0;
String summary;
String startAt;
String endAt;
bool allDay = false;
String status;
};
struct CalendarData {
bool valid = false;
std::vector<CalendarEvent> events;
};
// Fetches and parses the calendar API (already limited to next 10 events
// server-side).
CalendarData fetchCalendar();
+39
View File
@@ -0,0 +1,39 @@
#include "departures_api.h"
#include "http_client.h"
#include "../../include/secrets.h"
#include <ArduinoJson.h>
DeparturesData fetchDepartures() {
DeparturesData data;
ApiResult res = httpGet(DEPARTURES_API_URL);
if (!res.success) {
data.valid = false;
return data;
}
DynamicJsonDocument doc(16384);
DeserializationError err = deserializeJson(doc, res.body);
if (err) {
data.valid = false;
return data;
}
JsonArray departures = doc["departures"];
for (JsonObject d : departures) {
Departure dep;
dep.station = d["station"] | "";
dep.type = d["type"] | "";
dep.icon = d["icon"] | "";
dep.line = d["line"] | "";
dep.destination = d["destination"] | "";
dep.timeEpoch = d["time_epoch"] | 0;
dep.timeStr = d["time_str"] | "";
dep.delayMin = d["delay_min"] | 0;
dep.cancelled = d["cancelled"] | false;
data.departures.push_back(dep);
}
data.valid = true;
return data;
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <Arduino.h>
#include <vector>
struct Departure {
String station;
String type; // "UBAHN" or "SBAHN"
String icon; // "U" or "S"
String line;
String destination;
long timeEpoch = 0;
String timeStr;
int delayMin = 0;
bool cancelled = false;
};
struct DeparturesData {
bool valid = false;
std::vector<Departure> departures;
};
// Fetches and parses the departures API. No filtering is applied, all
// stations and lines are returned as-is, in the order provided by the API.
DeparturesData fetchDepartures();
+30
View File
@@ -0,0 +1,30 @@
#include "http_client.h"
#include <HTTPClient.h>
#include <WiFi.h>
ApiResult httpGet(const String &url, uint32_t timeoutMs) {
ApiResult result;
if (WiFi.status() != WL_CONNECTED) {
result.success = false;
result.httpCode = -1;
return result;
}
HTTPClient http;
http.setTimeout(timeoutMs);
http.begin(url);
int code = http.GET();
result.httpCode = code;
if (code == HTTP_CODE_OK) {
result.body = http.getString();
result.success = true;
} else {
result.success = false;
}
http.end();
return result;
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <Arduino.h>
// Result of a GET request against one of the JSON APIs.
struct ApiResult {
bool success = false;
String body;
int httpCode = -1;
};
// Performs a simple HTTP GET request with a timeout and returns the raw body.
// Kept deliberately simple: no retries here, retry logic lives in the screens
// (either via the regular refresh timer or a manual button press).
ApiResult httpGet(const String &url, uint32_t timeoutMs = 5000);
+56
View File
@@ -0,0 +1,56 @@
#include "weather_api.h"
#include "http_client.h"
#include "../../include/secrets.h"
#include <ArduinoJson.h>
WeatherData fetchWeather() {
WeatherData data;
ApiResult res = httpGet(WEATHER_API_URL);
if (!res.success) {
data.valid = false;
return data;
}
DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, res.body);
if (err) {
data.valid = false;
return data;
}
JsonObject current = doc["current"];
data.current.temperature = current["temperature"] | 0;
data.current.symbol = current["symbol"] | "";
data.current.description = current["description"] | "";
JsonArray forecast = doc["forecast"];
for (JsonObject entry : forecast) {
ForecastEntry fe;
fe.time = entry["time"] | "";
fe.temperature = entry["temperature"] | 0;
fe.symbol = entry["symbol"] | "";
fe.description = entry["description"] | "";
JsonObject precip = entry["precipitation"];
fe.precipitationProbability = precip["probability"] | 0.0f;
fe.precipitationType = precip["type"] | "";
data.forecast.push_back(fe);
}
data.valid = true;
return data;
}
bool willRainSoon(const WeatherData &data, int hours) {
if (!data.valid) return false;
int checked = 0;
for (const auto &entry : data.forecast) {
if (checked >= hours) break;
if (entry.precipitationType == "rain" && entry.precipitationProbability > 0.0f) {
return true;
}
checked++;
}
return false;
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <Arduino.h>
#include <vector>
struct WeatherCurrent {
int temperature = 0;
String symbol;
String description;
};
struct ForecastEntry {
String time;
int temperature = 0;
String symbol;
String description;
float precipitationProbability = 0.0f;
String precipitationType;
};
struct WeatherData {
bool valid = false;
WeatherCurrent current;
std::vector<ForecastEntry> forecast;
};
// Fetches and parses the weather API. Returns valid=false on any failure
// (network error, timeout, or malformed JSON).
WeatherData fetchWeather();
// Returns true if any forecast entry within the next `hours` has
// precipitationType == "rain" and probability > 0 (soft threshold as
// specified: warn as early as possible rather than too late).
bool willRainSoon(const WeatherData &data, int hours = 8);
+115
View File
@@ -0,0 +1,115 @@
#pragma once
#include <M5Stack.h>
#include "theme.h"
// Design note: instead of storing large RGB565 bitmap arrays (which would
// bloat flash usage for a first version), icons are drawn procedurally with
// M5Stack's canvas primitives (circles, arcs, lines). This keeps the binary
// small while still giving a colorful, modern look. If you later want true
// pixel-art bitmaps, replace the bodies of these functions with
// M5.Lcd.drawBitmap(...) calls against RGB565 arrays generated from PNGs.
namespace Icons {
// Draws a sun icon centered at (cx, cy) with given radius
inline void drawSun(int cx, int cy, int r) {
M5.Lcd.fillCircle(cx, cy, r, Theme::ACCENT_SUN);
for (int i = 0; i < 8; i++) {
float angle = i * (PI / 4.0);
int x1 = cx + cos(angle) * (r + 4);
int y1 = cy + sin(angle) * (r + 4);
int x2 = cx + cos(angle) * (r + 10);
int y2 = cy + sin(angle) * (r + 10);
M5.Lcd.drawLine(x1, y1, x2, y2, Theme::ACCENT_SUN);
}
}
// Draws a moon icon (crescent) centered at (cx, cy)
inline void drawMoon(int cx, int cy, int r) {
M5.Lcd.fillCircle(cx, cy, r, Theme::TEXT_DIM);
M5.Lcd.fillCircle(cx + r / 2, cy - r / 3, r, Theme::BG);
}
// Draws a cloud icon centered at (cx, cy)
inline void drawCloud(int cx, int cy, int scale) {
M5.Lcd.fillCircle(cx - scale, cy, scale, Theme::TEXT_DIM);
M5.Lcd.fillCircle(cx + scale, cy, scale, Theme::TEXT_DIM);
M5.Lcd.fillCircle(cx, cy - scale / 2, scale + 2, Theme::TEXT_DIM);
M5.Lcd.fillRect(cx - scale, cy, scale * 2, scale, Theme::TEXT_DIM);
}
// Draws rain drops below a given y position
inline void drawRainDrops(int cx, int cy, int count = 3) {
for (int i = 0; i < count; i++) {
int x = cx - (count - 1) * 5 + i * 10;
M5.Lcd.drawLine(x, cy, x - 3, cy + 8, Theme::ACCENT_RAIN);
}
}
// Combined weather icon based on the API "symbol" field.
// symbol examples: "mo____" (clear/moon), "mb____" (cloudy/moon),
// "wb____" (cloudy/day). First char roughly encodes day(w)/night(m),
// second char encodes condition (o=clear, b=cloudy, r=rain, etc).
inline void drawWeatherIcon(const String &symbol, int cx, int cy, int r = 20) {
bool isNight = symbol.length() > 0 && symbol.charAt(0) == 'm';
char condition = symbol.length() > 1 ? symbol.charAt(1) : 'o';
switch (condition) {
case 'o': // clear
if (isNight) drawMoon(cx, cy, r);
else drawSun(cx, cy, r);
break;
case 'b': // cloudy
drawCloud(cx, cy, r * 2 / 3);
break;
case 'r': // rain
drawCloud(cx, cy - 5, r * 2 / 3);
drawRainDrops(cx, cy + r / 2, 3);
break;
default:
drawCloud(cx, cy, r * 2 / 3);
break;
}
}
// Small rain-warning banner icon (drop with exclamation-ish accent)
inline void drawRainWarning(int cx, int cy) {
M5.Lcd.fillTriangle(cx, cy - 8, cx - 6, cy + 6, cx + 6, cy + 6, Theme::ACCENT_RAIN);
M5.Lcd.fillCircle(cx, cy + 2, 2, Theme::BG);
}
// Simple "U" badge for U-Bahn
inline void drawUBahnBadge(int x, int y, int w, int h) {
M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_UBAHN);
M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_UBAHN);
M5.Lcd.setCursor(x + w / 2 - 4, y + h / 2 - 4);
M5.Lcd.print("U");
}
// Simple "S" badge for S-Bahn
inline void drawSBahnBadge(int x, int y, int w, int h) {
M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_SBAHN);
M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_SBAHN);
M5.Lcd.setCursor(x + w / 2 - 4, y + h / 2 - 4);
M5.Lcd.print("S");
}
// Calendar icon: simple rectangle with a top bar and a page-marker dot
inline void drawCalendarIcon(int cx, int cy, int size) {
int x = cx - size / 2;
int y = cy - size / 2;
M5.Lcd.drawRoundRect(x, y, size, size, 3, Theme::ACCENT_CALENDAR);
M5.Lcd.fillRect(x, y, size, size / 4, Theme::ACCENT_CALENDAR);
M5.Lcd.fillCircle(cx, cy + size / 6, 2, Theme::TEXT);
}
// Retry / error icon: circular arrow suggestion using arcs (approximated
// with a broken circle) plus an exclamation mark for simplicity.
inline void drawRetryIcon(int cx, int cy, int r) {
M5.Lcd.drawCircle(cx, cy, r, Theme::ACCENT_ERROR);
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
M5.Lcd.setCursor(cx - 3, cy - 8);
M5.Lcd.print("!");
}
} // namespace Icons
+120
View File
@@ -0,0 +1,120 @@
#include <M5Stack.h>
#include <WiFi.h>
#include "../include/secrets.h"
#include "../include/theme.h"
#include "screens/screen_base.h"
#include "screens/home_screen.h"
#include "screens/weather_detail_screen.h"
#include "screens/calendar_detail_screen.h"
#include "screens/mvg_screen.h"
namespace {
ScreenId currentScreen = ScreenId::HOME;
unsigned long lastInteractionMs = 0;
const unsigned long IDLE_TIMEOUT_MS = 5UL * 60UL * 1000UL; // 5 minutes
bool wifiConnected = false;
void connectWifi() {
M5.Lcd.fillScreen(Theme::BG);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 100);
M5.Lcd.print("Verbinde mit WLAN...");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(300);
M5.Lcd.print(".");
}
wifiConnected = (WiFi.status() == WL_CONNECTED);
M5.Lcd.fillScreen(Theme::BG);
M5.Lcd.setCursor(10, 100);
if (wifiConnected) {
M5.Lcd.setTextColor(Theme::ACCENT_SBAHN, Theme::BG);
M5.Lcd.print("WLAN verbunden");
} else {
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
M5.Lcd.print("WLAN fehlgeschlagen");
}
delay(1000);
}
void renderCurrentScreen(bool forceRefresh = false) {
switch (currentScreen) {
case ScreenId::HOME:
renderHomeScreen(forceRefresh);
break;
case ScreenId::WEATHER_DETAIL:
renderWeatherDetailScreen(forceRefresh);
break;
case ScreenId::CALENDAR_DETAIL:
renderCalendarDetailScreen(forceRefresh);
break;
case ScreenId::MVG:
renderMvgScreen(forceRefresh);
break;
}
}
void goToScreen(ScreenId screen, bool forceRefresh = false) {
currentScreen = screen;
lastInteractionMs = millis();
renderCurrentScreen(forceRefresh);
}
}
void setup() {
M5.begin();
M5.Power.begin();
M5.Lcd.setBrightness(200);
connectWifi();
lastInteractionMs = millis();
goToScreen(ScreenId::HOME, true);
}
void loop() {
M5.update();
// Button A: toggle between Weather-Detail and Calendar-Detail
if (M5.BtnA.wasPressed()) {
if (currentScreen == ScreenId::WEATHER_DETAIL) {
goToScreen(ScreenId::CALENDAR_DETAIL, true);
} else {
goToScreen(ScreenId::WEATHER_DETAIL, true);
}
}
// Button B: back to Home
if (M5.BtnB.wasPressed()) {
goToScreen(ScreenId::HOME, true);
}
// Button C: go to MVG departures screen
if (M5.BtnC.wasPressed()) {
goToScreen(ScreenId::MVG, true);
}
// Idle timeout: jump back to Home after 5 minutes without any button press
if (currentScreen != ScreenId::HOME &&
millis() - lastInteractionMs >= IDLE_TIMEOUT_MS) {
goToScreen(ScreenId::HOME, true);
}
// Background refresh ticks for screens with their own refresh interval
if (currentScreen == ScreenId::HOME) {
updateHomeScreen();
}
if (currentScreen == ScreenId::MVG) {
updateMvgScreen();
}
delay(50);
}
+65
View File
@@ -0,0 +1,65 @@
#include "calendar_detail_screen.h"
#include <M5Stack.h>
#include "../../include/theme.h"
#include "../icons/icons.h"
#include "../api/calendar_api.h"
namespace {
CalendarData lastCalendar;
bool ok = false;
String formatEventTime(const CalendarEvent &ev) {
if (ev.allDay) {
int tIdx = ev.startAt.indexOf('T');
return tIdx > 0 ? ev.startAt.substring(0, tIdx) : ev.startAt;
} else {
int tIdx = ev.startAt.indexOf('T');
if (tIdx > 0 && ev.startAt.length() >= tIdx + 6) {
return ev.startAt.substring(tIdx + 1, tIdx + 6);
}
return ev.startAt;
}
}
}
void renderCalendarDetailScreen(bool forceRefresh) {
if (forceRefresh || !ok) {
CalendarData c = fetchCalendar();
ok = c.valid;
if (c.valid) lastCalendar = c;
}
M5.Lcd.fillScreen(Theme::BG);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 10);
M5.Lcd.print("Alle Termine");
if (!ok) {
Icons::drawRetryIcon(30, 60, 12);
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(50, 55);
M5.Lcd.print("Keine Verbindung");
return;
}
int y = 40;
for (const auto &ev : lastCalendar.events) {
if (y > 215) break;
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, y);
M5.Lcd.print(ev.summary.substring(0, 28));
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(230, y);
M5.Lcd.print(formatEventTime(ev));
y += 18;
}
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(10, 228);
M5.Lcd.print("A:Wetter B:Home C:MVG");
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void renderCalendarDetailScreen(bool forceRefresh = false);
+128
View File
@@ -0,0 +1,128 @@
#include "home_screen.h"
#include <M5Stack.h>
#include "../../include/theme.h"
#include "../icons/icons.h"
#include "../api/weather_api.h"
#include "../api/calendar_api.h"
namespace {
WeatherData lastWeather;
CalendarData lastCalendar;
bool weatherOk = false;
bool calendarOk = false;
unsigned long lastFetchMs = 0;
const unsigned long REFRESH_INTERVAL_MS = 10UL * 60UL * 1000UL; // 10 minutes
String formatEventTime(const CalendarEvent &ev) {
// Times are shown exactly as provided by the API, no timezone math.
if (ev.allDay) {
// start_at looks like "2026-08-03T00:00:00" -> show date part only
int tIdx = ev.startAt.indexOf('T');
return tIdx > 0 ? ev.startAt.substring(0, tIdx) : ev.startAt;
} else {
int tIdx = ev.startAt.indexOf('T');
if (tIdx > 0 && ev.startAt.length() >= tIdx + 6) {
return ev.startAt.substring(tIdx + 1, tIdx + 6); // HH:MM
}
return ev.startAt;
}
}
void doFetch() {
WeatherData w = fetchWeather();
weatherOk = w.valid;
if (w.valid) lastWeather = w;
CalendarData c = fetchCalendar();
calendarOk = c.valid;
if (c.valid) lastCalendar = c;
lastFetchMs = millis();
}
void drawErrorState(int x, int y, const char *label) {
Icons::drawRetryIcon(x, y, 10);
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(x + 20, y - 5);
M5.Lcd.print(label);
}
}
void updateHomeScreen() {
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) {
doFetch();
}
}
void renderHomeScreen(bool forceRefresh) {
if (forceRefresh || lastFetchMs == 0) {
doFetch();
}
M5.Lcd.fillScreen(Theme::BG);
// --- Weather block (top) ---
if (weatherOk) {
Icons::drawWeatherIcon(lastWeather.current.symbol, 45, 55, 24);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(4);
M5.Lcd.setCursor(90, 30);
M5.Lcd.printf("%d", lastWeather.current.temperature);
M5.Lcd.setTextSize(2);
M5.Lcd.print(" C");
M5.Lcd.setTextSize(2);
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(90, 65);
M5.Lcd.print(lastWeather.current.description);
if (willRainSoon(lastWeather, 8)) {
Icons::drawRainWarning(280, 30);
M5.Lcd.setTextColor(Theme::ACCENT_RAIN, Theme::BG);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(220, 45);
M5.Lcd.print("Regen moeglich");
}
} else {
drawErrorState(20, 40, "Wetter n.a.");
}
M5.Lcd.drawFastHLine(10, 95, Theme::SCREEN_W - 20, Theme::BG_CARD);
// --- Calendar block (next 2 events) ---
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 105);
Icons::drawCalendarIcon(20, 115, 14);
M5.Lcd.setCursor(35, 108);
M5.Lcd.print("Termine");
if (calendarOk) {
int y = 135;
int shown = 0;
for (const auto &ev : lastCalendar.events) {
if (shown >= 2) break;
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, y);
M5.Lcd.print(ev.summary.substring(0, 20));
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, y + 20);
M5.Lcd.print(formatEventTime(ev));
y += 40;
shown++;
}
} else {
drawErrorState(20, 150, "Kalender n.a.");
}
// --- Footer hint ---
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, 225);
M5.Lcd.print("A:Wetter/Kalender B:Home C:MVG");
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
// Renders the home screen: current weather, soft rain warning, and the
// next 2 calendar events. Fetches weather + calendar data if the 10 minute
// refresh interval has elapsed, or if forced (e.g. manual retry).
void renderHomeScreen(bool forceRefresh = false);
// Call regularly from loop() to check if a refresh is due.
void updateHomeScreen();
+83
View File
@@ -0,0 +1,83 @@
#include "mvg_screen.h"
#include <M5Stack.h>
#include "../../include/theme.h"
#include "../icons/icons.h"
#include "../api/departures_api.h"
namespace {
DeparturesData lastData;
bool ok = false;
unsigned long lastFetchMs = 0;
const unsigned long REFRESH_INTERVAL_MS = 60UL * 1000UL; // 1 minute
void doFetch() {
DeparturesData d = fetchDepartures();
ok = d.valid;
if (d.valid) lastData = d;
lastFetchMs = millis();
}
}
void updateMvgScreen() {
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) {
doFetch();
}
}
void renderMvgScreen(bool forceRefresh) {
if (forceRefresh || lastFetchMs == 0) {
doFetch();
}
M5.Lcd.fillScreen(Theme::BG);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 10);
M5.Lcd.print("Abfahrten");
if (!ok) {
Icons::drawRetryIcon(30, 60, 12);
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(50, 55);
M5.Lcd.print("Keine Verbindung");
return;
}
int y = 38;
for (const auto &dep : lastData.departures) {
if (y > 215) break;
if (dep.type == "UBAHN") {
Icons::drawUBahnBadge(8, y, 16, 14);
} else {
Icons::drawSBahnBadge(8, y, 16, 14);
}
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(30, y + 3);
M5.Lcd.print(dep.line);
M5.Lcd.setCursor(60, y + 3);
M5.Lcd.print(dep.destination.substring(0, 16));
M5.Lcd.setCursor(190, y + 3);
if (dep.cancelled) {
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
M5.Lcd.print("Ausfall");
} else {
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.print(dep.timeStr);
if (dep.delayMin > 0) {
M5.Lcd.setTextColor(Theme::ACCENT_WARN, Theme::BG);
M5.Lcd.printf(" +%d", dep.delayMin);
}
}
y += 18;
}
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(10, 228);
M5.Lcd.print("A:Wetter/Kalender B:Home");
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
void renderMvgScreen(bool forceRefresh = false);
void updateMvgScreen();
+11
View File
@@ -0,0 +1,11 @@
#pragma once
// Simple screen enum used as a state machine. Each screen has a render
// function that is called on refresh, and each screen owns its own last-
// fetch timestamp so refresh intervals can differ per screen.
enum class ScreenId {
HOME,
WEATHER_DETAIL,
CALENDAR_DETAIL,
MVG
};
+67
View File
@@ -0,0 +1,67 @@
#include "weather_detail_screen.h"
#include <M5Stack.h>
#include "../../include/theme.h"
#include "../icons/icons.h"
#include "../api/weather_api.h"
namespace {
WeatherData lastWeather;
bool ok = false;
}
void renderWeatherDetailScreen(bool forceRefresh) {
if (forceRefresh || !ok) {
WeatherData w = fetchWeather();
ok = w.valid;
if (w.valid) lastWeather = w;
}
M5.Lcd.fillScreen(Theme::BG);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 10);
M5.Lcd.print("Wettervorhersage");
if (!ok) {
Icons::drawRetryIcon(30, 60, 12);
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setCursor(50, 55);
M5.Lcd.print("Keine Verbindung");
return;
}
int y = 40;
int shown = 0;
for (const auto &fe : lastWeather.forecast) {
if (shown >= 6 || y > 220) break;
Icons::drawWeatherIcon(fe.symbol, 20, y + 8, 10);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(1);
int tIdx = fe.time.indexOf('T');
String hhmm = tIdx > 0 ? fe.time.substring(tIdx + 1, tIdx + 6) : fe.time;
M5.Lcd.setCursor(40, y);
M5.Lcd.print(hhmm);
M5.Lcd.setCursor(90, y);
M5.Lcd.printf("%d C", fe.temperature);
M5.Lcd.setCursor(140, y);
M5.Lcd.print(fe.description.substring(0, 12));
if (fe.precipitationType == "rain" && fe.precipitationProbability > 0.0f) {
M5.Lcd.setTextColor(Theme::ACCENT_RAIN, Theme::BG);
M5.Lcd.setCursor(250, y);
M5.Lcd.printf("%.0f%%", fe.precipitationProbability * 100);
}
y += 28;
shown++;
}
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, 228);
M5.Lcd.print("A:Kalender B:Home C:MVG");
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void renderWeatherDetailScreen(bool forceRefresh = false);