// ===================================================================================== // ui.cpp - LVGL-Oberflaeche (Querformat 800x480), Design an die Web-UI angelehnt // ===================================================================================== // Navigation ueber ein Hamburger-Menue (Icon oben links -> Schublade von links). // Seiten: // Dashboard - nur Anzeige (Temperaturen, Shot-/Dampf-Timer, Bezug) // Temperatur - Wasser-/Dampf-Soll per Drehregler (Arc) + Feinjustage, Toggles Eco + Wartung // Brew-Control - alle Brew-Control-Einstellungen // WiFi - Netzwerk scannen/verbinden, Status, Hinweis aufs Webinterface // Profile - Profile ansehen/laden/speichern // Oben rechts ein WLAN-Symbol (verbunden/getrennt); Antippen oeffnet die WiFi-Seite. // Eingaben ueber eine gemeinsame // On-Screen-Tastatur. Alle Aktionen laufen ueber den ProtocolClient zur Hauptplatine. // ===================================================================================== #include #include #include #include // Dashboard-Design P4-lokal merken (NVS) #include "ui.h" #include "theme.h" #include "config.h" #include "display_hal.h" // hal_backlight() - Helligkeit fuer den Aufweck-Dialog // ------------------------------------------------------------------------------------- // Zustand / Referenzen // ------------------------------------------------------------------------------------- static ProtocolClient* g_client = nullptr; static MachineState g_state; static lv_obj_t* g_kb = nullptr; // gemeinsame Tastatur (auf der Top-Layer) static lv_obj_t* g_kbTarget = nullptr; // aktuell editiertes Textfeld static lv_obj_t* g_toast = nullptr; // globale Rueckmeldung (oben, ueber allen Tabs) static lv_timer_t* g_toastTimer = nullptr; static lv_obj_t* g_standbyScreen = nullptr; // Vollbild-Overlay im Standby (Uhr) static lv_obj_t* g_standbyClock = nullptr; static lv_obj_t* g_wakeConfirm = nullptr; // Bestaetigungsdialog "Aus dem Standby aufwecken?" static bool g_wakeDialogOpen = false; // Dialog sichtbar -> Display hell schalten (lesbar) static bool g_standbyShown = false; // Standby-Uhr-Overlay zuletzt als sichtbar geschaltet (fuer Ein-/Ausblend-Uebergang) static lv_obj_t* g_splash = nullptr; // Boot-Splash (Start-Animation), faellt beim ersten State weg static bool g_splashActive = false; // Splash noch sichtbar / wartet auf Verbindung? static lv_obj_t* g_otaScreen = nullptr; // Vollbild-Overlay waehrend Firmware-Update static lv_obj_t* g_otaStatus = nullptr; static lv_obj_t* g_otaBytes = nullptr; // Navigation enum { PG_DASH = 0, PG_CHART, PG_STATS, PG_TEMP, PG_BREW, PG_SERVICE, PG_PROFILE, PG_LIGHT, PG_WIFI, PG_INFO, PG_COUNT }; static const char* PAGE_NAMES[PG_COUNT] = { "Dashboard", "Verlauf", "Statistik", "Temperatur", "Brew-Control", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Info" }; static lv_obj_t* g_content = nullptr; static lv_obj_t* g_pages[PG_COUNT] = { nullptr }; static lv_obj_t* g_drawer = nullptr; static lv_obj_t* g_hdrTitle = nullptr; static lv_obj_t* g_clock = nullptr; static lv_obj_t* g_wifiIcon = nullptr; static int g_curPage = PG_DASH; // Dashboard static lv_obj_t* g_maintIcon = nullptr; // Warnsymbol im Header (auf allen Seiten sichtbar) static lv_obj_t* g_errIcon = nullptr; // Fehlersymbol im Header (rot, auf allen Seiten sichtbar) // Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner. // Jeder Hinweis belegt einen Slot (Reihenfolge = Prioritaet, Rot vor Gelb vor Blau); // mehrere aktive Meldungen rotieren alle 4 s weich durch (siehe status_line_tick). enum StatusSlot { ST_SAFETY = 0, ST_DAMPF, ST_CLEAN, ST_TUNE, ST_MAINT, ST_HEAT, ST_COUNT }; struct StatusMsg { bool active = false; char text[120] = ""; lv_color_t bg, fg; }; static StatusMsg g_stMsg[ST_COUNT]; static lv_obj_t* g_statusLine = nullptr; // Container (feste Hoehe; Wartung/Reinigung antippbar) static lv_obj_t* g_statusLbl = nullptr; static int g_statusShown = -1; // aktuell gezeigter Slot (-1 = keiner) static uint32_t g_statusNextMs = 0; // Zeitpunkt des naechsten Rotationswechsels static void status_set(int slot, bool active, const char* text = "", lv_color_t bg = lv_color_hex(0x1e1e1e), lv_color_t fg = lv_color_hex(0xffffff)); // PID-Tuning Ergebnis-Anzeige: nach Lauf-Ende das Resultat (Erfolg/Abbruch+Grund) kurz einblenden static bool g_tunePrevW = false; // war Wasser-Tuning beim letzten Update aktiv? static bool g_tunePrevD = false; // war Dampf-Tuning beim letzten Update aktiv? static uint32_t g_tuneResUntilW = 0; // Ergebnis-Banner Wasser sichtbar bis (millis); 0 = aus static uint32_t g_tuneResUntilD = 0; // Ergebnis-Banner Dampf sichtbar bis (millis); 0 = aus static uint8_t g_tuneResW = 0; // anzuzeigender Wasser-Status static uint8_t g_tuneResD = 0; // anzuzeigender Dampf-Status static bool g_heatUpLatched = false; // true = Kaltstart erkannt -> Countdown bis Ende zeigen static void cln_open(lv_event_t*); // Reinigungs-Sub-Screen oeffnen (Definition weiter unten) static void label_set_if_changed(lv_obj_t* lbl, const char* txt); // Definition weiter unten // Temperatur-Anzeige: fuenf umschaltbare Designs (Auswahl auf der Info-Seite, P4-lokal in NVS). // TD_CARDS - klassische Karten mit Ist/Soll-Prozentbalken // TD_GAUGE - freistehende Rundinstrumente im Manometer-Look (Bezel + 270-Grad-Bogen) // TD_MINIMAL - rahmenlos, nur grosse Werte mit duenner Ist/Soll-Linie // TD_NEO - Soft-3D/Neumorph: Flaeche wirkt aus dem Hintergrund herausgedrueckt // TD_THERMO - vertikale Thermometer-Saeulen mit Kolben und Glanzstreifen enum TempDesign { TD_CARDS = 0, TD_GAUGE, TD_MINIMAL, TD_NEO, TD_THERMO, TD_DESIGN_COUNT }; static uint8_t g_tempDesign = TD_GAUGE; static lv_obj_t* g_tempRow = nullptr; // Container; Inhalt wird je Design (neu) aufgebaut static lv_obj_t* lblTempW; static lv_obj_t* lblSetW; static lv_obj_t* barDutyW; // barDuty*: lv_bar (Karten/Minimal/Neo/Thermo-Saeule) static lv_obj_t* lblTempD; static lv_obj_t* lblSetD; static lv_obj_t* barDutyD; static lv_obj_t* arcTempW = nullptr; static lv_obj_t* arcTempD = nullptr; // Rundinstrument-Bogen static lv_obj_t* bulbTempW = nullptr; static lv_obj_t* bulbTempD = nullptr; // Thermometer-Kolben static lv_obj_t* g_designBtn[TD_DESIGN_COUNT] = {}; // Umschalter auf der Info-Seite static lv_obj_t* g_readyPill = nullptr; // "BEREIT"-Pille im Header (Kessel im Zielband) static bool g_readyState = false; // mit Hysterese, gegen Flackern an der Bandgrenze static lv_obj_t* cellShot; static lv_obj_t* lblShotTimer; static lv_obj_t* g_bbtBar = nullptr; static lv_obj_t* cellSteam; static lv_obj_t* lblSteamTimer; static lv_obj_t* g_sbtBar = nullptr; static lv_obj_t* cellBezug; static lv_obj_t* lblBezug; static lv_obj_t* g_bbwBar = nullptr; static lv_obj_t* cellFlow; static lv_obj_t* lblFlow; // g/s, permanent bei Brew-by-Weight static float g_flowFrozen = 0.0f; // letzter Bezugswert (zum Nachhalten) static uint32_t g_flowHoldUntil = 0; // Verlauf (Chart-Seite) static lv_obj_t* g_chart = nullptr; static lv_chart_series_t* g_serW = nullptr; static lv_chart_series_t* g_serD = nullptr; static lv_chart_series_t* g_serC = nullptr; // Zusatzsensor (Gehaeuse/Tassenablage) statt Flow static lv_obj_t* g_legW = nullptr; static lv_obj_t* g_legD = nullptr; static lv_obj_t* g_legC = nullptr; static lv_obj_t* g_axHi = nullptr; static lv_obj_t* g_axMid = nullptr; static lv_obj_t* g_axLo = nullptr; // Y-Skala #define COL_CASE lv_color_hex(0xba68c8) // Farbe Zusatzsensor im Chart (Violett; klar distinkt von Wasser-Gold/Dampf-Teal) // Shot-Zusammenfassung (Popup nach Bezug) static lv_obj_t* g_shotSum = nullptr; static lv_obj_t* g_shotSumBody = nullptr; static lv_timer_t* g_shotSumTimer = nullptr; static bool g_sumShotWas = false; // Kombi-Zelle Eco/Gehaeuse (wechselt sich ab, wenn beide aktiv) static lv_obj_t* cellEco; static lv_obj_t* lblEco; static lv_obj_t* lblEcoTitle; static bool g_ecoCasePhase = false; // false = Eco, true = Gehaeuse (bei Wechselanzeige) // Timer-Zustand: Anzeige laeuft displayseitig im 100-ms-Timer und haelt nach Ende 2 s nach static bool g_shotActive = false; // Wasser-Timer laeuft (Bezug ODER Spuelen/Flush) static uint32_t g_shotStartMs = 0; // displayseitiger Start (bei Bezug aus shotElapsedMs synchronisiert) static uint32_t g_shotFrozenMs = 0; // zuletzt angezeigter Wert (zum Nachhalten) static uint32_t g_shotHoldUntil = 0; static bool g_shotBbtMode = false; // Bezug mit Brew-by-Time -> Ist/Ziel zweizeilig static float g_bbtTarget = 0.0f; // Zielzeit (s) static bool g_shotWasFlush = false; // laeuft im Wasser-Timer ein Flush (kein echter Bezug)? static uint32_t g_shotFlushCapMs = 0; // konfigurierte Flush-Dauer (ms) -> Endwert exakt darauf einrasten static bool g_scaleTaring = false; // Tarier-Phase vor Brew-by-Weight-Bezug -> "Tariere..." statt Timer // Pre-Infusion-Phasenleiste (Dashboard): Schritte Pre-Infusion -> Pause -> Extraktion static lv_obj_t* g_piRow = nullptr; // Container (nur waehrend PI-Bezug sichtbar) static lv_obj_t* g_piChip[3] = { nullptr, nullptr, nullptr }; // Chip je Schritt static lv_obj_t* g_piChipLbl[3] = { nullptr, nullptr, nullptr }; static bool g_piShotMode = false; // laufender Bezug nutzt Pre-Infusion static uint8_t g_piStateS3 = 0; // Phasenstatus der S3 (0=inaktiv,1=PI,2=Pause,3=Extraktion) static uint32_t g_piDurMs = 0, g_piPauseMs = 0; // beim Bezug gelatchte Phasendauern static int g_piLastPhase = -1; // zuletzt gestylte Phase (Styles nur bei Wechsel anfassen) static bool g_steamActive = false; static uint32_t g_steamStartMs = 0; static uint32_t g_steamFrozenMs = 0; static uint32_t g_steamHoldUntil = 0; static bool g_steamSbtMode = false; // Dampfbezug mit Steam-by-Time -> Ist/Ziel + Balken static bool g_steamWasFlush = false; // laeuft im Dampf-Timer ein Flush (kein echter Dampfbezug)? static uint32_t g_steamFlushCapMs = 0; // konfigurierte Dampf-Flush-Dauer (ms) -> Endwert exakt einrasten static float g_sbtTarget = 0.0f; // Dampf-Zielzeit (s) // Temperatur static lv_obj_t* lblTargW; static lv_obj_t* lblTargD; static lv_obj_t* swEco; static lv_obj_t* swMaint; static float g_targW = 0, g_targD = 0; static unsigned long g_targEditMs = 0; static lv_obj_t* lblHeatUp = nullptr; static int g_heatUp = 0; static unsigned long g_heatUpEditMs = 0; static bool g_heatUpDirty = false; // ungesendete Aufheizzeit-Aenderung (entprellt senden) static lv_timer_t* g_heatUpSendTimer = nullptr;// Einmal-Timer: sendet erst nach Tipp-Pause // Licht static lv_obj_t* btnLight; // Reinigung/Wartung static lv_obj_t* lblMaintStatus = nullptr; static lv_obj_t* taMaintInterval = nullptr; static lv_obj_t* swMaint2 = nullptr; static lv_obj_t* taFlushDur = nullptr; static lv_obj_t* taSteamFlushDur = nullptr; // Flush-Zeiten Wasser/Dampf (Sekunden) static lv_obj_t* taBacklightActive = nullptr; static lv_obj_t* taBacklightStandby = nullptr; // Display-Helligkeit aktiv / Standby-Uhr (%) // Reinigungsassistent (Sub-Screen) static lv_obj_t* g_clnScreen = nullptr; // Vollbild-Overlay static lv_obj_t* g_clnCfg = nullptr; // Parameter + Start (sichtbar wenn inaktiv) static lv_obj_t* g_clnProgress = nullptr; // Fortschrittsbereich (sichtbar wenn aktiv) static lv_obj_t* g_clnProgLbl = nullptr; // Live-Text im Fortschrittsbereich static lv_obj_t* g_clnCyclesVal = nullptr; static lv_obj_t* g_clnBrewVal = nullptr; static lv_obj_t* g_clnPauseVal = nullptr; static int g_clnCycles = 3; static int g_clnBrew = 4; static int g_clnPause = 4; // Brew-Control static lv_obj_t* swPI; static lv_obj_t* taPiDur; static lv_obj_t* taPiPause; static lv_obj_t* swBBT; static lv_obj_t* taBbtSecs; static lv_obj_t* swBBW; static lv_obj_t* taBbwTarget; static lv_obj_t* taBbwOffset; static lv_obj_t* swSBT; static lv_obj_t* taSbtSecs; // WiFi static lv_obj_t* wifiStatusLbl; static lv_obj_t* wifiHintLbl; static lv_obj_t* wifiList; static lv_obj_t* lblWifiSel; static lv_obj_t* taWifiPass; static lv_obj_t* g_btnScan = nullptr; static lv_timer_t* g_wifiScanTimer = nullptr; static bool g_wifiScanPending = false; static String g_wifiSel; // Profile static lv_obj_t* profList; static lv_obj_t* taProfName; static lv_obj_t* profDetailTitle; static lv_obj_t* profDetailLbl; static lv_obj_t* profLoadBtn; static lv_obj_t* profDelBtn = nullptr; static lv_timer_t* g_delResetTimer = nullptr; static bool g_delArmed = false; // Info static lv_obj_t* lblInfoS3 = nullptr; static lv_obj_t* lblInfoLink = nullptr; static String g_selProfile; // Statistik (Nutzungsverlauf) static lv_obj_t* g_statSummary = nullptr; // Aggregate (Heute/Woche/Gesamt) static lv_obj_t* g_statList = nullptr; // Liste der letzten Einzelbezuege // ===================================================================================== // Hilfen // ===================================================================================== static float ta_f(lv_obj_t* ta) { return atof(lv_textarea_get_text(ta)); } static void ta_setf(lv_obj_t* ta, float v, int dec) { if (ta == g_kbTarget) return; // nicht ueberschreiben waehrend Eingabe char b[24]; dtostrf(v, 0, dec, b); lv_textarea_set_text(ta, b); } static const char* boolStr(bool b) { return b ? "true" : "false"; } static const char* onoffStr(bool b) { return b ? "an" : "aus"; } // Temperatur als deutsches "93,0°C" (Komma-Dezimaltrennung, kein Leerzeichen vor °C). static String tempStr(float v, int dec) { String s(v, (unsigned int)dec); // unsigned int -> eindeutig String(float, decimalPlaces) s.replace('.', ','); s += "°C"; return s; } static void sw_set(lv_obj_t* sw, bool on) { if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); else lv_obj_remove_state(sw, LV_STATE_CHECKED); } static bool sw_get(lv_obj_t* sw) { return lv_obj_has_state(sw, LV_STATE_CHECKED); } // ===================================================================================== // Globaler Toast // ===================================================================================== static void toast_hide_cb(lv_timer_t* t) { (void)t; if (g_toast) lv_obj_add_flag(g_toast, LV_OBJ_FLAG_HIDDEN); g_toastTimer = nullptr; } static void showToast(const char* txt, bool error) { if (!g_toast) return; lv_label_set_text(g_toast, txt); lv_obj_set_style_bg_color(g_toast, error ? COL_DANGER : COL_OK, 0); lv_obj_remove_flag(g_toast, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_toast); if (g_toastTimer) { lv_timer_reset(g_toastTimer); } else { g_toastTimer = lv_timer_create(toast_hide_cb, 3500, nullptr); lv_timer_set_repeat_count(g_toastTimer, 1); } } // ===================================================================================== // On-Screen-Tastatur // ===================================================================================== // QWERTZ-Layout (deutsche Tastatur): wie das LVGL-Standardlayout, aber Y<->Z getauscht. // Die ctrl-Map entspricht exakt dem Default (gleiche Tastenbreiten/-flags) und wird // daher fuer Klein- und Grossschreibung gemeinsam verwendet. #define KB_BTN(w) (LV_BUTTONMATRIX_CTRL_POPOVER | (w)) #define KB_C(x) ((lv_buttonmatrix_ctrl_t)(x)) // C++: enum|int ergibt int -> zurueckcasten static const char* const kb_map_lc[] = { "1#", "q", "w", "e", "r", "t", "z", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n", "ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "y", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n", LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, "" }; static const char* const kb_map_uc[] = { "1#", "Q", "W", "E", "R", "T", "Z", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n", "abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n", "_", "-", "Y", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n", LV_SYMBOL_CLOSE, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, "" }; static const lv_buttonmatrix_ctrl_t kb_ctrl[] = { KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 5), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 7), KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 6), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 7), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 2), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 2), KB_C(6), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 2), KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 2) }; static void kb_event_cb(lv_event_t* e) { lv_event_code_t code = lv_event_get_code(e); if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { lv_obj_add_flag(g_kb, LV_OBJ_FLAG_HIDDEN); g_kbTarget = nullptr; } } static void ta_focus_cb(lv_event_t* e) { lv_event_code_t code = lv_event_get_code(e); lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); if (code == LV_EVENT_FOCUSED) { g_kbTarget = ta; bool isText = (intptr_t)lv_obj_get_user_data(ta) == 1; lv_keyboard_set_mode(g_kb, isText ? LV_KEYBOARD_MODE_TEXT_LOWER : LV_KEYBOARD_MODE_NUMBER); lv_keyboard_set_textarea(g_kb, ta); lv_obj_remove_flag(g_kb, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_kb); } } static void attach_kb(lv_obj_t* ta, bool isText) { lv_obj_set_user_data(ta, (void*)(intptr_t)(isText ? 1 : 0)); lv_obj_add_event_cb(ta, ta_focus_cb, LV_EVENT_FOCUSED, nullptr); } // ===================================================================================== // Generische Bausteine // ===================================================================================== static lv_obj_t* row(lv_obj_t* parent, const char* label) { lv_obj_t* r = lv_obj_create(parent); lv_obj_set_width(r, LV_PCT(100)); lv_obj_set_height(r, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(r, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(r, 0, 0); lv_obj_set_style_pad_ver(r, 4, 0); lv_obj_set_style_pad_hor(r, 2, 0); lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(r, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(r, LV_OBJ_FLAG_SCROLLABLE); if (label) { lv_obj_t* l = lv_label_create(r); lv_label_set_text(l, label); lv_obj_set_style_text_color(l, COL_TEXT, 0); } return r; } static lv_obj_t* add_switch(lv_obj_t* parent, const char* label) { lv_obj_t* r = row(parent, label); lv_obj_t* sw = lv_switch_create(r); lv_obj_set_style_bg_color(sw, COL_ACCENT, LV_PART_INDICATOR | LV_STATE_CHECKED); return sw; } static lv_obj_t* add_num(lv_obj_t* parent, const char* label) { lv_obj_t* r = row(parent, label); lv_obj_t* ta = th_textarea(r, true); lv_obj_set_width(ta, 130); attach_kb(ta, false); return ta; } static lv_obj_t* section_title(lv_obj_t* parent, const char* txt) { lv_obj_t* l = lv_label_create(parent); lv_label_set_text(l, txt); lv_obj_set_style_text_color(l, COL_ACCENT, 0); lv_obj_set_style_pad_top(l, 6, 0); return l; } static void add_save_button(lv_obj_t* parent, const char* txt, lv_event_cb_t cb) { lv_obj_t* b = th_accent_button(parent, txt); lv_obj_set_width(b, LV_PCT(100)); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); } // Eine Seite (Vollflaeche im Content-Bereich, scrollbar, Spalten-Layout) static lv_obj_t* make_page(lv_obj_t* parent) { lv_obj_t* p = lv_obj_create(parent); lv_obj_set_size(p, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(p, 0, 0); lv_obj_set_style_bg_opa(p, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(p, 0, 0); lv_obj_set_style_pad_all(p, 8, 0); lv_obj_set_style_pad_row(p, 6, 0); lv_obj_set_flex_flow(p, LV_FLEX_FLOW_COLUMN); return p; } // ===================================================================================== // Navigation (Hamburger-Schublade) // ===================================================================================== // Brew-Control-Felder einmalig aus dem Zustand fuellen (nur beim Oeffnen der Seite, // damit laufende State-Pushes die Eingaben nicht vor dem Speichern ueberschreiben). static void populate_brew(const MachineState& st) { if (!swPI) return; sw_set(swPI, st.piEnabled); ta_setf(taPiDur, st.piDurSecs, 1); ta_setf(taPiPause, st.piPauseSecs, 1); sw_set(swBBT, st.bbtEnabled); ta_setf(taBbtSecs, st.bbtSecs, 1); sw_set(swBBW, st.bbwEnabled); ta_setf(taBbwTarget, st.bbwTarget, 1); ta_setf(taBbwOffset, st.bbwOffset, 1); sw_set(swSBT, st.sbtEnabled); ta_setf(taSbtSecs, st.sbtSecs, 1); } // Wartungsintervall-Eingabefeld nur beim Oeffnen fuellen (laufende State-Pushes // duerfen die Eingabe nicht ueberschreiben). Das Status-Label aktualisiert ui_update. static void populate_service(const MachineState& st) { if (!taMaintInterval) return; ta_setf(taMaintInterval, (float)st.maintenanceInterval, 0); if (taFlushDur) ta_setf(taFlushDur, (float)st.flushDurationSeconds, 0); if (taSteamFlushDur) ta_setf(taSteamFlushDur, (float)st.steamFlushDurationSeconds, 0); if (taBacklightActive) ta_setf(taBacklightActive, (float)st.backlightActive, 0); if (taBacklightStandby) ta_setf(taBacklightStandby, (float)st.backlightStandbyClock, 0); } static void flush_heatup(); // Aufheizzeit jetzt senden (entprellt / beim Seitenwechsel) static void flush_targ(); // Solltemperaturen jetzt senden (entprellt / beim Seitenwechsel) static void stats_request(); // Nutzungsstatistik anfordern (Definition weiter unten) static void show_page(int idx) { if (idx < 0 || idx >= PG_COUNT) return; if (g_curPage == PG_TEMP && idx != PG_TEMP) { flush_heatup(); flush_targ(); } // beim Verlassen sofort sichern g_curPage = idx; for (int i = 0; i < PG_COUNT; i++) { if (!g_pages[i]) continue; if (i == idx) lv_obj_remove_flag(g_pages[i], LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_pages[i], LV_OBJ_FLAG_HIDDEN); } if (g_hdrTitle) lv_label_set_text(g_hdrTitle, PAGE_NAMES[idx]); if (idx == PG_PROFILE && g_client) g_client->sendListProfiles(); if (idx == PG_STATS) stats_request(); // Nutzungsstatistik beim Oeffnen frisch anfordern if (idx == PG_BREW) populate_brew(g_state); // Felder beim Oeffnen frisch laden if (idx == PG_SERVICE) populate_service(g_state); } static void drawer_open() { if (g_drawer) { lv_obj_remove_flag(g_drawer, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_drawer); } } static void drawer_close() { if (g_drawer) lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_HIDDEN); } static void hamburger_cb(lv_event_t*) { if (g_drawer && lv_obj_has_flag(g_drawer, LV_OBJ_FLAG_HIDDEN)) drawer_open(); else drawer_close(); } static void scrim_cb(lv_event_t* e) { // Klick auf den abdunkelnden Hintergrund schliesst if ((lv_obj_t*)lv_event_get_target(e) == g_drawer) drawer_close(); } static void nav_cb(lv_event_t* e) { int idx = (int)(intptr_t)lv_event_get_user_data(e); show_page(idx); drawer_close(); } static void brew_cell_cb(lv_event_t*) { show_page(PG_BREW); } // Tipp auf Waage-/Flow-Zelle -> Brew-Control static void err_icon_cb(lv_event_t*) { show_page(PG_DASH); } // Tipp auf Fehler-Symbol -> Dashboard (mit Fehlerbanner) // --- Dezente Ein-/Ausblendungen (Opazitaets-Fade) ------------------------------------- // Bewusst ueber das 'opa'-Style (faellt via opa_recursive auf die Kinder durch) und NICHT // ueber 'opa_layered' - Letzteres wuerde einen Vollbild-Zwischenpuffer (800x480) allozieren // und koennte den LVGL-Speicherpool sprengen. So bleibt das Fade speicher-neutral. static const uint32_t UI_FADE_MS = 450; // ruhige, unaufdringliche Dauer static void ui_fade_exec_cb(void* obj, int32_t v) { lv_obj_set_style_opa((lv_obj_t*)obj, (lv_opa_t)v, 0); } // Nach dem Ausblenden verstecken (ein unsichtbares Vollbild-Overlay wuerde sonst weiter // Beruehrungen abfangen) und Opazitaet fuer das naechste Einblenden zuruecksetzen. static void ui_fade_hide_done_cb(lv_anim_t* a) { lv_obj_t* obj = (lv_obj_t*)a->var; lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_opa(obj, LV_OPA_COVER, 0); } // Blendet 'obj' weich ein (sichtbar schalten, Opazitaet 0 -> voll). static void ui_fade_in(lv_obj_t* obj, uint32_t ms) { if (!obj) return; lv_anim_delete(obj, ui_fade_exec_cb); // evtl. laufende Ausblendung abbrechen lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, obj); lv_anim_set_exec_cb(&a, ui_fade_exec_cb); lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); lv_anim_set_duration(&a, ms); lv_anim_set_path_cb(&a, lv_anim_path_ease_out); lv_anim_start(&a); } // Blendet 'obj' weich aus und versteckt es danach. static void ui_fade_out_hide(lv_obj_t* obj, uint32_t ms) { if (!obj || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return; lv_anim_delete(obj, ui_fade_exec_cb); lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, obj); lv_anim_set_exec_cb(&a, ui_fade_exec_cb); lv_anim_set_values(&a, lv_obj_get_style_opa(obj, LV_PART_MAIN), LV_OPA_TRANSP); lv_anim_set_duration(&a, ms); lv_anim_set_path_cb(&a, lv_anim_path_ease_in); lv_anim_set_completed_cb(&a, ui_fade_hide_done_cb); lv_anim_start(&a); } // Loescht 'obj' (inkl. Kinder/Anims) nach dem Ausblenden -> gibt LVGL-Pool-Speicher frei. // Nur fuer Einweg-Overlays (Boot-Splash), die nie wieder gezeigt werden. static void ui_splash_done_cb(lv_anim_t* a) { lv_obj_delete((lv_obj_t*)a->var); g_splash = nullptr; } static void ui_fade_out_delete(lv_obj_t* obj, uint32_t ms) { if (!obj) return; lv_anim_delete(obj, ui_fade_exec_cb); lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, obj); lv_anim_set_exec_cb(&a, ui_fade_exec_cb); lv_anim_set_values(&a, lv_obj_get_style_opa(obj, LV_PART_MAIN), LV_OPA_TRANSP); lv_anim_set_duration(&a, ms); lv_anim_set_path_cb(&a, lv_anim_path_ease_in); lv_anim_set_completed_cb(&a, ui_splash_done_cb); lv_anim_start(&a); } // --- Standby-Aufwecken (Bestaetigungsdialog) --- // Helligkeit, die im Standby ohne Dialog gilt (Uhr gedimmt bzw. Display aus). static int standby_backlight() { return g_state.standbyShowClock ? (int)g_state.backlightStandbyClock : 0; } static void wake_confirm_show() { if (!g_wakeConfirm) return; g_wakeDialogOpen = true; // Display auf normale Helligkeit, damit der Dialog im Dunkeln lesbar ist (sofort, ohne auf // den naechsten State-Push zu warten; onState bestaetigt das ueber ui_wake_dialog_open()). int bl = g_state.backlightActive ? (int)g_state.backlightActive : 100; hal_backlight(bl); lv_obj_remove_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_wakeConfirm); } static void wake_confirm_hide() { if (g_wakeConfirm) lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); } static void standby_screen_cb(lv_event_t*) { wake_confirm_show(); } // Tipp auf Standby-Uhr -> nachfragen static void wake_no_cb(lv_event_t*) { // Abbrechen -> zurueck in die Standby-Helligkeit wake_confirm_hide(); g_wakeDialogOpen = false; if (g_state.standbyActive) hal_backlight(standby_backlight()); } static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 beendet den Standby wake_confirm_hide(); // g_wakeDialogOpen bleibt true, bis der Standby wirklich endet (ui_update) - sonst wuerde // das Display in der Uebergangsphase kurz auf Standby-Helligkeit abdunkeln. if (g_client) { g_client->sendAction("deactivateStandby"); showToast("Maschine wird aufgeweckt...", false); } } bool ui_wake_dialog_open() { return g_wakeDialogOpen; } // fuer die Helligkeitssteuerung in onState // ===================================================================================== // Dashboard (nur Anzeige) // ===================================================================================== static void temp_card_cb(lv_event_t*) { show_page(PG_TEMP); } // Tipp auf Temp-Karte -> Temperatur-Seite // Weicher Farbverlauf fuer den grossen Ist-Wert: kontinuierlich ueber den Abstand zum // Soll interpoliert (lv_color_mix), dadurch driftet die Farbe stufenlos statt zu springen. // weit drunter: Blau -> nahe dran: Gold -> im Zielband (+-0,5 Grad): Gruen // -> deutlich drueber: Richtung Warngelb static lv_color_t temp_state_color(float temp, float set) { if (set <= 0.0f) return COL_TEXT; const lv_color_t cCold = lv_color_hex(0x64b5f6); float d = set - temp; // > 0: noch zu kalt if (d >= 15.0f) return cCold; if (d > 3.0f) return lv_color_mix(COL_ACCENT, cCold, (uint8_t)((15.0f - d) / 12.0f * 255.0f)); if (d > 0.5f) return lv_color_mix(COL_OK, COL_ACCENT, (uint8_t)((3.0f - d) / 2.5f * 255.0f)); if (d >= -0.5f) return COL_OK; // Zielband erreicht float over = -d - 0.5f; // Ueberschwinger oberhalb des Bandes if (over >= 5.0f) return COL_WARN; return lv_color_mix(COL_WARN, COL_OK, (uint8_t)(over / 5.0f * 255.0f)); } // Ist/Soll-Fortschritt (0..100 %) in das aktive Design schreiben: lv_bar (Karten/Minimal/ // Neo/Thermo-Saeule), Rundinstrument-Bogen und/oder Thermometer-Kolben. Ausser bei den // klassischen Karten folgt die Indikatorfarbe der Zustandsfarbe des Ist-Werts. static void temp_progress_set(lv_obj_t* bar, lv_obj_t* arc, lv_obj_t* bulb, int pct, lv_color_t col) { if (pct > 100) pct = 100; else if (pct < 0) pct = 0; if (bar) { lv_bar_set_value(bar, pct, LV_ANIM_OFF); if (g_tempDesign != TD_CARDS) lv_obj_set_style_bg_color(bar, col, LV_PART_INDICATOR); } if (arc) { lv_arc_set_value(arc, pct); lv_obj_set_style_arc_color(arc, col, LV_PART_INDICATOR); } if (bulb) lv_obj_set_style_bg_color(bulb, col, 0); } static void tempCard(lv_obj_t* parent, const char* title, lv_color_t accent, lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar) { lv_obj_t* card = th_card(parent); lv_obj_set_size(card, LV_PCT(49), LV_PCT(100)); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(card, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite lv_obj_add_event_cb(card, temp_card_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0); *big = lv_label_create(card); lv_label_set_text(*big, "--.-"); lv_obj_set_style_text_color(*big, COL_TEXT, 0); lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0); *setp = lv_label_create(card); lv_label_set_text(*setp, "Soll --.-"); lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0); *bar = lv_bar_create(card); lv_obj_set_size(*bar, LV_PCT(92), 12); lv_obj_set_style_bg_color(*bar, lv_color_hex(0x3a3a3a), 0); // sichtbarer Track (auch bei 0 %) lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0); lv_obj_set_style_border_color(*bar, COL_BORDER, 0); lv_obj_set_style_border_width(*bar, 1, 0); lv_obj_set_style_radius(*bar, 6, 0); lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); lv_obj_set_style_radius(*bar, 6, LV_PART_INDICATOR); lv_bar_set_range(*bar, 0, 100); lv_bar_set_value(*bar, 0, LV_ANIM_OFF); } // Rundinstrument im Manometer-Look (Design TD_GAUGE): freistehend ohne Kartenrahmen. // Leichte 3D-Wirkung ueber einen "Metall"-Bezel (vertikaler Verlauf hell -> dunkel) mit // weichem Schlagschatten und ein leicht aufgehelltes Zifferblatt darunter. Der 270-Grad- // Bogen zeigt Ist/Soll in % und faerbt sich mit der Zustandsfarbe des Ist-Werts. static void tempGauge(lv_obj_t* parent, const char* title, lv_color_t accent, lv_obj_t** big, lv_obj_t** setp, lv_obj_t** arc) { lv_obj_t* holder = lv_obj_create(parent); lv_obj_set_size(holder, LV_PCT(49), LV_PCT(100)); lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(holder, 0, 0); lv_obj_set_style_pad_all(holder, 0, 0); lv_obj_clear_flag(holder, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(holder, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite lv_obj_add_event_cb(holder, temp_card_cb, LV_EVENT_CLICKED, nullptr); // Bezel-Ring: Verlauf von hell (oben) nach dunkel (unten) + Schatten = Metall-Optik lv_obj_t* bezel = lv_obj_create(holder); lv_obj_set_size(bezel, 188, 188); lv_obj_center(bezel); lv_obj_set_style_radius(bezel, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(bezel, lv_color_hex(0x5c5c5c), 0); lv_obj_set_style_bg_grad_color(bezel, lv_color_hex(0x141414), 0); lv_obj_set_style_bg_grad_dir(bezel, LV_GRAD_DIR_VER, 0); lv_obj_set_style_border_width(bezel, 0, 0); lv_obj_set_style_pad_all(bezel, 0, 0); lv_obj_set_style_shadow_width(bezel, 16, 0); lv_obj_set_style_shadow_offset_y(bezel, 5, 0); lv_obj_set_style_shadow_color(bezel, lv_color_hex(0x000000), 0); lv_obj_set_style_shadow_opa(bezel, LV_OPA_60, 0); lv_obj_clear_flag(bezel, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(bezel, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt zum Holder durch // Zifferblatt: leicht "vertieft" (Verlauf dunkel oben -> etwas heller unten) lv_obj_t* face = lv_obj_create(bezel); lv_obj_set_size(face, 174, 174); lv_obj_center(face); lv_obj_set_style_radius(face, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(face, lv_color_hex(0x0c0c0c), 0); lv_obj_set_style_bg_grad_color(face, lv_color_hex(0x232323), 0); lv_obj_set_style_bg_grad_dir(face, LV_GRAD_DIR_VER, 0); lv_obj_set_style_border_width(face, 0, 0); lv_obj_set_style_pad_all(face, 0, 0); lv_obj_clear_flag(face, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(face, LV_OBJ_FLAG_CLICKABLE); // 270-Grad-Bogen (Luecke unten): reine Anzeige, kein Griff, nicht bedienbar *arc = lv_arc_create(face); lv_obj_set_size(*arc, 158, 158); lv_obj_center(*arc); lv_arc_set_rotation(*arc, 135); lv_arc_set_bg_angles(*arc, 0, 270); lv_arc_set_range(*arc, 0, 100); lv_arc_set_value(*arc, 0); lv_obj_remove_style(*arc, nullptr, LV_PART_KNOB); lv_obj_clear_flag(*arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_width(*arc, 12, LV_PART_MAIN); lv_obj_set_style_arc_color(*arc, lv_color_hex(0x2e2e2e), LV_PART_MAIN); lv_obj_set_style_arc_width(*arc, 12, LV_PART_INDICATOR); lv_obj_set_style_arc_color(*arc, accent, LV_PART_INDICATOR); lv_obj_set_style_arc_rounded(*arc, true, LV_PART_INDICATOR); lv_obj_t* t = lv_label_create(face); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_14, 0); lv_obj_align(t, LV_ALIGN_CENTER, 0, -48); *big = lv_label_create(face); lv_label_set_text(*big, "--.-"); lv_obj_set_style_text_color(*big, COL_TEXT, 0); lv_obj_set_style_text_font(*big, &lv_font_maven_pro_40, 0); lv_obj_align(*big, LV_ALIGN_CENTER, 0, -2); // Soll-Wert sitzt in der Bogen-Luecke unten (dort ist die volle Breite frei) *setp = lv_label_create(face); lv_label_set_text(*setp, "Soll --.-"); lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_14, 0); lv_obj_align(*setp, LV_ALIGN_CENTER, 0, 62); } // Minimal-Design (TD_MINIMAL): komplett rahmenlos - nur grosser Ist-Wert in der // Zustandsfarbe, darunter eine duenne Ist/Soll-Linie und der Soll-Wert. Sehr ruhig. static void tempMinimal(lv_obj_t* parent, const char* title, lv_color_t accent, lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar) { lv_obj_t* holder = lv_obj_create(parent); lv_obj_set_size(holder, LV_PCT(49), LV_PCT(100)); lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(holder, 0, 0); lv_obj_set_style_pad_all(holder, 0, 0); lv_obj_set_style_pad_row(holder, 10, 0); lv_obj_set_flex_flow(holder, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(holder, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(holder, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(holder, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite lv_obj_add_event_cb(holder, temp_card_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* t = lv_label_create(holder); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0); *big = lv_label_create(holder); lv_label_set_text(*big, "--.-"); lv_obj_set_style_text_color(*big, COL_TEXT, 0); lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0); *bar = lv_bar_create(holder); // duenne Ist/Soll-Linie lv_obj_set_size(*bar, LV_PCT(64), 4); lv_obj_set_style_bg_color(*bar, lv_color_hex(0x2a2a2a), 0); lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0); lv_obj_set_style_border_width(*bar, 0, 0); lv_obj_set_style_radius(*bar, 2, 0); lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); // folgt spaeter der Zustandsfarbe lv_obj_set_style_radius(*bar, 2, LV_PART_INDICATOR); lv_bar_set_range(*bar, 0, 100); lv_bar_set_value(*bar, 0, LV_ANIM_OFF); *setp = lv_label_create(holder); lv_label_set_text(*setp, "Soll --.-"); lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0); } // Soft-3D/Neumorph (TD_NEO): Flaeche fast im Hintergrundton, wirkt herausgedrueckt. // LVGL kann nur einen Schatten je Objekt, daher zwei verschachtelte Objekte: // aussen dunkler Schatten unten rechts, innen heller "Lichtschein" oben links. static void tempNeo(lv_obj_t* parent, const char* title, lv_color_t accent, lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar) { lv_obj_t* shell = lv_obj_create(parent); lv_obj_set_size(shell, LV_PCT(48), LV_PCT(100)); lv_obj_set_style_radius(shell, 20, 0); lv_obj_set_style_bg_color(shell, lv_color_hex(0x171717), 0); lv_obj_set_style_bg_opa(shell, LV_OPA_COVER, 0); lv_obj_set_style_border_width(shell, 0, 0); lv_obj_set_style_pad_all(shell, 0, 0); lv_obj_set_style_shadow_width(shell, 14, 0); lv_obj_set_style_shadow_offset_x(shell, 6, 0); lv_obj_set_style_shadow_offset_y(shell, 6, 0); lv_obj_set_style_shadow_color(shell, lv_color_hex(0x000000), 0); lv_obj_set_style_shadow_opa(shell, LV_OPA_70, 0); lv_obj_clear_flag(shell, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(shell, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite lv_obj_add_event_cb(shell, temp_card_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* inner = lv_obj_create(shell); lv_obj_set_size(inner, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_radius(inner, 20, 0); lv_obj_set_style_bg_color(inner, lv_color_hex(0x1e1e1e), 0); // leicht gewoelbt: oben etwas heller lv_obj_set_style_bg_grad_color(inner, lv_color_hex(0x121212), 0); lv_obj_set_style_bg_grad_dir(inner, LV_GRAD_DIR_VER, 0); lv_obj_set_style_border_width(inner, 0, 0); lv_obj_set_style_shadow_width(inner, 14, 0); lv_obj_set_style_shadow_offset_x(inner, -6, 0); lv_obj_set_style_shadow_offset_y(inner, -6, 0); lv_obj_set_style_shadow_color(inner, lv_color_hex(0x3c3c3c), 0); lv_obj_set_style_shadow_opa(inner, LV_OPA_50, 0); lv_obj_set_style_pad_row(inner, 8, 0); lv_obj_set_flex_flow(inner, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(inner, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(inner, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(inner, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt zur Shell durch lv_obj_t* t = lv_label_create(inner); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0); *big = lv_label_create(inner); lv_label_set_text(*big, "--.-"); lv_obj_set_style_text_color(*big, COL_TEXT, 0); lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0); *setp = lv_label_create(inner); lv_label_set_text(*setp, "Soll --.-"); lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0); *bar = lv_bar_create(inner); // "eingelassener" Balken: Track dunkler als Flaeche lv_obj_set_size(*bar, LV_PCT(78), 10); lv_obj_set_style_bg_color(*bar, lv_color_hex(0x0a0a0a), 0); lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0); lv_obj_set_style_border_width(*bar, 0, 0); lv_obj_set_style_radius(*bar, 5, 0); lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); // folgt spaeter der Zustandsfarbe lv_obj_set_style_radius(*bar, 5, LV_PART_INDICATOR); lv_bar_set_range(*bar, 0, 100); lv_bar_set_value(*bar, 0, LV_ANIM_OFF); } // Thermometer-Saeulen (TD_THERMO): vertikale Saeule (lv_bar waechst bei Hochformat von // unten), Glanzstreifen als Glas-Effekt, runder Kolben unten - alles rahmenlos. static void tempThermo(lv_obj_t* parent, const char* title, lv_color_t accent, lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar, lv_obj_t** bulb) { lv_obj_t* holder = lv_obj_create(parent); lv_obj_set_size(holder, LV_PCT(49), LV_PCT(100)); lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(holder, 0, 0); lv_obj_set_style_pad_all(holder, 0, 0); lv_obj_set_style_pad_column(holder, 14, 0); lv_obj_set_flex_flow(holder, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(holder, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(holder, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(holder, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite lv_obj_add_event_cb(holder, temp_card_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* th = lv_obj_create(holder); // Saeule + Kolben (feste Groesse) lv_obj_set_size(th, 48, 190); lv_obj_set_style_bg_opa(th, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(th, 0, 0); lv_obj_set_style_pad_all(th, 0, 0); lv_obj_clear_flag(th, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(th, LV_OBJ_FLAG_CLICKABLE); *bar = lv_bar_create(th); // hochkant -> fuellt sich von unten lv_obj_set_size(*bar, 20, 158); lv_obj_align(*bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_bg_color(*bar, lv_color_hex(0x262626), 0); lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0); lv_obj_set_style_border_width(*bar, 0, 0); lv_obj_set_style_radius(*bar, 10, 0); lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); // folgt spaeter der Zustandsfarbe lv_obj_set_style_radius(*bar, 10, LV_PART_INDICATOR); lv_bar_set_range(*bar, 0, 100); lv_bar_set_value(*bar, 0, LV_ANIM_OFF); lv_obj_t* gloss = lv_obj_create(th); // Glanzstreifen (Glas-Effekt) lv_obj_set_size(gloss, 4, 138); lv_obj_align(gloss, LV_ALIGN_TOP_MID, -5, 8); lv_obj_set_style_bg_color(gloss, lv_color_hex(0xffffff), 0); lv_obj_set_style_bg_opa(gloss, LV_OPA_20, 0); lv_obj_set_style_border_width(gloss, 0, 0); lv_obj_set_style_radius(gloss, 2, 0); lv_obj_clear_flag(gloss, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(gloss, LV_OBJ_FLAG_CLICKABLE); *bulb = lv_obj_create(th); // Kolben (ueberlappt die Saeule leicht) lv_obj_set_size(*bulb, 36, 36); lv_obj_align(*bulb, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_set_style_radius(*bulb, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(*bulb, accent, 0); // folgt spaeter der Zustandsfarbe lv_obj_set_style_bg_opa(*bulb, LV_OPA_COVER, 0); lv_obj_set_style_border_width(*bulb, 0, 0); lv_obj_set_style_shadow_width(*bulb, 10, 0); lv_obj_set_style_shadow_offset_y(*bulb, 3, 0); lv_obj_set_style_shadow_color(*bulb, lv_color_hex(0x000000), 0); lv_obj_set_style_shadow_opa(*bulb, LV_OPA_40, 0); lv_obj_clear_flag(*bulb, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(*bulb, LV_OBJ_FLAG_CLICKABLE); lv_obj_t* col = lv_obj_create(holder); // Textspalte neben der Saeule lv_obj_set_size(col, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(col, 0, 0); lv_obj_set_style_pad_all(col, 0, 0); lv_obj_set_style_pad_row(col, 6, 0); lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(col, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(col, LV_OBJ_FLAG_CLICKABLE); lv_obj_t* t = lv_label_create(col); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0); *big = lv_label_create(col); lv_label_set_text(*big, "--.-"); lv_obj_set_style_text_color(*big, COL_TEXT, 0); lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0); *setp = lv_label_create(col); lv_label_set_text(*setp, "Soll --.-"); lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0); } // Temperaturzeile gemaess aktivem Design (neu) befuellen - auch beim Umschalten zur Laufzeit static void build_temp_row_content() { if (!g_tempRow) return; lv_obj_clean(g_tempRow); lblTempW = lblSetW = barDutyW = nullptr; lblTempD = lblSetD = barDutyD = nullptr; arcTempW = arcTempD = nullptr; bulbTempW = bulbTempD = nullptr; // Nur Soft-3D braucht Innenabstand, damit die weichen Schatten nicht am Rand abgeschnitten werden lv_obj_set_style_pad_hor(g_tempRow, (g_tempDesign == TD_NEO) ? 12 : 0, 0); lv_obj_set_style_pad_ver(g_tempRow, (g_tempDesign == TD_NEO) ? 12 : 0, 0); const lv_color_t teal = lv_color_hex(0x80cbc4); switch (g_tempDesign) { case TD_GAUGE: tempGauge(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &arcTempW); tempGauge(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &arcTempD); break; case TD_MINIMAL: tempMinimal(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW); tempMinimal(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD); break; case TD_NEO: tempNeo(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW); tempNeo(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD); break; case TD_THERMO: tempThermo(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW, &bulbTempW); tempThermo(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD, &bulbTempD); break; default: tempCard(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW); tempCard(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD); break; } } static lv_obj_t* infoCell(lv_obj_t* parent, const char* title, lv_obj_t** val, lv_color_t accent, lv_obj_t** titleOut = nullptr) { lv_obj_t* card = th_card(parent); lv_obj_set_height(card, LV_PCT(100)); lv_obj_set_flex_grow(card, 1); // alle Zellen teilen sich die Breite gleichmaessig lv_obj_set_style_pad_all(card, 6, 0); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); if (titleOut) *titleOut = t; *val = lv_label_create(card); lv_label_set_text(*val, "--"); lv_obj_set_style_text_color(*val, COL_TEXT, 0); lv_obj_set_style_text_font(*val, &lv_font_maven_pro_28, 0); lv_obj_set_style_text_align(*val, LV_TEXT_ALIGN_CENTER, 0); // fuer mehrzeilige Werte return card; } // Tipp auf die Statuszeile: Wartungsmeldung -> Serviceseite, Reinigungsmeldung -> Sub-Screen static void status_line_cb(lv_event_t*) { if (g_statusShown == ST_MAINT) show_page(PG_SERVICE); else if (g_statusShown == ST_CLEAN) cln_open(nullptr); } static void wifi_icon_cb(lv_event_t*) { show_page(PG_WIFI); } // Tipp auf WLAN-Symbol -> WiFi-Seite static void build_dashboard(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); // Konsolidierte Statuszeile: alle Hinweise (Sicherheit/Dampf/Reinigung/Tuning/Wartung/ // Aufheizen) teilen sich EINE feste einzeilige Meldung - mehrere aktive Meldungen // rotieren weich durch statt sich zu stapeln (Inhalte/Rotation: status_set/status_line_tick). g_statusLine = lv_obj_create(p); lv_obj_set_size(g_statusLine, LV_PCT(100), 30); lv_obj_set_style_radius(g_statusLine, 6, 0); lv_obj_set_style_border_width(g_statusLine, 0, 0); lv_obj_set_style_pad_hor(g_statusLine, 8, 0); lv_obj_set_style_pad_ver(g_statusLine, 0, 0); lv_obj_clear_flag(g_statusLine, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_statusLine, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(g_statusLine, status_line_cb, LV_EVENT_CLICKED, nullptr); g_statusLbl = lv_label_create(g_statusLine); lv_obj_set_width(g_statusLbl, LV_PCT(100)); lv_label_set_long_mode(g_statusLbl, LV_LABEL_LONG_SCROLL_CIRCULAR); // Ueberlaenge laeuft durch lv_obj_set_style_text_align(g_statusLbl, LV_TEXT_ALIGN_CENTER, 0); lv_obj_center(g_statusLbl); lv_obj_add_flag(g_statusLine, LV_OBJ_FLAG_HIDDEN); // Pre-Infusion-Phasenleiste: drei Schritt-Chips (Pre-Infusion -> Pause -> Extraktion), // nur waehrend eines Bezugs mit aktiver Pre-Infusion sichtbar (Update in pi_bar_update) g_piRow = lv_obj_create(p); lv_obj_set_width(g_piRow, LV_PCT(100)); lv_obj_set_height(g_piRow, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(g_piRow, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_piRow, 0, 0); lv_obj_set_style_pad_all(g_piRow, 0, 0); lv_obj_set_style_pad_column(g_piRow, 6, 0); lv_obj_set_flex_flow(g_piRow, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(g_piRow, LV_OBJ_FLAG_SCROLLABLE); for (int i = 0; i < 3; i++) { lv_obj_t* c = lv_obj_create(g_piRow); lv_obj_set_height(c, LV_SIZE_CONTENT); lv_obj_set_flex_grow(c, 1); lv_obj_set_style_bg_color(c, COL_CARD, 0); lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); lv_obj_set_style_border_width(c, 1, 0); lv_obj_set_style_border_color(c, lv_color_hex(0x2a2a2a), 0); lv_obj_set_style_radius(c, 6, 0); lv_obj_set_style_pad_all(c, 5, 0); lv_obj_clear_flag(c, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* l = lv_label_create(c); lv_label_set_text(l, ""); lv_obj_set_style_text_color(l, COL_TEXT_MUT, 0); lv_obj_center(l); g_piChip[i] = c; g_piChipLbl[i] = l; } lv_obj_add_flag(g_piRow, LV_OBJ_FLAG_HIDDEN); // Temperatur-Anzeige (Design umschaltbar: Karten / Rundinstrumente / Minimal) g_tempRow = lv_obj_create(p); lv_obj_set_width(g_tempRow, LV_PCT(100)); lv_obj_set_flex_grow(g_tempRow, 3); lv_obj_set_style_bg_opa(g_tempRow, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_tempRow, 0, 0); lv_obj_set_style_pad_all(g_tempRow, 0, 0); lv_obj_set_flex_flow(g_tempRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(g_tempRow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(g_tempRow, LV_OBJ_FLAG_SCROLLABLE); build_temp_row_content(); // Info-Zeile: Timer / Bezug / Eco-Countdown / Gehaeuse (teilen sich die Breite) lv_obj_t* ir = lv_obj_create(p); lv_obj_set_width(ir, LV_PCT(100)); lv_obj_set_flex_grow(ir, 2); lv_obj_set_style_bg_opa(ir, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(ir, 0, 0); lv_obj_set_style_pad_all(ir, 0, 0); lv_obj_set_style_pad_column(ir, 6, 0); lv_obj_set_flex_flow(ir, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(ir, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(ir, LV_OBJ_FLAG_SCROLLABLE); cellShot = infoCell(ir, "SHOT-TIMER", &lblShotTimer, COL_ACCENT); // Brew-by-Time-Fortschrittsbalken (unter dem Shot-Timer; nur waehrend BBT-Bezug sichtbar) g_bbtBar = lv_bar_create(cellShot); lv_obj_set_width(g_bbtBar, LV_PCT(90)); lv_obj_set_height(g_bbtBar, 8); lv_obj_set_style_bg_color(g_bbtBar, lv_color_hex(0x3a3a3a), 0); lv_obj_set_style_bg_opa(g_bbtBar, LV_OPA_COVER, 0); lv_obj_set_style_radius(g_bbtBar, 4, 0); lv_obj_set_style_bg_color(g_bbtBar, COL_OK, LV_PART_INDICATOR); lv_obj_set_style_radius(g_bbtBar, 4, LV_PART_INDICATOR); lv_bar_set_range(g_bbtBar, 0, 100); lv_bar_set_value(g_bbtBar, 0, LV_ANIM_OFF); lv_obj_add_flag(g_bbtBar, LV_OBJ_FLAG_HIDDEN); cellBezug = infoCell(ir, "WAAGE", &lblBezug, COL_ACCENT); lv_obj_add_flag(cellBezug, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Brew-Control lv_obj_add_event_cb(cellBezug, brew_cell_cb, LV_EVENT_CLICKED, nullptr); // Brew-by-Weight-Fortschrittsbalken (unter dem Gewichtswert; nur waehrend BBW-Bezug sichtbar) g_bbwBar = lv_bar_create(cellBezug); lv_obj_set_width(g_bbwBar, LV_PCT(90)); lv_obj_set_height(g_bbwBar, 8); lv_obj_set_style_bg_color(g_bbwBar, lv_color_hex(0x3a3a3a), 0); lv_obj_set_style_bg_opa(g_bbwBar, LV_OPA_COVER, 0); lv_obj_set_style_radius(g_bbwBar, 4, 0); lv_obj_set_style_bg_color(g_bbwBar, COL_OK, LV_PART_INDICATOR); lv_obj_set_style_radius(g_bbwBar, 4, LV_PART_INDICATOR); lv_bar_set_range(g_bbwBar, 0, 100); lv_bar_set_value(g_bbwBar, 0, LV_ANIM_OFF); lv_obj_add_flag(g_bbwBar, LV_OBJ_FLAG_HIDDEN); cellFlow = infoCell(ir, "FLOW", &lblFlow, lv_color_hex(0x64b5f6)); lv_obj_add_flag(cellFlow, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Brew-Control lv_obj_add_event_cb(cellFlow, brew_cell_cb, LV_EVENT_CLICKED, nullptr); cellSteam = infoCell(ir, "DAMPF-TIMER", &lblSteamTimer, lv_color_hex(0x80cbc4)); // Steam-by-Time-Fortschrittsbalken (unter dem Dampf-Timer; nur waehrend SBT-Dampfbezug sichtbar) g_sbtBar = lv_bar_create(cellSteam); lv_obj_set_width(g_sbtBar, LV_PCT(90)); lv_obj_set_height(g_sbtBar, 8); lv_obj_set_style_bg_color(g_sbtBar, lv_color_hex(0x3a3a3a), 0); lv_obj_set_style_bg_opa(g_sbtBar, LV_OPA_COVER, 0); lv_obj_set_style_radius(g_sbtBar, 4, 0); lv_obj_set_style_bg_color(g_sbtBar, COL_OK, LV_PART_INDICATOR); lv_obj_set_style_radius(g_sbtBar, 4, LV_PART_INDICATOR); lv_bar_set_range(g_sbtBar, 0, 100); lv_bar_set_value(g_sbtBar, 0, LV_ANIM_OFF); lv_obj_add_flag(g_sbtBar, LV_OBJ_FLAG_HIDDEN); // Kombi-Zelle: zeigt Eco-Info und/oder Gehaeusetemperatur (abwechselnd, siehe refresh_eco_case) cellEco = infoCell(ir, "ECO", &lblEco, COL_OK, &lblEcoTitle); lv_obj_add_flag(cellFlow, LV_OBJ_FLAG_HIDDEN); // nur waehrend Brew-by-Weight-Bezug (+ Nachhalten) lv_obj_add_flag(cellEco, LV_OBJ_FLAG_HIDDEN); // erst zeigen, wenn Eco-Info und/oder Gehaeusesensor aktiv } // ===================================================================================== // Temperatur-Seite (radiale Drehregler + Feinjustage + Toggles) // ===================================================================================== // Solltemperaturen NICHT bei jeder Wertaenderung senden: setTempW/setTempD schreiben auf // der S3 ins EEPROM - eine Drag-Geste am Drehregler wuerde die S3 mit Kommandos und // Schreibzugriffen fluten (gleiche Begruendung wie beim Aufheizzeit-Debounce). Deshalb // entprellt: Anzeige sofort, gesendet wird nach kurzer Bedienpause bzw. sofort beim // Loslassen des Reglers oder beim Verlassen der Seite (flush_targ()). static lv_obj_t* g_arcW = nullptr; static lv_obj_t* g_arcD = nullptr; static bool g_targWDirty = false, g_targDDirty = false; static lv_timer_t* g_targSendTimer = nullptr; static void send_targ_now() { if (g_targWDirty) { g_targWDirty = false; if (g_client) g_client->sendAction("setTempW", String(g_targW, 1)); } if (g_targDDirty) { g_targDDirty = false; if (g_client) g_client->sendAction("setTempD", String(g_targD, 1)); } g_targEditMs = millis(); // State-Echo kurz nicht ueberschreiben lassen } static void targ_send_timer_cb(lv_timer_t*) { g_targSendTimer = nullptr; // Einmal-Timer wird von LVGL nach diesem Callback selbst geloescht send_targ_now(); } static void schedule_targ_send() { g_targEditMs = millis(); // verhindert, dass der State-Push den Wert waehrend der Eingabe ueberschreibt if (g_targSendTimer) { lv_timer_reset(g_targSendTimer); return; } g_targSendTimer = lv_timer_create(targ_send_timer_cb, 400, nullptr); lv_timer_set_repeat_count(g_targSendTimer, 1); // Einmal-Timer (loescht sich selbst) } static void flush_targ() { // explizit (Loslassen/Seitenwechsel): laufenden Timer abbrechen und sofort senden if (g_targSendTimer) { lv_timer_del(g_targSendTimer); g_targSendTimer = nullptr; } send_targ_now(); } // Wert lokal uebernehmen: Label + Arc synchron halten, Senden entprellt anstossen static void targ_w_set(float v) { if (v < 0) v = 0; if (v > 135) v = 135; g_targW = v; if (lblTargW) lv_label_set_text(lblTargW, tempStr(g_targW, 0).c_str()); if (g_arcW) lv_arc_set_value(g_arcW, (int32_t)(g_targW + 0.5f)); g_targWDirty = true; schedule_targ_send(); } static void targ_d_set(float v) { if (v < 0) v = 0; if (v > 200) v = 200; g_targD = v; if (lblTargD) lv_label_set_text(lblTargD, tempStr(g_targD, 0).c_str()); if (g_arcD) lv_arc_set_value(g_arcD, (int32_t)(g_targD + 0.5f)); g_targDDirty = true; schedule_targ_send(); } static void arc_w_cb(lv_event_t* e) { targ_w_set((float)lv_arc_get_value((lv_obj_t*)lv_event_get_target(e))); } static void arc_d_cb(lv_event_t* e) { targ_d_set((float)lv_arc_get_value((lv_obj_t*)lv_event_get_target(e))); } static void arc_released_cb(lv_event_t*) { flush_targ(); } // Loslassen -> Wert sofort uebernehmen static void tw_minus(lv_event_t*) { targ_w_set(g_targW - 1.0f); } static void tw_plus (lv_event_t*) { targ_w_set(g_targW + 1.0f); } static void td_minus(lv_event_t*) { targ_d_set(g_targD - 1.0f); } static void td_plus (lv_event_t*) { targ_d_set(g_targD + 1.0f); } // Karte mit radialem Drehregler (Arc) + Feinjustage-Tasten fuer eine Solltemperatur. // Liefert das Wert-Label (mittig im Regler) zurueck. static lv_obj_t* build_temp_arc(lv_obj_t* parent, const char* title, lv_color_t accent, int maxVal, lv_obj_t** arcOut, lv_event_cb_t arcCb, lv_event_cb_t minusCb, lv_event_cb_t plusCb) { lv_obj_t* card = th_card(parent); lv_obj_set_size(card, LV_PCT(49), LV_SIZE_CONTENT); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(card, 6, 0); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0); lv_obj_t* arc = lv_arc_create(card); lv_obj_set_size(arc, 180, 180); lv_arc_set_bg_angles(arc, 135, 45); // klassischer 270-Grad-Regler (Oeffnung unten) lv_arc_set_range(arc, 0, maxVal); lv_arc_set_value(arc, 0); lv_obj_set_style_arc_width(arc, 14, LV_PART_MAIN); lv_obj_set_style_arc_color(arc, lv_color_hex(0x3a3a3a), LV_PART_MAIN); lv_obj_set_style_arc_width(arc, 14, LV_PART_INDICATOR); lv_obj_set_style_arc_color(arc, accent, LV_PART_INDICATOR); lv_obj_set_style_arc_rounded(arc, true, LV_PART_INDICATOR); lv_obj_set_style_bg_color(arc, accent, LV_PART_KNOB); lv_obj_set_style_pad_all(arc, 7, LV_PART_KNOB); // groesserer Griff (Touch-Ziel) lv_obj_add_flag(arc, LV_OBJ_FLAG_ADV_HITTEST); // nur Ring/Griff reagieren -> kein Wertsprung bei Tipp in die Mitte lv_obj_add_event_cb(arc, arcCb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(arc, arc_released_cb, LV_EVENT_RELEASED, nullptr); lv_obj_t* val = lv_label_create(arc); // Wert mittig im Regler lv_label_set_text(val, "--°C"); lv_obj_set_style_text_color(val, COL_TEXT, 0); lv_obj_set_style_text_font(val, &lv_font_maven_pro_40, 0); lv_obj_center(val); lv_obj_t* grp = lv_obj_create(card); // Feinjustage: [-] [+] lv_obj_set_size(grp, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(grp, 0, 0); lv_obj_set_style_pad_all(grp, 0, 0); lv_obj_set_style_pad_column(grp, 24, 0); lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(grp, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* bm = th_button(grp, "-", COL_CARD2, COL_TEXT); lv_obj_set_size(bm, 96, 48); lv_obj_add_event_cb(bm, minusCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* bp = th_button(grp, "+", COL_CARD2, COL_TEXT); lv_obj_set_size(bp, 96, 48); lv_obj_add_event_cb(bp, plusCb, LV_EVENT_CLICKED, nullptr); *arcOut = arc; return val; } static void eco_cb(lv_event_t* e) { if (g_client) g_client->sendAction(sw_get((lv_obj_t*)lv_event_get_target(e)) ? "startEco" : "stopEco"); } static void maint_cb(lv_event_t* e) { if (g_client) g_client->sendAction("toggleMaintenance", sw_get((lv_obj_t*)lv_event_get_target(e)) ? "true" : "false"); } // Stepper-Zeile: Beschriftung | [-] Wert [+] static lv_obj_t* build_stepper(lv_obj_t* parent, const char* label, lv_event_cb_t minus, lv_event_cb_t plus, int dec) { lv_obj_t* card = th_card(parent); lv_obj_set_width(card, LV_PCT(100)); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* l = lv_label_create(card); lv_label_set_text(l, label); lv_obj_set_style_text_color(l, COL_ACCENT, 0); lv_obj_t* grp = lv_obj_create(card); lv_obj_set_size(grp, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(grp, 0, 0); lv_obj_set_style_pad_all(grp, 0, 0); lv_obj_set_style_pad_column(grp, 12, 0); lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(grp, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(grp, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* bm = th_button(grp, "-", COL_CARD2, COL_TEXT); lv_obj_set_size(bm, 64, 56); lv_obj_add_event_cb(bm, minus, LV_EVENT_CLICKED, nullptr); lv_obj_t* val = lv_label_create(grp); lv_label_set_text(val, dec ? "--,-°C" : "--°C"); lv_obj_set_style_text_color(val, COL_TEXT, 0); lv_obj_set_style_text_font(val, &lv_font_maven_pro_40, 0); lv_obj_set_width(val, 130); lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_CENTER, 0); lv_obj_t* bp = th_button(grp, "+", COL_CARD2, COL_TEXT); lv_obj_set_size(bp, 64, 56); lv_obj_add_event_cb(bp, plus, LV_EVENT_CLICKED, nullptr); return val; } static void hu_set_label() { if (lblHeatUp) lv_label_set_text(lblHeatUp, g_heatUp > 0 ? String(g_heatUp).c_str() : "Aus"); } // Aufheizzeit NICHT bei jedem Tipp senden: setHeatUpMinutes committet auf der S3 sofort ins // EEPROM (blockierend) - schnelles Klicken wuerde die S3 mit Commits fluten und Befehle // verlieren. Deshalb entprellt: Label sofort, gesendet wird erst nach kurzer Tipp-Pause // (oder sofort beim Verlassen der Seite via flush_heatup()). static void send_heatup_now() { if (!g_heatUpDirty) return; g_heatUpDirty = false; if (g_client) g_client->sendAction("setHeatUpMinutes", String(g_heatUp)); g_heatUpEditMs = millis(); // State-Echo kurz nicht ueberschreiben lassen } static void flush_heatup() { // explizit (Seitenwechsel): laufenden Timer abbrechen und sofort senden if (g_heatUpSendTimer) { lv_timer_del(g_heatUpSendTimer); g_heatUpSendTimer = nullptr; } send_heatup_now(); } static void heatup_send_timer_cb(lv_timer_t* t) { (void)t; g_heatUpSendTimer = nullptr; // Einmal-Timer wird von LVGL nach diesem Callback selbst geloescht send_heatup_now(); } static void schedule_heatup_send() { g_heatUpDirty = true; g_heatUpEditMs = millis(); // verhindert, dass der State-Push den Wert waehrend der Eingabe ueberschreibt if (g_heatUpSendTimer) { lv_timer_reset(g_heatUpSendTimer); return; } g_heatUpSendTimer = lv_timer_create(heatup_send_timer_cb, 700, nullptr); lv_timer_set_repeat_count(g_heatUpSendTimer, 1); // Einmal-Timer (loescht sich selbst) } static void hu_minus(lv_event_t*) { if (g_heatUp > 0) g_heatUp--; hu_set_label(); schedule_heatup_send(); } static void hu_plus (lv_event_t*) { if (g_heatUp < 60) g_heatUp++; hu_set_label(); schedule_heatup_send(); } static void build_temp(lv_obj_t* p) { section_title(p, "Solltemperaturen"); // Zwei Drehregler-Karten nebeneinander (Wasser | Dampf) lv_obj_t* tr = lv_obj_create(p); lv_obj_set_width(tr, LV_PCT(100)); lv_obj_set_height(tr, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(tr, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(tr, 0, 0); lv_obj_set_style_pad_all(tr, 0, 0); lv_obj_set_flex_flow(tr, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(tr, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); lv_obj_clear_flag(tr, LV_OBJ_FLAG_SCROLLABLE); lblTargW = build_temp_arc(tr, "Wasser", COL_ACCENT, 135, &g_arcW, arc_w_cb, tw_minus, tw_plus); lblTargD = build_temp_arc(tr, "Dampf", lv_color_hex(0x80cbc4), 200, &g_arcD, arc_d_cb, td_minus, td_plus); section_title(p, "Aufheizzeit (Countdown bis durchgewärmt)"); lblHeatUp = build_stepper(p, "Minuten", hu_minus, hu_plus, 0); hu_set_label(); section_title(p, "Modi"); swEco = add_switch(p, "Eco-Modus"); lv_obj_add_event_cb(swEco, eco_cb, LV_EVENT_VALUE_CHANGED, nullptr); swMaint = add_switch(p, "Wartungsmodus"); lv_obj_add_event_cb(swMaint, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr); } // ===================================================================================== // Licht // ===================================================================================== static void light_cb(lv_event_t*) { // toggleLight setzt gezielt: aktuellen Zustand invertieren if (g_client) g_client->sendAction("toggleLight", g_state.lightOn ? "false" : "true"); } static void build_light(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_flex_align(p, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(p, 18, 0); section_title(p, "Beleuchtung"); btnLight = th_button(p, "Licht", COL_CARD2, COL_TEXT); lv_obj_set_size(btnLight, 320, 150); lv_obj_add_event_cb(btnLight, light_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_obj_get_child(btnLight, 0); if (l) lv_obj_set_style_text_font(l, &lv_font_maven_pro_28, 0); } // ===================================================================================== // Verlauf (Chart): Wasser/Dampf + Zusatzsensor (Gehaeuse/Tassenablage), ~2 min, // mit automatisch skalierter Temperatur-Achse und Y-Skala-Beschriftung links // ===================================================================================== static void build_chart(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); section_title(p, "Temperatur-Verlauf (2 min)"); g_chart = lv_chart_create(p); lv_obj_set_width(g_chart, LV_PCT(100)); lv_obj_set_flex_grow(g_chart, 1); lv_obj_set_style_bg_color(g_chart, COL_CARD, 0); lv_obj_set_style_border_color(g_chart, COL_BORDER, 0); lv_obj_set_style_border_width(g_chart, 1, 0); lv_obj_set_style_radius(g_chart, 8, 0); lv_obj_set_style_pad_left(g_chart, 42, 0); // linker Gutter fuer die Y-Skala lv_obj_set_style_line_width(g_chart, 2, LV_PART_ITEMS); // Linienstaerke der Serien lv_obj_set_style_width(g_chart, 0, LV_PART_INDICATOR); // keine Punkt-Marker lv_obj_set_style_height(g_chart, 0, LV_PART_INDICATOR); lv_chart_set_type(g_chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(g_chart, 120); // 120 * 1 s = 2 min lv_chart_set_update_mode(g_chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_div_line_count(g_chart, 5, 0); lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, 80, 160); // Temperaturen (Grad C) g_serW = lv_chart_add_series(g_chart, COL_ACCENT, LV_CHART_AXIS_PRIMARY_Y); g_serD = lv_chart_add_series(g_chart, lv_color_hex(0x80cbc4), LV_CHART_AXIS_PRIMARY_Y); g_serC = lv_chart_add_series(g_chart, COL_CASE, LV_CHART_AXIS_PRIMARY_Y); lv_chart_set_all_value(g_chart, g_serW, LV_CHART_POINT_NONE); lv_chart_set_all_value(g_chart, g_serD, LV_CHART_POINT_NONE); lv_chart_set_all_value(g_chart, g_serC, LV_CHART_POINT_NONE); // Y-Skala-Beschriftung (Grad C) im linken Gutter: oben=hi, mitte, unten=lo g_axHi = lv_label_create(g_chart); lv_obj_align(g_axHi, LV_ALIGN_TOP_LEFT, -38, -2); g_axMid = lv_label_create(g_chart); lv_obj_align(g_axMid, LV_ALIGN_LEFT_MID, -38, 0); g_axLo = lv_label_create(g_chart); lv_obj_align(g_axLo, LV_ALIGN_BOTTOM_LEFT, -38, 2); lv_obj_t* ax[3] = { g_axHi, g_axMid, g_axLo }; for (int i = 0; i < 3; i++) { lv_obj_set_style_text_color(ax[i], COL_TEXT_DIM, 0); lv_obj_set_style_text_font(ax[i], &lv_font_maven_pro_14, 0); lv_label_set_text(ax[i], "--"); } // Legende mit Live-Werten lv_obj_t* leg = row(p, nullptr); g_legW = lv_label_create(leg); lv_label_set_text(g_legW, "Wasser --"); lv_obj_set_style_text_color(g_legW, COL_ACCENT, 0); g_legD = lv_label_create(leg); lv_label_set_text(g_legD, "Dampf --"); lv_obj_set_style_text_color(g_legD, lv_color_hex(0x80cbc4), 0); g_legC = lv_label_create(leg); lv_label_set_text(g_legC, ""); lv_obj_set_style_text_color(g_legC, COL_CASE, 0); lv_obj_add_flag(g_legC, LV_OBJ_FLAG_HIDDEN); // nur wenn Zusatzsensor aktiv } static void chart_sample_cb(lv_timer_t*) { if (!g_chart) return; int wv = (int)(g_state.tempW + 0.5f); int dv = (int)(g_state.tempD + 0.5f); bool caseOn = g_state.caseSensorEnabled && g_state.hasCaseTemp; int cv = caseOn ? (int)(g_state.caseTemp + 0.5f) : 0; lv_chart_set_next_value(g_chart, g_serW, (int32_t)wv); lv_chart_set_next_value(g_chart, g_serD, (int32_t)dv); lv_chart_set_next_value(g_chart, g_serC, caseOn ? (int32_t)cv : LV_CHART_POINT_NONE); // Automatische Skalierung der Temperatur-Achse ueber ein gleitendes 120-Werte-Fenster. // Werte < 30 (kalter/inaktiver Sensor) werden ignoriert, damit sie die Skala nicht // an den unteren Rand ziehen (sonst sehen die Kurven flach aus). static int16_t hW[120], hD[120], hC[120]; static int pos = 0, fill = 0; hW[pos] = (int16_t)wv; hD[pos] = (int16_t)dv; hC[pos] = caseOn ? (int16_t)cv : -1000; pos = (pos + 1) % 120; if (fill < 120) fill++; int lo = 1000, hi = -1000; for (int i = 0; i < fill; i++) { if (hW[i] > 30) { if (hW[i] < lo) lo = hW[i]; if (hW[i] > hi) hi = hW[i]; } if (hD[i] > 30) { if (hD[i] < lo) lo = hD[i]; if (hD[i] > hi) hi = hD[i]; } if (hC[i] > 30) { if (hC[i] < lo) lo = hC[i]; if (hC[i] > hi) hi = hC[i]; } } if (hi >= lo) { lo = ((lo - 3) / 5) * 5; // auf 5 abrunden (mit Rand) hi = ((hi + 7) / 5) * 5; // auf 5 aufrunden (mit Rand) if (hi - lo < 10) hi = lo + 10; // Mindestspanne 10 Grad lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, lo, hi); if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hi); if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (lo + hi) / 2); if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", lo); } if (g_legW) lv_label_set_text(g_legW, ("Wasser " + tempStr(g_state.tempW, 1)).c_str()); if (g_legD) lv_label_set_text(g_legD, ("Dampf " + tempStr(g_state.tempD, 0)).c_str()); if (g_legC) { if (caseOn) { const char* name = (g_state.caseSensorType == 1) ? "Tassenablage " : "Gehäuse "; lv_label_set_text(g_legC, (String(name) + tempStr(g_state.caseTemp, 1)).c_str()); lv_obj_remove_flag(g_legC, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_legC, LV_OBJ_FLAG_HIDDEN); } } // --- Aufheiz-Countdown: konfigurierte Aufheizzeit ab Start (vom S3 als heatUpRemainSec) --- // 40-Grad-Regel ist nur ein KALTSTART-Gate: einmal "kalt gestartet" gelatcht, laeuft der // Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird. { long rs = g_state.heatUpRemainSec; if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen else if (g_state.tempW < 40.0f) g_heatUpLatched = true; // Kaltstart erkannt -> ganzen Countdown zeigen if (g_heatUpLatched) { char b[56]; if (rs >= 60) snprintf(b, sizeof(b), LV_SYMBOL_UP " Aufheizen - bereit in ~%ld:%02ld", rs / 60, rs % 60); else snprintf(b, sizeof(b), LV_SYMBOL_UP " Aufheizen - bereit in ~%ld s", rs); status_set(ST_HEAT, true, b, lv_color_hex(0x1976d2), COL_TEXT); } else { status_set(ST_HEAT, false); } } } // ===================================================================================== // Shot-Zusammenfassung (Popup, blendet nach 8 s aus oder per Tipp) // ===================================================================================== static void shot_sum_hide_cb(lv_timer_t*) { if (g_shotSum) lv_obj_add_flag(g_shotSum, LV_OBJ_FLAG_HIDDEN); g_shotSumTimer = nullptr; } static void shot_sum_tap_cb(lv_event_t*) { if (g_shotSum) lv_obj_add_flag(g_shotSum, LV_OBJ_FLAG_HIDDEN); if (g_shotSumTimer) { lv_timer_del(g_shotSumTimer); g_shotSumTimer = nullptr; } } static void show_shot_summary(uint32_t durMs, float weight, bool haveScale) { if (!g_shotSum) return; float durS = durMs / 1000.0f; String t = "Zeit: " + String(durS, 1) + " s"; if (haveScale) { t += "\nGewicht: " + String(weight, 1) + " g"; float avg = (durS > 0.1f) ? weight / durS : 0.0f; t += "\nMittl. Flow: " + String(avg, 1) + " g/s"; } lv_label_set_text(g_shotSumBody, t.c_str()); lv_obj_remove_flag(g_shotSum, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_shotSum); if (g_shotSumTimer) lv_timer_reset(g_shotSumTimer); else { g_shotSumTimer = lv_timer_create(shot_sum_hide_cb, 8000, nullptr); lv_timer_set_repeat_count(g_shotSumTimer, 1); } } // ===================================================================================== // Brew-Control // ===================================================================================== static void send_save_brew(lv_event_t*) { if (!g_client) return; String f; f += "\"piEnabled\":"; f += boolStr(sw_get(swPI)); f += ","; f += "\"piDurSecs\":"; f += String(ta_f(taPiDur), 1); f += ","; f += "\"piPauseSecs\":"; f += String(ta_f(taPiPause), 1); f += ","; f += "\"bbtEnabled\":"; f += boolStr(sw_get(swBBT)); f += ","; f += "\"bbtSecs\":"; f += String(ta_f(taBbtSecs), 1); f += ","; f += "\"bbwEnabled\":"; f += boolStr(sw_get(swBBW)); f += ","; f += "\"bbwTarget\":"; f += String(ta_f(taBbwTarget), 1); f += ","; f += "\"bbwOffset\":"; f += String(ta_f(taBbwOffset), 1); f += ","; f += "\"sbtEnabled\":"; f += boolStr(sw_get(swSBT)); f += ","; f += "\"sbtSecs\":"; f += String(ta_f(taSbtSecs), 1); g_client->sendCommand("saveBrew", f); showToast("Speichere Brühen...", false); } static void build_brew(lv_obj_t* p) { section_title(p, "Pre-Infusion"); swPI = add_switch(p, "Aktiv"); taPiDur = add_num(p, "Dauer (s)"); taPiPause = add_num(p, "Pause (s)"); section_title(p, "Brew-by-Time / Weight / Steam-by-Time"); swBBT = add_switch(p, "Brew-by-Time aktiv"); taBbtSecs = add_num(p, "Zielzeit (s)"); swBBW = add_switch(p, "Brew-by-Weight aktiv"); taBbwTarget= add_num(p, "Zielgewicht (g)"); taBbwOffset= add_num(p, "Offset (g)"); swSBT = add_switch(p, "Steam-by-Time aktiv"); taSbtSecs = add_num(p, "Dampf-Zielzeit (s)"); add_save_button(p, "Brühen speichern", send_save_brew); } // ===================================================================================== // Reinigungsassistent (Sub-Screen, geoeffnet ueber die Reinigung/Wartung-Seite) // ===================================================================================== static void label_set_if_changed(lv_obj_t* lbl, const char* txt); // Definition weiter unten static void cln_set_labels() { if (g_clnCyclesVal) lv_label_set_text_fmt(g_clnCyclesVal, "%d", g_clnCycles); if (g_clnBrewVal) lv_label_set_text_fmt(g_clnBrewVal, "%d s", g_clnBrew); if (g_clnPauseVal) lv_label_set_text_fmt(g_clnPauseVal, "%d s", g_clnPause); } static void clnCyc_m(lv_event_t*) { if (g_clnCycles > 1) g_clnCycles--; cln_set_labels(); } static void clnCyc_p(lv_event_t*) { if (g_clnCycles < 30) g_clnCycles++; cln_set_labels(); } static void clnBrew_m(lv_event_t*) { if (g_clnBrew > 1) g_clnBrew--; cln_set_labels(); } static void clnBrew_p(lv_event_t*) { if (g_clnBrew < 30) g_clnBrew++; cln_set_labels(); } static void clnPause_m(lv_event_t*){ if (g_clnPause > 1) g_clnPause--; cln_set_labels(); } static void clnPause_p(lv_event_t*){ if (g_clnPause < 60) g_clnPause++; cln_set_labels(); } // Int-Stepper-Zeile: Beschriftung | [-] Wert [+] (Rueckgabe: Wert-Label) static lv_obj_t* cln_stepper(lv_obj_t* parent, const char* label, lv_event_cb_t minus, lv_event_cb_t plus) { lv_obj_t* card = th_card(parent); lv_obj_set_width(card, LV_PCT(100)); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(card, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* l = lv_label_create(card); lv_label_set_text(l, label); lv_obj_set_style_text_color(l, COL_ACCENT, 0); lv_obj_t* grp = lv_obj_create(card); lv_obj_set_size(grp, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(grp, 0, 0); lv_obj_set_style_pad_all(grp, 0, 0); lv_obj_set_style_pad_column(grp, 12, 0); lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(grp, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(grp, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* bm = th_button(grp, "-", COL_CARD2, COL_TEXT); lv_obj_set_size(bm, 64, 56); lv_obj_add_event_cb(bm, minus, LV_EVENT_CLICKED, nullptr); lv_obj_t* val = lv_label_create(grp); lv_label_set_text(val, "--"); lv_obj_set_style_text_color(val, COL_TEXT, 0); lv_obj_set_style_text_font(val, &lv_font_maven_pro_28, 0); lv_obj_t* bp = th_button(grp, "+", COL_CARD2, COL_TEXT); lv_obj_set_size(bp, 64, 56); lv_obj_add_event_cb(bp, plus, LV_EVENT_CLICKED, nullptr); return val; } static void cln_refresh() { if (!g_clnScreen || lv_obj_has_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN)) return; const MachineState& st = g_state; if (st.cleaningActive) { if (g_clnCfg) lv_obj_add_flag(g_clnCfg, LV_OBJ_FLAG_HIDDEN); if (g_clnProgress) lv_obj_remove_flag(g_clnProgress, LV_OBJ_FLAG_HIDDEN); String s; if (st.cleaningWaiting) { s = "Heizt auf 93 °C vor...\n\n"; s += "Wasser: " + tempStr(st.tempW, 0) + " / 93°C\n\n"; s += "Die Zyklen starten automatisch,\nsobald die Temperatur erreicht ist."; } else { s = "Zyklus " + String(st.cleaningCycle) + " / " + String(st.cleaningCycles) + "\n\n"; s += "Phase: " + String(st.cleaningBrewPhase ? "Bezug" : "Pause") + "\n"; s += "Rest: " + String(st.cleaningPhaseRemainSec) + " s\n\n"; s += "Wasser: " + tempStr(st.tempW, 0) + " / 93°C\n"; s += "Dampfheizung aus"; } if (g_clnProgLbl) label_set_if_changed(g_clnProgLbl, s.c_str()); } else { if (g_clnProgress) lv_obj_add_flag(g_clnProgress, LV_OBJ_FLAG_HIDDEN); if (g_clnCfg) lv_obj_remove_flag(g_clnCfg, LV_OBJ_FLAG_HIDDEN); } } static void cln_close(lv_event_t*) { if (g_clnScreen) lv_obj_add_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN); } static void cln_open(lv_event_t*) { if (!g_clnScreen) return; if (!g_state.cleaningActive) { // Parameter aus dem Zustand vorbelegen if (g_state.cleaningCycles) g_clnCycles = g_state.cleaningCycles; if (g_state.cleaningBrewSeconds) g_clnBrew = g_state.cleaningBrewSeconds; if (g_state.cleaningPauseSeconds) g_clnPause = g_state.cleaningPauseSeconds; cln_set_labels(); } lv_obj_remove_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN); // bleibt unter Toast/Standby/OTA (Erstellreihenfolge) cln_refresh(); } static void cln_start_cb(lv_event_t*) { if (!g_client) return; String f = "\"cycles\":" + String(g_clnCycles) + ",\"brewSeconds\":" + String(g_clnBrew) + ",\"pauseSeconds\":" + String(g_clnPause); g_client->sendCommand("startCleaning", f); } static void cln_stop_cb(lv_event_t*) { if (g_client) g_client->sendCommand("stopCleaning"); } static void build_cleaning_screen() { g_clnScreen = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_clnScreen, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_clnScreen, COL_BG, 0); lv_obj_set_style_bg_opa(g_clnScreen, LV_OPA_COVER, 0); lv_obj_set_style_border_width(g_clnScreen, 0, 0); lv_obj_set_style_radius(g_clnScreen, 0, 0); lv_obj_set_style_pad_all(g_clnScreen, 10, 0); lv_obj_set_style_pad_row(g_clnScreen, 8, 0); lv_obj_set_flex_flow(g_clnScreen, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(g_clnScreen, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN); // Kopfzeile: Zurueck + Titel lv_obj_t* hdr = lv_obj_create(g_clnScreen); lv_obj_set_width(hdr, LV_PCT(100)); lv_obj_set_height(hdr, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(hdr, 0, 0); lv_obj_set_style_pad_all(hdr, 0, 0); lv_obj_set_style_pad_column(hdr, 12, 0); lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* back = th_button(hdr, LV_SYMBOL_LEFT " Zurück", COL_CARD2, COL_TEXT); lv_obj_add_event_cb(back, cln_close, LV_EVENT_CLICKED, nullptr); lv_obj_t* title = lv_label_create(hdr); lv_label_set_text(title, "Reinigungsassistent"); lv_obj_set_style_text_color(title, COL_ACCENT, 0); lv_obj_set_style_text_font(title, &lv_font_maven_pro_18, 0); // --- Konfigurationsbereich (sichtbar, wenn inaktiv) --- g_clnCfg = lv_obj_create(g_clnScreen); lv_obj_set_width(g_clnCfg, LV_PCT(100)); lv_obj_set_flex_grow(g_clnCfg, 1); lv_obj_set_style_bg_opa(g_clnCfg, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_clnCfg, 0, 0); lv_obj_set_style_pad_all(g_clnCfg, 0, 0); lv_obj_set_style_pad_row(g_clnCfg, 8, 0); lv_obj_set_flex_flow(g_clnCfg, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(g_clnCfg, LV_DIR_VER); lv_obj_set_scrollbar_mode(g_clnCfg, LV_SCROLLBAR_MODE_AUTO); lv_obj_t* hint = lv_label_create(g_clnCfg); lv_obj_set_width(hint, LV_PCT(100)); lv_label_set_text(hint, "Heizt auf 93 °C vor und führt dann automatisch die eingestellten Spül-Zyklen aus (Siebträger mit Blindsieb einsetzen)."); lv_obj_set_style_text_color(hint, COL_TEXT_DIM, 0); g_clnCyclesVal = cln_stepper(g_clnCfg, "Zyklen", clnCyc_m, clnCyc_p); g_clnBrewVal = cln_stepper(g_clnCfg, "Bezug", clnBrew_m, clnBrew_p); g_clnPauseVal = cln_stepper(g_clnCfg, "Pause", clnPause_m, clnPause_p); cln_set_labels(); lv_obj_t* startBtn = th_accent_button(g_clnCfg, LV_SYMBOL_PLAY " Reinigung starten"); lv_obj_set_width(startBtn, LV_PCT(100)); lv_obj_add_event_cb(startBtn, cln_start_cb, LV_EVENT_CLICKED, nullptr); // --- Fortschrittsbereich (sichtbar, wenn aktiv) --- g_clnProgress = lv_obj_create(g_clnScreen); lv_obj_set_width(g_clnProgress, LV_PCT(100)); lv_obj_set_flex_grow(g_clnProgress, 1); lv_obj_set_style_bg_opa(g_clnProgress, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_clnProgress, 0, 0); lv_obj_set_style_pad_all(g_clnProgress, 0, 0); lv_obj_set_style_pad_row(g_clnProgress, 12, 0); lv_obj_set_flex_flow(g_clnProgress, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(g_clnProgress, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_clnProgress, LV_OBJ_FLAG_HIDDEN); lv_obj_t* pcard = th_card(g_clnProgress); lv_obj_set_width(pcard, LV_PCT(100)); lv_obj_set_flex_grow(pcard, 1); lv_obj_clear_flag(pcard, LV_OBJ_FLAG_SCROLLABLE); g_clnProgLbl = lv_label_create(pcard); lv_obj_set_width(g_clnProgLbl, LV_PCT(100)); lv_label_set_text(g_clnProgLbl, "Reinigung läuft..."); lv_obj_set_style_text_color(g_clnProgLbl, COL_TEXT, 0); lv_obj_set_style_text_font(g_clnProgLbl, &lv_font_maven_pro_18, 0); lv_obj_t* stopBtn = th_button(g_clnProgress, LV_SYMBOL_STOP " Reinigung abbrechen", COL_DANGER, COL_TEXT); lv_obj_set_width(stopBtn, LV_PCT(100)); lv_obj_add_event_cb(stopBtn, cln_stop_cb, LV_EVENT_CLICKED, nullptr); } // ===================================================================================== // Reinigung/Wartung // ===================================================================================== static void send_save_maint(lv_event_t*) { if (!g_client) return; int iv = (int)(ta_f(taMaintInterval) + 0.5f); if (iv < 0) iv = 0; g_client->sendCommand("saveService", "\"maintenanceInterval\":" + String(iv)); showToast("Intervall gespeichert", false); } static void send_save_flush(lv_event_t*) { if (!g_client) return; int fw = (int)(ta_f(taFlushDur) + 0.5f); int fs = (int)(ta_f(taSteamFlushDur) + 0.5f); if (fw < 1) fw = 1; if (fw > 30) fw = 30; // S3 klemmt ebenso auf 1..30 if (fs < 1) fs = 1; if (fs > 30) fs = 30; g_client->sendCommand("saveService", "\"flushDurationSeconds\":" + String(fw) + ",\"steamFlushDurationSeconds\":" + String(fs)); showToast("Flush-Zeiten gespeichert", false); } static void send_save_display(lv_event_t*) { if (!g_client) return; int ba = (int)(ta_f(taBacklightActive) + 0.5f); int bs = (int)(ta_f(taBacklightStandby) + 0.5f); if (ba < 5) ba = 5; if (ba > 100) ba = 100; // S3 klemmt ebenso (aktiv 5..100) if (bs < 0) bs = 0; if (bs > 100) bs = 100; // Standby-Uhr 0..100 g_client->sendCommand("saveDisplay", "\"backlightActive\":" + String(ba) + ",\"backlightStandbyClock\":" + String(bs)); showToast("Helligkeit gespeichert", false); } static void send_reset_maint(lv_event_t*) { if (!g_client) return; g_client->sendCommand("saveService", "\"resetMaintenanceCounter\":true"); showToast("Wartungszähler zurückgesetzt", false); } static void build_service(lv_obj_t* p) { section_title(p, "Reinigung & Wartung"); // Live-Status (Zaehler / Intervall / Faelligkeit) - aktualisiert ui_update lv_obj_t* sc = th_card(p); lv_obj_set_width(sc, LV_PCT(100)); lv_obj_set_height(sc, LV_SIZE_CONTENT); lv_obj_set_flex_flow(sc, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(sc, 2, 0); lv_obj_clear_flag(sc, LV_OBJ_FLAG_SCROLLABLE); lblMaintStatus = lv_label_create(sc); lv_obj_set_width(lblMaintStatus, LV_PCT(100)); lv_label_set_text(lblMaintStatus, "Bezüge seit Reset: --"); lv_obj_set_style_text_color(lblMaintStatus, COL_TEXT, 0); // Intervall einstellen + speichern taMaintInterval = add_num(p, "Intervall (Bezüge)"); add_save_button(p, "Intervall speichern", send_save_maint); // Zaehler zuruecksetzen lv_obj_t* rb = th_button(p, "Wartungszähler zurücksetzen", COL_WARN, lv_color_hex(0x000000)); lv_obj_set_width(rb, LV_PCT(100)); lv_obj_add_event_cb(rb, send_reset_maint, LV_EVENT_CLICKED, nullptr); // Flush-Zeiten (Wasser- und Dampfkreislauf), 1..30 s - wie in der Web-UI der S3 section_title(p, "Flush-Zeiten"); taFlushDur = add_num(p, "Wasser-Flush (s)"); taSteamFlushDur = add_num(p, "Dampf-Flush (s)"); add_save_button(p, "Flush-Zeiten speichern", send_save_flush); // Display-Helligkeit dieses Touch-Displays (aktiv 5..100 %, Standby-Uhr 0..100 %) section_title(p, "Display-Helligkeit"); taBacklightActive = add_num(p, "Aktiv (%)"); taBacklightStandby = add_num(p, "Standby-Uhr (%)"); add_save_button(p, "Helligkeit speichern", send_save_display); // Reinigungsassistent (eigener Sub-Screen mit Parametern + Live-Fortschritt) section_title(p, "Reinigungsassistent"); lv_obj_t* cb = th_accent_button(p, "Reinigungsassistent öffnen"); lv_obj_set_width(cb, LV_PCT(100)); lv_obj_add_event_cb(cb, cln_open, LV_EVENT_CLICKED, nullptr); // Wartungsmodus-Toggle (gleiche Aktion wie auf der Temperatur-Seite) section_title(p, "Wartungsmodus (Entkalkung)"); swMaint2 = add_switch(p, "Wartungsmodus"); lv_obj_add_event_cb(swMaint2, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr); } // ===================================================================================== // WiFi // ===================================================================================== // Scan-Button-Beschriftung setzen (Label ist Kind 0 des th_-Buttons) static void wifi_scan_set_label(const char* txt) { if (!g_btnScan) return; lv_obj_t* l = lv_obj_get_child(g_btnScan, 0); if (l) lv_label_set_text(l, txt); } // Scan-Zustand zuruecksetzen (Button wieder freigeben) - bei Ergebnis ODER Timeout static void wifi_scan_done() { g_wifiScanPending = false; if (g_wifiScanTimer) { lv_timer_del(g_wifiScanTimer); g_wifiScanTimer = nullptr; } if (g_btnScan) lv_obj_remove_state(g_btnScan, LV_STATE_DISABLED); wifi_scan_set_label(LV_SYMBOL_REFRESH " Netzwerke suchen"); } static void wifi_scan_timeout_cb(lv_timer_t*) { g_wifiScanTimer = nullptr; wifi_scan_done(); } static void wifi_scan_cb(lv_event_t*) { if (!g_client) return; if (g_wifiScanPending) return; // laufenden Scan nicht erneut anstossen g_wifiScanPending = true; if (g_btnScan) lv_obj_add_state(g_btnScan, LV_STATE_DISABLED); wifi_scan_set_label(LV_SYMBOL_REFRESH " Sucht..."); g_wifiScanTimer = lv_timer_create(wifi_scan_timeout_cb, 16000, nullptr); // Sicherheits-Timeout lv_timer_set_repeat_count(g_wifiScanTimer, 1); g_client->sendScanWifi(); showToast("Suche Netzwerke...", false); } static void wifi_pick_cb(lv_event_t* e) { lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e); const char* txt = lv_list_get_button_text(wifiList, btn); if (!txt) return; // Eintrag ist "SSID (-xx dBm) [Schloss]" -> SSID bis zum doppelten Leerzeichen String s = txt; int cut = s.indexOf(" "); if (cut > 0) s = s.substring(0, cut); s.trim(); g_wifiSel = s; if (lblWifiSel) lv_label_set_text_fmt(lblWifiSel, "Netz: %s", s.c_str()); } static void wifi_connect_cb(lv_event_t*) { if (!g_client) return; if (g_wifiSel.length() == 0) { showToast("Erst ein Netzwerk wählen", true); return; } String pass = taWifiPass ? String(lv_textarea_get_text(taWifiPass)) : String(""); g_client->sendSetWifi(g_wifiSel, pass); showToast(("Verbinde mit " + g_wifiSel + "...").c_str(), false); } static void build_wifi(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); // Statuskarte lv_obj_t* sc = th_card(p); lv_obj_set_width(sc, LV_PCT(100)); lv_obj_set_height(sc, LV_SIZE_CONTENT); lv_obj_set_flex_flow(sc, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(sc, 2, 0); lv_obj_clear_flag(sc, LV_OBJ_FLAG_SCROLLABLE); wifiStatusLbl = lv_label_create(sc); lv_label_set_text(wifiStatusLbl, "Status: --"); lv_obj_set_style_text_color(wifiStatusLbl, COL_TEXT, 0); wifiHintLbl = lv_label_create(sc); lv_obj_set_width(wifiHintLbl, LV_PCT(100)); // feste Breite -> LVGL 9 umbricht standardmaessig lv_label_set_text(wifiHintLbl, "Erweiterte Konfiguration im Webinterface."); lv_obj_set_style_text_color(wifiHintLbl, COL_TEXT_DIM, 0); // Aktionszeile: Scan + gewaehltes Netz lv_obj_t* ar = row(p, nullptr); g_btnScan = th_accent_button(ar, LV_SYMBOL_REFRESH " Netzwerke suchen"); lv_obj_set_style_bg_opa(g_btnScan, LV_OPA_40, LV_PART_MAIN | LV_STATE_DISABLED); lv_obj_add_event_cb(g_btnScan, wifi_scan_cb, LV_EVENT_CLICKED, nullptr); lblWifiSel = lv_label_create(ar); lv_label_set_text(lblWifiSel, "Netz: -"); lv_obj_set_style_text_color(lblWifiSel, COL_TEXT_DIM, 0); // Passwort + Verbinden (oben, damit die Tastatur das Feld nicht verdeckt) lv_obj_t* pr = row(p, "Passwort:"); taWifiPass = th_textarea(pr, true); lv_textarea_set_password_mode(taWifiPass, true); lv_obj_set_width(taWifiPass, 300); attach_kb(taWifiPass, true); lv_obj_t* bc = th_accent_button(pr, "Verbinden"); lv_obj_add_event_cb(bc, wifi_connect_cb, LV_EVENT_CLICKED, nullptr); // Netzliste (fuellt den Rest, scrollbar) wifiList = lv_list_create(p); lv_obj_set_width(wifiList, LV_PCT(100)); lv_obj_set_flex_grow(wifiList, 1); lv_obj_set_style_bg_color(wifiList, COL_CARD, 0); } // ===================================================================================== // Profile // ===================================================================================== // Profil-Loeschen mit Zwei-Tipp-Bestaetigung (gegen versehentliches Loeschen, kein Modal noetig) static void prof_del_set_label(const char* txt) { if (!profDelBtn) return; lv_obj_t* l = lv_obj_get_child(profDelBtn, 0); if (l) lv_label_set_text(l, txt); } static void prof_del_disarm() { g_delArmed = false; if (g_delResetTimer) { lv_timer_del(g_delResetTimer); g_delResetTimer = nullptr; } prof_del_set_label("Profil löschen"); } static void prof_del_reset_timer_cb(lv_timer_t*) { g_delResetTimer = nullptr; prof_del_disarm(); } static void prof_delete_cb(lv_event_t*) { if (!g_client || g_selProfile.length() == 0) return; if (!g_delArmed) { // erster Tipp: scharf schalten g_delArmed = true; prof_del_set_label("Wirklich löschen?"); g_delResetTimer = lv_timer_create(prof_del_reset_timer_cb, 4000, nullptr); lv_timer_set_repeat_count(g_delResetTimer, 1); return; } // zweiter Tipp innerhalb 4 s: ausfuehren g_client->sendCommand("deleteProfile", "\"profile\":\"" + g_selProfile + "\""); g_client->sendListProfiles(); showToast(("Profil gelöscht: " + g_selProfile).c_str(), false); g_selProfile = ""; if (profDetailTitle) lv_label_set_text(profDetailTitle, "Kein Profil gewählt"); if (profDetailLbl) lv_label_set_text(profDetailLbl, "Links ein Profil antippen, um seine Werte zu sehen."); if (profLoadBtn) lv_obj_add_state(profLoadBtn, LV_STATE_DISABLED); if (profDelBtn) lv_obj_add_state(profDelBtn, LV_STATE_DISABLED); prof_del_disarm(); } static void prof_select_cb(lv_event_t* e) { if (!g_client) return; lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e); const char* name = lv_list_get_button_text(profList, btn); if (!name) return; prof_del_disarm(); // bei Auswahl eines Profils Loesch-Bestaetigung zuruecksetzen g_selProfile = name; if (taProfName) lv_textarea_set_text(taProfName, name); if (profDetailTitle) lv_label_set_text(profDetailTitle, name); if (profDetailLbl) { lv_label_set_text(profDetailLbl, "Lade Werte..."); lv_obj_set_style_text_color(profDetailLbl, COL_TEXT_DIM, 0); } if (profLoadBtn) lv_obj_remove_state(profLoadBtn, LV_STATE_DISABLED); if (profDelBtn) lv_obj_remove_state(profDelBtn, LV_STATE_DISABLED); g_client->sendGetProfileDetails(g_selProfile); } static void prof_do_load_cb(lv_event_t*) { if (!g_client || g_selProfile.length() == 0) return; g_client->sendLoadProfile(g_selProfile); showToast("Profil wird geladen...", false); } static void prof_save_cb(lv_event_t*) { if (!g_client) return; String name = lv_textarea_get_text(taProfName); name.trim(); if (name.length() == 0) { showToast("Profilname leer", true); return; } g_client->sendCommand("saveProfile", "\"profile\":\"" + name + "\""); g_client->sendListProfiles(); showToast("Speichere Profil...", false); } static void prof_refresh_cb(lv_event_t*) { if (g_client) g_client->sendListProfiles(); } static void build_profiles(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* hdr = row(p, nullptr); section_title(hdr, "Profile (antippen zum Ansehen)"); lv_obj_t* rf = th_button(hdr, "Aktualisieren", COL_CARD, COL_TEXT); lv_obj_add_event_cb(rf, prof_refresh_cb, LV_EVENT_CLICKED, nullptr); // "Speichern als" oben, damit die Tastatur das Feld nicht verdeckt lv_obj_t* saveRow = row(p, "Speichern als:"); taProfName = th_textarea(saveRow, true); lv_obj_set_width(taProfName, 220); attach_kb(taProfName, true); lv_obj_t* sb = th_accent_button(saveRow, "Speichern"); lv_obj_add_event_cb(sb, prof_save_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* body = lv_obj_create(p); lv_obj_set_width(body, LV_PCT(100)); lv_obj_set_flex_grow(body, 1); lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(body, 0, 0); lv_obj_set_style_pad_all(body, 0, 0); lv_obj_set_style_pad_column(body, 8, 0); lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(body, LV_OBJ_FLAG_SCROLLABLE); profList = lv_list_create(body); lv_obj_set_size(profList, LV_PCT(38), LV_PCT(100)); lv_obj_set_style_bg_color(profList, COL_CARD, 0); lv_obj_t* dcard = th_card(body); lv_obj_set_size(dcard, LV_PCT(60), LV_PCT(100)); lv_obj_set_flex_flow(dcard, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(dcard, 6, 0); profDetailTitle = lv_label_create(dcard); lv_label_set_text(profDetailTitle, "Kein Profil gewählt"); lv_obj_set_style_text_color(profDetailTitle, COL_ACCENT, 0); profDetailLbl = lv_label_create(dcard); lv_obj_set_width(profDetailLbl, LV_PCT(100)); lv_label_set_text(profDetailLbl, "Links ein Profil antippen, um seine Werte zu sehen."); lv_obj_set_style_text_color(profDetailLbl, COL_TEXT_DIM, 0); profLoadBtn = th_accent_button(dcard, "Profil laden"); lv_obj_set_width(profLoadBtn, LV_PCT(100)); lv_obj_set_style_bg_opa(profLoadBtn, LV_OPA_40, LV_PART_MAIN | LV_STATE_DISABLED); lv_obj_add_state(profLoadBtn, LV_STATE_DISABLED); lv_obj_add_event_cb(profLoadBtn, prof_do_load_cb, LV_EVENT_CLICKED, nullptr); profDelBtn = th_button(dcard, "Profil löschen", COL_DANGER, COL_TEXT); lv_obj_set_width(profDelBtn, LV_PCT(100)); lv_obj_set_style_bg_opa(profDelBtn, LV_OPA_40, LV_PART_MAIN | LV_STATE_DISABLED); lv_obj_add_state(profDelBtn, LV_STATE_DISABLED); lv_obj_add_event_cb(profDelBtn, prof_delete_cb, LV_EVENT_CLICKED, nullptr); } // ===================================================================================== // Info (Firmware-Staende + Verbindung) // ===================================================================================== static void info_block(lv_obj_t* card, const char* title, lv_obj_t** valOut, const char* initial) { lv_obj_t* t = lv_label_create(card); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, COL_ACCENT, 0); lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0); lv_obj_t* v = lv_label_create(card); lv_obj_set_width(v, LV_PCT(100)); lv_label_set_text(v, initial); lv_obj_set_style_text_color(v, COL_TEXT, 0); if (valOut) *valOut = v; } // --- Darstellung: Dashboard-Temperaturdesign umschalten (P4-lokal, in NVS gespeichert) --- static const char* TEMP_DESIGN_NAMES[TD_DESIGN_COUNT] = { "Klassisch", "Instrumente", "Minimal", "Soft-3D", "Thermometer" }; static void design_btns_refresh() { for (int i = 0; i < TD_DESIGN_COUNT; i++) { if (!g_designBtn[i]) continue; bool sel = (g_tempDesign == i); lv_obj_set_style_bg_color(g_designBtn[i], sel ? COL_ACCENT : COL_CARD2, 0); lv_obj_set_style_text_color(lv_obj_get_child(g_designBtn[i], 0), sel ? lv_color_hex(0x000000) : COL_TEXT, 0); } } static void design_btn_cb(lv_event_t* e) { int d = (int)(intptr_t)lv_event_get_user_data(e); if (d == (int)g_tempDesign) return; g_tempDesign = (uint8_t)d; { Preferences prefs; prefs.begin("ui", false); prefs.putUChar("tempdsgn", g_tempDesign); prefs.end(); } design_btns_refresh(); build_temp_row_content(); ui_update(g_state); // neu aufgebaute Anzeige sofort mit den aktuellen Werten fuellen } static void build_info(lv_obj_t* p) { section_title(p, "Darstellung"); lv_obj_t* dcard = th_card(p); lv_obj_set_width(dcard, LV_PCT(100)); lv_obj_set_height(dcard, LV_SIZE_CONTENT); lv_obj_set_flex_flow(dcard, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(dcard, 8, 0); lv_obj_clear_flag(dcard, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* dl = lv_label_create(dcard); lv_label_set_text(dl, "Dashboard-Temperaturanzeige"); lv_obj_set_style_text_color(dl, COL_TEXT_DIM, 0); lv_obj_t* drow = row(dcard, nullptr); lv_obj_set_style_pad_column(drow, 8, 0); for (int i = 0; i < TD_DESIGN_COUNT; i++) { g_designBtn[i] = th_button(drow, TEMP_DESIGN_NAMES[i], COL_CARD2, COL_TEXT); lv_obj_set_flex_grow(g_designBtn[i], 1); lv_obj_add_event_cb(g_designBtn[i], design_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } design_btns_refresh(); section_title(p, "Firmware-Stände & Verbindung"); lv_obj_t* card = th_card(p); lv_obj_set_width(card, LV_PCT(100)); lv_obj_set_height(card, LV_SIZE_CONTENT); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(card, 10, 0); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); // Display (P4) - feste Werte aus config.h char p4buf[64]; snprintf(p4buf, sizeof(p4buf), "Firmware: %s\nProtokoll (erwartet): v%d", DISPLAY_FW_VERSION, PROTO_EXPECTED_VERSION); info_block(card, "Display (ESP32-P4)", nullptr, p4buf); // Steuerung (S3) - live aus dem State-Push info_block(card, "Steuerung (ESP32-S3)", &lblInfoS3, "Firmware: --\nProtokoll: --"); // Verbindung - live info_block(card, "Verbindung", &lblInfoLink, "UART: --"); } // ===================================================================================== // Statistik (Nutzungsverlauf) - Daten kommen vom S3 als 'usageStats' (asynchron, bounded) // ===================================================================================== static void stats_request() { if (!g_client) return; if (g_statSummary) label_set_if_changed(g_statSummary, "Lade Statistik..."); if (g_statList) label_set_if_changed(g_statList, ""); g_client->sendGetUsageStats(); } static void stats_refresh_cb(lv_event_t*) { stats_request(); } static void build_stats(lv_obj_t* p) { lv_obj_set_scroll_dir(p, LV_DIR_VER); lv_obj_set_scrollbar_mode(p, LV_SCROLLBAR_MODE_AUTO); // Kopfzeile mit Aktualisieren-Button lv_obj_t* hdr = row(p, nullptr); section_title(hdr, "Nutzungsstatistik"); lv_obj_t* rf = th_button(hdr, LV_SYMBOL_REFRESH " Aktualisieren", COL_CARD, COL_TEXT); lv_obj_add_event_cb(rf, stats_refresh_cb, LV_EVENT_CLICKED, nullptr); // Aggregat-Karte (Heute/Woche/Gesamt) lv_obj_t* sc = th_card(p); lv_obj_set_width(sc, LV_PCT(100)); lv_obj_set_height(sc, LV_SIZE_CONTENT); lv_obj_set_flex_flow(sc, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(sc, 2, 0); lv_obj_clear_flag(sc, LV_OBJ_FLAG_SCROLLABLE); g_statSummary = lv_label_create(sc); lv_obj_set_width(g_statSummary, LV_PCT(100)); lv_label_set_text(g_statSummary, "Lade Statistik..."); lv_obj_set_style_text_color(g_statSummary, COL_TEXT, 0); // Letzte Einzelbezuege section_title(p, "Letzte Bezüge"); lv_obj_t* lc = th_card(p); lv_obj_set_width(lc, LV_PCT(100)); lv_obj_set_height(lc, LV_SIZE_CONTENT); lv_obj_set_flex_flow(lc, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(lc, LV_OBJ_FLAG_SCROLLABLE); g_statList = lv_label_create(lc); lv_obj_set_width(g_statList, LV_PCT(100)); lv_label_set_text(g_statList, ""); lv_obj_set_style_text_color(g_statList, COL_TEXT_DIM, 0); } // ===================================================================================== // Kombi-Zelle Eco / Gehaeuse // - nur Eco aktiv -> Eco-Info // - nur Gehaeuse aktiv -> Gehaeusetemperatur (an Stelle der Eco-Anzeige) // - beide aktiv -> Wechsel alle 5 s (eco_case_timer_cb togglet g_ecoCasePhase) // ===================================================================================== static void refresh_eco_case() { if (!cellEco) return; bool ecoShow = g_state.ecoShowInfo; bool caseShow = g_state.hasCaseTemp; if (!ecoShow && !caseShow) { lv_obj_add_flag(cellEco, LV_OBJ_FLAG_HIDDEN); return; } lv_obj_remove_flag(cellEco, LV_OBJ_FLAG_HIDDEN); bool showCase = caseShow && (!ecoShow || g_ecoCasePhase); if (showCase) { // Sensorart 1 = Tassenablage, sonst Gehaeuse lv_label_set_text(lblEcoTitle, g_state.caseSensorType == 1 ? "TASSENABLAGE" : "GEHÄUSE"); lv_obj_set_style_text_color(lblEcoTitle, lv_color_hex(0xffb74d), 0); lv_label_set_text(lblEco, tempStr(g_state.caseTemp, 1).c_str()); lv_obj_set_style_text_color(lblEco, COL_TEXT, 0); } else { lv_obj_set_style_text_color(lblEcoTitle, COL_OK, 0); if (g_state.ecoActive) { lv_label_set_text(lblEcoTitle, "ECO"); lv_label_set_text(lblEco, "AKTIV"); lv_obj_set_style_text_color(lblEco, COL_OK, 0); } else if (g_state.ecoCountdownSec >= 0) { lv_label_set_text(lblEcoTitle, "ECO IN"); long s = g_state.ecoCountdownSec; if (s >= 60) lv_label_set_text_fmt(lblEco, "%ld min", s / 60); else lv_label_set_text_fmt(lblEco, "%ld s", s); lv_obj_set_style_text_color(lblEco, COL_TEXT, 0); } else { lv_label_set_text(lblEcoTitle, "ECO"); lv_label_set_text(lblEco, "AUS"); lv_obj_set_style_text_color(lblEco, COL_TEXT_DIM, 0); } } } static void eco_case_timer_cb(lv_timer_t*) { g_ecoCasePhase = !g_ecoCasePhase; refresh_eco_case(); } // ===================================================================================== // Dashboard-Timer (100 ms): laeuft displayseitig weiter und haelt 2 s nach dem Ende // ===================================================================================== // Setzt den Labeltext nur, wenn er sich aendert -> verhindert unnoetige Invalidierung // (im Full-Refresh-Modus loest jede Aenderung ein komplettes Neuzeichnen aus). static void label_set_if_changed(lv_obj_t* lbl, const char* txt) { const char* cur = lv_label_get_text(lbl); if (!cur || strcmp(cur, txt) != 0) lv_label_set_text(lbl, txt); } // Pre-Infusion-Phasenleiste aktualisieren (100-ms-Takt aus dash_timers_cb). // Die aktive Phase kommt bevorzugt von der S3 (piState = echte Statemachine, Quelle der // Wahrheit; Push-Latenz max. 250 ms). Sendet eine aeltere S3-Firmware das Feld noch nicht // (piState bleibt 0), wird die Phase zeitbasiert aus den gelatchten Phasendauern abgeleitet. static void pi_bar_update() { if (!g_piRow) return; bool show = g_shotActive && !g_shotWasFlush && g_piShotMode; if (!show) { if (!lv_obj_has_flag(g_piRow, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(g_piRow, LV_OBJ_FLAG_HIDDEN); g_piLastPhase = -1; return; } uint32_t el = g_shotFrozenMs; int phase; if (g_piStateS3 >= 1 && g_piStateS3 <= 3) phase = (int)g_piStateS3; else phase = (el < g_piDurMs) ? 1 : ((el < g_piDurMs + g_piPauseMs) ? 2 : 3); // Pause-Chip nur zeigen, wenn tatsaechlich eine Pause konfiguriert ist if (g_piChip[1]) { if (g_piPauseMs == 0) lv_obj_add_flag(g_piChip[1], LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(g_piChip[1], LV_OBJ_FLAG_HIDDEN); } // Chip-Styles nur bei Phasenwechsel anfassen (erledigt/aktiv/anstehend) if (phase != g_piLastPhase) { for (int i = 0; i < 3; i++) { if (!g_piChip[i] || !g_piChipLbl[i]) continue; int chipPhase = i + 1; if (chipPhase == phase) { // aktiv: Gold lv_obj_set_style_bg_color(g_piChip[i], COL_ACCENT, 0); lv_obj_set_style_text_color(g_piChipLbl[i], lv_color_hex(0x000000), 0); } else { lv_obj_set_style_bg_color(g_piChip[i], COL_CARD, 0); lv_obj_set_style_text_color(g_piChipLbl[i], (chipPhase < phase) ? COL_OK : COL_TEXT_MUT, 0); // erledigt gruen, anstehend gedimmt } } g_piLastPhase = phase; } // Texte: erledigte Schritte mit Haken, aktive zeitgesteuerte Phase mit Restzeit static const char* piNames[3] = { "Pre-Infusion", "Pause", "Extraktion" }; char pbuf[40]; for (int i = 0; i < 3; i++) { if (!g_piChipLbl[i]) continue; int chipPhase = i + 1; if (chipPhase < phase) { snprintf(pbuf, sizeof(pbuf), LV_SYMBOL_OK " %s", piNames[i]); } else if (chipPhase == phase && chipPhase <= 2) { uint32_t endMs = (chipPhase == 1) ? g_piDurMs : (g_piDurMs + g_piPauseMs); uint32_t remainMs = (el < endMs) ? (endMs - el) : 0; snprintf(pbuf, sizeof(pbuf), "%s noch %.1f s", piNames[i], remainMs / 1000.0f); } else { snprintf(pbuf, sizeof(pbuf), "%s", piNames[i]); } label_set_if_changed(g_piChipLbl[i], pbuf); } if (lv_obj_has_flag(g_piRow, LV_OBJ_FLAG_HIDDEN)) lv_obj_remove_flag(g_piRow, LV_OBJ_FLAG_HIDDEN); } // --- Statuszeile (konsolidierte Banner) ------------------------------------------------ // Meldung eines Slots setzen/loeschen. Ist der Slot gerade sichtbar, werden Text und // Farben sofort nachgezogen (laufende Countdowns); Einblenden/Rotation macht der Tick. static void status_set(int slot, bool active, const char* text, lv_color_t bg, lv_color_t fg) { StatusMsg& m = g_stMsg[slot]; m.active = active; if (!active) return; snprintf(m.text, sizeof(m.text), "%s", text); m.bg = bg; m.fg = fg; if (slot == g_statusShown && g_statusLbl) { label_set_if_changed(g_statusLbl, m.text); lv_obj_set_style_bg_color(g_statusLine, m.bg, 0); lv_obj_set_style_text_color(g_statusLbl, m.fg, 0); } } static void status_line_apply(int slot) { const StatusMsg& m = g_stMsg[slot]; lv_obj_set_style_bg_color(g_statusLine, m.bg, 0); lv_obj_set_style_text_color(g_statusLbl, m.fg, 0); lv_label_set_text(g_statusLbl, m.text); g_statusShown = slot; } // Sichtbarkeit + Rotation (100-ms-Takt aus dash_timers_cb): keine aktive Meldung -> weich // ausblenden; genau eine -> stehen lassen; mehrere -> alle 4 s in Prioritaets-Reihenfolge // (Slot-Reihenfolge, round-robin) zur naechsten wechseln, jeweils mit kurzem Fade. static const uint32_t STATUS_ROTATE_MS = 4000; static void status_line_tick() { if (!g_statusLine) return; uint32_t now = millis(); int nAct = 0; for (int i = 0; i < ST_COUNT; i++) if (g_stMsg[i].active) nAct++; bool hidden = lv_obj_has_flag(g_statusLine, LV_OBJ_FLAG_HIDDEN); if (nAct == 0) { if (!hidden) ui_fade_out_hide(g_statusLine, UI_FADE_MS); g_statusShown = -1; return; } bool shownOk = (g_statusShown >= 0) && g_stMsg[g_statusShown].active; bool rotate = shownOk && nAct > 1 && (int32_t)(now - g_statusNextMs) >= 0; if (!shownOk || rotate || hidden) { int next = g_statusShown; if (!shownOk) { for (int i = 0; i < ST_COUNT; i++) if (g_stMsg[i].active) { next = i; break; } } else if (rotate) { for (int k = 1; k <= ST_COUNT; k++) { int i = (g_statusShown + k) % ST_COUNT; if (g_stMsg[i].active) { next = i; break; } } } if (next != g_statusShown || hidden) { status_line_apply(next); ui_fade_in(g_statusLine, hidden ? UI_FADE_MS : 300); } g_statusNextMs = now + STATUS_ROTATE_MS; } } static void dash_timers_cb(lv_timer_t*) { uint32_t now = millis(); char buf[24]; if (lblShotTimer) { if (g_shotActive) { g_shotFrozenMs = now - g_shotStartMs; // Flush nie ueber die konfigurierte Dauer hinaus hochzaehlen lassen if (g_shotWasFlush && g_shotFlushCapMs > 0 && g_shotFrozenMs > g_shotFlushCapMs) g_shotFrozenMs = g_shotFlushCapMs; g_shotHoldUntil = now + 2000; } if (g_scaleTaring && !g_shotActive) { // Tarier-Phase vor Brew-by-Weight: signalisieren, dass die Waage genullt wird label_set_if_changed(lblShotTimer, "Tariere..."); } else if (g_shotActive || (int32_t)(g_shotHoldUntil - now) > 0) { float el = g_shotFrozenMs / 1000.0f; if (g_shotBbtMode) snprintf(buf, sizeof(buf), "%.1f s\n/ %.1f s", el, g_bbtTarget); // Ist/Ziel else snprintf(buf, sizeof(buf), "%.1f s", el); label_set_if_changed(lblShotTimer, buf); } else { strcpy(buf, "--"); g_shotBbtMode = false; // nach dem Nachhalten zuruecksetzen label_set_if_changed(lblShotTimer, buf); } // Brew-by-Time-Fortschrittsbalken (verstrichen/Ziel), nur waehrend eines BBT-Bezugs if (g_bbtBar) { if (g_shotActive && g_shotBbtMode && g_bbtTarget > 0.0f) { int pct = (int)(g_shotFrozenMs / (g_bbtTarget * 1000.0f) * 100.0f + 0.5f); if (pct < 0) pct = 0; if (pct > 100) pct = 100; lv_bar_set_value(g_bbtBar, pct, LV_ANIM_OFF); lv_obj_remove_flag(g_bbtBar, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_bbtBar, LV_OBJ_FLAG_HIDDEN); } } } if (lblSteamTimer) { if (g_steamActive) { g_steamFrozenMs = now - g_steamStartMs; if (g_steamWasFlush && g_steamFlushCapMs > 0 && g_steamFrozenMs > g_steamFlushCapMs) g_steamFrozenMs = g_steamFlushCapMs; g_steamHoldUntil = now + 2000; } if (g_steamActive || (int32_t)(g_steamHoldUntil - now) > 0) { float el = g_steamFrozenMs / 1000.0f; if (g_steamSbtMode) snprintf(buf, sizeof(buf), "%.1f s\n/ %.1f s", el, g_sbtTarget); // Ist/Ziel else snprintf(buf, sizeof(buf), "%.1f s", el); label_set_if_changed(lblSteamTimer, buf); } else { strcpy(buf, "--"); g_steamSbtMode = false; // nach dem Nachhalten zuruecksetzen label_set_if_changed(lblSteamTimer, buf); } // Steam-by-Time-Fortschrittsbalken (verstrichen/Ziel), nur waehrend eines SBT-Dampfbezugs if (g_sbtBar) { if (g_steamActive && g_steamSbtMode && g_sbtTarget > 0.0f) { int pct = (int)(g_steamFrozenMs / (g_sbtTarget * 1000.0f) * 100.0f + 0.5f); if (pct < 0) pct = 0; if (pct > 100) pct = 100; lv_bar_set_value(g_sbtBar, pct, LV_ANIM_OFF); lv_obj_remove_flag(g_sbtBar, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_sbtBar, LV_OBJ_FLAG_HIDDEN); } } } // Flow-Anzeige: Bezugswert (waehrend Bezug + 2,5 s Nachhalten), sonst 0,0 g/s if (cellFlow && !lv_obj_has_flag(cellFlow, LV_OBJ_FLAG_HIDDEN)) { if ((int32_t)(g_flowHoldUntil - now) > 0) { // Wie bei der Waage: winzige negative Flusswerte nicht als "-0.0 g/s" zeigen. float fDisp = g_flowFrozen; if (fDisp > -0.05f && fDisp <= 0.0f) fDisp = 0.0f; snprintf(buf, sizeof(buf), "%.1f g/s", fDisp); } else { strcpy(buf, "0.0 g/s"); } label_set_if_changed(lblFlow, buf); } // Pre-Infusion-Phasenleiste (Sichtbarkeit/Phase/Restzeit) pi_bar_update(); // Statuszeile (Einblenden/Rotation der konsolidierten Meldungen) status_line_tick(); } // ===================================================================================== // Aufbau (Header + Schublade + Seiten) // ===================================================================================== void ui_init(ProtocolClient* client) { g_client = client; // Gespeichertes Dashboard-Temperaturdesign laden (P4-lokal; Standard: Rundinstrumente) { Preferences prefs; prefs.begin("ui", false); g_tempDesign = prefs.getUChar("tempdsgn", TD_GAUGE); prefs.end(); if (g_tempDesign >= TD_DESIGN_COUNT) g_tempDesign = TD_GAUGE; } lv_obj_t* scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, COL_BG, 0); lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE); // --- Kopfleiste --- lv_obj_t* header = lv_obj_create(scr); lv_obj_set_size(header, LV_PCT(100), 44); lv_obj_set_pos(header, 0, 0); lv_obj_set_style_bg_color(header, COL_BG2, 0); lv_obj_set_style_border_width(header, 0, 0); lv_obj_set_style_radius(header, 0, 0); lv_obj_set_style_pad_hor(header, 8, 0); lv_obj_set_style_pad_ver(header, 0, 0); lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* burger = th_button(header, LV_SYMBOL_LIST, COL_CARD2, COL_TEXT); lv_obj_set_size(burger, 48, 36); lv_obj_add_event_cb(burger, hamburger_cb, LV_EVENT_CLICKED, nullptr); g_hdrTitle = lv_label_create(header); lv_label_set_text(g_hdrTitle, PAGE_NAMES[PG_DASH]); lv_obj_set_style_text_color(g_hdrTitle, COL_TEXT, 0); lv_obj_set_style_pad_left(g_hdrTitle, 10, 0); lv_obj_set_flex_grow(g_hdrTitle, 1); lv_obj_set_style_text_font(g_hdrTitle, &lv_font_maven_pro_18, 0); g_readyPill = lv_label_create(header); // "BEREIT": Kessel im Zielband (auf allen Seiten sichtbar) lv_label_set_text(g_readyPill, "BEREIT"); lv_obj_set_style_bg_color(g_readyPill, COL_OK, 0); lv_obj_set_style_bg_opa(g_readyPill, LV_OPA_COVER, 0); lv_obj_set_style_text_color(g_readyPill, lv_color_hex(0x000000), 0); lv_obj_set_style_radius(g_readyPill, 12, 0); lv_obj_set_style_pad_hor(g_readyPill, 10, 0); lv_obj_set_style_pad_ver(g_readyPill, 3, 0); lv_obj_set_style_margin_right(g_readyPill, 10, 0); lv_obj_add_flag(g_readyPill, LV_OBJ_FLAG_HIDDEN); g_errIcon = lv_label_create(header); // Fehler-/Sicherheitssymbol (rot, nur im Fehlerfall) lv_label_set_text(g_errIcon, LV_SYMBOL_WARNING); lv_obj_set_style_text_color(g_errIcon, COL_DANGER, 0); lv_obj_set_style_pad_right(g_errIcon, 10, 0); lv_obj_add_flag(g_errIcon, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(g_errIcon, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Dashboard (Fehlerbanner) lv_obj_set_ext_click_area(g_errIcon, 12); lv_obj_add_event_cb(g_errIcon, err_icon_cb, LV_EVENT_CLICKED, nullptr); g_maintIcon = lv_label_create(header); // Wartungs-Warnsymbol (nur wenn faellig) lv_label_set_text(g_maintIcon, LV_SYMBOL_WARNING); lv_obj_set_style_text_color(g_maintIcon, COL_WARN, 0); lv_obj_set_style_pad_right(g_maintIcon, 10, 0); lv_obj_add_flag(g_maintIcon, LV_OBJ_FLAG_HIDDEN); g_clock = lv_label_create(header); // Uhrzeit links vom WLAN-Symbol lv_label_set_text(g_clock, ""); lv_obj_set_style_text_color(g_clock, COL_TEXT, 0); lv_obj_set_style_pad_right(g_clock, 10, 0); lv_obj_set_style_text_font(g_clock, &lv_font_maven_pro_18, 0); g_wifiIcon = lv_label_create(header); lv_label_set_text(g_wifiIcon, LV_SYMBOL_WIFI); lv_obj_set_style_text_color(g_wifiIcon, COL_TEXT_MUT, 0); lv_obj_add_flag(g_wifiIcon, LV_OBJ_FLAG_CLICKABLE); // antippbar -> WiFi-Seite lv_obj_set_ext_click_area(g_wifiIcon, 12); // groesserer Trefferbereich fuer den Finger lv_obj_add_event_cb(g_wifiIcon, wifi_icon_cb, LV_EVENT_CLICKED, nullptr); // --- Content-Bereich (Seiten gestapelt) --- g_content = lv_obj_create(scr); lv_obj_set_size(g_content, LV_PCT(100), 480 - 44); lv_obj_set_pos(g_content, 0, 44); lv_obj_set_style_bg_opa(g_content, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_content, 0, 0); lv_obj_set_style_pad_all(g_content, 0, 0); lv_obj_clear_flag(g_content, LV_OBJ_FLAG_SCROLLABLE); g_pages[PG_DASH] = make_page(g_content); build_dashboard(g_pages[PG_DASH]); g_pages[PG_CHART] = make_page(g_content); build_chart(g_pages[PG_CHART]); g_pages[PG_STATS] = make_page(g_content); build_stats(g_pages[PG_STATS]); g_pages[PG_TEMP] = make_page(g_content); build_temp(g_pages[PG_TEMP]); g_pages[PG_LIGHT] = make_page(g_content); build_light(g_pages[PG_LIGHT]); g_pages[PG_BREW] = make_page(g_content); build_brew(g_pages[PG_BREW]); g_pages[PG_SERVICE] = make_page(g_content); build_service(g_pages[PG_SERVICE]); g_pages[PG_WIFI] = make_page(g_content); build_wifi(g_pages[PG_WIFI]); g_pages[PG_PROFILE] = make_page(g_content); build_profiles(g_pages[PG_PROFILE]); g_pages[PG_INFO] = make_page(g_content); build_info(g_pages[PG_INFO]); // --- Schublade (Scrim + Menue-Panel) --- g_drawer = lv_obj_create(scr); lv_obj_set_size(g_drawer, LV_PCT(100), 480 - 44); lv_obj_set_pos(g_drawer, 0, 44); lv_obj_set_style_bg_color(g_drawer, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(g_drawer, LV_OPA_50, 0); lv_obj_set_style_border_width(g_drawer, 0, 0); lv_obj_set_style_pad_all(g_drawer, 0, 0); lv_obj_set_style_radius(g_drawer, 0, 0); lv_obj_clear_flag(g_drawer, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_CLICKABLE); // Scrim faengt Tipps ab (schliessen + abschirmen) lv_obj_add_event_cb(g_drawer, scrim_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* menu = lv_obj_create(g_drawer); lv_obj_set_size(menu, 240, LV_PCT(100)); lv_obj_set_pos(menu, 0, 0); lv_obj_set_style_bg_color(menu, COL_BG2, 0); lv_obj_set_style_border_width(menu, 0, 0); lv_obj_set_style_radius(menu, 0, 0); lv_obj_set_style_pad_all(menu, 8, 0); lv_obj_set_style_pad_row(menu, 6, 0); lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(menu, LV_DIR_VER); // bei vielen Eintraegen vertikal scrollbar lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_AUTO); for (int i = 0; i < PG_COUNT; i++) { lv_obj_t* mb = th_button(menu, PAGE_NAMES[i], COL_CARD, COL_TEXT); lv_obj_set_width(mb, LV_PCT(100)); lv_obj_set_height(mb, 52); lv_obj_set_style_flex_grow(mb, 0, 0); // feste Hoehe, nicht stauchen lv_obj_clear_flag(mb, LV_OBJ_FLAG_SCROLLABLE); // Button selbst faengt keine Scrolls ab lv_obj_add_event_cb(mb, nav_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } // --- Reinigungsassistent-Sub-Screen (oberste Ebene, unter Tastatur/Toast/Overlays) --- build_cleaning_screen(); // --- Gemeinsame Tastatur (oberste Ebene) --- g_kb = lv_keyboard_create(lv_layer_top()); lv_keyboard_set_map(g_kb, LV_KEYBOARD_MODE_TEXT_LOWER, kb_map_lc, kb_ctrl); // QWERTZ lv_keyboard_set_map(g_kb, LV_KEYBOARD_MODE_TEXT_UPPER, kb_map_uc, kb_ctrl); lv_obj_add_flag(g_kb, LV_OBJ_FLAG_HIDDEN); lv_obj_add_event_cb(g_kb, kb_event_cb, LV_EVENT_READY, nullptr); lv_obj_add_event_cb(g_kb, kb_event_cb, LV_EVENT_CANCEL, nullptr); // --- Globaler Toast (oben mittig) --- g_toast = lv_label_create(lv_layer_top()); lv_obj_set_style_bg_opa(g_toast, LV_OPA_COVER, 0); lv_obj_set_style_bg_color(g_toast, COL_OK, 0); lv_obj_set_style_text_color(g_toast, COL_TEXT, 0); lv_obj_set_style_radius(g_toast, 8, 0); lv_obj_set_style_pad_all(g_toast, 8, 0); lv_obj_set_style_max_width(g_toast, LV_PCT(90), 0); lv_obj_align(g_toast, LV_ALIGN_TOP_MID, 0, 50); lv_obj_add_flag(g_toast, LV_OBJ_FLAG_HIDDEN); // Standby-Overlay (Vollbild, schwarz, grosse zentrierte Uhr), zunaechst versteckt g_standbyScreen = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_standbyScreen, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_standbyScreen, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(g_standbyScreen, LV_OPA_COVER, 0); lv_obj_set_style_border_width(g_standbyScreen, 0, 0); lv_obj_set_style_radius(g_standbyScreen, 0, 0); lv_obj_clear_flag(g_standbyScreen, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_standbyScreen, LV_OBJ_FLAG_CLICKABLE); // schirmt Beruehrungen ab g_standbyClock = lv_label_create(g_standbyScreen); lv_label_set_text(g_standbyClock, "--:--"); lv_obj_set_style_text_color(g_standbyClock, COL_TEXT, 0); lv_obj_set_style_text_font(g_standbyClock, &lv_font_clock_240, 0); // grosse Uhr im Standby lv_obj_center(g_standbyClock); lv_obj_add_event_cb(g_standbyScreen, standby_screen_cb, LV_EVENT_CLICKED, nullptr); // Tippen -> Aufweck-Abfrage lv_obj_t* standbyHint = lv_label_create(g_standbyScreen); // dezenter Hinweis am unteren Rand lv_label_set_text(standbyHint, "Zum Aufwecken tippen"); lv_obj_set_style_text_color(standbyHint, COL_TEXT_MUT, 0); lv_obj_align(standbyHint, LV_ALIGN_BOTTOM_MID, 0, -24); lv_obj_add_flag(g_standbyScreen, LV_OBJ_FLAG_HIDDEN); // Aufweck-Bestaetigung (halbtransparenter Scrim + zentrierte Karte), zunaechst versteckt g_wakeConfirm = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_wakeConfirm, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_wakeConfirm, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(g_wakeConfirm, LV_OPA_70, 0); lv_obj_set_style_border_width(g_wakeConfirm, 0, 0); lv_obj_set_style_radius(g_wakeConfirm, 0, 0); lv_obj_clear_flag(g_wakeConfirm, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_CLICKABLE); // Scrim faengt Tipps ab lv_obj_t* wakeCard = th_card(g_wakeConfirm); lv_obj_set_width(wakeCard, 420); lv_obj_set_height(wakeCard, LV_SIZE_CONTENT); lv_obj_center(wakeCard); lv_obj_set_flex_flow(wakeCard, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(wakeCard, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(wakeCard, 18, 0); lv_obj_clear_flag(wakeCard, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* wakeMsg = lv_label_create(wakeCard); lv_obj_set_width(wakeMsg, LV_PCT(100)); lv_obj_set_style_text_align(wakeMsg, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(wakeMsg, COL_TEXT, 0); lv_obj_set_style_text_font(wakeMsg, &lv_font_maven_pro_18, 0); lv_label_set_text(wakeMsg, "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?"); lv_obj_t* wakeBtns = lv_obj_create(wakeCard); lv_obj_set_width(wakeBtns, LV_PCT(100)); lv_obj_set_height(wakeBtns, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(wakeBtns, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(wakeBtns, 0, 0); lv_obj_set_style_pad_all(wakeBtns, 0, 0); lv_obj_set_style_pad_column(wakeBtns, 12, 0); lv_obj_set_flex_flow(wakeBtns, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(wakeBtns, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(wakeBtns, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* wakeNo = th_button(wakeBtns, "Abbrechen", COL_CARD2, COL_TEXT); lv_obj_set_width(wakeNo, 150); lv_obj_add_event_cb(wakeNo, wake_no_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* wakeYes = th_accent_button(wakeBtns, "Aufwecken"); lv_obj_set_width(wakeYes, 150); lv_obj_add_event_cb(wakeYes, wake_yes_cb, LV_EVENT_CLICKED, nullptr); lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); // OTA-Overlay (Vollbild, waehrend Firmware-Update) g_otaScreen = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_otaScreen, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_otaScreen, COL_BG, 0); lv_obj_set_style_bg_opa(g_otaScreen, LV_OPA_COVER, 0); lv_obj_set_style_border_width(g_otaScreen, 0, 0); lv_obj_set_style_radius(g_otaScreen, 0, 0); lv_obj_set_flex_flow(g_otaScreen, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(g_otaScreen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(g_otaScreen, 16, 0); lv_obj_clear_flag(g_otaScreen, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_otaScreen, LV_OBJ_FLAG_CLICKABLE); lv_obj_t* otaTitle = lv_label_create(g_otaScreen); lv_label_set_text(otaTitle, "Display-Firmware-Update"); lv_obj_set_style_text_color(otaTitle, COL_ACCENT, 0); lv_obj_set_style_text_font(otaTitle, &lv_font_maven_pro_28, 0); g_otaStatus = lv_label_create(g_otaScreen); lv_label_set_text(g_otaStatus, "Bitte warten..."); lv_obj_set_style_text_color(g_otaStatus, COL_TEXT, 0); lv_obj_set_style_text_font(g_otaStatus, &lv_font_maven_pro_18, 0); g_otaBytes = lv_label_create(g_otaScreen); lv_label_set_text(g_otaBytes, ""); lv_obj_set_style_text_color(g_otaBytes, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(g_otaBytes, &lv_font_maven_pro_18, 0); lv_obj_add_flag(g_otaScreen, LV_OBJ_FLAG_HIDDEN); // Shot-Zusammenfassung (zentrierte Karte, antippbar zum Schliessen) g_shotSum = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_shotSum, 360, LV_SIZE_CONTENT); lv_obj_set_style_bg_color(g_shotSum, COL_CARD, 0); lv_obj_set_style_bg_opa(g_shotSum, LV_OPA_COVER, 0); lv_obj_set_style_border_color(g_shotSum, COL_ACCENT, 0); lv_obj_set_style_border_width(g_shotSum, 2, 0); lv_obj_set_style_radius(g_shotSum, 12, 0); lv_obj_set_style_pad_all(g_shotSum, 18, 0); lv_obj_set_style_pad_row(g_shotSum, 8, 0); lv_obj_set_flex_flow(g_shotSum, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(g_shotSum, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_shotSum, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(g_shotSum, shot_sum_tap_cb, LV_EVENT_CLICKED, nullptr); lv_obj_center(g_shotSum); lv_obj_t* sumTitle = lv_label_create(g_shotSum); lv_label_set_text(sumTitle, "Bezug beendet"); lv_obj_set_style_text_color(sumTitle, COL_ACCENT, 0); lv_obj_set_style_text_font(sumTitle, &lv_font_maven_pro_28, 0); g_shotSumBody = lv_label_create(g_shotSum); lv_label_set_text(g_shotSumBody, ""); lv_obj_set_style_text_color(g_shotSumBody, COL_TEXT, 0); lv_obj_set_style_text_font(g_shotSumBody, &lv_font_maven_pro_18, 0); lv_obj_add_flag(g_shotSum, LV_OBJ_FLAG_HIDDEN); // --- Boot-Splash / Start-Animation (oberstes Overlay) --- // Deckt beim Start alles ab und blendet sich weich ein; der Spinner signalisiert // "Verbinde mit Maschine...". Beim ersten empfangenen State (Link steht) faedet er // in ui_update() aus. Bleibt die S3 stumm, laeuft der Spinner sichtbar weiter. g_splash = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_splash, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_splash, COL_BG, 0); lv_obj_set_style_bg_opa(g_splash, LV_OPA_COVER, 0); lv_obj_set_style_border_width(g_splash, 0, 0); lv_obj_set_style_radius(g_splash, 0, 0); lv_obj_set_flex_flow(g_splash, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(g_splash, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(g_splash, 14, 0); lv_obj_clear_flag(g_splash, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_splash, LV_OBJ_FLAG_CLICKABLE); // schirmt Beruehrungen waehrend des Starts ab lv_obj_t* splashTitle = lv_label_create(g_splash); lv_label_set_text(splashTitle, "Dual-PID"); lv_obj_set_style_text_color(splashTitle, COL_ACCENT, 0); lv_obj_set_style_text_font(splashTitle, &lv_font_maven_pro_48, 0); lv_obj_t* splashSub = lv_label_create(g_splash); lv_label_set_text(splashSub, "Siebträgersteuerung"); lv_obj_set_style_text_color(splashSub, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(splashSub, &lv_font_maven_pro_18, 0); lv_obj_t* splashSpin = lv_spinner_create(g_splash); lv_spinner_set_anim_params(splashSpin, 1000, 60); lv_obj_set_size(splashSpin, 48, 48); lv_obj_set_style_arc_color(splashSpin, COL_CARD2, LV_PART_MAIN); lv_obj_set_style_arc_color(splashSpin, COL_ACCENT, LV_PART_INDICATOR); lv_obj_set_style_margin_top(splashSpin, 8, 0); lv_obj_t* splashVer = lv_label_create(g_splash); lv_label_set_text(splashVer, "Firmware " DISPLAY_FW_VERSION); lv_obj_set_style_text_color(splashVer, COL_TEXT_MUT, 0); lv_obj_set_style_text_font(splashVer, &lv_font_maven_pro_14, 0); g_splashActive = true; ui_fade_in(g_splash, UI_FADE_MS); // weiches Erscheinen beim Boot lv_timer_create(dash_timers_cb, 100, nullptr); // Timer-Anzeige fortlaufend aktualisieren lv_timer_create(eco_case_timer_cb, 5000, nullptr); // Eco/Gehaeuse alle 5 s wechseln lv_timer_create(chart_sample_cb, 1000, nullptr); // Verlauf jede Sekunde abtasten show_page(PG_DASH); if (g_client) g_client->sendListProfiles(); } // Haengt den Ergebnis-/Abbruchtext eines beendeten Tuning-Laufs an 'out' an. // 'sev' wird auf die hoechste vorkommende Schwere gesetzt (0=ok, 1=warnung, 2=fehler). static void appendTuneResult(String& out, const char* kreis, uint8_t status, int& sev) { const char* msg = nullptr; int s = 0; switch (status) { case 2: msg = ": Tuning erfolgreich"; s = 0; break; // AT_SUCCESS case 3: msg = ": Tuning beendet (Failsafe, evtl. ungenau)"; s = 1; break; // AT_FAILSAFE_PEAKS case 4: msg = ": abgebrochen - keine Schwingung"; s = 2; break; // AT_ABORT_DEGENERATE case 5: msg = ": abgebrochen - Sensorfehler"; s = 2; break; // AT_ABORT_SENSOR case 6: msg = ": abgebrochen - Sicherheitsabsch."; s = 2; break; // AT_ABORT_SAFETY case 7: msg = ": manuell abgebrochen"; s = 1; break; // AT_ABORT_MANUAL default: return; // 0=Idle, 1=laeuft -> hier nichts anzeigen } if (out.length()) out += " | "; // Statuszeile ist einzeilig -> nebeneinander statt Umbruch out += kreis; out += msg; if (s > sev) sev = s; } // ===================================================================================== // Live-Aktualisierung // ===================================================================================== void ui_update(const MachineState& st) { g_state = st; // Boot-Splash: erster State = Verbindung zur Maschine steht -> Start-Animation ausblenden // und den Splash danach freigeben (wird nie wieder gebraucht). if (g_splashActive) { g_splashActive = false; ui_fade_out_delete(g_splash, UI_FADE_MS); } // WLAN-Symbol bool wifiOk = st.wifiConnected && !st.apMode; if (g_wifiIcon) lv_obj_set_style_text_color(g_wifiIcon, wifiOk ? COL_OK : (st.apMode ? COL_WARN : COL_TEXT_MUT), 0); // Uhrzeit (nur wenn NTP synchronisiert) if (g_clock) { if (st.timeStr.length()) { lv_label_set_text(g_clock, st.timeStr.c_str()); lv_obj_remove_flag(g_clock, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_clock, LV_OBJ_FLAG_HIDDEN); } } // Standby-Uhr-Overlay (Backlight wird in onState gesteuert). Uebergaenge werden weich // gefadet: Einschlafen blendet die Uhr ein, Aufwecken blendet sie aus. Nur bei echtem // Statuswechsel animieren (g_standbyShown), sonst nur Uhrzeit/Vordergrund aktualisieren. if (g_standbyScreen) { bool shouldShow = st.standbyActive && st.standbyShowClock; if (shouldShow) { lv_label_set_text(g_standbyClock, st.timeStr.length() ? st.timeStr.c_str() : "--:--"); if (!g_standbyShown) { ui_fade_in(g_standbyScreen, UI_FADE_MS); // Einschlaf-Animation g_standbyShown = true; } else { lv_obj_move_foreground(g_standbyScreen); } } else if (g_standbyShown) { ui_fade_out_hide(g_standbyScreen, UI_FADE_MS); // Aufweck-Animation g_standbyShown = false; } } // Aufweck-Ablauf: nach beendetem Standby Dialog schliessen und Helligkeits-Flag loeschen // (auch wenn der Dialog schon ausgeblendet ist, weil "Aufwecken" das Flag bewusst haelt). if (!st.standbyActive) { g_wakeDialogOpen = false; if (g_wakeConfirm && !lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); } else if (g_wakeConfirm && !lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) { lv_obj_move_foreground(g_wakeConfirm); // ueber dem Standby-Overlay halten } // Sicherheitsabschaltung / Sensorfehler: roter Banner + Header-Icon (sicherheitskritisch) bool safetyErr = st.wasserSafetyShutdown || st.dampfSafetyShutdown; bool sensorErr = st.wasserSensorError || st.dampfSensorError; bool anyErr = safetyErr || sensorErr; if (g_errIcon) { if (anyErr) lv_obj_remove_flag(g_errIcon, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_errIcon, LV_OBJ_FLAG_HIDDEN); } if (anyErr) { // statusText enthaelt bereits den aggregierten Klartext (z.B. "Sicherheitsabsch. Wasser! ...") String txt = LV_SYMBOL_WARNING " "; txt += st.statusText.length() ? st.statusText : String("Sicherheitsabschaltung"); status_set(ST_SAFETY, true, txt.c_str(), COL_DANGER, COL_TEXT); } else { status_set(ST_SAFETY, false); } // Dampf-Hinweis (Heizen aus hat Vorrang vor Startverzoegerung) if (st.steamHeatDisabled) { status_set(ST_DAMPF, true, "Dampf: Heizen deaktiviert", COL_DANGER, COL_TEXT); } else if (st.steamDelayActive) { long mins = (st.steamDelayRemainSec + 59) / 60; // aufgerundet char b[64]; snprintf(b, sizeof(b), "Dampf-Startverzögerung: noch %ld min", mins); status_set(ST_DAMPF, true, b, COL_WARN, lv_color_hex(0x000000)); } else { status_set(ST_DAMPF, false); } // Reinigung/Wartung faellig (gleiche Bedingung wie auf dem OLED: // Intervall aktiv und Zaehler hat das Intervall erreicht/ueberschritten) bool maintDue = (st.maintenanceInterval > 0) && (st.maintenanceIntervalCounter >= (uint32_t)st.maintenanceInterval); if (g_maintIcon) { if (maintDue) lv_obj_remove_flag(g_maintIcon, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_maintIcon, LV_OBJ_FLAG_HIDDEN); } status_set(ST_MAINT, maintDue, LV_SYMBOL_WARNING " Reinigung/Wartung fällig (antippen)", COL_WARN, lv_color_hex(0x000000)); // Seite Reinigung/Wartung: Live-Status (Zaehler / Intervall / Faelligkeit) if (lblMaintStatus) { String s = "Bezüge seit Reset: " + String(st.maintenanceIntervalCounter); if (st.maintenanceInterval > 0) { s += "\nIntervall: " + String(st.maintenanceInterval) + " Bezüge"; if (maintDue) { s += "\n" LV_SYMBOL_WARNING " Reinigung/Wartung fällig"; } else { long rest = (long)st.maintenanceInterval - (long)st.maintenanceIntervalCounter; s += "\nNoch " + String(rest) + " Bezüge bis fällig"; } } else { s += "\nIntervall: deaktiviert"; } label_set_if_changed(lblMaintStatus, s.c_str()); lv_obj_set_style_text_color(lblMaintStatus, maintDue ? COL_WARN : COL_TEXT, 0); } // Reinigungsassistent-Hinweis (laeuft -> Statuszeile antippen oeffnet den Sub-Screen) if (st.cleaningActive) { String c; if (st.cleaningWaiting) { c = LV_SYMBOL_REFRESH " Reinigung: heizt auf 93 °C vor..."; } else { c = LV_SYMBOL_REFRESH " Reinigung: Zyklus "; c += String(st.cleaningCycle) + "/" + String(st.cleaningCycles); c += st.cleaningBrewPhase ? " (Bezug)" : " (Pause)"; } status_set(ST_CLEAN, true, c.c_str(), COL_ACCENT, lv_color_hex(0x000000)); } else { status_set(ST_CLEAN, false); } // PID-Tuning-Hinweis (AutoTune Wasser/Dampf laeuft) { // Fallende Flanke je Kreis erkennen -> Ergebnis ~30 s einblenden uint32_t nowMs = millis(); if (g_tunePrevW && !st.autoTuneW) { g_tuneResW = st.autoTuneWStatus; g_tuneResUntilW = nowMs + 30000; } if (g_tunePrevD && !st.autoTuneD) { g_tuneResD = st.autoTuneDStatus; g_tuneResUntilD = nowMs + 30000; } g_tunePrevW = st.autoTuneW; g_tunePrevD = st.autoTuneD; bool showResW = (g_tuneResUntilW != 0) && ((int32_t)(nowMs - g_tuneResUntilW) < 0); bool showResD = (g_tuneResUntilD != 0) && ((int32_t)(nowMs - g_tuneResUntilD) < 0); if (g_tuneResUntilW != 0 && !showResW) g_tuneResUntilW = 0; // abgelaufen -> aus if (g_tuneResUntilD != 0 && !showResD) g_tuneResUntilD = 0; if (st.autoTuneW || st.autoTuneD) { const char* txt = (st.autoTuneW && st.autoTuneD) ? LV_SYMBOL_SETTINGS " PID-Tuning läuft (Wasser + Dampf)..." : st.autoTuneW ? LV_SYMBOL_SETTINGS " PID-Tuning Wasser läuft..." : LV_SYMBOL_SETTINGS " PID-Tuning Dampf läuft..."; status_set(ST_TUNE, true, txt, COL_WARN, lv_color_hex(0x000000)); } else if (showResW || showResD) { // Ergebnis des/der zuletzt beendeten Laufs/Laeufe anzeigen String txt; int sev = 0; if (showResW) appendTuneResult(txt, "Wasser", g_tuneResW, sev); if (showResD) appendTuneResult(txt, "Dampf", g_tuneResD, sev); if (txt.length()) { lv_color_t bg = (sev >= 2) ? COL_DANGER : (sev == 1 ? COL_WARN : COL_OK); lv_color_t fg = (sev == 1) ? lv_color_hex(0x000000) : COL_TEXT; status_set(ST_TUNE, true, txt.c_str(), bg, fg); } else { status_set(ST_TUNE, false); } } else { status_set(ST_TUNE, false); } } // Reinigungsassistent-Sub-Screen: Fortschritt/Sichtbarkeit aktualisieren (nur wenn offen) cln_refresh(); // Dashboard - Temperaturkarten (im Fehlerfall Warnsymbol + Klartext statt Zahl, wie OLED) if (lblTempW) { if (st.wasserSafetyShutdown || st.wasserSensorError) { lv_label_set_text(lblTempW, LV_SYMBOL_WARNING); lv_obj_set_style_text_color(lblTempW, COL_DANGER, 0); lv_label_set_text(lblSetW, st.wasserSafetyShutdown ? "Sicherheitsabsch." : "Sensorfehler!"); lv_obj_set_style_text_color(lblSetW, COL_DANGER, 0); temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER); } else { lv_label_set_text(lblTempW, tempStr(st.tempW, 1).c_str()); // Ist-Wert faerbt sich weich Richtung Soll (Blau -> Gold -> Gruen im Zielband) lv_color_t colW = temp_state_color(st.tempW, st.setW); lv_obj_set_style_text_color(lblTempW, colW, 0); lv_label_set_text(lblSetW, ("Soll " + tempStr(st.setW, 1)).c_str()); lv_obj_set_style_text_color(lblSetW, COL_TEXT_DIM, 0); // Fortschritt = Ist/Soll in %, gedeckelt auf 100 (Ist >= Soll -> voll) int pctW = (st.setW > 0.0f) ? (int)(st.tempW / st.setW * 100.0f + 0.5f) : 0; temp_progress_set(barDutyW, arcTempW, bulbTempW, pctW, colW); } if (st.dampfSafetyShutdown || st.dampfSensorError) { lv_label_set_text(lblTempD, LV_SYMBOL_WARNING); lv_obj_set_style_text_color(lblTempD, COL_DANGER, 0); lv_label_set_text(lblSetD, st.dampfSafetyShutdown ? "Sicherheitsabsch." : "Sensorfehler!"); lv_obj_set_style_text_color(lblSetD, COL_DANGER, 0); temp_progress_set(barDutyD, arcTempD, bulbTempD, 0, COL_DANGER); } else { lv_label_set_text(lblTempD, tempStr(st.tempD, 0).c_str()); lv_color_t colD = temp_state_color(st.tempD, st.setD); lv_obj_set_style_text_color(lblTempD, colD, 0); lv_label_set_text(lblSetD, ("Soll " + tempStr(st.setD, 0)).c_str()); lv_obj_set_style_text_color(lblSetD, COL_TEXT_DIM, 0); int pctD = (st.setD > 0.0f) ? (int)(st.tempD / st.setD * 100.0f + 0.5f) : 0; temp_progress_set(barDutyD, arcTempD, bulbTempD, pctD, colD); } } // "BEREIT"-Pille im Header: Wasser im Zielband (und Dampf, sofern er heizt), kein // Fehler/Standby/Eco und kein laufender Aufheiz-Countdown. Mit Hysterese gegen // Flackern an der Bandgrenze; erscheint und verschwindet weich (Fade). if (g_readyPill) { bool dampfRelevant = !st.steamHeatDisabled && !st.steamDelayActive && !st.dampfSafetyShutdown && !st.dampfSensorError && st.setD > 0.0f; float devW = fabsf(st.tempW - st.setW); float devD = dampfRelevant ? fabsf(st.tempD - st.setD) : 0.0f; float band = g_readyState ? 1.8f : 1.0f; // Eintritt enger als Austritt bool ready = !anyErr && !st.standbyActive && !st.ecoActive && !g_heatUpLatched && st.setW > 0.0f && devW <= band && devD <= band; if (ready != g_readyState) { g_readyState = ready; if (ready) ui_fade_in(g_readyPill, UI_FADE_MS); else ui_fade_out_hide(g_readyPill, UI_FADE_MS); } } // Timer-Synchronisation (die laufende Anzeige macht der 100-ms-Timer dash_timers_cb) // Tarier-Phase vor Brew-by-Weight-Bezug -> dash_timers_cb zeigt "Tariere..." im Shot-Timer g_scaleTaring = st.scaleTaring; // Wasser-Timer: Bezug ODER Wasser-Spuelen(Flush). Bei Bezug an shotElapsedMs koppeln // (exakt), beim Flush displayseitig ab Start zaehlen. bool wasShot = g_shotActive; if (st.shotActive) { g_shotStartMs = millis() - st.shotElapsedMs; // Start so legen, dass now-Start = Bezugszeit g_shotActive = true; g_shotWasFlush = false; g_shotBbtMode = st.bbtEnabled && st.bbtSecs > 0.0f; // Bezug mit Brew-by-Time? if (g_shotBbtMode) g_bbtTarget = st.bbtSecs; // Pre-Infusion-Phasenleiste: Modus, S3-Phase und Phasendauern latchen (pi_bar_update) g_piShotMode = st.piEnabled; g_piStateS3 = st.piState; g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f); g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f); } else if (st.flushActive) { g_shotStartMs = millis() - st.flushElapsedMs; // rueckwirkend koppeln (wie Bezug) g_shotActive = true; g_shotWasFlush = true; g_shotFlushCapMs = (uint32_t)st.flushDurationSeconds * 1000UL; // Ziel-Endstand des Flushs g_shotBbtMode = false; // Flush hat kein Zeitziel } else { if (wasShot) { // gerade beendet: Endwert einfrieren // Flush laeuft auf der S3 exakt flushDurationSeconds -> Endstand darauf einrasten // (sonst je nach Push-Zeitpunkt 0,1-s-Abweichung). Echter Bezug: gemessene Dauer. g_shotFrozenMs = (g_shotWasFlush && g_shotFlushCapMs > 0) ? g_shotFlushCapMs : (millis() - g_shotStartMs); g_shotHoldUntil = millis() + 2000; // Anzeige macht dash_timers_cb } g_shotActive = false; g_piShotMode = false; // Phasenleiste ausblenden (pi_bar_update) g_piStateS3 = 0; } // Shot-Zusammenfassung beim Ende eines echten Bezugs (kein Flush, min. 3 s) if (g_sumShotWas && !st.shotActive) { uint32_t dur = millis() - g_shotStartMs; // Gewichtsbasierter Bezug: Endgewicht inkl. Offset (wie OLED) statt Live-Messwert anzeigen. float sumWeight = st.lastShotByWeight ? st.lastShotWeight : st.weight; if (dur >= 3000) show_shot_summary(dur, sumWeight, st.scaleEnabled); } g_sumShotWas = st.shotActive; // Dampf-Timer: Dampfkreis ODER Dampf-Flush. Beim Dampfbezug an steamElapsedMs // koppeln (rueckwirkend, wie beim Wasser-Timer), beim Flush displayseitig zaehlen. bool wasSteam = g_steamActive; if (st.steamFlushActive) { // Dampf-Flush: an steamFlushElapsedMs koppeln (aus steamFlushEndTime, race-frei) statt an // steamElapsedMs (haengt am steamCircuitStartTime -> beim Flush-Start zeitweise falsch). // Nur verstrichene Zeit, KEIN Steam-by-Time-Ziel/Balken. g_steamStartMs = millis() - st.steamFlushElapsedMs; g_steamActive = true; g_steamWasFlush = true; g_steamFlushCapMs = (uint32_t)st.steamFlushDurationSeconds * 1000UL; // Ziel-Endstand des Dampf-Flushs g_steamSbtMode = false; // Flush hat kein Zeitziel } else if (st.steamCircuitActive) { g_steamStartMs = millis() - st.steamElapsedMs; // Start so legen, dass now-Start = Dampfzeit g_steamActive = true; g_steamWasFlush = false; g_steamSbtMode = st.sbtEnabled && st.sbtSecs > 0.0f; // Dampfbezug mit Steam-by-Time? if (g_steamSbtMode) g_sbtTarget = st.sbtSecs; } else { if (wasSteam) { // gerade beendet: Endwert einfrieren (Anzeige macht dash_timers_cb) // Dampf-Flush laeuft exakt steamFlushDurationSeconds -> Endstand darauf einrasten. g_steamFrozenMs = (g_steamWasFlush && g_steamFlushCapMs > 0) ? g_steamFlushCapMs : (millis() - g_steamStartMs); g_steamHoldUntil = millis() + 2000; } g_steamActive = false; } // WAAGE-Karte nur zeigen, wenn tatsaechlich eine Waage verbunden ist if (cellBezug) { if (st.scaleConnected) lv_obj_remove_flag(cellBezug, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(cellBezug, LV_OBJ_FLAG_HIDDEN); } if (lblBezug) { bool bbwShot = st.shotActive && st.bbwEnabled && st.bbwTarget > 0.0f; // Winzige negative Messwerte (z.B. -0.04 g) oder negatives Float-Null wuerden von // "%.1f" als "-0.0" dargestellt -> auf 0.0 normalisieren (kein fuehrendes Minus). float wDisp = st.weight; if (wDisp > -0.05f && wDisp <= 0.0f) wDisp = 0.0f; if (!st.scaleEnabled) { lv_label_set_text(lblBezug, "--"); } else if (bbwShot) { // Waehrend des Bezugs mit Brew-by-Weight: Ist oben, Ziel darunter (zweizeilig) lv_label_set_text_fmt(lblBezug, "%.1f g\n/ %.1f g", wDisp, st.bbwTarget); } else { lv_label_set_text_fmt(lblBezug, "%.1f g", wDisp); } // Fortschrittsbalken Gewicht/Ziel (nur waehrend BBW-Bezug) if (g_bbwBar) { if (bbwShot) { int pct = (int)(st.weight / st.bbwTarget * 100.0f + 0.5f); if (pct < 0) pct = 0; if (pct > 100) pct = 100; lv_bar_set_value(g_bbwBar, pct, LV_ANIM_OFF); lv_obj_remove_flag(g_bbwBar, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_bbwBar, LV_OBJ_FLAG_HIDDEN); } } } // Flow-Zelle: permanent sichtbar, sobald Brew-by-Weight aktiviert ist. // (Anzeige selbst macht dash_timers_cb: Bezugswert + 2,5 s Nachhalten, sonst 0,0 g/s.) if (cellFlow) { if (st.bbwEnabled) { lv_obj_remove_flag(cellFlow, LV_OBJ_FLAG_HIDDEN); if (st.shotActive) { g_flowFrozen = st.flowRate; g_flowHoldUntil = millis() + 2500; } } else { lv_obj_add_flag(cellFlow, LV_OBJ_FLAG_HIDDEN); } } // Kombi-Zelle Eco/Gehaeuse (Sichtbarkeit + Inhalt; Wechsel macht eco_case_timer_cb) refresh_eco_case(); // Temperatur-Seite (Drehregler nicht ueberschreiben, wenn gerade bedient wird) if (lblTargW && (millis() - g_targEditMs > 1500)) { g_targW = st.setW; g_targD = st.setD; lv_label_set_text(lblTargW, tempStr(g_targW, 0).c_str()); lv_label_set_text(lblTargD, tempStr(g_targD, 0).c_str()); if (g_arcW) lv_arc_set_value(g_arcW, (int32_t)(g_targW + 0.5f)); if (g_arcD) lv_arc_set_value(g_arcD, (int32_t)(g_targD + 0.5f)); } if (lblHeatUp && (millis() - g_heatUpEditMs > 1500)) { // nicht ueberschreiben waehrend Eingabe g_heatUp = st.heatUpMinutes; hu_set_label(); } if (swEco) sw_set(swEco, st.ecoActive); if (swMaint) sw_set(swMaint, st.maintenanceActive); if (swMaint2) sw_set(swMaint2, st.maintenanceActive); // Licht-Button spiegeln (gold = an) if (btnLight) { lv_obj_t* l = lv_obj_get_child(btnLight, 0); if (l) lv_label_set_text(l, st.lightOn ? "Licht: AN" : "Licht: AUS"); lv_obj_set_style_bg_color(btnLight, st.lightOn ? COL_ACCENT : COL_CARD2, 0); if (l) lv_obj_set_style_text_color(l, st.lightOn ? lv_color_hex(0x000000) : COL_TEXT, 0); } // (Brew-Control wird NICHT laufend gespiegelt - nur beim Oeffnen der Seite via // populate_brew(), sonst wuerden Eingaben vor dem Speichern ueberschrieben.) // WiFi-Status if (wifiStatusLbl) { if (wifiOk) { lv_label_set_text_fmt(wifiStatusLbl, LV_SYMBOL_OK " Verbunden: %s (%d dBm)\nIP: %s", st.ssid.c_str(), st.rssi, st.ip.c_str()); lv_obj_set_style_text_color(wifiStatusLbl, COL_OK, 0); lv_label_set_text_fmt(wifiHintLbl, "Erweiterte Konfiguration im Webinterface: http://%s", st.ip.c_str()); } else if (st.apMode) { lv_label_set_text_fmt(wifiStatusLbl, LV_SYMBOL_WARNING " Access-Point-Modus\nIP: %s", st.ip.c_str()); lv_obj_set_style_text_color(wifiStatusLbl, COL_WARN, 0); lv_label_set_text_fmt(wifiHintLbl, "Erweiterte Konfiguration im Webinterface: http://%s", st.ip.c_str()); } else { lv_label_set_text(wifiStatusLbl, LV_SYMBOL_CLOSE " Nicht verbunden"); lv_obj_set_style_text_color(wifiStatusLbl, COL_TEXT_DIM, 0); lv_label_set_text(wifiHintLbl, "Netzwerk suchen, auswählen, Passwort eingeben und verbinden."); } } // Info-Seite: S3-Firmware/Protokoll und Verbindungsstatus (live) if (lblInfoS3) { String s = "Firmware: " + (st.firmwareVersion.length() ? st.firmwareVersion : String("--")); s += "\nProtokoll: v" + String(st.protocolVersion); label_set_if_changed(lblInfoS3, s.c_str()); } if (lblInfoLink) { String s = "UART: "; s += st.linkUp ? "verbunden" : "getrennt"; if (st.linkUp && st.protocolVersion != 0 && st.protocolVersion != PROTO_EXPECTED_VERSION) { s += "\n" LV_SYMBOL_WARNING " Protokoll-Versionen unterschiedlich (S3 v"; s += String(st.protocolVersion) + " / P4 v" + String(PROTO_EXPECTED_VERSION) + ")"; } label_set_if_changed(lblInfoLink, s.c_str()); lv_obj_set_style_text_color(lblInfoLink, st.linkUp ? COL_OK : COL_WARN, 0); } } void ui_toast(const char* txt, bool error) { showToast(txt, error); } void ui_set_profiles(const String profiles[], int count) { if (!profList) return; lv_obj_clean(profList); for (int i = 0; i < count; i++) { lv_obj_t* b = lv_list_add_button(profList, LV_SYMBOL_FILE, profiles[i].c_str()); lv_obj_set_style_bg_color(b, COL_CARD2, 0); lv_obj_set_style_text_color(b, COL_TEXT, 0); lv_obj_add_event_cb(b, prof_select_cb, LV_EVENT_CLICKED, nullptr); } } void ui_set_profile_details(const String& json) { if (!profDetailLbl) return; JsonDocument doc; if (deserializeJson(doc, json)) { lv_label_set_text(profDetailLbl, "Profilwerte konnten nicht gelesen werden."); lv_obj_set_style_text_color(profDetailLbl, COL_DANGER, 0); return; } if (profDetailTitle) { const char* pname = doc["profile"] | ""; if (pname[0]) lv_label_set_text(profDetailTitle, pname); } String t; t += "Wasser Soll "; t += tempStr((float)(doc["setW"] | 0.0f), 1); t += " Offset "; t += tempStr((float)(doc["offW"] | 0.0f), 1); t += "\n"; t += "Dampf Soll "; t += tempStr((float)(doc["setD"] | 0.0f), 1); t += " Offset "; t += tempStr((float)(doc["offD"] | 0.0f), 1); t += "\n\n"; t += "PID Wasser "; t += String((float)(doc["kpW"] | 0.0f), 2); t += " / "; t += String((float)(doc["kiW"] | 0.0f), 2); t += " / "; t += String((float)(doc["kdW"] | 0.0f), 2); t += "\n"; t += "PID Dampf "; t += String((float)(doc["kpD"] | 0.0f), 2); t += " / "; t += String((float)(doc["kiD"] | 0.0f), 2); t += " / "; t += String((float)(doc["kdD"] | 0.0f), 2); t += "\n\n"; t += "Pre-Infusion: "; t += onoffStr(doc["piEnabled"] | false); t += " ("; t += String((float)(doc["piDurSecs"] | 0.0f), 1); t += "s / "; t += String((float)(doc["piPauseSecs"] | 0.0f), 1); t += "s Pause)\n"; t += "Brew-by-Time: "; t += onoffStr(doc["bbtEnabled"] | false); t += " ("; t += String((float)(doc["bbtSecs"] | 0.0f), 1); t += "s)\n"; t += "Brew-by-Weight: "; t += onoffStr(doc["bbwEnabled"] | false); t += " ("; t += String((float)(doc["bbwTarget"] | 0.0f), 1); t += "g, Offset "; t += String((float)(doc["bbwOffset"] | 0.0f), 1); t += "g)\n"; t += "Steam-by-Time: "; t += onoffStr(doc["sbtEnabled"] | false); t += " ("; t += String((float)(doc["sbtSecs"] | 0.0f), 1); t += "s)\n\n"; t += "Eco: "; t += String((int)(doc["ecoMin"] | 0)); t += " min W "; t += String((int)(doc["ecoTempW"] | 0)); t += "°C / D "; t += String((int)(doc["ecoTempD"] | 0)); t += "°C\n"; t += "Fast-Heat-Up: "; t += onoffStr(doc["fastHeatUp"] | false); t += " Piezo: "; t += onoffStr(doc["piezoEnabled"] | false); lv_label_set_text(profDetailLbl, t.c_str()); lv_obj_set_style_text_color(profDetailLbl, COL_TEXT, 0); } void ui_set_usage_stats(const String& json) { if (!g_statSummary) return; JsonDocument doc; if (deserializeJson(doc, json)) { label_set_if_changed(g_statSummary, "Statistik konnte nicht gelesen werden."); if (g_statList) label_set_if_changed(g_statList, ""); return; } bool avail = doc["available"] | false; if (!avail) { label_set_if_changed(g_statSummary, "Noch keine Nutzungsdaten vorhanden."); if (g_statList) label_set_if_changed(g_statList, ""); return; } long total = doc["total"] | 0; long today = doc["today"] | 0; long week = doc["week"] | 0; bool partial = doc["partial"] | false; // Liste + Mittelwert der angezeigten Bezuege aufbauen String l; int n = 0; float durSum = 0.0f; for (JsonVariant v : doc["shots"].as()) { const char* dt = v["dt"] | ""; float d = v["d"] | 0.0f; float w = v["w"] | -1.0f; if (n > 0) l += "\n"; l += String(dt) + " " + String(d, 1) + " s"; if (w >= 0.0f) l += " " + String(w, 1) + " g"; durSum += d; n++; } String s; s += "Heute: " + String(today) + "\n"; s += "Diese Woche: " + String(week) + "\n"; // total ist exakt, solange die ganze Datei ins Fenster passte (sonst Fenster-Untergrenze) s += "Gesamt: " + (partial ? String("min. ") : String("")) + String(total) + " Bezüge"; if (n > 0) s += "\nMittl. Dauer (letzte " + String(n) + "): " + String(durSum / n, 1) + " s"; label_set_if_changed(g_statSummary, s.c_str()); if (g_statList) { if (n == 0) l = "Keine Einzelbezüge vorhanden."; label_set_if_changed(g_statList, l.c_str()); } } void ui_set_wifi_networks(const String& json) { wifi_scan_done(); // Scan abgeschlossen: Button wieder freigeben if (!wifiList) return; // 1) Netze in kompakte lokale Labels parsen und den JSON-Speicher wieder freigeben, // BEVOR die LVGL-Listenobjekte alloziert werden (haelt den LVGL-Pool-Peak klein). static const int MAXW = 12; // Liste begrenzen String labels[MAXW]; int n = 0; { JsonDocument doc; if (deserializeJson(doc, json)) { showToast("Netzliste fehlerhaft", true); return; } for (JsonVariant v : doc["networks"].as()) { if (n >= MAXW) break; const char* ssid = v["ssid"] | ""; if (!ssid[0]) continue; int rssi = v["rssi"] | 0; bool enc = v["enc"] | true; int pct = 2 * (rssi + 100); // dBm -> % (verstaendlicher), -100..-50 dBm = 0..100 % if (pct < 0) pct = 0; if (pct > 100) pct = 100; String lbl = String(ssid) + " (" + String(pct) + "%)"; if (!enc) lbl += " (offen)"; // offene Netze markieren labels[n++] = lbl; } } // doc (und sein Speicher) hier freigegeben // 2) Liste neu aufbauen (JSON-Speicher ist bereits frei) lv_obj_clean(wifiList); for (int i = 0; i < n; i++) { lv_obj_t* b = lv_list_add_button(wifiList, LV_SYMBOL_WIFI, labels[i].c_str()); lv_obj_set_style_bg_color(b, COL_CARD2, 0); lv_obj_set_style_text_color(b, COL_TEXT, 0); lv_obj_add_event_cb(b, wifi_pick_cb, LV_EVENT_CLICKED, nullptr); } if (n == 0) showToast("Keine Netzwerke gefunden", true); else showToast((String(n) + " Netzwerke gefunden").c_str(), false); } void ui_set_debug(const char* txt) { (void)txt; } // --- OTA-Overlay (Aufrufe unter hal_lock()) --- void ui_ota_begin() { if (!g_otaScreen) return; lv_label_set_text(g_otaStatus, "Update läuft - bitte nicht ausschalten!"); lv_obj_set_style_text_color(g_otaStatus, COL_TEXT, 0); lv_label_set_text(g_otaBytes, "0 KB"); lv_obj_remove_flag(g_otaScreen, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_otaScreen); } void ui_ota_progress(uint32_t bytes) { if (!g_otaBytes) return; lv_label_set_text_fmt(g_otaBytes, "%lu KB übertragen", (unsigned long)(bytes / 1024)); } void ui_ota_done() { if (!g_otaStatus) return; lv_label_set_text(g_otaStatus, "Fertig - Neustart..."); lv_obj_set_style_text_color(g_otaStatus, COL_OK, 0); } void ui_ota_fail(const char* msg) { if (!g_otaStatus) return; lv_label_set_text_fmt(g_otaStatus, "Fehlgeschlagen: %s", msg); lv_obj_set_style_text_color(g_otaStatus, COL_DANGER, 0); }