Merge pull request #1 from skoelle/feature/improve-ui

Feature/improve UI
This commit is contained in:
Stefan Koelle
2026-08-02 08:21:54 +02:00
committed by GitHub
29 changed files with 302 additions and 817 deletions
-9
View File
@@ -7,27 +7,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: | path: |
~/.cache/pip ~/.cache/pip
~/.platformio/.cache ~/.platformio/.cache
key: ${{ runner.os }}-pio key: ${{ runner.os }}-pio
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: pip install --upgrade platformio
# secrets.h is git-ignored on purpose (contains real WiFi credentials).
# For CI, we generate a throwaway secrets.h from the example file so
# the build compiles without ever needing real credentials or storing
# any secret files in the repo.
- name: Generate placeholder secrets.h for CI build - name: Generate placeholder secrets.h for CI build
run: cp include/secrets.h.example include/secrets.h run: cp include/secrets.h.example include/secrets.h
- name: Build PlatformIO Project - name: Build PlatformIO Project
run: pio run run: pio run
+2 -1
View File
@@ -1,6 +1,7 @@
.pio/ .pio/
.vscode/ .vscode/
.venv-platformio/
include/secrets.h include/secrets.h
*.o *.o
*.bin *.bin
.venv-platformio/
+9 -119
View File
@@ -1,122 +1,12 @@
# PLAN.md Umsetzungsplan M5Stack Dashboard # PLAN.md Umsetzungsplan
_Letztes Update: 2026-08-01 23:18 CEST_ _Letztes Update: 2026-08-02_
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. Status: Grundgerüst, alle 4 Screens, Navigation, Icons, Fehlerbehandlung
und Datumsformat sind umgesetzt (entspricht Phasen 0-6 aus der ursprünglichen
Planung plus UI-Feinschliff aus dem Feedback nach dem ersten Gerätetest).
## Leitprinzip Nächste mögliche Schritte:
- Weitere Feinabstimmung Layout/Abstände nach echtem Dauerbetrieb
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. - Optional: echte Pixel-Art-Bitmaps statt prozeduraler Icons
- Git-Repository initialisieren, sobald gewünscht
## 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)
+52 -35
View File
@@ -3,50 +3,45 @@
Wetter-, Kalender- und MVG-Abfahrten-Dashboard für den M5Stack Core (ESP32), Wetter-, Kalender- und MVG-Abfahrten-Dashboard für den M5Stack Core (ESP32),
gesteuert über die 3 eingebauten Buttons (A, B, C). gesteuert über die 3 eingebauten Buttons (A, B, C).
Details zu Funktionsumfang, API-Formaten und Design-Entscheidungen stehen in
`SPEC.md` im Space. Umsetzungsschritte stehen in `PLAN.md` und `TODO.md`.
![Hauptbildschirm](docs/screenshot1.jpg) ![Hauptbildschirm](docs/screenshot1.jpg)
![Abfahrten](docs/screenshot2.jpg) ![Abfahrten](docs/screenshot2.jpg)
![Wetter](docs/screenshot3.jpg) ![Wetter](docs/screenshot3.jpg)
![Termine](docs/screenshot4.jpg) ![Termine](docs/screenshot4.jpg)
## Voraussetzungen
- Python 3 (für das lokale venv, in dem PlatformIO installiert wird)
- Kein globales `pip install platformio` nötig, alle Skripte legen sich ihr
eigenes isoliertes venv im Projektordner an (`.venv-platformio/`)
**Windows-Hinweis**: Falls mehrere Python-Versionen installiert sind, nutzen
die Windows-Skripte automatisch den `py`-Launcher (`py -3`), der zuverlässig
Python 3 findet. Prüfen mit: `py -3 --version`
## Setup ## Setup
1. PlatformIO CLI installieren: 1. WLAN-Zugangsdaten eintragen:
```
pip install -U platformio
```
2. WLAN-Zugangsdaten eintragen:
``` ```
cp include/secrets.h.example include/secrets.h cp include/secrets.h.example include/secrets.h
``` ```
Dann in `include/secrets.h` `WIFI_SSID` und `WIFI_PASSWORD` anpassen. Dann `WIFI_SSID` und `WIFI_PASSWORD` anpassen. Die drei API-URLs sind
Die drei API-URLs sind bereits vorbefüllt. bereits vorbefüllt. Diese Datei ist über `.gitignore` geschützt.
3. Gerät per USB anschließen. 2. Beim ersten Aufruf eines Skripts wird automatisch ein lokales venv unter
`.venv-platformio/` angelegt.
## Build & Flash ## Nur bauen (Test ohne Gerät)
``` Linux/macOS: `./scripts/build.sh`
./scripts/deploy.sh Windows: `scripts\build.cmd`
```
Falls mehrere serielle Geräte angeschlossen sind und die automatische ## Bauen + Flashen
Port-Erkennung fehlschlägt, kann der Port explizit übergeben werden:
``` Linux/macOS: `./scripts/deploy.sh [/dev/ttyUSB0]`
./scripts/deploy.sh /dev/ttyUSB0 Windows: `scripts\deploy.cmd [COM4]`
```
Das Skript baut nur und flasht, es öffnet keinen seriellen Monitor. **Wichtig**: Vor dem Flashen sicherstellen, dass keine andere Anwendung
(z.B. Arduino IDE mit offenem Monitor) den COM-Port belegt.
Falls du den Monitor separat sehen willst:
```
pio device monitor
```
## Bedienung ## Bedienung
@@ -57,13 +52,35 @@ pio device monitor
| C | MVG-Abfahrtsseite | | C | MVG-Abfahrtsseite |
Nach 5 Minuten ohne Tastendruck springt das Gerät automatisch zurück zur Nach 5 Minuten ohne Tastendruck springt das Gerät automatisch zurück zur
Hauptseite. Die Hauptseite aktualisiert sich alle 10 Minuten, die Hauptseite. Hauptseite: Refresh alle 10 Minuten. MVG: jede Minute.
MVG-Seite jede Minute.
## Textdarstellung (Umlaute)
`include/text_utils.h` wandelt UTF-8-Umlaute automatisch in ASCII um
(ö -> oe, ä -> ae, ü -> ue, ß -> ss), da der Standardfont sie nicht
korrekt darstellt.
## Datumsformat im Kalender
Kurzformat `Wochentag Tag.Monat. Stunde:Minute`, z.B. `Mo 3.8. 14:00`.
Ganztägige Termine ohne Uhrzeit, z.B. `Mo 3.8.`. Der Wochentag wird rein
rechnerisch ermittelt (`include/date_utils.h`), keine NTP-Sync nötig.
## Icons ## Icons
Die Icons (Sonne, Wolke, Regen, U-/S-Bahn-Badges, Kalender, Fehler-Symbol) Alle Icons werden prozedural gezeichnet (`src/icons/icons.h`), um Flash zu
werden aktuell prozedural mit M5Stack-Grafikprimitiven gezeichnet sparen. "Gewitter" wird durch Wolke+Blitz optisch von reiner Bewölkung
(`src/icons/icons.h`), um den Flash-Speicher zu schonen. Für echte unterschieden. Das Kalender-Icon ist ein echtes Kalenderblatt-Symbol statt
Pixel-Art-Bitmaps können die Funktionskörper später durch einer Checkbox.
`M5.Lcd.drawBitmap(...)`-Aufrufe mit RGB565-Arrays ersetzt werden.
## GitHub Actions Build-Check
`.github/workflows/build.yml` kompiliert das Projekt bei jedem Push, ohne
echte Zugangsdaten zu benötigen. Es wird dort automatisch eine
Wegwerf-`secrets.h` aus `secrets.h.example` erzeugt, die nie ins Repo
gelangt.
## Git
Noch nicht initialisiert. Bei Bedarf: `git init && git add . && git commit -m "Initial"`.
`include/secrets.h` und `.venv-platformio/` sind bereits in `.gitignore`.
+18 -236
View File
@@ -1,239 +1,21 @@
# M5Stack Core Wetter/Kalender/MVG Dashboard # M5Stack Core Wetter/Kalender/MVG Dashboard (SPEC-final)
_Letztes Update: 2026-08-01 23:16 CEST_ _Letztes Update: 2026-08-02_
## 1. Ziel Siehe README.md für Setup/Build. Diese Datei dokumentiert die finalen
funktionalen Entscheidungen:
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. - 3 Buttons: A = Toggle Wetter/Kalender-Detail, B = Home, C = MVG
- Home-Refresh: 10 Min, MVG-Refresh: 1 Min, Idle-Timeout: 5 Min zurück zu Home
## 2. Hardware - Regen-Hinweis: weicher Schwellwert, sobald irgendein Forecast-Eintrag der
nächsten 8 Stunden `precipitation.type == "rain"` mit `probability > 0` hat
- **Gerät**: M5Stack Core (Basic), ESP32-basiert - MVG: kein Filter, alle Stationen/Linien wie von der API geliefert
- **Display**: 2.0" IPS, 320x240 px - UI: dunkler iOS-Dark-Mode-Look (schwarz/weiß Basis, dezente Akzentfarben),
- **Eingabe**: 3 Buttons (A, B, C) farbige prozedurale Icons statt Bitmap-Assets
- **Netzwerk**: WLAN (Heimnetz, Zugriff auf `*.fritz.box` Hosts) - Kalender-Datumsformat: `Wochentag Tag.Monat. Stunde:Minute` (z.B. "Mo 3.8. 14:00"),
ganztägig ohne Uhrzeit
## 3. Toolchain - Fehlerbehandlung: einfache Anzeige + Retry-Icon, Retry automatisch beim
nächsten Refresh-Intervall oder manuell per Tastendruck
- **Build-System**: PlatformIO (kein Arduino IDE) - WLAN-Zugangsdaten: lokal in `include/secrets.h`, nie eingecheckt
- **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`). - Keine eigene Zeitsynchronisation (NTP), Zeiten wie von APIs geliefert
- **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. - Build: PlatformIO, plattformübergreifende Build-/Deploy-Skripte (Linux/macOS/Windows)
- **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.
+16 -96
View File
@@ -1,98 +1,18 @@
# TODO.md Einzelne Tasks # TODO.md
_Letztes Update: 2026-08-01 23:18 CEST_ _Letztes Update: 2026-08-02_
Checkliste abgeleitet aus PLAN.md. Reihenfolge entspricht der empfohlenen Umsetzungsreihenfolge. - [x] PlatformIO-Grundgerüst, WLAN, HTTP-Clients
- [x] Screen-Navigation über 3 Buttons, Idle-Timeout
## Phase 0 Projekt-Grundgerüst - [x] Home-Screen mit Wetter, Regen-Hinweis, nächsten 2 Terminen
- [x] Wetter-Detailseite mit Icons je Stunde
- [ ] PlatformIO-Projekt für M5Stack Core initialisieren (`platformio.ini`: Board, Framework Arduino, M5Stack-Library) - [x] Kalender-Detailseite mit allen Terminen
- [ ] Ordnerstruktur anlegen: `include/`, `src/screens/`, `src/api/`, `src/icons/`, `scripts/` - [x] MVG-Abfahrtsseite
- [ ] `include/secrets.h.example` mit Platzhaltern für WLAN SSID/Passwort erstellen - [x] Fehlerbehandlung (Retry-Icon, automatischer + manueller Retry)
- [ ] `.gitignore` vorbereiten (schließt `include/secrets.h`, `.pio/` aus) - [x] Umlaut-Fix (ASCII-Transliteration)
- [ ] Minimalen `src/main.cpp` schreiben, der "Hello Dashboard" anzeigt - [x] Gewitter-Icon von Bewölkt-Icon unterschieden
- [ ] `scripts/deploy.sh` schreiben (Build + Flash, automatische Port-Erkennung, optionaler Port-Parameter) - [x] Kalender-Icon (statt Checkbox-Symbol)
- [ ] Deploy-Skript testen, Testtext auf echtem Gerät prüfen - [x] Kalender-Datumsformat "Mo 3.8. 14:00" / "Mo 3.8." für ganztägig
- [x] Mehr Zeilenabstand in Kalender-Detail und MVG-Screen
## Phase 1 WLAN & HTTP-Grundlage - [ ] Optional: echte Pixel-Art-Icons statt prozeduraler Zeichnung
- [ ] Git-Repository initialisieren (bewusst noch offen)
- [ ] 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
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <Arduino.h>
// No NTP/network time used, purely computed from date strings coming from
// the calendar API itself, as specified.
namespace DateUtils {
inline const char *weekdayShortDE(int isoWeekday /* 0=Mon..6=Sun */) {
static const char *names[7] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"};
if (isoWeekday < 0 || isoWeekday > 6) return "??";
return names[isoWeekday];
}
// Sakamoto's algorithm, returns 0=Monday..6=Sunday
inline int computeWeekdayMonBased(int year, int month, int day) {
static const int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
int y = year;
if (month < 3) y -= 1;
int w = (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7; // 0=Sun
return (w + 6) % 7; // convert to 0=Mon
}
struct ParsedDateTime {
bool valid = false;
int year = 0, month = 0, day = 0, hour = 0, minute = 0;
};
inline ParsedDateTime parseIso(const String &iso) {
ParsedDateTime r;
if (iso.length() < 10) return r;
int y = iso.substring(0, 4).toInt();
int mo = iso.substring(5, 7).toInt();
int d = iso.substring(8, 10).toInt();
int h = 0, mi = 0;
int tIdx = iso.indexOf('T');
if (tIdx > 0 && iso.length() >= tIdx + 6) {
h = iso.substring(tIdx + 1, tIdx + 3).toInt();
mi = iso.substring(tIdx + 4, tIdx + 6).toInt();
}
if (y == 0 || mo == 0 || d == 0) return r;
r.valid = true;
r.year = y; r.month = mo; r.day = d; r.hour = h; r.minute = mi;
return r;
}
// "Mo 3.8. 14:00" for timed events, "Mo 3.8." for all-day events.
inline String formatShortDE(const String &iso, bool allDay) {
ParsedDateTime p = parseIso(iso);
if (!p.valid) return iso;
int wd = computeWeekdayMonBased(p.year, p.month, p.day);
String out = String(weekdayShortDE(wd)) + " " + String(p.day) + "." + String(p.month) + ".";
if (!allDay) {
char buf[8];
snprintf(buf, sizeof(buf), " %02d:%02d", p.hour, p.minute);
out += buf;
}
return out;
}
} // namespace DateUtils
+9 -17
View File
@@ -1,35 +1,27 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
// The M5Stack default font does not correctly render UTF-8 encoded German // M5Stack default font can't render UTF-8 German umlauts correctly.
// umlauts (ä, ö, ü, ß), they show up as garbled characters or boxes. // Transliterate them to ASCII before printing.
// This helper replaces them with their common ASCII transliteration
// (ö -> oe, ä -> ae, ü -> ue, ß -> ss) before printing to the display.
// This is the simplest, most robust fix without needing a custom font.
inline String sanitizeGermanText(const String &input) { inline String sanitizeGermanText(const String &input) {
String out; String out;
out.reserve(input.length()); out.reserve(input.length());
for (size_t i = 0; i < input.length(); i++) { for (size_t i = 0; i < input.length(); i++) {
unsigned char c1 = input[i]; unsigned char c1 = input[i];
// UTF-8 two-byte sequences for German umlauts start with 0xC3
if (c1 == 0xC3 && i + 1 < input.length()) { if (c1 == 0xC3 && i + 1 < input.length()) {
unsigned char c2 = input[i + 1]; unsigned char c2 = input[i + 1];
switch (c2) { switch (c2) {
case 0xA4: out += "ae"; i++; continue; // ä case 0xA4: out += "ae"; i++; continue;
case 0xB6: out += "oe"; i++; continue; // ö case 0xB6: out += "oe"; i++; continue;
case 0xBC: out += "ue"; i++; continue; // ü case 0xBC: out += "ue"; i++; continue;
case 0x84: out += "Ae"; i++; continue; // Ä case 0x84: out += "Ae"; i++; continue;
case 0x96: out += "Oe"; i++; continue; // Ö case 0x96: out += "Oe"; i++; continue;
case 0x9C: out += "Ue"; i++; continue; // Ü case 0x9C: out += "Ue"; i++; continue;
case 0x9F: out += "ss"; i++; continue; // ß case 0x9F: out += "ss"; i++; continue;
default: break; default: break;
} }
} }
out += (char)c1; out += (char)c1;
} }
return out; return out;
} }
+13 -15
View File
@@ -1,23 +1,21 @@
#pragma once #pragma once
#include <M5Stack.h> #include <M5Stack.h>
// "iPhone Dark Mode" inspired theme: near-black background, white text,
// desaturated accent colors used sparingly.
namespace Theme { namespace Theme {
constexpr uint16_t BG = 0x0000; // near-black background constexpr uint16_t BG = 0x0000;
constexpr uint16_t BG_CARD = 0x1082; // slightly lighter card background (dark gray) constexpr uint16_t BG_CARD = 0x1082;
constexpr uint16_t TEXT = 0xFFFF; // white constexpr uint16_t TEXT = 0xFFFF;
constexpr uint16_t TEXT_DIM = 0x8410; // light gray, secondary text constexpr uint16_t TEXT_DIM = 0x8410;
constexpr uint16_t ACCENT_WEATHER = 0x051D; // muted blue constexpr uint16_t ACCENT_WEATHER = 0x051D;
constexpr uint16_t ACCENT_SUN = 0xFEA0; // warm yellow constexpr uint16_t ACCENT_SUN = 0xFEA0;
constexpr uint16_t ACCENT_RAIN = 0x03BF; // muted cyan/blue constexpr uint16_t ACCENT_RAIN = 0x03BF;
constexpr uint16_t ACCENT_CALENDAR= 0x7BEF; // muted lavender/gray constexpr uint16_t ACCENT_CALENDAR= 0x7BEF;
constexpr uint16_t ACCENT_UBAHN = 0x0410; // U-Bahn blue (muted) constexpr uint16_t ACCENT_UBAHN = 0x0410;
constexpr uint16_t ACCENT_SBAHN = 0x0540; // S-Bahn green (muted) constexpr uint16_t ACCENT_SBAHN = 0x0540;
constexpr uint16_t ACCENT_WARN = 0xFB00; // muted orange for delays/warnings constexpr uint16_t ACCENT_WARN = 0xFB00;
constexpr uint16_t ACCENT_ERROR = 0xF800; // red for errors/cancellations constexpr uint16_t ACCENT_ERROR = 0xF800;
constexpr uint16_t ACCENT_STORM = 0xFD20;
constexpr int SCREEN_W = 320; constexpr int SCREEN_W = 320;
constexpr int SCREEN_H = 240; constexpr int SCREEN_H = 240;
+2 -24
View File
@@ -1,23 +1,9 @@
@echo off @echo off
REM Local build-only test script for Windows.
REM Compiles the project WITHOUT uploading to any device.
REM Usage: scripts\build.cmd
setlocal setlocal
cd /d "%~dp0\.." cd /d "%~dp0\.."
set VENV_DIR=.venv-platformio set VENV_DIR=.venv-platformio
REM Prefer the "py" launcher (ships with official Python installer) which
REM reliably picks Python 3, even if "python" on PATH points to an old
REM Python 2.x install.
where py >nul 2>nul where py >nul 2>nul
if %errorlevel%==0 ( if %errorlevel%==0 (set PYTHON_CMD=py -3) else (set PYTHON_CMD=python)
set PYTHON_CMD=py -3
) else (
set PYTHON_CMD=python
)
if not exist "%VENV_DIR%" ( if not exist "%VENV_DIR%" (
echo Creating local virtualenv in %VENV_DIR% using: %PYTHON_CMD% echo Creating local virtualenv in %VENV_DIR% using: %PYTHON_CMD%
%PYTHON_CMD% -m venv "%VENV_DIR%" %PYTHON_CMD% -m venv "%VENV_DIR%"
@@ -25,21 +11,13 @@ if not exist "%VENV_DIR%" (
echo. echo.
echo ERROR: Could not create a Python 3 virtualenv. echo ERROR: Could not create a Python 3 virtualenv.
echo Make sure Python 3 is installed from https://www.python.org/downloads/ echo Make sure Python 3 is installed from https://www.python.org/downloads/
echo and that "py -3 --version" or "python --version" reports Python 3.x
echo ^(your "python" currently points to: %PYTHON_CMD%^)
exit /b 1 exit /b 1
) )
"%VENV_DIR%\Scripts\python.exe" -m pip install --upgrade pip "%VENV_DIR%\Scripts\python.exe" -m pip install --upgrade pip
"%VENV_DIR%\Scripts\pip.exe" install platformio "%VENV_DIR%\Scripts\pip.exe" install platformio
) )
echo Running build (no upload) ... echo Running build (no upload) ...
"%VENV_DIR%\Scripts\pio.exe" run "%VENV_DIR%\Scripts\pio.exe" run
if errorlevel 1 (echo Build FAILED. & exit /b 1)
if errorlevel 1 (
echo Build FAILED.
exit /b 1
)
echo Build finished successfully. echo Build finished successfully.
endlocal endlocal
-12
View File
@@ -1,25 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Local build-only test script for Linux/macOS.
# Compiles the project WITHOUT uploading to any device.
# Useful to quickly check "does it even compile" without touching the M5Stack.
#
# Usage:
# ./scripts/build.sh
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
VENV_DIR=".venv-platformio" VENV_DIR=".venv-platformio"
if [ ! -d "$VENV_DIR" ]; then if [ ! -d "$VENV_DIR" ]; then
echo "Creating local virtualenv in $VENV_DIR ..." echo "Creating local virtualenv in $VENV_DIR ..."
python3 -m venv "$VENV_DIR" python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install --upgrade pip "$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install platformio "$VENV_DIR/bin/pip" install platformio
fi fi
echo "Running build (no upload) ..." echo "Running build (no upload) ..."
"$VENV_DIR/bin/pio" run "$VENV_DIR/bin/pio" run
echo "Build finished successfully." echo "Build finished successfully."
+2 -26
View File
@@ -1,24 +1,9 @@
@echo off @echo off
REM Build + flash the M5Stack Dashboard project via PlatformIO on Windows.
REM
REM Usage:
REM scripts\deploy.cmd (auto-detect USB port)
REM scripts\deploy.cmd COM5 (force a specific port)
REM
REM Does NOT open the serial monitor afterwards.
setlocal setlocal
cd /d "%~dp0\.." cd /d "%~dp0\.."
set VENV_DIR=.venv-platformio set VENV_DIR=.venv-platformio
where py >nul 2>nul where py >nul 2>nul
if %errorlevel%==0 ( if %errorlevel%==0 (set PYTHON_CMD=py -3) else (set PYTHON_CMD=python)
set PYTHON_CMD=py -3
) else (
set PYTHON_CMD=python
)
if not exist "%VENV_DIR%" ( if not exist "%VENV_DIR%" (
echo Creating local virtualenv in %VENV_DIR% using: %PYTHON_CMD% echo Creating local virtualenv in %VENV_DIR% using: %PYTHON_CMD%
%PYTHON_CMD% -m venv "%VENV_DIR%" %PYTHON_CMD% -m venv "%VENV_DIR%"
@@ -26,16 +11,12 @@ if not exist "%VENV_DIR%" (
echo. echo.
echo ERROR: Could not create a Python 3 virtualenv. echo ERROR: Could not create a Python 3 virtualenv.
echo Make sure Python 3 is installed from https://www.python.org/downloads/ echo Make sure Python 3 is installed from https://www.python.org/downloads/
echo and that "py -3 --version" or "python --version" reports Python 3.x
echo ^(your "python" currently points to: %PYTHON_CMD%^)
exit /b 1 exit /b 1
) )
"%VENV_DIR%\Scripts\python.exe" -m pip install --upgrade pip "%VENV_DIR%\Scripts\python.exe" -m pip install --upgrade pip
"%VENV_DIR%\Scripts\pip.exe" install platformio "%VENV_DIR%\Scripts\pip.exe" install platformio
) )
set PIO=%VENV_DIR%\Scripts\pio.exe set PIO=%VENV_DIR%\Scripts\pio.exe
if "%~1"=="" ( if "%~1"=="" (
echo Auto-detecting upload port... echo Auto-detecting upload port...
"%PIO%" run --target upload "%PIO%" run --target upload
@@ -43,11 +24,6 @@ if "%~1"=="" (
echo Using forced upload port: %~1 echo Using forced upload port: %~1
"%PIO%" run --target upload --upload-port %~1 "%PIO%" run --target upload --upload-port %~1
) )
if errorlevel 1 (echo Deploy FAILED. & exit /b 1)
if errorlevel 1 (
echo Deploy FAILED.
exit /b 1
)
echo Build + flash complete. echo Build + flash complete.
endlocal endlocal
-15
View File
@@ -1,28 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Build + flash the M5Stack Dashboard project via PlatformIO on Linux/macOS.
#
# 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.
# Uses the same local virtualenv as build.sh, created automatically if missing.
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
VENV_DIR=".venv-platformio" VENV_DIR=".venv-platformio"
if [ ! -d "$VENV_DIR" ]; then if [ ! -d "$VENV_DIR" ]; then
echo "Creating local virtualenv in $VENV_DIR ..." echo "Creating local virtualenv in $VENV_DIR ..."
python3 -m venv "$VENV_DIR" python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install --upgrade pip "$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install platformio "$VENV_DIR/bin/pip" install platformio
fi fi
PIO="$VENV_DIR/bin/pio" PIO="$VENV_DIR/bin/pio"
if [ "$#" -ge 1 ]; then if [ "$#" -ge 1 ]; then
PORT="$1" PORT="$1"
echo "Using forced upload port: $PORT" echo "Using forced upload port: $PORT"
@@ -31,5 +17,4 @@ else
echo "Auto-detecting upload port..." echo "Auto-detecting upload port..."
"$PIO" run --target upload "$PIO" run --target upload
fi fi
echo "Build + flash complete." echo "Build + flash complete."
+2 -11
View File
@@ -5,19 +5,11 @@
CalendarData fetchCalendar() { CalendarData fetchCalendar() {
CalendarData data; CalendarData data;
ApiResult res = httpGet(CALENDAR_API_URL); ApiResult res = httpGet(CALENDAR_API_URL);
if (!res.success) { if (!res.success) { data.valid = false; return data; }
data.valid = false;
return data;
}
DynamicJsonDocument doc(8192); DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, res.body); if (deserializeJson(doc, res.body)) { data.valid = false; return data; }
if (err) {
data.valid = false;
return data;
}
JsonArray events = doc["events"]; JsonArray events = doc["events"];
for (JsonObject e : events) { for (JsonObject e : events) {
@@ -30,7 +22,6 @@ CalendarData fetchCalendar() {
ev.status = e["status"] | ""; ev.status = e["status"] | "";
data.events.push_back(ev); data.events.push_back(ev);
} }
data.valid = true; data.valid = true;
return data; return data;
} }
-2
View File
@@ -16,6 +16,4 @@ struct CalendarData {
std::vector<CalendarEvent> events; std::vector<CalendarEvent> events;
}; };
// Fetches and parses the calendar API (already limited to next 10 events
// server-side).
CalendarData fetchCalendar(); CalendarData fetchCalendar();
+2 -11
View File
@@ -5,19 +5,11 @@
DeparturesData fetchDepartures() { DeparturesData fetchDepartures() {
DeparturesData data; DeparturesData data;
ApiResult res = httpGet(DEPARTURES_API_URL); ApiResult res = httpGet(DEPARTURES_API_URL);
if (!res.success) { if (!res.success) { data.valid = false; return data; }
data.valid = false;
return data;
}
DynamicJsonDocument doc(16384); DynamicJsonDocument doc(16384);
DeserializationError err = deserializeJson(doc, res.body); if (deserializeJson(doc, res.body)) { data.valid = false; return data; }
if (err) {
data.valid = false;
return data;
}
JsonArray departures = doc["departures"]; JsonArray departures = doc["departures"];
for (JsonObject d : departures) { for (JsonObject d : departures) {
@@ -33,7 +25,6 @@ DeparturesData fetchDepartures() {
dep.cancelled = d["cancelled"] | false; dep.cancelled = d["cancelled"] | false;
data.departures.push_back(dep); data.departures.push_back(dep);
} }
data.valid = true; data.valid = true;
return data; return data;
} }
+2 -4
View File
@@ -4,8 +4,8 @@
struct Departure { struct Departure {
String station; String station;
String type; // "UBAHN" or "SBAHN" String type;
String icon; // "U" or "S" String icon;
String line; String line;
String destination; String destination;
long timeEpoch = 0; long timeEpoch = 0;
@@ -19,6 +19,4 @@ struct DeparturesData {
std::vector<Departure> departures; 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(); DeparturesData fetchDepartures();
-5
View File
@@ -4,27 +4,22 @@
ApiResult httpGet(const String &url, uint32_t timeoutMs) { ApiResult httpGet(const String &url, uint32_t timeoutMs) {
ApiResult result; ApiResult result;
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
result.success = false; result.success = false;
result.httpCode = -1; result.httpCode = -1;
return result; return result;
} }
HTTPClient http; HTTPClient http;
http.setTimeout(timeoutMs); http.setTimeout(timeoutMs);
http.begin(url); http.begin(url);
int code = http.GET(); int code = http.GET();
result.httpCode = code; result.httpCode = code;
if (code == HTTP_CODE_OK) { if (code == HTTP_CODE_OK) {
result.body = http.getString(); result.body = http.getString();
result.success = true; result.success = true;
} else { } else {
result.success = false; result.success = false;
} }
http.end(); http.end();
return result; return result;
} }
-4
View File
@@ -1,14 +1,10 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
// Result of a GET request against one of the JSON APIs.
struct ApiResult { struct ApiResult {
bool success = false; bool success = false;
String body; String body;
int httpCode = -1; 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); ApiResult httpGet(const String &url, uint32_t timeoutMs = 5000);
+3 -15
View File
@@ -5,19 +5,11 @@
WeatherData fetchWeather() { WeatherData fetchWeather() {
WeatherData data; WeatherData data;
ApiResult res = httpGet(WEATHER_API_URL); ApiResult res = httpGet(WEATHER_API_URL);
if (!res.success) { if (!res.success) { data.valid = false; return data; }
data.valid = false;
return data;
}
DynamicJsonDocument doc(8192); DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, res.body); if (deserializeJson(doc, res.body)) { data.valid = false; return data; }
if (err) {
data.valid = false;
return data;
}
JsonObject current = doc["current"]; JsonObject current = doc["current"];
data.current.temperature = current["temperature"] | 0; data.current.temperature = current["temperature"] | 0;
@@ -36,20 +28,16 @@ WeatherData fetchWeather() {
fe.precipitationType = precip["type"] | ""; fe.precipitationType = precip["type"] | "";
data.forecast.push_back(fe); data.forecast.push_back(fe);
} }
data.valid = true; data.valid = true;
return data; return data;
} }
bool willRainSoon(const WeatherData &data, int hours) { bool willRainSoon(const WeatherData &data, int hours) {
if (!data.valid) return false; if (!data.valid) return false;
int checked = 0; int checked = 0;
for (const auto &entry : data.forecast) { for (const auto &entry : data.forecast) {
if (checked >= hours) break; if (checked >= hours) break;
if (entry.precipitationType == "rain" && entry.precipitationProbability > 0.0f) { if (entry.precipitationType == "rain" && entry.precipitationProbability > 0.0f) return true;
return true;
}
checked++; checked++;
} }
return false; return false;
-6
View File
@@ -23,11 +23,5 @@ struct WeatherData {
std::vector<ForecastEntry> forecast; std::vector<ForecastEntry> forecast;
}; };
// Fetches and parses the weather API. Returns valid=false on any failure
// (network error, timeout, or malformed JSON).
WeatherData fetchWeather(); 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); bool willRainSoon(const WeatherData &data, int hours = 8);
+54 -32
View File
@@ -2,16 +2,10 @@
#include <M5Stack.h> #include <M5Stack.h>
#include "theme.h" #include "theme.h"
// Design note: instead of storing large RGB565 bitmap arrays (which would // Icons drawn procedurally with M5Stack graphics primitives instead of
// bloat flash usage for a first version), icons are drawn procedurally with // stored RGB565 bitmaps, to keep flash usage small.
// 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 { namespace Icons {
// Draws a sun icon centered at (cx, cy) with given radius
inline void drawSun(int cx, int cy, int r) { inline void drawSun(int cx, int cy, int r) {
M5.Lcd.fillCircle(cx, cy, r, Theme::ACCENT_SUN); M5.Lcd.fillCircle(cx, cy, r, Theme::ACCENT_SUN);
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
@@ -24,21 +18,18 @@ inline void drawSun(int cx, int cy, int r) {
} }
} }
// Draws a moon icon (crescent) centered at (cx, cy)
inline void drawMoon(int cx, int cy, int r) { inline void drawMoon(int cx, int cy, int r) {
M5.Lcd.fillCircle(cx, cy, r, Theme::TEXT_DIM); M5.Lcd.fillCircle(cx, cy, r, Theme::TEXT_DIM);
M5.Lcd.fillCircle(cx + r / 2, cy - r / 3, r, Theme::BG); 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, uint16_t color = Theme::TEXT_DIM) {
inline void drawCloud(int cx, int cy, int scale) { M5.Lcd.fillCircle(cx - scale, cy, scale, color);
M5.Lcd.fillCircle(cx - scale, cy, scale, Theme::TEXT_DIM); M5.Lcd.fillCircle(cx + scale, cy, scale, color);
M5.Lcd.fillCircle(cx + scale, cy, scale, Theme::TEXT_DIM); M5.Lcd.fillCircle(cx, cy - scale / 2, scale + 2, color);
M5.Lcd.fillCircle(cx, cy - scale / 2, scale + 2, Theme::TEXT_DIM); M5.Lcd.fillRect(cx - scale, cy, scale * 2, scale, color);
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) { inline void drawRainDrops(int cx, int cy, int count = 3) {
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
int x = cx - (count - 1) * 5 + i * 10; int x = cx - (count - 1) * 5 + i * 10;
@@ -46,23 +37,46 @@ inline void drawRainDrops(int cx, int cy, int count = 3) {
} }
} }
// Combined weather icon based on the API "symbol" field. // Lightning bolt used for thunderstorm icon.
// symbol examples: "mo____" (clear/moon), "mb____" (cloudy/moon), inline void drawLightningBolt(int cx, int cy, int size, uint16_t color = Theme::ACCENT_STORM) {
// "wb____" (cloudy/day). First char roughly encodes day(w)/night(m), int x0 = cx + size / 4, y0 = cy - size;
// second char encodes condition (o=clear, b=cloudy, r=rain, etc). int x1 = cx - size / 3, y1 = cy;
inline void drawWeatherIcon(const String &symbol, int cx, int cy, int r = 20) { int x2 = cx, y2 = cy;
int x3 = cx - size / 6, y3 = cy + size;
M5.Lcd.drawLine(x0, y0, x1, y1, color);
M5.Lcd.drawLine(x1, y1, x2, y2, color);
M5.Lcd.drawLine(x2, y2, x3, y3, color);
M5.Lcd.drawLine(x0 + 1, y0, x1 + 1, y1, color);
M5.Lcd.drawLine(x1 + 1, y1, x2 + 1, y2, color);
}
// Thunderstorm: dark cloud + bolt, visually distinct from plain "bewoelkt".
inline void drawThunderstorm(int cx, int cy, int scale) {
drawCloud(cx, cy - scale / 3, scale, Theme::TEXT_DIM);
drawLightningBolt(cx, cy + scale / 2, scale, Theme::ACCENT_STORM);
}
// symbol: e.g. "mo____" (clear/night), "mb____" (cloudy/night), "wb____"
// (cloudy/day). description used as secondary signal to catch "Gewitter".
inline void drawWeatherIcon(const String &symbol, const String &description, int cx, int cy, int r = 20) {
bool isNight = symbol.length() > 0 && symbol.charAt(0) == 'm'; bool isNight = symbol.length() > 0 && symbol.charAt(0) == 'm';
char condition = symbol.length() > 1 ? symbol.charAt(1) : 'o'; char condition = symbol.length() > 1 ? symbol.charAt(1) : 'o';
bool isThunder = (condition == 't') || (description.indexOf("Gewitter") >= 0);
if (isThunder) {
drawThunderstorm(cx, cy, r * 2 / 3);
return;
}
switch (condition) { switch (condition) {
case 'o': // clear case 'o':
if (isNight) drawMoon(cx, cy, r); if (isNight) drawMoon(cx, cy, r);
else drawSun(cx, cy, r); else drawSun(cx, cy, r);
break; break;
case 'b': // cloudy case 'b':
drawCloud(cx, cy, r * 2 / 3); drawCloud(cx, cy, r * 2 / 3);
break; break;
case 'r': // rain case 'r':
drawCloud(cx, cy - 5, r * 2 / 3); drawCloud(cx, cy - 5, r * 2 / 3);
drawRainDrops(cx, cy + r / 2, 3); drawRainDrops(cx, cy + r / 2, 3);
break; break;
@@ -72,13 +86,15 @@ inline void drawWeatherIcon(const String &symbol, int cx, int cy, int r = 20) {
} }
} }
// Small rain-warning banner icon (drop with exclamation-ish accent) inline void drawWeatherIcon(const String &symbol, int cx, int cy, int r = 20) {
drawWeatherIcon(symbol, String(""), cx, cy, r);
}
inline void drawRainWarning(int cx, int cy) { 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.fillTriangle(cx, cy - 8, cx - 6, cy + 6, cx + 6, cy + 6, Theme::ACCENT_RAIN);
M5.Lcd.fillCircle(cx, cy + 2, 2, Theme::BG); 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) { inline void drawUBahnBadge(int x, int y, int w, int h) {
M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_UBAHN); M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_UBAHN);
M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_UBAHN); M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_UBAHN);
@@ -86,7 +102,6 @@ inline void drawUBahnBadge(int x, int y, int w, int h) {
M5.Lcd.print("U"); M5.Lcd.print("U");
} }
// Simple "S" badge for S-Bahn
inline void drawSBahnBadge(int x, int y, int w, int h) { inline void drawSBahnBadge(int x, int y, int w, int h) {
M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_SBAHN); M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_SBAHN);
M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_SBAHN); M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_SBAHN);
@@ -94,17 +109,24 @@ inline void drawSBahnBadge(int x, int y, int w, int h) {
M5.Lcd.print("S"); M5.Lcd.print("S");
} }
// Calendar icon: simple rectangle with a top bar and a page-marker dot // Proper calendar icon (rounded body, header bar, binder rings, day-dot),
// replacing the earlier checkbox-style placeholder.
inline void drawCalendarIcon(int cx, int cy, int size) { inline void drawCalendarIcon(int cx, int cy, int size) {
int x = cx - size / 2; int x = cx - size / 2;
int y = cy - size / 2; int y = cy - size / 2;
M5.Lcd.fillRoundRect(x, y, size, size, 3, Theme::BG);
M5.Lcd.drawRoundRect(x, y, size, size, 3, Theme::ACCENT_CALENDAR); 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); int headerH = size / 3;
M5.Lcd.fillRect(x + 1, y + 1, size - 2, headerH, Theme::ACCENT_CALENDAR);
M5.Lcd.fillRect(x + size / 4 - 1, y - 2, 2, 4, Theme::ACCENT_CALENDAR);
M5.Lcd.fillRect(x + size * 3 / 4 - 1, y - 2, 2, 4, Theme::ACCENT_CALENDAR);
M5.Lcd.fillCircle(cx, y + headerH + (size - headerH) / 2, 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) { inline void drawRetryIcon(int cx, int cy, int r) {
M5.Lcd.drawCircle(cx, cy, r, Theme::ACCENT_ERROR); M5.Lcd.drawCircle(cx, cy, r, Theme::ACCENT_ERROR);
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG); M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
+12 -42
View File
@@ -11,8 +11,7 @@
namespace { namespace {
ScreenId currentScreen = ScreenId::HOME; ScreenId currentScreen = ScreenId::HOME;
unsigned long lastInteractionMs = 0; unsigned long lastInteractionMs = 0;
const unsigned long IDLE_TIMEOUT_MS = 5UL * 60UL * 1000UL; // 5 minutes const unsigned long IDLE_TIMEOUT_MS = 5UL * 60UL * 1000UL;
bool wifiConnected = false; bool wifiConnected = false;
void connectWifi() { void connectWifi() {
@@ -47,18 +46,10 @@ namespace {
void renderCurrentScreen(bool forceRefresh = false) { void renderCurrentScreen(bool forceRefresh = false) {
switch (currentScreen) { switch (currentScreen) {
case ScreenId::HOME: case ScreenId::HOME: renderHomeScreen(forceRefresh); break;
renderHomeScreen(forceRefresh); case ScreenId::WEATHER_DETAIL: renderWeatherDetailScreen(forceRefresh); break;
break; case ScreenId::CALENDAR_DETAIL: renderCalendarDetailScreen(forceRefresh); break;
case ScreenId::WEATHER_DETAIL: case ScreenId::MVG: renderMvgScreen(forceRefresh); break;
renderWeatherDetailScreen(forceRefresh);
break;
case ScreenId::CALENDAR_DETAIL:
renderCalendarDetailScreen(forceRefresh);
break;
case ScreenId::MVG:
renderMvgScreen(forceRefresh);
break;
} }
} }
@@ -73,9 +64,7 @@ void setup() {
M5.begin(); M5.begin();
M5.Power.begin(); M5.Power.begin();
M5.Lcd.setBrightness(200); M5.Lcd.setBrightness(200);
connectWifi(); connectWifi();
lastInteractionMs = millis(); lastInteractionMs = millis();
goToScreen(ScreenId::HOME, true); goToScreen(ScreenId::HOME, true);
} }
@@ -83,38 +72,19 @@ void setup() {
void loop() { void loop() {
M5.update(); M5.update();
// Button A: toggle between Weather-Detail and Calendar-Detail
if (M5.BtnA.wasPressed()) { if (M5.BtnA.wasPressed()) {
if (currentScreen == ScreenId::WEATHER_DETAIL) { if (currentScreen == ScreenId::WEATHER_DETAIL) goToScreen(ScreenId::CALENDAR_DETAIL, true);
goToScreen(ScreenId::CALENDAR_DETAIL, true); else goToScreen(ScreenId::WEATHER_DETAIL, true);
} else {
goToScreen(ScreenId::WEATHER_DETAIL, true);
}
} }
if (M5.BtnB.wasPressed()) goToScreen(ScreenId::HOME, true);
if (M5.BtnC.wasPressed()) goToScreen(ScreenId::MVG, true);
// Button B: back to Home if (currentScreen != ScreenId::HOME && millis() - lastInteractionMs >= IDLE_TIMEOUT_MS) {
if (M5.BtnB.wasPressed()) {
goToScreen(ScreenId::HOME, true); goToScreen(ScreenId::HOME, true);
} }
// Button C: go to MVG departures screen if (currentScreen == ScreenId::HOME) updateHomeScreen();
if (M5.BtnC.wasPressed()) { if (currentScreen == ScreenId::MVG) updateMvgScreen();
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); delay(50);
} }
+14 -20
View File
@@ -1,26 +1,14 @@
#include "calendar_detail_screen.h" #include "calendar_detail_screen.h"
#include "../../include/text_utils.h"
#include <M5Stack.h> #include <M5Stack.h>
#include "../../include/theme.h" #include "../../include/theme.h"
#include "../../include/text_utils.h"
#include "../../include/date_utils.h"
#include "../icons/icons.h" #include "../icons/icons.h"
#include "../api/calendar_api.h" #include "../api/calendar_api.h"
namespace { namespace {
CalendarData lastCalendar; CalendarData lastCalendar;
bool ok = false; 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) { void renderCalendarDetailScreen(bool forceRefresh) {
@@ -44,20 +32,26 @@ void renderCalendarDetailScreen(bool forceRefresh) {
return; return;
} }
int y = 40; // Taller rows + separator line between entries for clearer name/time
// pairing than the previous cramped layout.
int y = 38;
const int rowH = 24;
for (const auto &ev : lastCalendar.events) { for (const auto &ev : lastCalendar.events) {
if (y > 215) break; if (y > 215) break;
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(1); M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, y); M5.Lcd.setCursor(10, y);
M5.Lcd.print(sanitizeGermanText(ev.summary.substring(0, 28))); M5.Lcd.print(sanitizeGermanText(ev.summary.substring(0, 24)));
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG); M5.Lcd.setTextColor(Theme::ACCENT_CALENDAR, Theme::BG);
M5.Lcd.setCursor(230, y); M5.Lcd.setCursor(10, y + 11);
M5.Lcd.print(formatEventTime(ev)); M5.Lcd.print(DateUtils::formatShortDE(ev.startAt, ev.allDay));
y += 18; M5.Lcd.drawFastHLine(10, y + rowH - 4, Theme::SCREEN_W - 20, Theme::BG_CARD);
y += rowH;
} }
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
+10 -34
View File
@@ -1,7 +1,8 @@
#include "home_screen.h" #include "home_screen.h"
#include "../../include/text_utils.h"
#include <M5Stack.h> #include <M5Stack.h>
#include "../../include/theme.h" #include "../../include/theme.h"
#include "../../include/text_utils.h"
#include "../../include/date_utils.h"
#include "../icons/icons.h" #include "../icons/icons.h"
#include "../api/weather_api.h" #include "../api/weather_api.h"
#include "../api/calendar_api.h" #include "../api/calendar_api.h"
@@ -12,32 +13,15 @@ namespace {
bool weatherOk = false; bool weatherOk = false;
bool calendarOk = false; bool calendarOk = false;
unsigned long lastFetchMs = 0; unsigned long lastFetchMs = 0;
const unsigned long REFRESH_INTERVAL_MS = 10UL * 60UL * 1000UL; // 10 minutes const unsigned long REFRESH_INTERVAL_MS = 10UL * 60UL * 1000UL;
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() { void doFetch() {
WeatherData w = fetchWeather(); WeatherData w = fetchWeather();
weatherOk = w.valid; weatherOk = w.valid;
if (w.valid) lastWeather = w; if (w.valid) lastWeather = w;
CalendarData c = fetchCalendar(); CalendarData c = fetchCalendar();
calendarOk = c.valid; calendarOk = c.valid;
if (c.valid) lastCalendar = c; if (c.valid) lastCalendar = c;
lastFetchMs = millis(); lastFetchMs = millis();
} }
@@ -50,21 +34,16 @@ namespace {
} }
void updateHomeScreen() { void updateHomeScreen() {
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) { if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) doFetch();
doFetch();
}
} }
void renderHomeScreen(bool forceRefresh) { void renderHomeScreen(bool forceRefresh) {
if (forceRefresh || lastFetchMs == 0) { if (forceRefresh || lastFetchMs == 0) doFetch();
doFetch();
}
M5.Lcd.fillScreen(Theme::BG); M5.Lcd.fillScreen(Theme::BG);
// --- Weather block (top) ---
if (weatherOk) { if (weatherOk) {
Icons::drawWeatherIcon(lastWeather.current.symbol, 45, 55, 24); Icons::drawWeatherIcon(lastWeather.current.symbol, lastWeather.current.description, 45, 55, 24);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(4); M5.Lcd.setTextSize(4);
@@ -91,12 +70,10 @@ void renderHomeScreen(bool forceRefresh) {
M5.Lcd.drawFastHLine(10, 95, Theme::SCREEN_W - 20, Theme::BG_CARD); M5.Lcd.drawFastHLine(10, 95, Theme::SCREEN_W - 20, Theme::BG_CARD);
// --- Calendar block (next 2 events) --- Icons::drawCalendarIcon(20, 112, 16);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(2); M5.Lcd.setTextSize(2);
M5.Lcd.setCursor(10, 105); M5.Lcd.setCursor(38, 104);
Icons::drawCalendarIcon(20, 115, 14);
M5.Lcd.setCursor(35, 108);
M5.Lcd.print("Termine"); M5.Lcd.print("Termine");
if (calendarOk) { if (calendarOk) {
@@ -112,16 +89,15 @@ void renderHomeScreen(bool forceRefresh) {
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setTextSize(1); M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, y + 20); M5.Lcd.setCursor(10, y + 20);
M5.Lcd.print(formatEventTime(ev)); M5.Lcd.print(DateUtils::formatShortDE(ev.startAt, ev.allDay));
y += 40; y += 42;
shown++; shown++;
} }
} else { } else {
drawErrorState(20, 150, "Kalender n.a."); drawErrorState(20, 150, "Kalender n.a.");
} }
// --- Footer hint ---
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
M5.Lcd.setTextSize(1); M5.Lcd.setTextSize(1);
M5.Lcd.setCursor(10, 225); M5.Lcd.setCursor(10, 225);
-5
View File
@@ -1,9 +1,4 @@
#pragma once #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); void renderHomeScreen(bool forceRefresh = false);
// Call regularly from loop() to check if a refresh is due.
void updateHomeScreen(); void updateHomeScreen();
+12 -16
View File
@@ -1,7 +1,7 @@
#include "mvg_screen.h" #include "mvg_screen.h"
#include "../../include/text_utils.h"
#include <M5Stack.h> #include <M5Stack.h>
#include "../../include/theme.h" #include "../../include/theme.h"
#include "../../include/text_utils.h"
#include "../icons/icons.h" #include "../icons/icons.h"
#include "../api/departures_api.h" #include "../api/departures_api.h"
@@ -9,7 +9,7 @@ namespace {
DeparturesData lastData; DeparturesData lastData;
bool ok = false; bool ok = false;
unsigned long lastFetchMs = 0; unsigned long lastFetchMs = 0;
const unsigned long REFRESH_INTERVAL_MS = 60UL * 1000UL; // 1 minute const unsigned long REFRESH_INTERVAL_MS = 60UL * 1000UL;
void doFetch() { void doFetch() {
DeparturesData d = fetchDepartures(); DeparturesData d = fetchDepartures();
@@ -20,15 +20,11 @@ namespace {
} }
void updateMvgScreen() { void updateMvgScreen() {
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) { if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) doFetch();
doFetch();
}
} }
void renderMvgScreen(bool forceRefresh) { void renderMvgScreen(bool forceRefresh) {
if (forceRefresh || lastFetchMs == 0) { if (forceRefresh || lastFetchMs == 0) doFetch();
doFetch();
}
M5.Lcd.fillScreen(Theme::BG); M5.Lcd.fillScreen(Theme::BG);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
@@ -44,15 +40,15 @@ void renderMvgScreen(bool forceRefresh) {
return; return;
} }
// Slightly more vertical breathing room per row than the earlier version.
int y = 38; int y = 38;
for (const auto &dep : lastData.departures) { const int rowH = 20;
if (y > 215) break;
if (dep.type == "UBAHN") { for (const auto &dep : lastData.departures) {
Icons::drawUBahnBadge(8, y, 16, 14); if (y > 212) break;
} else {
Icons::drawSBahnBadge(8, y, 16, 14); 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.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(1); M5.Lcd.setTextSize(1);
@@ -75,7 +71,7 @@ void renderMvgScreen(bool forceRefresh) {
} }
} }
y += 18; y += rowH;
} }
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
-3
View File
@@ -1,8 +1,5 @@
#pragma once #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 { enum class ScreenId {
HOME, HOME,
WEATHER_DETAIL, WEATHER_DETAIL,
+2 -2
View File
@@ -1,7 +1,7 @@
#include "weather_detail_screen.h" #include "weather_detail_screen.h"
#include "../../include/text_utils.h"
#include <M5Stack.h> #include <M5Stack.h>
#include "../../include/theme.h" #include "../../include/theme.h"
#include "../../include/text_utils.h"
#include "../icons/icons.h" #include "../icons/icons.h"
#include "../api/weather_api.h" #include "../api/weather_api.h"
@@ -36,7 +36,7 @@ void renderWeatherDetailScreen(bool forceRefresh) {
for (const auto &fe : lastWeather.forecast) { for (const auto &fe : lastWeather.forecast) {
if (shown >= 6 || y > 220) break; if (shown >= 6 || y > 220) break;
Icons::drawWeatherIcon(fe.symbol, 20, y + 8, 10); Icons::drawWeatherIcon(fe.symbol, fe.description, 20, y + 8, 10);
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG); M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
M5.Lcd.setTextSize(1); M5.Lcd.setTextSize(1);