mirror of
https://github.com/skoelle/m5stack-dashboard.git
synced 2026-09-18 00:50:24 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
#include "calendar_api.h"
|
||||
#include "http_client.h"
|
||||
#include "../../include/secrets.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
CalendarData fetchCalendar() {
|
||||
CalendarData data;
|
||||
|
||||
ApiResult res = httpGet(CALENDAR_API_URL);
|
||||
if (!res.success) {
|
||||
data.valid = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(8192);
|
||||
DeserializationError err = deserializeJson(doc, res.body);
|
||||
if (err) {
|
||||
data.valid = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
JsonArray events = doc["events"];
|
||||
for (JsonObject e : events) {
|
||||
CalendarEvent ev;
|
||||
ev.id = e["id"] | 0;
|
||||
ev.summary = e["summary"] | "";
|
||||
ev.startAt = e["start_at"] | "";
|
||||
ev.endAt = e["end_at"] | "";
|
||||
ev.allDay = e["all_day"] | false;
|
||||
ev.status = e["status"] | "";
|
||||
data.events.push_back(ev);
|
||||
}
|
||||
|
||||
data.valid = true;
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
struct CalendarEvent {
|
||||
long id = 0;
|
||||
String summary;
|
||||
String startAt;
|
||||
String endAt;
|
||||
bool allDay = false;
|
||||
String status;
|
||||
};
|
||||
|
||||
struct CalendarData {
|
||||
bool valid = false;
|
||||
std::vector<CalendarEvent> events;
|
||||
};
|
||||
|
||||
// Fetches and parses the calendar API (already limited to next 10 events
|
||||
// server-side).
|
||||
CalendarData fetchCalendar();
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "departures_api.h"
|
||||
#include "http_client.h"
|
||||
#include "../../include/secrets.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
DeparturesData fetchDepartures() {
|
||||
DeparturesData data;
|
||||
|
||||
ApiResult res = httpGet(DEPARTURES_API_URL);
|
||||
if (!res.success) {
|
||||
data.valid = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(16384);
|
||||
DeserializationError err = deserializeJson(doc, res.body);
|
||||
if (err) {
|
||||
data.valid = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
JsonArray departures = doc["departures"];
|
||||
for (JsonObject d : departures) {
|
||||
Departure dep;
|
||||
dep.station = d["station"] | "";
|
||||
dep.type = d["type"] | "";
|
||||
dep.icon = d["icon"] | "";
|
||||
dep.line = d["line"] | "";
|
||||
dep.destination = d["destination"] | "";
|
||||
dep.timeEpoch = d["time_epoch"] | 0;
|
||||
dep.timeStr = d["time_str"] | "";
|
||||
dep.delayMin = d["delay_min"] | 0;
|
||||
dep.cancelled = d["cancelled"] | false;
|
||||
data.departures.push_back(dep);
|
||||
}
|
||||
|
||||
data.valid = true;
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
struct Departure {
|
||||
String station;
|
||||
String type; // "UBAHN" or "SBAHN"
|
||||
String icon; // "U" or "S"
|
||||
String line;
|
||||
String destination;
|
||||
long timeEpoch = 0;
|
||||
String timeStr;
|
||||
int delayMin = 0;
|
||||
bool cancelled = false;
|
||||
};
|
||||
|
||||
struct DeparturesData {
|
||||
bool valid = false;
|
||||
std::vector<Departure> departures;
|
||||
};
|
||||
|
||||
// Fetches and parses the departures API. No filtering is applied, all
|
||||
// stations and lines are returned as-is, in the order provided by the API.
|
||||
DeparturesData fetchDepartures();
|
||||
@@ -0,0 +1,30 @@
|
||||
#include "http_client.h"
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
ApiResult httpGet(const String &url, uint32_t timeoutMs) {
|
||||
ApiResult result;
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
result.success = false;
|
||||
result.httpCode = -1;
|
||||
return result;
|
||||
}
|
||||
|
||||
HTTPClient http;
|
||||
http.setTimeout(timeoutMs);
|
||||
http.begin(url);
|
||||
|
||||
int code = http.GET();
|
||||
result.httpCode = code;
|
||||
|
||||
if (code == HTTP_CODE_OK) {
|
||||
result.body = http.getString();
|
||||
result.success = true;
|
||||
} else {
|
||||
result.success = false;
|
||||
}
|
||||
|
||||
http.end();
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
// Result of a GET request against one of the JSON APIs.
|
||||
struct ApiResult {
|
||||
bool success = false;
|
||||
String body;
|
||||
int httpCode = -1;
|
||||
};
|
||||
|
||||
// Performs a simple HTTP GET request with a timeout and returns the raw body.
|
||||
// Kept deliberately simple: no retries here, retry logic lives in the screens
|
||||
// (either via the regular refresh timer or a manual button press).
|
||||
ApiResult httpGet(const String &url, uint32_t timeoutMs = 5000);
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "weather_api.h"
|
||||
#include "http_client.h"
|
||||
#include "../../include/secrets.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
WeatherData fetchWeather() {
|
||||
WeatherData data;
|
||||
|
||||
ApiResult res = httpGet(WEATHER_API_URL);
|
||||
if (!res.success) {
|
||||
data.valid = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(8192);
|
||||
DeserializationError err = deserializeJson(doc, res.body);
|
||||
if (err) {
|
||||
data.valid = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
JsonObject current = doc["current"];
|
||||
data.current.temperature = current["temperature"] | 0;
|
||||
data.current.symbol = current["symbol"] | "";
|
||||
data.current.description = current["description"] | "";
|
||||
|
||||
JsonArray forecast = doc["forecast"];
|
||||
for (JsonObject entry : forecast) {
|
||||
ForecastEntry fe;
|
||||
fe.time = entry["time"] | "";
|
||||
fe.temperature = entry["temperature"] | 0;
|
||||
fe.symbol = entry["symbol"] | "";
|
||||
fe.description = entry["description"] | "";
|
||||
JsonObject precip = entry["precipitation"];
|
||||
fe.precipitationProbability = precip["probability"] | 0.0f;
|
||||
fe.precipitationType = precip["type"] | "";
|
||||
data.forecast.push_back(fe);
|
||||
}
|
||||
|
||||
data.valid = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
bool willRainSoon(const WeatherData &data, int hours) {
|
||||
if (!data.valid) return false;
|
||||
|
||||
int checked = 0;
|
||||
for (const auto &entry : data.forecast) {
|
||||
if (checked >= hours) break;
|
||||
if (entry.precipitationType == "rain" && entry.precipitationProbability > 0.0f) {
|
||||
return true;
|
||||
}
|
||||
checked++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
struct WeatherCurrent {
|
||||
int temperature = 0;
|
||||
String symbol;
|
||||
String description;
|
||||
};
|
||||
|
||||
struct ForecastEntry {
|
||||
String time;
|
||||
int temperature = 0;
|
||||
String symbol;
|
||||
String description;
|
||||
float precipitationProbability = 0.0f;
|
||||
String precipitationType;
|
||||
};
|
||||
|
||||
struct WeatherData {
|
||||
bool valid = false;
|
||||
WeatherCurrent current;
|
||||
std::vector<ForecastEntry> forecast;
|
||||
};
|
||||
|
||||
// Fetches and parses the weather API. Returns valid=false on any failure
|
||||
// (network error, timeout, or malformed JSON).
|
||||
WeatherData fetchWeather();
|
||||
|
||||
// Returns true if any forecast entry within the next `hours` has
|
||||
// precipitationType == "rain" and probability > 0 (soft threshold as
|
||||
// specified: warn as early as possible rather than too late).
|
||||
bool willRainSoon(const WeatherData &data, int hours = 8);
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
#include <M5Stack.h>
|
||||
#include "theme.h"
|
||||
|
||||
// Design note: instead of storing large RGB565 bitmap arrays (which would
|
||||
// bloat flash usage for a first version), icons are drawn procedurally with
|
||||
// M5Stack's canvas primitives (circles, arcs, lines). This keeps the binary
|
||||
// small while still giving a colorful, modern look. If you later want true
|
||||
// pixel-art bitmaps, replace the bodies of these functions with
|
||||
// M5.Lcd.drawBitmap(...) calls against RGB565 arrays generated from PNGs.
|
||||
|
||||
namespace Icons {
|
||||
|
||||
// Draws a sun icon centered at (cx, cy) with given radius
|
||||
inline void drawSun(int cx, int cy, int r) {
|
||||
M5.Lcd.fillCircle(cx, cy, r, Theme::ACCENT_SUN);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
float angle = i * (PI / 4.0);
|
||||
int x1 = cx + cos(angle) * (r + 4);
|
||||
int y1 = cy + sin(angle) * (r + 4);
|
||||
int x2 = cx + cos(angle) * (r + 10);
|
||||
int y2 = cy + sin(angle) * (r + 10);
|
||||
M5.Lcd.drawLine(x1, y1, x2, y2, Theme::ACCENT_SUN);
|
||||
}
|
||||
}
|
||||
|
||||
// Draws a moon icon (crescent) centered at (cx, cy)
|
||||
inline void drawMoon(int cx, int cy, int r) {
|
||||
M5.Lcd.fillCircle(cx, cy, r, Theme::TEXT_DIM);
|
||||
M5.Lcd.fillCircle(cx + r / 2, cy - r / 3, r, Theme::BG);
|
||||
}
|
||||
|
||||
// Draws a cloud icon centered at (cx, cy)
|
||||
inline void drawCloud(int cx, int cy, int scale) {
|
||||
M5.Lcd.fillCircle(cx - scale, cy, scale, Theme::TEXT_DIM);
|
||||
M5.Lcd.fillCircle(cx + scale, cy, scale, Theme::TEXT_DIM);
|
||||
M5.Lcd.fillCircle(cx, cy - scale / 2, scale + 2, Theme::TEXT_DIM);
|
||||
M5.Lcd.fillRect(cx - scale, cy, scale * 2, scale, Theme::TEXT_DIM);
|
||||
}
|
||||
|
||||
// Draws rain drops below a given y position
|
||||
inline void drawRainDrops(int cx, int cy, int count = 3) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
int x = cx - (count - 1) * 5 + i * 10;
|
||||
M5.Lcd.drawLine(x, cy, x - 3, cy + 8, Theme::ACCENT_RAIN);
|
||||
}
|
||||
}
|
||||
|
||||
// Combined weather icon based on the API "symbol" field.
|
||||
// symbol examples: "mo____" (clear/moon), "mb____" (cloudy/moon),
|
||||
// "wb____" (cloudy/day). First char roughly encodes day(w)/night(m),
|
||||
// second char encodes condition (o=clear, b=cloudy, r=rain, etc).
|
||||
inline void drawWeatherIcon(const String &symbol, int cx, int cy, int r = 20) {
|
||||
bool isNight = symbol.length() > 0 && symbol.charAt(0) == 'm';
|
||||
char condition = symbol.length() > 1 ? symbol.charAt(1) : 'o';
|
||||
|
||||
switch (condition) {
|
||||
case 'o': // clear
|
||||
if (isNight) drawMoon(cx, cy, r);
|
||||
else drawSun(cx, cy, r);
|
||||
break;
|
||||
case 'b': // cloudy
|
||||
drawCloud(cx, cy, r * 2 / 3);
|
||||
break;
|
||||
case 'r': // rain
|
||||
drawCloud(cx, cy - 5, r * 2 / 3);
|
||||
drawRainDrops(cx, cy + r / 2, 3);
|
||||
break;
|
||||
default:
|
||||
drawCloud(cx, cy, r * 2 / 3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Small rain-warning banner icon (drop with exclamation-ish accent)
|
||||
inline void drawRainWarning(int cx, int cy) {
|
||||
M5.Lcd.fillTriangle(cx, cy - 8, cx - 6, cy + 6, cx + 6, cy + 6, Theme::ACCENT_RAIN);
|
||||
M5.Lcd.fillCircle(cx, cy + 2, 2, Theme::BG);
|
||||
}
|
||||
|
||||
// Simple "U" badge for U-Bahn
|
||||
inline void drawUBahnBadge(int x, int y, int w, int h) {
|
||||
M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_UBAHN);
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_UBAHN);
|
||||
M5.Lcd.setCursor(x + w / 2 - 4, y + h / 2 - 4);
|
||||
M5.Lcd.print("U");
|
||||
}
|
||||
|
||||
// Simple "S" badge for S-Bahn
|
||||
inline void drawSBahnBadge(int x, int y, int w, int h) {
|
||||
M5.Lcd.fillRoundRect(x, y, w, h, 4, Theme::ACCENT_SBAHN);
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::ACCENT_SBAHN);
|
||||
M5.Lcd.setCursor(x + w / 2 - 4, y + h / 2 - 4);
|
||||
M5.Lcd.print("S");
|
||||
}
|
||||
|
||||
// Calendar icon: simple rectangle with a top bar and a page-marker dot
|
||||
inline void drawCalendarIcon(int cx, int cy, int size) {
|
||||
int x = cx - size / 2;
|
||||
int y = cy - size / 2;
|
||||
M5.Lcd.drawRoundRect(x, y, size, size, 3, Theme::ACCENT_CALENDAR);
|
||||
M5.Lcd.fillRect(x, y, size, size / 4, Theme::ACCENT_CALENDAR);
|
||||
M5.Lcd.fillCircle(cx, cy + size / 6, 2, Theme::TEXT);
|
||||
}
|
||||
|
||||
// Retry / error icon: circular arrow suggestion using arcs (approximated
|
||||
// with a broken circle) plus an exclamation mark for simplicity.
|
||||
inline void drawRetryIcon(int cx, int cy, int r) {
|
||||
M5.Lcd.drawCircle(cx, cy, r, Theme::ACCENT_ERROR);
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
|
||||
M5.Lcd.setCursor(cx - 3, cy - 8);
|
||||
M5.Lcd.print("!");
|
||||
}
|
||||
|
||||
} // namespace Icons
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
#include <M5Stack.h>
|
||||
#include <WiFi.h>
|
||||
#include "../include/secrets.h"
|
||||
#include "../include/theme.h"
|
||||
#include "screens/screen_base.h"
|
||||
#include "screens/home_screen.h"
|
||||
#include "screens/weather_detail_screen.h"
|
||||
#include "screens/calendar_detail_screen.h"
|
||||
#include "screens/mvg_screen.h"
|
||||
|
||||
namespace {
|
||||
ScreenId currentScreen = ScreenId::HOME;
|
||||
unsigned long lastInteractionMs = 0;
|
||||
const unsigned long IDLE_TIMEOUT_MS = 5UL * 60UL * 1000UL; // 5 minutes
|
||||
|
||||
bool wifiConnected = false;
|
||||
|
||||
void connectWifi() {
|
||||
M5.Lcd.fillScreen(Theme::BG);
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setCursor(10, 100);
|
||||
M5.Lcd.print("Verbinde mit WLAN...");
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||
|
||||
unsigned long start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
|
||||
delay(300);
|
||||
M5.Lcd.print(".");
|
||||
}
|
||||
|
||||
wifiConnected = (WiFi.status() == WL_CONNECTED);
|
||||
|
||||
M5.Lcd.fillScreen(Theme::BG);
|
||||
M5.Lcd.setCursor(10, 100);
|
||||
if (wifiConnected) {
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_SBAHN, Theme::BG);
|
||||
M5.Lcd.print("WLAN verbunden");
|
||||
} else {
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
|
||||
M5.Lcd.print("WLAN fehlgeschlagen");
|
||||
}
|
||||
delay(1000);
|
||||
}
|
||||
|
||||
void renderCurrentScreen(bool forceRefresh = false) {
|
||||
switch (currentScreen) {
|
||||
case ScreenId::HOME:
|
||||
renderHomeScreen(forceRefresh);
|
||||
break;
|
||||
case ScreenId::WEATHER_DETAIL:
|
||||
renderWeatherDetailScreen(forceRefresh);
|
||||
break;
|
||||
case ScreenId::CALENDAR_DETAIL:
|
||||
renderCalendarDetailScreen(forceRefresh);
|
||||
break;
|
||||
case ScreenId::MVG:
|
||||
renderMvgScreen(forceRefresh);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void goToScreen(ScreenId screen, bool forceRefresh = false) {
|
||||
currentScreen = screen;
|
||||
lastInteractionMs = millis();
|
||||
renderCurrentScreen(forceRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
M5.begin();
|
||||
M5.Power.begin();
|
||||
M5.Lcd.setBrightness(200);
|
||||
|
||||
connectWifi();
|
||||
|
||||
lastInteractionMs = millis();
|
||||
goToScreen(ScreenId::HOME, true);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
M5.update();
|
||||
|
||||
// Button A: toggle between Weather-Detail and Calendar-Detail
|
||||
if (M5.BtnA.wasPressed()) {
|
||||
if (currentScreen == ScreenId::WEATHER_DETAIL) {
|
||||
goToScreen(ScreenId::CALENDAR_DETAIL, true);
|
||||
} else {
|
||||
goToScreen(ScreenId::WEATHER_DETAIL, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Button B: back to Home
|
||||
if (M5.BtnB.wasPressed()) {
|
||||
goToScreen(ScreenId::HOME, true);
|
||||
}
|
||||
|
||||
// Button C: go to MVG departures screen
|
||||
if (M5.BtnC.wasPressed()) {
|
||||
goToScreen(ScreenId::MVG, true);
|
||||
}
|
||||
|
||||
// Idle timeout: jump back to Home after 5 minutes without any button press
|
||||
if (currentScreen != ScreenId::HOME &&
|
||||
millis() - lastInteractionMs >= IDLE_TIMEOUT_MS) {
|
||||
goToScreen(ScreenId::HOME, true);
|
||||
}
|
||||
|
||||
// Background refresh ticks for screens with their own refresh interval
|
||||
if (currentScreen == ScreenId::HOME) {
|
||||
updateHomeScreen();
|
||||
}
|
||||
if (currentScreen == ScreenId::MVG) {
|
||||
updateMvgScreen();
|
||||
}
|
||||
|
||||
delay(50);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "calendar_detail_screen.h"
|
||||
#include <M5Stack.h>
|
||||
#include "../../include/theme.h"
|
||||
#include "../icons/icons.h"
|
||||
#include "../api/calendar_api.h"
|
||||
|
||||
namespace {
|
||||
CalendarData lastCalendar;
|
||||
bool ok = false;
|
||||
|
||||
String formatEventTime(const CalendarEvent &ev) {
|
||||
if (ev.allDay) {
|
||||
int tIdx = ev.startAt.indexOf('T');
|
||||
return tIdx > 0 ? ev.startAt.substring(0, tIdx) : ev.startAt;
|
||||
} else {
|
||||
int tIdx = ev.startAt.indexOf('T');
|
||||
if (tIdx > 0 && ev.startAt.length() >= tIdx + 6) {
|
||||
return ev.startAt.substring(tIdx + 1, tIdx + 6);
|
||||
}
|
||||
return ev.startAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderCalendarDetailScreen(bool forceRefresh) {
|
||||
if (forceRefresh || !ok) {
|
||||
CalendarData c = fetchCalendar();
|
||||
ok = c.valid;
|
||||
if (c.valid) lastCalendar = c;
|
||||
}
|
||||
|
||||
M5.Lcd.fillScreen(Theme::BG);
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setCursor(10, 10);
|
||||
M5.Lcd.print("Alle Termine");
|
||||
|
||||
if (!ok) {
|
||||
Icons::drawRetryIcon(30, 60, 12);
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(50, 55);
|
||||
M5.Lcd.print("Keine Verbindung");
|
||||
return;
|
||||
}
|
||||
|
||||
int y = 40;
|
||||
for (const auto &ev : lastCalendar.events) {
|
||||
if (y > 215) break;
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
M5.Lcd.setCursor(10, y);
|
||||
M5.Lcd.print(ev.summary.substring(0, 28));
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(230, y);
|
||||
M5.Lcd.print(formatEventTime(ev));
|
||||
|
||||
y += 18;
|
||||
}
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(10, 228);
|
||||
M5.Lcd.print("A:Wetter B:Home C:MVG");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void renderCalendarDetailScreen(bool forceRefresh = false);
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "home_screen.h"
|
||||
#include <M5Stack.h>
|
||||
#include "../../include/theme.h"
|
||||
#include "../icons/icons.h"
|
||||
#include "../api/weather_api.h"
|
||||
#include "../api/calendar_api.h"
|
||||
|
||||
namespace {
|
||||
WeatherData lastWeather;
|
||||
CalendarData lastCalendar;
|
||||
bool weatherOk = false;
|
||||
bool calendarOk = false;
|
||||
unsigned long lastFetchMs = 0;
|
||||
const unsigned long REFRESH_INTERVAL_MS = 10UL * 60UL * 1000UL; // 10 minutes
|
||||
|
||||
String formatEventTime(const CalendarEvent &ev) {
|
||||
// Times are shown exactly as provided by the API, no timezone math.
|
||||
if (ev.allDay) {
|
||||
// start_at looks like "2026-08-03T00:00:00" -> show date part only
|
||||
int tIdx = ev.startAt.indexOf('T');
|
||||
return tIdx > 0 ? ev.startAt.substring(0, tIdx) : ev.startAt;
|
||||
} else {
|
||||
int tIdx = ev.startAt.indexOf('T');
|
||||
if (tIdx > 0 && ev.startAt.length() >= tIdx + 6) {
|
||||
return ev.startAt.substring(tIdx + 1, tIdx + 6); // HH:MM
|
||||
}
|
||||
return ev.startAt;
|
||||
}
|
||||
}
|
||||
|
||||
void doFetch() {
|
||||
WeatherData w = fetchWeather();
|
||||
weatherOk = w.valid;
|
||||
if (w.valid) lastWeather = w;
|
||||
|
||||
CalendarData c = fetchCalendar();
|
||||
calendarOk = c.valid;
|
||||
if (c.valid) lastCalendar = c;
|
||||
|
||||
lastFetchMs = millis();
|
||||
}
|
||||
|
||||
void drawErrorState(int x, int y, const char *label) {
|
||||
Icons::drawRetryIcon(x, y, 10);
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(x + 20, y - 5);
|
||||
M5.Lcd.print(label);
|
||||
}
|
||||
}
|
||||
|
||||
void updateHomeScreen() {
|
||||
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) {
|
||||
doFetch();
|
||||
}
|
||||
}
|
||||
|
||||
void renderHomeScreen(bool forceRefresh) {
|
||||
if (forceRefresh || lastFetchMs == 0) {
|
||||
doFetch();
|
||||
}
|
||||
|
||||
M5.Lcd.fillScreen(Theme::BG);
|
||||
|
||||
// --- Weather block (top) ---
|
||||
if (weatherOk) {
|
||||
Icons::drawWeatherIcon(lastWeather.current.symbol, 45, 55, 24);
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(4);
|
||||
M5.Lcd.setCursor(90, 30);
|
||||
M5.Lcd.printf("%d", lastWeather.current.temperature);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.print(" C");
|
||||
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(90, 65);
|
||||
M5.Lcd.print(lastWeather.current.description);
|
||||
|
||||
if (willRainSoon(lastWeather, 8)) {
|
||||
Icons::drawRainWarning(280, 30);
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_RAIN, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
M5.Lcd.setCursor(220, 45);
|
||||
M5.Lcd.print("Regen moeglich");
|
||||
}
|
||||
} else {
|
||||
drawErrorState(20, 40, "Wetter n.a.");
|
||||
}
|
||||
|
||||
M5.Lcd.drawFastHLine(10, 95, Theme::SCREEN_W - 20, Theme::BG_CARD);
|
||||
|
||||
// --- Calendar block (next 2 events) ---
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setCursor(10, 105);
|
||||
Icons::drawCalendarIcon(20, 115, 14);
|
||||
M5.Lcd.setCursor(35, 108);
|
||||
M5.Lcd.print("Termine");
|
||||
|
||||
if (calendarOk) {
|
||||
int y = 135;
|
||||
int shown = 0;
|
||||
for (const auto &ev : lastCalendar.events) {
|
||||
if (shown >= 2) break;
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setCursor(10, y);
|
||||
M5.Lcd.print(ev.summary.substring(0, 20));
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
M5.Lcd.setCursor(10, y + 20);
|
||||
M5.Lcd.print(formatEventTime(ev));
|
||||
|
||||
y += 40;
|
||||
shown++;
|
||||
}
|
||||
} else {
|
||||
drawErrorState(20, 150, "Kalender n.a.");
|
||||
}
|
||||
|
||||
// --- Footer hint ---
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
M5.Lcd.setCursor(10, 225);
|
||||
M5.Lcd.print("A:Wetter/Kalender B:Home C:MVG");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// Renders the home screen: current weather, soft rain warning, and the
|
||||
// next 2 calendar events. Fetches weather + calendar data if the 10 minute
|
||||
// refresh interval has elapsed, or if forced (e.g. manual retry).
|
||||
void renderHomeScreen(bool forceRefresh = false);
|
||||
|
||||
// Call regularly from loop() to check if a refresh is due.
|
||||
void updateHomeScreen();
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "mvg_screen.h"
|
||||
#include <M5Stack.h>
|
||||
#include "../../include/theme.h"
|
||||
#include "../icons/icons.h"
|
||||
#include "../api/departures_api.h"
|
||||
|
||||
namespace {
|
||||
DeparturesData lastData;
|
||||
bool ok = false;
|
||||
unsigned long lastFetchMs = 0;
|
||||
const unsigned long REFRESH_INTERVAL_MS = 60UL * 1000UL; // 1 minute
|
||||
|
||||
void doFetch() {
|
||||
DeparturesData d = fetchDepartures();
|
||||
ok = d.valid;
|
||||
if (d.valid) lastData = d;
|
||||
lastFetchMs = millis();
|
||||
}
|
||||
}
|
||||
|
||||
void updateMvgScreen() {
|
||||
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) {
|
||||
doFetch();
|
||||
}
|
||||
}
|
||||
|
||||
void renderMvgScreen(bool forceRefresh) {
|
||||
if (forceRefresh || lastFetchMs == 0) {
|
||||
doFetch();
|
||||
}
|
||||
|
||||
M5.Lcd.fillScreen(Theme::BG);
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setCursor(10, 10);
|
||||
M5.Lcd.print("Abfahrten");
|
||||
|
||||
if (!ok) {
|
||||
Icons::drawRetryIcon(30, 60, 12);
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(50, 55);
|
||||
M5.Lcd.print("Keine Verbindung");
|
||||
return;
|
||||
}
|
||||
|
||||
int y = 38;
|
||||
for (const auto &dep : lastData.departures) {
|
||||
if (y > 215) break;
|
||||
|
||||
if (dep.type == "UBAHN") {
|
||||
Icons::drawUBahnBadge(8, y, 16, 14);
|
||||
} else {
|
||||
Icons::drawSBahnBadge(8, y, 16, 14);
|
||||
}
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
M5.Lcd.setCursor(30, y + 3);
|
||||
M5.Lcd.print(dep.line);
|
||||
|
||||
M5.Lcd.setCursor(60, y + 3);
|
||||
M5.Lcd.print(dep.destination.substring(0, 16));
|
||||
|
||||
M5.Lcd.setCursor(190, y + 3);
|
||||
if (dep.cancelled) {
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_ERROR, Theme::BG);
|
||||
M5.Lcd.print("Ausfall");
|
||||
} else {
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.print(dep.timeStr);
|
||||
if (dep.delayMin > 0) {
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_WARN, Theme::BG);
|
||||
M5.Lcd.printf(" +%d", dep.delayMin);
|
||||
}
|
||||
}
|
||||
|
||||
y += 18;
|
||||
}
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(10, 228);
|
||||
M5.Lcd.print("A:Wetter/Kalender B:Home");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
void renderMvgScreen(bool forceRefresh = false);
|
||||
void updateMvgScreen();
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// Simple screen enum used as a state machine. Each screen has a render
|
||||
// function that is called on refresh, and each screen owns its own last-
|
||||
// fetch timestamp so refresh intervals can differ per screen.
|
||||
enum class ScreenId {
|
||||
HOME,
|
||||
WEATHER_DETAIL,
|
||||
CALENDAR_DETAIL,
|
||||
MVG
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
#include "weather_detail_screen.h"
|
||||
#include <M5Stack.h>
|
||||
#include "../../include/theme.h"
|
||||
#include "../icons/icons.h"
|
||||
#include "../api/weather_api.h"
|
||||
|
||||
namespace {
|
||||
WeatherData lastWeather;
|
||||
bool ok = false;
|
||||
}
|
||||
|
||||
void renderWeatherDetailScreen(bool forceRefresh) {
|
||||
if (forceRefresh || !ok) {
|
||||
WeatherData w = fetchWeather();
|
||||
ok = w.valid;
|
||||
if (w.valid) lastWeather = w;
|
||||
}
|
||||
|
||||
M5.Lcd.fillScreen(Theme::BG);
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(2);
|
||||
M5.Lcd.setCursor(10, 10);
|
||||
M5.Lcd.print("Wettervorhersage");
|
||||
|
||||
if (!ok) {
|
||||
Icons::drawRetryIcon(30, 60, 12);
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setCursor(50, 55);
|
||||
M5.Lcd.print("Keine Verbindung");
|
||||
return;
|
||||
}
|
||||
|
||||
int y = 40;
|
||||
int shown = 0;
|
||||
for (const auto &fe : lastWeather.forecast) {
|
||||
if (shown >= 6 || y > 220) break;
|
||||
|
||||
Icons::drawWeatherIcon(fe.symbol, 20, y + 8, 10);
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
int tIdx = fe.time.indexOf('T');
|
||||
String hhmm = tIdx > 0 ? fe.time.substring(tIdx + 1, tIdx + 6) : fe.time;
|
||||
M5.Lcd.setCursor(40, y);
|
||||
M5.Lcd.print(hhmm);
|
||||
|
||||
M5.Lcd.setCursor(90, y);
|
||||
M5.Lcd.printf("%d C", fe.temperature);
|
||||
|
||||
M5.Lcd.setCursor(140, y);
|
||||
M5.Lcd.print(fe.description.substring(0, 12));
|
||||
|
||||
if (fe.precipitationType == "rain" && fe.precipitationProbability > 0.0f) {
|
||||
M5.Lcd.setTextColor(Theme::ACCENT_RAIN, Theme::BG);
|
||||
M5.Lcd.setCursor(250, y);
|
||||
M5.Lcd.printf("%.0f%%", fe.precipitationProbability * 100);
|
||||
}
|
||||
|
||||
y += 28;
|
||||
shown++;
|
||||
}
|
||||
|
||||
M5.Lcd.setTextColor(Theme::TEXT_DIM, Theme::BG);
|
||||
M5.Lcd.setTextSize(1);
|
||||
M5.Lcd.setCursor(10, 228);
|
||||
M5.Lcd.print("A:Kalender B:Home C:MVG");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void renderWeatherDetailScreen(bool forceRefresh = false);
|
||||
Reference in New Issue
Block a user