feat: add data manager system for background weather/calendar fetching

This commit is contained in:
2026-08-02 21:20:25 +02:00
parent a6a769fc3e
commit 2cff48a2ac
6 changed files with 214 additions and 69 deletions
+93
View File
@@ -0,0 +1,93 @@
#include "data_manager.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
namespace {
constexpr unsigned long REFRESH_INTERVAL_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t TASK_STACK_WORDS = 16384; // 64 KB — JSON parsing needs headroom
// --- Shared state between background task and main loop ---
// The task is the only writer of s_weather/s_calendar; the main loop is the
// only reader. Access to the data structs is serialized via s_mux (a
// portMUX_TYPE critical section which, on ESP32, suspends task switches on
// the current core). Simple flag/counter reads (bool/uint32_t) are atomic
// on 32-bit Xtensa and only need `volatile`.
static WeatherData s_weather;
static CalendarData s_calendar;
static volatile bool s_weatherOk = false;
static volatile bool s_calendarOk = false;
static volatile bool s_loading = false;
static volatile uint32_t s_dataVersion = 0;
static volatile unsigned long s_lastFetchMs = 0;
static volatile bool s_refreshRequested = false;
static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED;
static void dataFetchTask(void *param) {
(void)param;
while (true) {
bool needRefresh = s_refreshRequested ||
s_lastFetchMs == 0 ||
(millis() - s_lastFetchMs) >= REFRESH_INTERVAL_MS;
if (needRefresh) {
s_refreshRequested = false;
s_loading = true;
WeatherData w = fetchWeather();
CalendarData c = fetchCalendar();
portENTER_CRITICAL(&s_mux);
if (w.valid) {
s_weather = std::move(w);
s_weatherOk = true;
} else {
s_weatherOk = false;
}
if (c.valid) {
s_calendar = std::move(c);
s_calendarOk = true;
} else {
s_calendarOk = false;
}
s_dataVersion++;
s_lastFetchMs = millis();
s_loading = false;
portEXIT_CRITICAL(&s_mux);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
} // namespace
void dataManager_begin() {
s_lastFetchMs = 0; // ensure first iteration fetches immediately
xTaskCreatePinnedToCore(dataFetchTask, "dataFetch", TASK_STACK_WORDS,
nullptr, 1, nullptr, 1);
}
void dataManager_tick() {}
void dataManager_triggerRefresh() {
s_refreshRequested = true;
}
void dataManager_getWeather(WeatherData &out) {
portENTER_CRITICAL(&s_mux);
out = s_weather;
portEXIT_CRITICAL(&s_mux);
}
void dataManager_getCalendar(CalendarData &out) {
portENTER_CRITICAL(&s_mux);
out = s_calendar;
portEXIT_CRITICAL(&s_mux);
}
bool dataManager_isWeatherOk() { return s_weatherOk; }
bool dataManager_isCalendarOk() { return s_calendarOk; }
bool dataManager_isLoading() { return s_loading; }
uint32_t dataManager_dataVersion() { return s_dataVersion; }
unsigned long dataManager_lastFetchMs() { return s_lastFetchMs; }
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
#include "api/weather_api.h"
#include "api/calendar_api.h"
// Centralized async data manager for weather + calendar.
//
// A background FreeRTOS task fetches weather and calendar data every 10
// minutes (or on manual trigger). Results are cached in memory and exposed
// to the UI screens via thread-safe getters.
//
// Screens poll dataManager_dataVersion() in their tick_fn to detect changes
// and re-render from cache. Navigation between screens never blocks on a
// fetch — views always render instantly from whatever is cached.
// Create the background task and trigger the initial fetch. Call once in setup().
void dataManager_begin();
// No-op for now (background task runs independently). Reserved for future use.
void dataManager_tick();
// Request a manual refresh (non-blocking). The background task picks this up
// within ~1 second.
void dataManager_triggerRefresh();
// Thread-safe copy of the cached weather/calendar data into `out`.
void dataManager_getWeather(WeatherData &out);
void dataManager_getCalendar(CalendarData &out);
// Status flags (safe to read from the main loop without a mutex).
bool dataManager_isWeatherOk();
bool dataManager_isCalendarOk();
bool dataManager_isLoading();
uint32_t dataManager_dataVersion();
unsigned long dataManager_lastFetchMs();
+5
View File
@@ -4,6 +4,7 @@
#include <secrets.h>
#include <theme.h>
#include "display/display_setup.h"
#include "data/data_manager.h"
#include "ui/screen_base.h"
#include "ui/home_screen.h"
#include "ui/weather_detail_screen.h"
@@ -53,6 +54,9 @@ void setup() {
display_init();
connectWifi();
// Start background data fetcher (weather + calendar every 10 min).
dataManager_begin();
// Build all four screens and wire navigation callbacks.
screens[(int)ScreenId::HOME] = homeScreen_make();
screens[(int)ScreenId::WEATHER_DETAIL] = weatherDetailScreen_make();
@@ -73,6 +77,7 @@ void loop() {
if (now - lastTickMs >= 5) {
lastTickMs = now;
display_loop();
dataManager_tick();
screens[(int)current].tick();
// Inactivity: return to home after 5 minutes without input.
+18 -10
View File
@@ -1,6 +1,6 @@
#include "calendar_detail_screen.h"
#include "ui/widgets/back_button.h"
#include "api/calendar_api.h"
#include "data/data_manager.h"
#include <theme.h>
#include <text_utils.h>
#include <date_utils.h>
@@ -11,24 +11,28 @@ namespace {
ScreenId (*g_navigate)(ScreenId) = nullptr;
void on_back(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::HOME); }
CalendarData lastCalendar;
bool ok = false;
lv_obj_t *list = nullptr;
void buildList() {
if (!list) return;
lv_obj_clean(list);
CalendarData c;
dataManager_getCalendar(c);
bool ok = dataManager_isCalendarOk();
bool loading = dataManager_isLoading();
if (!ok) {
lv_obj_t *lbl = lv_label_create(list);
lv_label_set_text(lbl, LV_SYMBOL_WARNING " Keine Verbindung");
lv_obj_set_style_text_color(lbl, Theme::accentError(), 0);
lv_label_set_text(lbl, loading ? LV_SYMBOL_REFRESH " Lade..."
: LV_SYMBOL_WARNING " Keine Verbindung");
lv_obj_set_style_text_color(lbl, loading ? Theme::textDim() : Theme::accentError(), 0);
return;
}
// Columns (content width = 280 - 2*6 pad = 268), side by side like a table:
// date 72 px left (dim grey) | time 56 px centered (grey) | summary (white, wraps)
for (const auto &ev : lastCalendar.events) {
for (const auto &ev : c.events) {
lv_obj_t *row = lv_obj_create(list);
lv_obj_set_size(row, 280, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
@@ -78,13 +82,17 @@ void calendarDetailScreen_create(Screen &s) {
}
void calendarDetailScreen_refresh(Screen &) {
CalendarData c = fetchCalendar();
ok = c.valid;
if (c.valid) lastCalendar = c;
buildList();
}
void calendarDetailScreen_tick(Screen &) {}
void calendarDetailScreen_tick(Screen &) {
static uint32_t lastVersion = 0;
uint32_t v = dataManager_dataVersion();
if (v != lastVersion) {
lastVersion = v;
buildList();
}
}
Screen calendarDetailScreen_make() {
Screen s;
+45 -49
View File
@@ -1,8 +1,7 @@
#include "home_screen.h"
#include "ui/widgets/tile_button.h"
#include "icons/icons.h"
#include "api/weather_api.h"
#include "api/calendar_api.h"
#include "data/data_manager.h"
#include <theme.h>
#include <text_utils.h>
#include <date_utils.h>
@@ -13,21 +12,9 @@ namespace {
// --- navigation callback target (set by main.cpp) ---
ScreenId (*g_navigate)(ScreenId) = nullptr;
// Forward declarations (definitions below).
void updateWidgets();
void doFetch();
void on_calendar_tile(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::CALENDAR_DETAIL); }
void on_mvg_tile(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::MVG); }
// --- cached data + refresh timer (ported from old home_screen.cpp) ---
WeatherData lastWeather;
CalendarData lastCalendar;
bool weatherOk = false;
bool calendarOk = false;
unsigned long lastFetchMs = 0;
const unsigned long REFRESH_INTERVAL_MS = 10UL * 60UL * 1000UL;
// --- tile handles (value labels, for updating without rebuild) ---
TileButton weatherTile{};
TileButton calendarTile{};
@@ -43,45 +30,28 @@ void rebuildWeatherIcon(const String &symbol, const String &description) {
lv_obj_align(weatherTile.icon, LV_ALIGN_CENTER, 0, -14);
}
void updateRainWarning() {
void updateRainWarning(const WeatherData &w, bool ok) {
if (rainWarningIcon) {
lv_obj_del(rainWarningIcon);
rainWarningIcon = nullptr;
}
if (weatherOk && willRainSoon(lastWeather, 8)) {
if (ok && willRainSoon(w, 8)) {
rainWarningIcon = Icons::createRainWarning(weatherTile.btn, 28);
lv_obj_align(rainWarningIcon, LV_ALIGN_TOP_RIGHT, -4, 4);
}
}
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();
}
// Manual retry: tapping the weather tile when it is in error state triggers
// a refresh instead of navigating away. Acts as the SPEC.md "retry per tap".
void on_weather_tile(lv_event_t *e) {
if (!weatherOk) {
doFetch();
updateWidgets();
// a background refresh instead of navigating away.
void on_weather_tile(lv_event_t *) {
if (!dataManager_isWeatherOk()) {
dataManager_triggerRefresh();
return;
}
if (g_navigate) g_navigate(ScreenId::WEATHER_DETAIL);
}
// --- formatting helpers (ported from old home_screen.cpp rendering) ---
String weatherIconGlyph(const String &symbol) {
// Kept for fallback; real icons are drawn as canvas via rebuildWeatherIcon.
(void)symbol;
return LV_SYMBOL_IMAGE;
}
// --- formatting helpers ---
String formatWeatherValue(const WeatherData &w) {
if (!w.valid) return "Wetter n.a.";
@@ -96,9 +66,9 @@ String formatCalendarPreview(const CalendarData &c) {
int shown = 0;
for (const auto &ev : c.events) {
if (shown >= 2) break;
if (shown > 0) s += "\n\n";
s += DateUtils::formatShortDE(ev.startAt, ev.allDay);
s += "\n" + sanitizeGermanText(ev.summary.substring(0, 22));
if (shown > 0) s += "\n";
s += sanitizeGermanText(ev.summary.substring(0, 22));
s += "\n" + DateUtils::formatShortDE(ev.startAt, ev.allDay);
++shown;
}
if (s.length() == 0) s = "Keine Termine";
@@ -107,12 +77,38 @@ String formatCalendarPreview(const CalendarData &c) {
void updateWidgets() {
if (!widgetsBuilt) return;
rebuildWeatherIcon(lastWeather.current.symbol, lastWeather.current.description);
updateRainWarning();
tile_button_set_value(weatherTile, formatWeatherValue(lastWeather).c_str());
WeatherData w;
CalendarData c;
dataManager_getWeather(w);
dataManager_getCalendar(c);
bool weatherOk = dataManager_isWeatherOk();
bool calendarOk = dataManager_isCalendarOk();
bool loading = dataManager_isLoading();
// Weather tile
if (weatherOk) {
rebuildWeatherIcon(w.current.symbol, w.current.description);
updateRainWarning(w, true);
tile_button_set_value(weatherTile, formatWeatherValue(w).c_str());
} else if (loading) {
tile_button_set_value(weatherTile, "Lade...");
} else {
tile_button_set_value(weatherTile, "Wetter n.a.");
}
lv_obj_align(weatherTile.value, LV_ALIGN_CENTER, 0, 50);
tile_button_set_value(calendarTile, formatCalendarPreview(lastCalendar).c_str());
// Calendar tile
if (calendarOk) {
tile_button_set_value(calendarTile, formatCalendarPreview(c).c_str());
} else if (loading) {
tile_button_set_value(calendarTile, "Lade...");
} else {
tile_button_set_value(calendarTile, "Kalender n.a.");
}
tile_button_set_icon(calendarTile, LV_SYMBOL_FILE);
// MVG tile
tile_button_set_value(mvgTile, "Abfahrten");
}
@@ -148,13 +144,14 @@ void homeScreen_create(Screen &s) {
}
void homeScreen_refresh(Screen &) {
doFetch();
updateWidgets();
}
void homeScreen_tick(Screen &) {
if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) {
doFetch();
static uint32_t lastVersion = 0;
uint32_t v = dataManager_dataVersion();
if (v != lastVersion) {
lastVersion = v;
updateWidgets();
}
}
@@ -166,4 +163,3 @@ Screen homeScreen_make() {
s.tick_fn = homeScreen_tick;
return s;
}
+18 -10
View File
@@ -1,6 +1,6 @@
#include "weather_detail_screen.h"
#include "ui/widgets/back_button.h"
#include "api/weather_api.h"
#include "data/data_manager.h"
#include "icons/icons.h"
#include <theme.h>
#include <text_utils.h>
@@ -11,18 +11,22 @@ namespace {
ScreenId (*g_navigate)(ScreenId) = nullptr;
void on_back(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::HOME); }
WeatherData lastWeather;
bool ok = false;
lv_obj_t *list = nullptr;
void buildList() {
if (!list) return;
lv_obj_clean(list);
WeatherData w;
dataManager_getWeather(w);
bool ok = dataManager_isWeatherOk();
bool loading = dataManager_isLoading();
if (!ok) {
lv_obj_t *lbl = lv_label_create(list);
lv_label_set_text(lbl, LV_SYMBOL_WARNING " Keine Verbindung");
lv_obj_set_style_text_color(lbl, Theme::accentError(), 0);
lv_label_set_text(lbl, loading ? LV_SYMBOL_REFRESH " Lade..."
: LV_SYMBOL_WARNING " Keine Verbindung");
lv_obj_set_style_text_color(lbl, loading ? Theme::textDim() : Theme::accentError(), 0);
return;
}
@@ -39,7 +43,7 @@ void buildList() {
lv_obj_set_style_line_color(header_line, Theme::textDim(), 0);
lv_obj_set_style_line_width(header_line, 1, 0);
for (const auto &fe : lastWeather.forecast) {
for (const auto &fe : w.forecast) {
lv_obj_t *row = lv_obj_create(list);
lv_obj_set_size(row, 280, 30);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
@@ -105,13 +109,17 @@ void weatherDetailScreen_create(Screen &s) {
}
void weatherDetailScreen_refresh(Screen &) {
WeatherData w = fetchWeather();
ok = w.valid;
if (w.valid) lastWeather = w;
buildList();
}
void weatherDetailScreen_tick(Screen &) {}
void weatherDetailScreen_tick(Screen &) {
static uint32_t lastVersion = 0;
uint32_t v = dataManager_dataVersion();
if (v != lastVersion) {
lastVersion = v;
buildList();
}
}
Screen weatherDetailScreen_make() {
Screen s;