mirror of
https://github.com/skoelle/m5stack-dashboard.git
synced 2026-09-17 16:50:23 +00:00
improve ui
This commit is contained in:
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
.pio/
|
.pio/
|
||||||
.vscode/
|
.vscode/
|
||||||
.venv-platformio/
|
|
||||||
include/secrets.h
|
include/secrets.h
|
||||||
*.o
|
*.o
|
||||||
*.bin
|
*.bin
|
||||||
|
|
||||||
|
.venv-platformio/
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|||||||
@@ -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.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://<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.
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user