diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2469b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio/ +.vscode/ +include/secrets.h +*.o +*.bin diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..3c731ec --- /dev/null +++ b/PLAN.md @@ -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) diff --git a/README.md b/README.md index 0f8d0a8..2e0200d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..207a87a --- /dev/null +++ b/SPEC.md @@ -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://:/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.0–1.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://:/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://:/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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6b905ed --- /dev/null +++ b/TODO.md @@ -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 diff --git a/include/secrets.h.example b/include/secrets.h.example new file mode 100644 index 0000000..775d156 --- /dev/null +++ b/include/secrets.h.example @@ -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://:/api/weather" +#define CALENDAR_API_URL "http://:/api/events" +#define DEPARTURES_API_URL "http://:/api/departures" diff --git a/include/theme.h b/include/theme.h new file mode 100644 index 0000000..b36af44 --- /dev/null +++ b/include/theme.h @@ -0,0 +1,24 @@ +#pragma once +#include + +// "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; +} diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..a976dfc --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..602f377 --- /dev/null +++ b/scripts/deploy.sh @@ -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." diff --git a/src/api/calendar_api.cpp b/src/api/calendar_api.cpp new file mode 100644 index 0000000..3bb33fc --- /dev/null +++ b/src/api/calendar_api.cpp @@ -0,0 +1,36 @@ +#include "calendar_api.h" +#include "http_client.h" +#include "../../include/secrets.h" +#include + +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; +} diff --git a/src/api/calendar_api.h b/src/api/calendar_api.h new file mode 100644 index 0000000..bb56e56 --- /dev/null +++ b/src/api/calendar_api.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include + +struct CalendarEvent { + long id = 0; + String summary; + String startAt; + String endAt; + bool allDay = false; + String status; +}; + +struct CalendarData { + bool valid = false; + std::vector events; +}; + +// Fetches and parses the calendar API (already limited to next 10 events +// server-side). +CalendarData fetchCalendar(); diff --git a/src/api/departures_api.cpp b/src/api/departures_api.cpp new file mode 100644 index 0000000..3ea8249 --- /dev/null +++ b/src/api/departures_api.cpp @@ -0,0 +1,39 @@ +#include "departures_api.h" +#include "http_client.h" +#include "../../include/secrets.h" +#include + +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; +} diff --git a/src/api/departures_api.h b/src/api/departures_api.h new file mode 100644 index 0000000..3a1e627 --- /dev/null +++ b/src/api/departures_api.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +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 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(); diff --git a/src/api/http_client.cpp b/src/api/http_client.cpp new file mode 100644 index 0000000..5cad9d5 --- /dev/null +++ b/src/api/http_client.cpp @@ -0,0 +1,30 @@ +#include "http_client.h" +#include +#include + +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; +} diff --git a/src/api/http_client.h b/src/api/http_client.h new file mode 100644 index 0000000..6c3c79b --- /dev/null +++ b/src/api/http_client.h @@ -0,0 +1,14 @@ +#pragma once +#include + +// 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); diff --git a/src/api/weather_api.cpp b/src/api/weather_api.cpp new file mode 100644 index 0000000..cf91536 --- /dev/null +++ b/src/api/weather_api.cpp @@ -0,0 +1,56 @@ +#include "weather_api.h" +#include "http_client.h" +#include "../../include/secrets.h" +#include + +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; +} diff --git a/src/api/weather_api.h b/src/api/weather_api.h new file mode 100644 index 0000000..da44ec6 --- /dev/null +++ b/src/api/weather_api.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include + +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 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); diff --git a/src/icons/icons.h b/src/icons/icons.h new file mode 100644 index 0000000..2bbb9a6 --- /dev/null +++ b/src/icons/icons.h @@ -0,0 +1,115 @@ +#pragma once +#include +#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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..fd6b92b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,120 @@ +#include +#include +#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); +} diff --git a/src/screens/calendar_detail_screen.cpp b/src/screens/calendar_detail_screen.cpp new file mode 100644 index 0000000..990defd --- /dev/null +++ b/src/screens/calendar_detail_screen.cpp @@ -0,0 +1,65 @@ +#include "calendar_detail_screen.h" +#include +#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"); +} diff --git a/src/screens/calendar_detail_screen.h b/src/screens/calendar_detail_screen.h new file mode 100644 index 0000000..e7f1dde --- /dev/null +++ b/src/screens/calendar_detail_screen.h @@ -0,0 +1,3 @@ +#pragma once + +void renderCalendarDetailScreen(bool forceRefresh = false); diff --git a/src/screens/home_screen.cpp b/src/screens/home_screen.cpp new file mode 100644 index 0000000..fd516c8 --- /dev/null +++ b/src/screens/home_screen.cpp @@ -0,0 +1,128 @@ +#include "home_screen.h" +#include +#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"); +} diff --git a/src/screens/home_screen.h b/src/screens/home_screen.h new file mode 100644 index 0000000..0a1c565 --- /dev/null +++ b/src/screens/home_screen.h @@ -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(); diff --git a/src/screens/mvg_screen.cpp b/src/screens/mvg_screen.cpp new file mode 100644 index 0000000..7e12335 --- /dev/null +++ b/src/screens/mvg_screen.cpp @@ -0,0 +1,83 @@ +#include "mvg_screen.h" +#include +#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"); +} diff --git a/src/screens/mvg_screen.h b/src/screens/mvg_screen.h new file mode 100644 index 0000000..a464932 --- /dev/null +++ b/src/screens/mvg_screen.h @@ -0,0 +1,4 @@ +#pragma once + +void renderMvgScreen(bool forceRefresh = false); +void updateMvgScreen(); diff --git a/src/screens/screen_base.h b/src/screens/screen_base.h new file mode 100644 index 0000000..6cde191 --- /dev/null +++ b/src/screens/screen_base.h @@ -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 +}; diff --git a/src/screens/weather_detail_screen.cpp b/src/screens/weather_detail_screen.cpp new file mode 100644 index 0000000..e53213e --- /dev/null +++ b/src/screens/weather_detail_screen.cpp @@ -0,0 +1,67 @@ +#include "weather_detail_screen.h" +#include +#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"); +} diff --git a/src/screens/weather_detail_screen.h b/src/screens/weather_detail_screen.h new file mode 100644 index 0000000..02f239c --- /dev/null +++ b/src/screens/weather_detail_screen.h @@ -0,0 +1,3 @@ +#pragma once + +void renderWeatherDetailScreen(bool forceRefresh = false);