// ===================================================================================== // ui.cpp - LVGL-Oberflaeche (Querformat, 800x480 bzw. 1024x600 je nach Panel), // 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 // Verlaufs-Historie im PSRAM allozieren (interner RAM ist knapp) #include "ui.h" #include #include "esp_system.h" #include "display_hal.h" #include "esp_rom_sys.h" #include "lvgl_private.h" #include // ------------------------------------------------------------------------------------ // Schriftgroesse // // LVGL-Schriften sind fest einkompilierte Bilddaten, es gibt sie also nur in den Groessen, // die im Projekt liegen: 14, 18, 28, 40 und 48 Pixel. Stufenlos skalieren laesst sich // nichts. Umschalten zwischen ihnen dagegen schon - deshalb drei Rollen statt fester // Groessen, die je nach Einstellung eine Stufe hoeher greifen. // // Die grossen Anzeigen (Temperatur, Uhr) bleiben unveraendert: Sie sind bereits auf die // Bildschirmgroesse abgestimmt und sollen nicht mitwachsen. // // Die Einstellung liegt lokal im P4, nicht auf der Hauptplatine - sie betrifft nur dieses // Display. Sie wirkt beim naechsten Start: Die Schriften stecken in Hunderten bereits // angelegter Bedienelemente, die sich nicht nachtraeglich umhaengen lassen, ohne die // gesamte Oberflaeche neu aufzubauen. // ------------------------------------------------------------------------------------ enum { FONT_KLEIN = 0, FONT_MITTEL = 1, FONT_GROSS = 2 }; static uint8_t g_fontStep = FONT_KLEIN; static const lv_font_t* font_small() { switch (g_fontStep) { case FONT_GROSS: return &lv_font_maven_pro_28; case FONT_MITTEL: return &lv_font_maven_pro_18; default: return &lv_font_maven_pro_14; } } static const lv_font_t* font_text() { switch (g_fontStep) { case FONT_GROSS: return &lv_font_maven_pro_40; case FONT_MITTEL: return &lv_font_maven_pro_28; default: return &lv_font_maven_pro_18; } } static const lv_font_t* font_title() { switch (g_fontStep) { case FONT_GROSS: return &lv_font_maven_pro_48; case FONT_MITTEL: return &lv_font_maven_pro_40; default: return &lv_font_maven_pro_28; } } static void font_step_load() { Preferences p; if (p.begin("anzeige", true)) { g_fontStep = (uint8_t)p.getUChar("schrift", FONT_KLEIN); p.end(); } if (g_fontStep > FONT_GROSS) g_fontStep = FONT_KLEIN; } static void font_step_save_and_restart(uint8_t step) { if (step > FONT_GROSS) return; Preferences p; if (p.begin("anzeige", false)) { p.putUChar("schrift", step); p.end(); } // Der Zaehler der Absturzsicherung darf diesen gewollten Neustart nicht mitzaehlen. { Preferences b; if (b.begin("bootguard", false)) { b.putUShort("early", 0); b.end(); } } // Beleuchtung aus, bevor der Neustart die Bildausgabe abreissen laesst. Ohne Signal // zeigt das Panel von sich aus ein blaues Bild - das blitzte beim Wechsel der // Schriftgroesse kurz auf. Dunkel ist unauffaelliger als blau. hal_backlight(0); delay(120); esp_restart(); } #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 lv_obj_t* g_wakeColdBtn = nullptr; // "Cold Extraction" im Aufweck-Dialog static lv_obj_t* g_wakeMsg = nullptr; // Text des Aufweck-Dialogs (wechselt bei Abfrage-Modus) static lv_obj_t* g_wakeYesLbl = nullptr; // Beschriftung des Bestaetigen-Knopfs static lv_obj_t* g_wakeNoBtn = nullptr; // "Abbrechen" (entfaellt beim Auswahlfenster nach dem 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_COLDEX, PG_SERVICE, PG_PROFILE, PG_LIGHT, PG_WIFI, PG_DISPLAY, PG_INFO, PG_COUNT }; static const char* PAGE_NAMES[PG_COUNT] = { "Dashboard", "Verlauf", "Statistik", "Temperatur", "Brew-Control", "Cold Extraction", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Darstellung", "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). // ST_COLDEX steht direkt hinter der Sicherheitsmeldung: Solange der Modus laeuft, heizt der // Wasserkreis nicht - das muss der Benutzer sehen, sonst wartet er auf Temperatur, die nicht kommt. enum StatusSlot { ST_SAFETY = 0, ST_COLDEX, 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_GEWICHTET = 0, TD_VERLAUF, TD_FLAECHEN, TD_BAND, TD_DESIGN_COUNT }; static uint8_t g_tempDesign = TD_BAND; static bool g_tempColored = false; // Temperaturzahl in der Zustandsfarbe statt weiss #define DASH_SPARK_POINTS 60 // eine Minute Verlauf, ein Punkt je Sekunde static lv_obj_t* g_sparkW = nullptr; // Verlaufskurven der Darstellung TD_VERLAUF static lv_obj_t* g_sparkD = nullptr; static lv_chart_series_t* g_sparkSerW = nullptr; static lv_chart_series_t* g_sparkSerD = nullptr; static lv_obj_t* g_readyBand = nullptr; // Zustandsband der Darstellung TD_BAND static lv_obj_t* g_readyBandLbl = nullptr; 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 // Profil-Chip im Header (zuletzt geladenes Profil; "*" = danach geaenderte Einstellungen). // Antippen oeffnet die Profil-Schnellwahl (Overlay mit allen Profilen zum Direkt-Laden). static lv_obj_t* g_profChip = nullptr; static lv_obj_t* g_profPicker = nullptr; // Schnellwahl-Overlay (Vollbild-Abdunkelung) static lv_obj_t* g_profPickerList = nullptr; // Button-Liste im Overlay static lv_obj_t* g_profPickerInfo = nullptr; // "Lade Profile..."-Hinweis im Overlay static lv_obj_t* g_infoRow = nullptr; // untere Zeile; waechst waehrend eines Bezugs static bool g_dashBrewMode = false; 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) // Umschaltbares Zeitfenster: das Chart zeigt immer 120 Punkte; die Werte kommen aus einer // 1-s-Historie (Ringpuffer ueber 60 min) und werden je Fenster dezimiert. So bleibt beim // Fensterwechsel der komplette Verlauf sofort sichtbar (kein leeres Chart). static const uint16_t CHART_WIN_MIN[4] = { 2, 10, 30, 60 }; // waehlbare Fenster (Minuten) #define CHART_HIST_MAX 3600 // Historie: 60 min à 1 s #define CHART_HIST_NONE INT16_MIN // Markierung "keine Probe" // Die ~29 KB Historie liegen im PSRAM (heap_caps_malloc in ui_init, wie die LVGL- // Framebuffer) - als statische Arrays wuerden sie den knappen internen RAM sprengen // (Board-Limit war vor diesem Feature bereits zu ~98 % belegt). struct ChartHist { int16_t w[CHART_HIST_MAX]; // Wasser (Zehntelgrad) int16_t d[CHART_HIST_MAX]; // Dampf (Zehntelgrad) int16_t c[CHART_HIST_MAX]; // Zusatzsensor (Zehntelgrad; CHART_HIST_NONE = aus) uint8_t dutyW[CHART_HIST_MAX]; // Heizleistung Wasser (%) uint8_t dutyD[CHART_HIST_MAX]; // Heizleistung Dampf (%) }; static ChartHist* g_hist = nullptr; static uint16_t g_histPos = 0, g_histFill = 0; static uint8_t g_chartWin = 0; // Index in CHART_WIN_MIN (P4-lokal in NVS, "chartwin") static bool g_chartDuty = false; // Heizleistung als Zusatzkurven (NVS "chartduty") static lv_obj_t* g_chartTitle = nullptr; static lv_obj_t* g_chartWinBtn[4] = {}; static lv_obj_t* g_chartDutyBtn = nullptr; static lv_chart_series_t* g_serDutyW = nullptr; // Heizleistung auf Sekundaerachse 0..100 % static lv_chart_series_t* g_serDutyD = nullptr; static lv_obj_t* g_legDuty = nullptr; static lv_obj_t* g_lineSetW = nullptr; // gestrichelte Soll-Linien im Chart static lv_obj_t* g_lineSetD = nullptr; static lv_point_precise_t g_lineSetPts[2][2]; #define COL_DUTY_W lv_color_hex(0x8a6206) // gedimmtes Gold (Heizleistung Wasser) #define COL_DUTY_D lv_color_hex(0x4d7a76) // gedimmtes Teal (Heizleistung Dampf) // PID-Tuning-Live-Karte (Verlauf-Seite): Laufzeit, Heiz-/Abkuehlphase und Schwingungshub static lv_obj_t* g_tuneCard = nullptr; static lv_obj_t* g_tuneCardTitle = nullptr; static lv_obj_t* g_tuneCardBody = nullptr; static lv_obj_t* g_tuneCardSpin = nullptr; static bool g_tuneCardShown = false; static uint32_t g_tuneStartW = 0, g_tuneStartD = 0; // millis() beim Tuning-Start static float g_tuneMinW = 0, g_tuneMaxW = 0; // Schwingungs-Min/Max seit Start static float g_tuneMinD = 0, g_tuneMaxD = 0; // Shot-Zusammenfassung (Karte nach Bezug; blendet weich ein/aus) static lv_obj_t* g_shotSum = nullptr; static lv_obj_t* g_sumCol[3] = {}; // Spalten Dauer/Gewicht/Flow (Gewicht+Flow nur mit Waage) static lv_obj_t* g_sumVal[3] = {}; static lv_timer_t* g_shotSumTimer = nullptr; static bool g_sumShotWas = false; // Live-Bezugsschirm (Vollbild-Overlay waehrend eines Bezugs; abschaltbar, NVS "brewlive") static bool g_brewLiveEnabled = true; static lv_obj_t* g_brewScreen = nullptr; static lv_obj_t* g_bzTimer = nullptr; static lv_obj_t* g_bzWeight = nullptr; static lv_obj_t* g_bzFlow = nullptr; static lv_obj_t* g_bzPhase = nullptr; static lv_obj_t* g_bzWeightBlock = nullptr; static lv_obj_t* g_bzFlowBlock = nullptr; static lv_obj_t* g_bzChartWrap = nullptr; // Chart samt Beschriftung (ohne Waage versteckt) static lv_obj_t* g_bzChart = nullptr; static lv_chart_series_t* g_bzSer = nullptr; static lv_obj_t* g_bzBarRow = nullptr; // Fortschritt Richtung Ziel (BBW/BBT), sonst versteckt static lv_obj_t* g_bzBar = nullptr; static lv_obj_t* g_bzBarLbl = nullptr; static float g_bzFlowMax = 0.0f; // Auto-Skalierung der Flow-Achse static bool g_brewLiveShown = false; // Overlay ist fuer den laufenden Bezug sichtbar static bool g_brewLiveClosed = false; // Nutzer hat das Overlay fuer diesen Bezug weggetippt static uint8_t g_bzTick = 0; // Chart nur alle 200 ms fuettern (Timer laeuft mit 100 ms) static lv_obj_t* swBrewLive = nullptr; // Ein/Aus-Schalter auf der Info-Seite // 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 bool g_piColdMode = false; // laufender Bezug ist eine kalte Extraktion (andere Phasennamen) 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 lv_obj_t* swColdEx = nullptr; // Cold Extraction (nur bei S3-FW >= 5.3.0 sichtbar) static lv_obj_t* lblColdExHint = nullptr; // Erklaerung/Sperrgrund unter dem Schalter 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; // Cold-Extraction-Seite (eigener Menuepunkt) static lv_obj_t* taCxMaxTemp = nullptr; static lv_obj_t* taCxPreInf = nullptr; static lv_obj_t* taCxPreInfDuty = nullptr; static lv_obj_t* taCxPulseMs = nullptr; static lv_obj_t* taCxDuty = nullptr; static lv_obj_t* taCxTarget = nullptr; static lv_obj_t* taCxMaxSecs = nullptr; static lv_obj_t* taCxPumpMaxRun = nullptr; static lv_obj_t* taCxPumpRest = nullptr; static lv_obj_t* swCxEnabled = nullptr; // Freischaltung (entspricht dem Web-Toggle) static lv_obj_t* swCxAskOnWake = nullptr; // Abfrage beim Aufwecken aus dem Standby static lv_obj_t* lblCxPageHint = nullptr; static uint32_t g_cxEditMs = 0; // waehrend der Eingabe die Felder nicht ueberschreiben // 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) // ===================================================================================== // 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, TH_SEL(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; } // Gruppen-Karte: abgesetzter Block mit Titel, in den Zeilen (row/add_switch/add_num) // gelegt werden. Deutlich uebersichtlicher als eine lange Liste gleich aussehender // Zeilen unter Zwischenueberschriften. static lv_obj_t* group_card(lv_obj_t* parent, const char* title, const char* subtitle = nullptr) { lv_obj_t* c = th_card(parent); lv_obj_set_width(c, LV_PCT(100)); lv_obj_set_height(c, LV_SIZE_CONTENT); lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(c, 2, 0); lv_obj_clear_flag(c, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* t = lv_label_create(c); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, COL_ACCENT, 0); lv_obj_set_style_text_font(t, font_text(), 0); if (subtitle) { lv_obj_t* s = lv_label_create(c); lv_label_set_long_mode(s, LV_LABEL_LONG_WRAP); lv_obj_set_width(s, LV_PCT(100)); lv_label_set_text(s, subtitle); lv_obj_set_style_text_color(s, COL_TEXT_MUT, 0); } return c; } 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) // Nachlauf beim Scrollen abschalten. // // Ohne Doppelpufferung entsteht beim Scrollen jedes Bild komplett neu - 1280x676 Pixel in // 24 Bit, per Software. Am staerksten faellt das beim Ausrollen nach dem Loslassen auf: // Dort bewegt sich das Bild weiter, ohne dass ein Finger es fuehrt, und jedes Stocken ist // unmittelbar zu sehen. Folgt die Liste dagegen nur dem Finger, entstehen weniger Bilder, // und die verbleibenden fallen kaum auf. // // Nur fuer das Waveshare-Panel: Die JC-Panels haben das Tempoproblem nicht, ihr // Bediengefuehl soll unveraendert bleiben. // ------------------------------------------------------------------------------------ // Mehrspaltige Seiten fuer das grosse Panel // // Die Seiten sind als einspaltige Stapel entstanden - entworfen fuer 800x480, wo eine // Spalte alles ist, was hineinpasst. Auf 1280x720 bleibt daneben rund 40 % der Flaeche // leer, waehrend unten Karten aus dem Bild fallen und gescrollt werden muss. Und Scrollen // ist auf diesem Panel teuer: Ohne Doppelpufferung entsteht dabei jedes Bild komplett neu. // // Deshalb hier ein Raster statt eines Stapels. Passt eine Seite vollstaendig auf den // Bildschirm, entfaellt der teuerste Vorgang der Oberflaeche ersatzlos. // // Die JC-Panels bleiben einspaltig: Dort ist der Platz knapp, nicht ueppig. // ------------------------------------------------------------------------------------ #if JC_PANEL_TYPE == WS_PANEL_7H static const int32_t PAGE_COLS_2[] = { LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST }; // Zwei Kartenreihen, darunter eine Reihe fuer den Speichern-Knopf. static const int32_t PAGE_ROWS_2C[] = { LV_GRID_CONTENT, LV_GRID_CONTENT, LV_GRID_CONTENT, LV_GRID_TEMPLATE_LAST }; // Drei Spalten fuer Seiten mit sechs Karten: Die Erklaerungstexte brauchen keine volle // Zeilenbreite, und in drei Spalten bleibt am Ende keine halbleere Reihe stehen. static const int32_t PAGE_COLS_3[] = { LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST }; static void page_grid(lv_obj_t* p, const int32_t* cols, const int32_t* rows) { lv_obj_set_layout(p, LV_LAYOUT_GRID); lv_obj_set_grid_dsc_array(p, cols, rows); lv_obj_set_style_pad_column(p, 12, 0); lv_obj_set_style_pad_row(p, 12, 0); } // Karte in eine Rasterzelle setzen. Die Breite kommt ab jetzt aus der Spalte, deshalb muss // die Prozentbreite aus group_card() zurueckgenommen werden - sie bezoege sich sonst auf // die ganze Seite statt auf die Zelle. static void grid_at(lv_obj_t* c, uint8_t col, uint8_t row, uint8_t col_span = 1) { lv_obj_set_width(c, LV_SIZE_CONTENT); lv_obj_set_grid_cell(c, LV_GRID_ALIGN_STRETCH, col, col_span, LV_GRID_ALIGN_START, row, 1); } #endif // ------------------------------------------------------------------------------------ // Liste der letzten Bezuege // // Frueher war das EIN mehrzeiliges Textfeld: Zeitpunkt, Dauer und Gewicht standen in einer // Zeile aneinandergereiht, durch Leerzeichen getrennt. Weil die Zahlen unterschiedlich // breit sind, stand nichts untereinander - man konnte zwei Bezuege nicht vergleichen, ohne // jede Zeile einzeln zu lesen. // // Jetzt eine Tabelle mit festen Spalten und Ziffern gleicher Breite. Die Zeilen werden // einmal angelegt und danach nur noch gefuellt oder ausgeblendet - eine Statistikseite, // die bei jeder Aktualisierung Dutzende Objekte neu anlegt, waere unnoetig teuer. // ------------------------------------------------------------------------------------ #define STAT_ROW_COUNT 12 static lv_obj_t* g_statRow[STAT_ROW_COUNT] = {}; static lv_obj_t* g_statTime[STAT_ROW_COUNT] = {}; static lv_obj_t* g_statDur[STAT_ROW_COUNT] = {}; static lv_obj_t* g_statWgt[STAT_ROW_COUNT] = {}; static lv_obj_t* g_statCold[STAT_ROW_COUNT] = {}; static lv_obj_t* g_statEmpty = nullptr; static lv_obj_t* stat_cell(lv_obj_t* row, int32_t width, bool rechts, lv_color_t col) { lv_obj_t* l = lv_label_create(row); if (width > 0) lv_obj_set_width(l, width); else lv_obj_set_flex_grow(l, 1); lv_obj_set_style_text_color(l, col, 0); lv_obj_set_style_text_align(l, rechts ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT, 0); lv_label_set_text(l, ""); return l; } static void stat_list_build(lv_obj_t* parent) { 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_COLUMN); lv_obj_set_style_pad_row(card, 0, 0); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); // Kopfzeile - benennt die Spalten, damit die Zahlen ohne Einheit auskommen lv_obj_t* head = lv_obj_create(card); lv_obj_set_width(head, LV_PCT(100)); lv_obj_set_height(head, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(head, 0, 0); lv_obj_set_style_pad_all(head, 4, 0); lv_obj_set_style_pad_column(head, 10, 0); lv_obj_set_flex_flow(head, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(head, LV_OBJ_FLAG_SCROLLABLE); stat_cell(head, 0, false, COL_TEXT_MUT); lv_label_set_text(lv_obj_get_child(head, 0), "Zeitpunkt"); lv_label_set_text(stat_cell(head, 96, true, COL_TEXT_MUT), "Dauer"); lv_label_set_text(stat_cell(head, 96, true, COL_TEXT_MUT), "Gewicht"); lv_label_set_text(stat_cell(head, 58, true, COL_TEXT_MUT), ""); for (int i = 0; i < STAT_ROW_COUNT; i++) { lv_obj_t* r = lv_obj_create(card); lv_obj_set_width(r, LV_PCT(100)); lv_obj_set_height(r, LV_SIZE_CONTENT); // Jede zweite Zeile leicht abgesetzt - fuehrt das Auge ueber die Zeile, ohne dass // dafuer Trennlinien noetig waeren. lv_obj_set_style_bg_color(r, lv_color_hex(0x242424), 0); lv_obj_set_style_bg_opa(r, (i % 2) ? LV_OPA_COVER : LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(r, 0, 0); lv_obj_set_style_radius(r, 5, 0); lv_obj_set_style_pad_all(r, 5, 0); lv_obj_set_style_pad_column(r, 10, 0); lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(r, LV_OBJ_FLAG_SCROLLABLE); g_statTime[i] = stat_cell(r, 0, false, COL_TEXT_DIM); g_statDur[i] = stat_cell(r, 96, true, COL_TEXT); g_statWgt[i] = stat_cell(r, 96, true, COL_TEXT); g_statCold[i] = stat_cell(r, 58, true, lv_color_hex(0x80cbc4)); g_statRow[i] = r; lv_obj_add_flag(r, LV_OBJ_FLAG_HIDDEN); } g_statEmpty = lv_label_create(card); lv_obj_set_width(g_statEmpty, LV_PCT(100)); lv_label_set_text(g_statEmpty, "Noch keine Bezüge aufgezeichnet."); lv_obj_set_style_text_color(g_statEmpty, COL_TEXT_MUT, 0); lv_obj_set_style_pad_all(g_statEmpty, 5, 0); } // Eine Zeile fuellen. 'w' unter 0 bedeutet: ohne Waage bezogen, dann bleibt die Spalte leer // statt eine Null vorzutaeuschen. static void stat_list_set(int i, const char* dt, float d, float w, bool kalt) { if (i < 0 || i >= STAT_ROW_COUNT || !g_statRow[i]) return; lv_obj_remove_flag(g_statRow[i], LV_OBJ_FLAG_HIDDEN); lv_label_set_text(g_statTime[i], dt); lv_label_set_text_fmt(g_statDur[i], "%.1f s", d); if (w >= 0.0f) lv_label_set_text_fmt(g_statWgt[i], "%.1f g", w); // Bewusst ein einfacher Bindestrich: Der typografisch schoenere Gedankenstrich // fehlt im Zeichensatz der Schrift und erschiene als leeres Rechteck. else lv_label_set_text(g_statWgt[i], "-"); lv_label_set_text(g_statCold[i], kalt ? "kalt" : ""); } static void stat_list_finish(int used) { for (int i = used; i < STAT_ROW_COUNT; i++) if (g_statRow[i]) lv_obj_add_flag(g_statRow[i], LV_OBJ_FLAG_HIDDEN); if (g_statEmpty) { if (used > 0) lv_obj_add_flag(g_statEmpty, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(g_statEmpty, LV_OBJ_FLAG_HIDDEN); } } // Groesse der Standby-Uhr. Anders als die allgemeine Schriftgroesse braucht diese keinen // Neustart: Es haengt an genau EINEM Textfeld, das sich sofort umstellen laesst. enum { UHR_KLEIN = 0, UHR_MITTEL = 1, UHR_GROSS = 2 }; static uint8_t g_clockSize = UHR_GROSS; static lv_obj_t* g_clockSizeBtn[3] = {}; static const lv_font_t* clock_font() { switch (g_clockSize) { #if JC_PANEL_TYPE == WS_PANEL_7H // 7 Zoll, 1280x720: Hier war selbst die groesste Stufe noch zu klein. case UHR_KLEIN: return &lv_font_clock_180; case UHR_MITTEL: return &lv_font_clock_270; default: return &lv_font_clock_360; #else // 4,3 Zoll, 480x480: Groessere Stufen passen schlicht nicht auf das Panel. case UHR_KLEIN: return &lv_font_clock_120; case UHR_MITTEL: return &lv_font_clock_180; default: return &lv_font_clock_240; #endif } } static void clock_size_apply() { if (g_standbyClock) lv_obj_set_style_text_font(g_standbyClock, clock_font(), 0); } static void scroll_ohne_nachlauf(lv_obj_t* o) { #if JC_PANEL_TYPE == WS_PANEL_7H lv_obj_clear_flag(o, LV_OBJ_FLAG_SCROLL_MOMENTUM); #else (void)o; #endif } 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); // Deckend in der Hintergrundfarbe statt durchsichtig. Sieht identisch aus - die Ebenen // darunter haben dieselbe Farbe -, erspart LVGL beim Scrollen aber, sie mitzuzeichnen // und durchzumischen. lv_obj_set_style_bg_color(p, COL_BG, 0); lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 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); scroll_ohne_nachlauf(p); 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_coldex(const MachineState& st); // Definition auf der Cold-Extraction-Seite 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_COLDEX) populate_coldex(g_state); 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 allozieren // und koennte den LVGL-Speicherpool sprengen. So bleibt das Fade speicher-neutral. // Dauer der weichen Uebergaenge (Standby-Uhr, Aufweckdialog, Bezugsuebersicht). // // Auf dem Waveshare-Panel: 0, also ohne Animation. Eine Deckkraft-Animation ist bei einem // Bildspeicher in RGB888 das Teuerste, was die Oberflaeche tun kann - fuer jedes Einzelbild // muss die gesamte Flaeche unter der Ebene neu gezeichnet und mit ihr gemischt werden. Auf // 1280x720 dauert eine solche Blende deutlich laenger als die 450 ms, die sie dauern soll; // spuerbar war das als Verzoegerung, bis die Standby-Uhr erschien und bis nach dem // Fingertipp der Aufweckdialog kam. Ohne Animation erscheinen beide sofort. #if JC_PANEL_TYPE == WS_PANEL_7H static const uint32_t UI_FADE_MS = 0; #else static const uint32_t UI_FADE_MS = 450; // ruhige, unaufdringliche Dauer #endif 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; if (ms == 0) { // ohne Animation: sofort sichtbar lv_anim_delete(obj, ui_fade_exec_cb); lv_obj_set_style_opa(obj, LV_OPA_COVER, 0); lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); 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); if (ms == 0) { // ohne Animation: sofort weg lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_opa(obj, LV_OPA_COVER, 0); return; } 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; } // Der Aufweckdialog schliesst sich von selbst, wenn niemand entscheidet. Sonst bliebe das // Display hell stehen, weil der Dialog auf normale Helligkeit schaltet - jemand tippt im // Vorbeigehen versehentlich auf die Uhr, und die Maschine leuchtet die ganze Nacht. // Zeitablauf wird wie "Abbrechen" gewertet: zurueck in die Standby-Helligkeit. #define WAKE_DIALOG_TIMEOUT_MS 30000 static lv_timer_t* g_wakeTimeout = nullptr; static void wake_timeout_stop() { if (g_wakeTimeout) { lv_timer_delete(g_wakeTimeout); g_wakeTimeout = nullptr; } } static void wake_timeout_cb(lv_timer_t* t); // weiter unten, braucht wake_cancel() 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); wake_timeout_stop(); g_wakeTimeout = lv_timer_create(wake_timeout_cb, WAKE_DIALOG_TIMEOUT_MS, nullptr); lv_timer_set_repeat_count(g_wakeTimeout, 1); } static void wake_confirm_hide() { if (g_wakeConfirm) lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); } // Einzige Quelle der Wahrheit dafuer, ob der Aufweck-Dialog die Auswahl "Espresso oder Cold // Extraction" wirklich anbietet. Wird an ZWEI Stellen gebraucht: beim Aufbau des Dialogs und // beim Absenden. Standen dort verschiedene Bedingungen, konnte der Dialog nur "Aufwecken" // zeigen, aber trotzdem "wakeEspresso" senden - und damit das Auswahlfenster der S3 // schliessen. Dann war die Frage auf beiden Seiten weg (der Schalterweg hat diese Luecke // nicht, weil er allein am S3 haengt). static bool wake_choice_offered() { return g_state.cxWakeChoice || (g_state.cxEnabled && g_state.cxArmable && g_state.cxAskOnWake); } static void standby_screen_cb(lv_event_t*) { wake_confirm_show(); } // Tipp auf Standby-Uhr -> nachfragen static void wake_cancel() { // Abbrechen -> zurueck in die Standby-Helligkeit wake_timeout_stop(); wake_confirm_hide(); g_wakeDialogOpen = false; if (g_state.standbyActive) hal_backlight(standby_backlight()); } static void wake_no_cb(lv_event_t*) { wake_cancel(); } static void wake_timeout_cb(lv_timer_t*) { // niemand hat entschieden -> wie Abbrechen g_wakeTimeout = nullptr; // Timer laeuft nur einmal, loescht sich selbst wake_cancel(); } static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 beendet den Standby wake_timeout_stop(); 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) return; // "wakeEspresso" beendet den Standby UND schliesst das Auswahlfenster der S3 - das ist // nur dann richtig, wenn dieser Dialog die Auswahl gerade wirklich angeboten hat. Sonst // "deactivateStandby": dann oeffnet die S3 ihr eigenes Auswahlfenster, und die Frage // kommt trotzdem (gleicher Weg wie beim Aufwecken ueber den Schalter). g_client->sendAction(wake_choice_offered() ? "wakeEspresso" : "deactivateStandby"); showToast(g_state.standbyActive ? "Maschine wird aufgeweckt..." : "Espresso: Kessel heizt auf.", false); } // Aufwecken direkt in die kalte Extraktion. Reihenfolge ist wichtig: erst den Modus scharf // stellen, dann den Standby beenden - sonst heizt der Wasserkreis in der Zwischenzeit an, // und genau das soll die Funktion ja verhindern. static void wake_cold_cb(lv_event_t*) { wake_timeout_stop(); wake_confirm_hide(); if (g_client) { g_client->sendAction("startColdExtraction"); if (g_state.standbyActive) g_client->sendAction("deactivateStandby"); showToast("Cold Extraction aktiviert.", 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) { // Wert und Farbe nur bei echter Aenderung setzen - beides macht den Balken sonst // bei jeder Zustandsmeldung ungueltig, obwohl sich meist nichts bewegt hat. if (lv_bar_get_value(bar) != pct) lv_bar_set_value(bar, pct, LV_ANIM_OFF); if (lv_color_to_u32(lv_obj_get_style_bg_color(bar, LV_PART_INDICATOR)) != lv_color_to_u32(col)) 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); } // Farbe der grossen Temperaturzahl. // // Die Zustandsfarbe steckt ohnehin schon im Balken darunter. Ob die Zahl selbst mitgefaerbt // wird, ist deshalb Geschmackssache: Eingefaerbt ist der Zustand auch aus dem Augenwinkel // erkennbar, weiss wirkt ruhiger und laesst die Zahl als Zahl stehen. // // Warnungen bleiben immer rot - das ist keine Gestaltung, sondern eine Meldung. static lv_color_t temp_value_color(lv_color_t zustand) { return g_tempColored ? zustand : COL_TEXT; } // ------------------------------------------------------------------------------------ // Temperaturbereich des Dashboards - vier Darstellungen // // Gemeinsam ist allen: zwei Kessel, die Zahl ist das Erste, was man sieht. Unterschiedlich // ist, was die Flaeche zusaetzlich leistet. // // Die grossen Zahlen brauchen Schriftgrade, die es vorher nicht gab (96 und 140 Pixel). // Diese beiden Schriften enthalten nur Ziffern, Komma, Punkt, Minus und Gradzeichen - mit // vollem Zeichensatz waeren sie um ein Vielfaches groesser, ohne etwas beizutragen. // ------------------------------------------------------------------------------------ // Ein Kesselfeld. 'grow' verteilt die Breite zwischen den beiden, 'valueFont' bestimmt die // Groesse der Zahl, 'flat' laesst den Kartenrahmen weg und faerbt stattdessen die Flaeche. static lv_obj_t* tempField(lv_obj_t* parent, const char* title, lv_color_t accent, lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar, uint8_t grow, const lv_font_t* valueFont, bool flat, uint32_t flatBg) { lv_obj_t* c; if (flat) { c = lv_obj_create(parent); lv_obj_set_style_bg_color(c, lv_color_hex(flatBg), 0); lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); lv_obj_set_style_border_width(c, 0, 0); lv_obj_set_style_radius(c, 0, 0); lv_obj_set_style_pad_all(c, 14, 0); } else { c = th_card(parent); lv_obj_set_style_pad_all(c, 12, 0); } lv_obj_set_height(c, LV_PCT(100)); lv_obj_set_flex_grow(c, grow); lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(c, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(c, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite lv_obj_add_event_cb(c, temp_card_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* t = lv_label_create(c); lv_label_set_text(t, title); lv_obj_set_style_text_color(t, accent, 0); lv_obj_set_style_text_font(t, font_small(), 0); *big = lv_label_create(c); lv_label_set_text(*big, "--.-"); lv_obj_set_style_text_color(*big, COL_TEXT, 0); lv_obj_set_style_text_font(*big, valueFont, 0); // Feste Breite, Text zentriert. Waechst oder schrumpft eine Beschriftung mit ihrem // Inhalt - etwa von "92,4 °C" auf "100,1 °C" -, rechnet LVGL das Flex-Layout der // gesamten Seite neu und macht sie damit komplett ungueltig. Genau das war im // Messprotokoll als "x 0..1279, y 44..719" zu sehen: der ganze Inhaltsbereich, bei // jeder Zustandsmeldung. Mit fester Breite bleibt nur die Beschriftung selbst ungueltig. lv_obj_set_width(*big, LV_PCT(100)); lv_obj_set_style_text_align(*big, LV_TEXT_ALIGN_CENTER, 0); // Auch die HOEHE muss fest sein. LVGL prueft beim Setzen eines Textes, ob sich die // Eigengroesse der Beschriftung geaendert haben koennte - ist eines der beiden Masse // frei, gilt das Layout des Elternteils als neu zu berechnen, und das macht die ganze // Seite ungueltig. Erst mit festen Massen bleibt nur die Beschriftung selbst ungueltig. lv_obj_set_height(*big, lv_font_get_line_height(valueFont) + 6); // Grosse Zahlen etwas enger setzen. Bei dieser Groesse wirken die Werksabstaende // auseinandergezogen; ein paar Pixel weniger binden die Ziffern zu einer Zahl zusammen. if (valueFont == &lv_font_maven_pro_bold_140) lv_obj_set_style_text_letter_space(*big, -3, 0); else if (valueFont == &lv_font_maven_pro_bold_96) lv_obj_set_style_text_letter_space(*big, -2, 0); *setp = lv_label_create(c); lv_label_set_text(*setp, "Soll --.-"); lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(*setp, font_text(), 0); lv_obj_set_width(*setp, LV_PCT(100)); lv_obj_set_style_text_align(*setp, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_height(*setp, lv_font_get_line_height(font_text()) + 6); *bar = lv_bar_create(c); lv_obj_set_size(*bar, LV_PCT(92), 8); lv_obj_set_style_bg_color(*bar, lv_color_hex(0x2c2c2c), 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, 4, 0); lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); lv_obj_set_style_radius(*bar, 4, LV_PART_INDICATOR); lv_bar_set_range(*bar, 0, 100); lv_bar_set_value(*bar, 0, LV_ANIM_OFF); return c; } // Flache Verlaufskurve unter einer Temperatur. Zeigt, ob die Regelung haelt - das ist die // eigentliche Frage an eine PID-Regelung, und eine Zahl kann sie grundsaetzlich nicht // beantworten. Gespeist wird sie aus derselben Sekundenabtastung wie die Verlaufsseite. static lv_obj_t* tempSpark(lv_obj_t* parent, lv_color_t col, lv_chart_series_t** ser, float jetzt, float soll) { lv_obj_t* ch = lv_chart_create(parent); lv_obj_set_size(ch, LV_PCT(96), 64); lv_chart_set_type(ch, LV_CHART_TYPE_LINE); lv_chart_set_point_count(ch, DASH_SPARK_POINTS); lv_chart_set_div_line_count(ch, 0, 0); // Schwach abgesetzte Flaeche: Ohne sie waere der Bereich vor dem ersten Messwert // vollstaendig unsichtbar - man haette gar nicht erkannt, dass dort eine Kurve gehoert. lv_obj_set_style_bg_opa(ch, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(ch, 0, 0); lv_obj_set_style_pad_all(ch, 4, 0); lv_obj_set_style_size(ch, 0, 0, LV_PART_INDICATOR); // keine Punktmarkierungen lv_obj_set_style_line_width(ch, 3, LV_PART_ITEMS); lv_obj_clear_flag(ch, LV_OBJ_FLAG_SCROLLABLE); *ser = lv_chart_add_series(ch, col, LV_CHART_AXIS_PRIMARY_Y); // Ausschnitt und Startwert sofort setzen. Ein frisch angelegtes Diagramm hat lauter // "kein Wert"-Punkte und zeichnet deshalb nichts - beim Umschalten der Darstellung // saehe man bis zur ersten Sekundenprobe eine leere Flaeche. int32_t sollT = (int32_t)(soll * 10.0f); lv_chart_set_range(ch, LV_CHART_AXIS_PRIMARY_Y, sollT - 60, sollT + 30); lv_chart_set_all_value(ch, *ser, (int32_t)(jetzt * 10.0f)); return ch; } // Temperaturbereich gemaess aktiver Darstellung (neu) aufbauen - auch beim Umschalten 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; g_sparkW = g_sparkD = nullptr; g_sparkSerW = g_sparkSerD = nullptr; g_readyBand = g_readyBandLbl = nullptr; const lv_color_t teal = lv_color_hex(0x80cbc4); lv_obj_set_style_pad_all(g_tempRow, 0, 0); lv_obj_set_style_pad_column(g_tempRow, 12, 0); lv_obj_set_style_pad_row(g_tempRow, 10, 0); lv_obj_set_flex_flow(g_tempRow, LV_FLEX_FLOW_ROW); switch (g_tempDesign) { case TD_GEWICHTET: { // Beim Espresso zaehlt die Wassertemperatur; der Dampfkessel ist Nebenwert. Also // zwei Drittel Flaeche fuer Wasser, ein Drittel fuer Dampf - die Asymmetrie ist die // Aussage, nicht ein Versehen. tempField(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW, 2, &lv_font_maven_pro_bold_140, false, 0); tempField(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD, 1, &lv_font_maven_pro_48, false, 0); break; } case TD_VERLAUF: { lv_obj_t* cw = tempField(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW, 1, &lv_font_maven_pro_bold_96, false, 0); g_sparkW = tempSpark(cw, COL_OK, &g_sparkSerW, g_state.tempW, g_state.setW); lv_obj_t* cd = tempField(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD, 1, &lv_font_maven_pro_bold_96, false, 0); g_sparkD = tempSpark(cd, COL_ACCENT, &g_sparkSerD, g_state.tempD, g_state.setD); break; } case TD_FLAECHEN: { // Keine Kartenkanten: Der Bereich ist in zwei Felder geteilt, getrennt nur durch // einen Hauch Helligkeitsunterschied. tempField(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW, 1, &lv_font_maven_pro_bold_140, true, 0x0d0d0d); tempField(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD, 1, &lv_font_maven_pro_bold_140, true, 0x151515); lv_obj_set_style_pad_column(g_tempRow, 0, 0); // Felder stossen direkt aneinander break; } default: { // TD_BAND // Ein farbiges Band ueber die volle Breite sagt den Zustand, bevor man eine Zahl // liest. Deshalb hier eine Spalte: Band oben, darunter die beiden Kessel. lv_obj_set_flex_flow(g_tempRow, LV_FLEX_FLOW_COLUMN); g_readyBand = lv_obj_create(g_tempRow); lv_obj_set_width(g_readyBand, LV_PCT(100)); lv_obj_set_height(g_readyBand, 34); lv_obj_set_style_radius(g_readyBand, 8, 0); lv_obj_set_style_border_width(g_readyBand, 0, 0); lv_obj_set_style_pad_all(g_readyBand, 0, 0); lv_obj_clear_flag(g_readyBand, LV_OBJ_FLAG_SCROLLABLE); g_readyBandLbl = lv_label_create(g_readyBand); lv_obj_set_style_text_font(g_readyBandLbl, font_text(), 0); lv_label_set_text(g_readyBandLbl, ""); lv_obj_center(g_readyBandLbl); lv_obj_t* inner = lv_obj_create(g_tempRow); lv_obj_set_width(inner, LV_PCT(100)); lv_obj_set_flex_grow(inner, 1); lv_obj_set_style_bg_opa(inner, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(inner, 0, 0); lv_obj_set_style_pad_all(inner, 0, 0); lv_obj_set_style_pad_column(inner, 12, 0); lv_obj_set_flex_flow(inner, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(inner, LV_OBJ_FLAG_SCROLLABLE); tempField(inner, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW, 1, &lv_font_maven_pro_bold_96, false, 0); tempField(inner, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD, 1, &lv_font_maven_pro_bold_96, false, 0); 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, font_title(), 0); lv_obj_set_style_text_align(*val, LV_TEXT_ALIGN_CENTER, 0); // fuer mehrzeilige Werte // Feste Masse in BEIDE Richtungen - siehe die ausfuehrliche Begruendung in tempField. // Zwei Zeilen Platz, weil manche Werte zweizeilig sind ("32,4 g / 36,0 g"). lv_obj_set_width(*val, LV_PCT(100)); lv_obj_set_height(*val, lv_font_get_line_height(font_title()) * 2 + 6); return card; } // Tipp auf die Statuszeile: Wartung -> Serviceseite, Reinigung -> Sub-Screen, // PID-Tuning -> Verlauf-Seite (dort liegen Live-Karte und Schwingungskurve) 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); else if (g_statusShown == ST_TUNE) show_page(PG_CHART); } 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); g_infoRow = ir; 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) label_set_if_changed(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) label_set_if_changed(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, font_text(), 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"); } // Sperrgrund-Code der S3 (cxBlock) in Klartext. Die S3 sendet bewusst nur den Code, damit // die State-Zeile kurz bleibt - die Texte gehoeren ins Display (analog statusKey). static String coldex_block_text(uint8_t code, float maxTemp, float tempW) { switch (code) { case 1: return "nicht freigeschaltet"; case 2: return "Standby aktiv"; case 3: return "Wartungsmodus aktiv"; case 4: return "Reinigungsassistent aktiv"; case 5: return "PID-Tuning läuft"; case 6: return "Wasser-Sensor liefert keinen gültigen Wert"; case 7: { char b[72]; snprintf(b, sizeof(b), "Wasserkessel zu warm (%.1f °C, benötigt < %.1f °C)", (double)tempW, (double)maxTemp); return String(b); } default: return ""; } } // Cold Extraction: gezielt start/stop senden (nicht toggle), damit ein abgewiesener Befehl // den Schalter nicht in einen Zustand bringt, den die S3 nie bestaetigt. static void coldex_cb(lv_event_t* e) { bool want = sw_get((lv_obj_t*)lv_event_get_target(e)); if (g_client) g_client->sendAction(want ? "startColdExtraction" : "stopColdExtraction"); } // 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); // Cold Extraction hat eine eigene Seite (PG_COLDEX) - Schalter und Parameter stehen dort. } // ===================================================================================== // 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, font_title(), 0); } // ===================================================================================== // Verlauf (Chart): Wasser/Dampf + Zusatzsensor (Gehaeuse/Tassenablage), ~2 min, // mit automatisch skalierter Temperatur-Achse und Y-Skala-Beschriftung links // ===================================================================================== // Zeitfenster-/Leistungs-Buttons gemaess Auswahl einfaerben, Titel aktualisieren static void chart_win_btns_refresh() { for (int i = 0; i < 4; i++) { if (!g_chartWinBtn[i]) continue; bool sel = (g_chartWin == i); lv_obj_set_style_bg_color(g_chartWinBtn[i], sel ? COL_ACCENT : COL_CARD2, 0); lv_obj_set_style_text_color(lv_obj_get_child(g_chartWinBtn[i], 0), sel ? lv_color_hex(0x000000) : COL_TEXT, 0); } if (g_chartDutyBtn) { lv_obj_set_style_bg_color(g_chartDutyBtn, g_chartDuty ? COL_ACCENT : COL_CARD2, 0); lv_obj_set_style_text_color(lv_obj_get_child(g_chartDutyBtn, 0), g_chartDuty ? lv_color_hex(0x000000) : COL_TEXT, 0); } if (g_chartTitle) lv_label_set_text_fmt(g_chartTitle, "Temperatur-Verlauf (%u min)", (unsigned)CHART_WIN_MIN[g_chartWin]); } static void chart_redraw(); // Definition weiter unten static void chart_win_btn_cb(lv_event_t* e) { int idx = (int)(intptr_t)lv_event_get_user_data(e); if (idx == (int)g_chartWin) return; g_chartWin = (uint8_t)idx; { Preferences prefs; prefs.begin("ui", false); prefs.putUChar("chartwin", g_chartWin); prefs.end(); } chart_win_btns_refresh(); chart_redraw(); // neues Fenster sofort aus der Historie zeichnen } static void chart_duty_btn_cb(lv_event_t*) { g_chartDuty = !g_chartDuty; { Preferences prefs; prefs.begin("ui", false); prefs.putBool("chartduty", g_chartDuty); prefs.end(); } chart_win_btns_refresh(); if (g_chart && g_serDutyW) { lv_chart_hide_series(g_chart, g_serDutyW, !g_chartDuty); lv_chart_hide_series(g_chart, g_serDutyD, !g_chartDuty); } if (g_legDuty) { if (g_chartDuty) lv_obj_remove_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN); } chart_redraw(); // Verlaufskurven des Dashboards (Darstellung "Verlauf") mitfuehren. Der Ausschnitt // haengt am Sollwert, damit die Abweichung sichtbar wird und nicht in der Skala // untergeht - eine feste Skala von 0 bis 130 Grad zeigte nur eine gerade Linie. if (g_sparkSerW && g_sparkW) { int32_t soll = (int32_t)(g_state.setW * 10.0f); lv_chart_set_range(g_sparkW, LV_CHART_AXIS_PRIMARY_Y, soll - 60, soll + 30); lv_chart_set_next_value(g_sparkW, g_sparkSerW, (int32_t)(g_state.tempW * 10.0f)); } if (g_sparkSerD && g_sparkD) { int32_t soll = (int32_t)(g_state.setD * 10.0f); lv_chart_set_range(g_sparkD, LV_CHART_AXIS_PRIMARY_Y, soll - 120, soll + 60); lv_chart_set_next_value(g_sparkD, g_sparkSerD, (int32_t)(g_state.tempD * 10.0f)); } } static void build_chart(lv_obj_t* p) { lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); // Kopfzeile: Titel links, Zeitfenster-Umschalter + Heizleistung-Toggle rechts lv_obj_t* hdr = row(p, nullptr); g_chartTitle = section_title(hdr, "Temperatur-Verlauf (2 min)"); lv_obj_t* btns = lv_obj_create(hdr); lv_obj_set_size(btns, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(btns, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(btns, 0, 0); lv_obj_set_style_pad_all(btns, 0, 0); lv_obj_set_style_pad_column(btns, 6, 0); lv_obj_set_flex_flow(btns, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(btns, LV_OBJ_FLAG_SCROLLABLE); static const char* winNames[4] = { "2 min", "10 min", "30 min", "60 min" }; for (int i = 0; i < 4; i++) { g_chartWinBtn[i] = th_button(btns, winNames[i], COL_CARD2, COL_TEXT); lv_obj_set_style_pad_hor(g_chartWinBtn[i], 10, 0); lv_obj_set_style_pad_ver(g_chartWinBtn[i], 5, 0); lv_obj_set_style_text_font(lv_obj_get_child(g_chartWinBtn[i], 0), font_small(), 0); lv_obj_add_event_cb(g_chartWinBtn[i], chart_win_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } g_chartDutyBtn = th_button(btns, "Leistung", COL_CARD2, COL_TEXT); lv_obj_set_style_pad_hor(g_chartDutyBtn, 10, 0); lv_obj_set_style_pad_ver(g_chartDutyBtn, 5, 0); lv_obj_set_style_text_font(lv_obj_get_child(g_chartDutyBtn, 0), font_small(), 0); lv_obj_add_event_cb(g_chartDutyBtn, chart_duty_btn_cb, LV_EVENT_CLICKED, nullptr); chart_win_btns_refresh(); // PID-Tuning-Live-Karte: nur waehrend/kurz nach einem AutoTune-Lauf sichtbar // (Inhalt kommt aus ui_update; die Statuszeilen-Meldung ist antippbar und fuehrt hierher) g_tuneCard = th_card(p); lv_obj_set_width(g_tuneCard, LV_PCT(100)); lv_obj_set_height(g_tuneCard, LV_SIZE_CONTENT); lv_obj_set_flex_flow(g_tuneCard, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(g_tuneCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(g_tuneCard, 12, 0); lv_obj_clear_flag(g_tuneCard, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_tuneCard, LV_OBJ_FLAG_HIDDEN); g_tuneCardSpin = lv_spinner_create(g_tuneCard); lv_spinner_set_anim_params(g_tuneCardSpin, 1000, 60); lv_obj_set_size(g_tuneCardSpin, 28, 28); lv_obj_set_style_arc_width(g_tuneCardSpin, 4, LV_PART_MAIN); lv_obj_set_style_arc_width(g_tuneCardSpin, 4, LV_PART_INDICATOR); lv_obj_set_style_arc_color(g_tuneCardSpin, COL_CARD2, LV_PART_MAIN); lv_obj_set_style_arc_color(g_tuneCardSpin, COL_WARN, LV_PART_INDICATOR); lv_obj_t* tcol = lv_obj_create(g_tuneCard); lv_obj_set_size(tcol, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(tcol, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(tcol, 0, 0); lv_obj_set_style_pad_all(tcol, 0, 0); lv_obj_set_style_pad_row(tcol, 2, 0); lv_obj_set_flex_flow(tcol, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(tcol, LV_OBJ_FLAG_SCROLLABLE); g_tuneCardTitle = lv_label_create(tcol); lv_label_set_text(g_tuneCardTitle, "PID-Tuning läuft..."); lv_obj_set_style_text_color(g_tuneCardTitle, COL_WARN, 0); lv_obj_set_style_text_font(g_tuneCardTitle, font_text(), 0); g_tuneCardBody = lv_label_create(tcol); lv_label_set_text(g_tuneCardBody, "--"); lv_obj_set_style_text_color(g_tuneCardBody, COL_TEXT, 0); 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); // fest; Zeit je Punkt haengt am Fenster lv_chart_set_div_line_count(g_chart, 5, 0); lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, 800, 1600); // Zehntelgrad (feinere Kurven) lv_chart_set_range(g_chart, LV_CHART_AXIS_SECONDARY_Y, 0, 100); // Heizleistung (%) 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); g_serDutyW = lv_chart_add_series(g_chart, COL_DUTY_W, LV_CHART_AXIS_SECONDARY_Y); g_serDutyD = lv_chart_add_series(g_chart, COL_DUTY_D, LV_CHART_AXIS_SECONDARY_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); lv_chart_set_all_value(g_chart, g_serDutyW, LV_CHART_POINT_NONE); lv_chart_set_all_value(g_chart, g_serDutyD, LV_CHART_POINT_NONE); lv_chart_hide_series(g_chart, g_serDutyW, !g_chartDuty); lv_chart_hide_series(g_chart, g_serDutyD, !g_chartDuty); // Gestrichelte Soll-Linien (Wasser/Dampf); Position rechnet chart_redraw aus der Skala lv_color_t setCols[2] = { COL_ACCENT, lv_color_hex(0x80cbc4) }; lv_obj_t** setLines[2] = { &g_lineSetW, &g_lineSetD }; for (int i = 0; i < 2; i++) { lv_obj_t* ln = lv_line_create(g_chart); lv_obj_set_style_line_width(ln, 1, 0); lv_obj_set_style_line_color(ln, setCols[i], 0); lv_obj_set_style_line_opa(ln, LV_OPA_60, 0); lv_obj_set_style_line_dash_width(ln, 6, 0); lv_obj_set_style_line_dash_gap(ln, 6, 0); lv_obj_clear_flag(ln, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(ln, LV_OBJ_FLAG_HIDDEN); *setLines[i] = ln; } // 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], font_small(), 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 g_legDuty = lv_label_create(leg); lv_label_set_text(g_legDuty, "Leistung --"); lv_obj_set_style_text_color(g_legDuty, COL_TEXT_DIM, 0); if (!g_chartDuty) lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN); } // Zeichnet die 120 Chart-Punkte aus der 1-s-Historie (je Fenster dezimiert), skaliert die // Y-Achse ueber das sichtbare Fenster (Werte < 30 Grad = kalter/inaktiver Sensor werden // fuer die Skala ignoriert) und positioniert die gestrichelten Soll-Linien. static void chart_redraw() { if (!g_chart || !g_hist) return; const int stride = (int)CHART_WIN_MIN[g_chartWin] * 60 / 120; // Sekunden je Chart-Punkt int32_t lo = INT32_MAX, hi = INT32_MIN; // Zehntelgrad bool anyW = false, anyD = false; for (int i = 0; i < 120; i++) { int ageStart = (119 - i) * stride; // juengstes Alter im Bucket (Sekunden) int32_t vW = LV_CHART_POINT_NONE, vD = LV_CHART_POINT_NONE, vC = LV_CHART_POINT_NONE; int32_t dW = LV_CHART_POINT_NONE, dD = LV_CHART_POINT_NONE; if (ageStart < (int)g_histFill) { // Ueber den ganzen Bucket mitteln statt ein Einzelsample zu picken: das // Abtast-Raster haengt an g_histPos und wandert sonst mit jedem Sekundentick, // wodurch schnell schaltende Werte (Duty-PWM) sichtbar zwischen zwei // Kurvenbildern hin- und herspringen. Der Mittelwert ist zudem bei der // Heizleistung die ehrlichere Aussage als ein zufaelliger Momentwert. int32_t sW = 0, sD = 0, sC = 0, sDW = 0, sDD = 0; int nT = 0, nC = 0; for (int s = 0; s < stride; s++) { int age = ageStart + s; if (age >= (int)g_histFill) break; int idx = (int)g_histPos - 1 - age; if (idx < 0) idx += CHART_HIST_MAX; sW += g_hist->w[idx]; sD += g_hist->d[idx]; sDW += g_hist->dutyW[idx]; sDD += g_hist->dutyD[idx]; nT++; if (g_hist->c[idx] != CHART_HIST_NONE) { sC += g_hist->c[idx]; nC++; } } if (nT > 0) { vW = (sW + nT / 2) / nT; vD = (sD + nT / 2) / nT; dW = (sDW + nT / 2) / nT; dD = (sDD + nT / 2) / nT; if (nC > 0) vC = (sC + nC / 2) / nC; if (vW > 300) { anyW = true; if (vW < lo) lo = vW; if (vW > hi) hi = vW; } if (vD > 300) { anyD = true; if (vD < lo) lo = vD; if (vD > hi) hi = vD; } if (vC != LV_CHART_POINT_NONE && vC > 300) { if (vC < lo) lo = vC; if (vC > hi) hi = vC; } } } lv_chart_set_value_by_id(g_chart, g_serW, i, vW); lv_chart_set_value_by_id(g_chart, g_serD, i, vD); lv_chart_set_value_by_id(g_chart, g_serC, i, vC); lv_chart_set_value_by_id(g_chart, g_serDutyW, i, dW); lv_chart_set_value_by_id(g_chart, g_serDutyD, i, dD); } // Soll-Werte in die Skala einbeziehen, damit die Soll-Linien im Bild liegen - // aber nur fuer Kreise, deren Ist-Kurve im Fenster ueberhaupt sichtbar ist int32_t setWt = (int32_t)(g_state.setW * 10.0f + 0.5f); int32_t setDt = (int32_t)(g_state.setD * 10.0f + 0.5f); bool showLineW = anyW && setWt > 300; bool showLineD = anyD && setDt > 300; if (showLineW) { if (setWt < lo) lo = setWt; if (setWt > hi) hi = setWt; } if (showLineD) { if (setDt < lo) lo = setDt; if (setDt > hi) hi = setDt; } if (hi >= lo) { int loDeg = ((int)(lo / 10) - 3) / 5 * 5; // auf 5 Grad abrunden (mit Rand) int hiDeg = ((int)(hi / 10) + 7) / 5 * 5; // auf 5 Grad aufrunden (mit Rand) if (hiDeg - loDeg < 10) hiDeg = loDeg + 10; // Mindestspanne 10 Grad lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, loDeg * 10, hiDeg * 10); if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hiDeg); if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (loDeg + hiDeg) / 2); if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", loDeg); // Soll-Linien positionieren (horizontale Linie ueber die volle Plotbreite) int32_t w = lv_obj_get_content_width(g_chart); int32_t h = lv_obj_get_content_height(g_chart); struct { lv_obj_t* line; lv_point_precise_t* pts; int32_t val; bool show; } L[2] = { { g_lineSetW, g_lineSetPts[0], setWt, showLineW }, { g_lineSetD, g_lineSetPts[1], setDt, showLineD }, }; for (int i = 0; i < 2; i++) { if (!L[i].line) continue; int32_t span = (hiDeg - loDeg) * 10; if (L[i].show && w > 0 && h > 0 && span > 0 && L[i].val >= loDeg * 10 && L[i].val <= hiDeg * 10) { int32_t y = (int32_t)((int64_t)h * (hiDeg * 10 - L[i].val) / span); L[i].pts[0].x = 0; L[i].pts[0].y = 0; L[i].pts[1].x = w; L[i].pts[1].y = 0; lv_line_set_points(L[i].line, L[i].pts, 2); lv_obj_align(L[i].line, LV_ALIGN_TOP_LEFT, 0, y); lv_obj_remove_flag(L[i].line, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(L[i].line, LV_OBJ_FLAG_HIDDEN); } } } lv_chart_refresh(g_chart); } static void chart_sample_cb(lv_timer_t*) { if (!g_chart || !g_hist) return; // 1-s-Probe in die 60-min-Historie (Ringpuffer); Anzeige zeichnet chart_redraw bool caseOn = g_state.caseSensorEnabled && g_state.hasCaseTemp; g_hist->w[g_histPos] = (int16_t)(g_state.tempW * 10.0f + 0.5f); g_hist->d[g_histPos] = (int16_t)(g_state.tempD * 10.0f + 0.5f); g_hist->c[g_histPos] = caseOn ? (int16_t)(g_state.caseTemp * 10.0f + 0.5f) : CHART_HIST_NONE; float dw = g_state.dutyW, dd = g_state.dutyD; g_hist->dutyW[g_histPos] = (uint8_t)(dw < 0.0f ? 0 : (dw > 100.0f ? 100 : (int)(dw + 0.5f))); g_hist->dutyD[g_histPos] = (uint8_t)(dd < 0.0f ? 0 : (dd > 100.0f ? 100 : (int)(dd + 0.5f))); g_histPos = (uint16_t)((g_histPos + 1) % CHART_HIST_MAX); if (g_histFill < CHART_HIST_MAX) g_histFill++; chart_redraw(); if (g_legDuty && g_chartDuty) lv_label_set_text_fmt(g_legDuty, "Leistung W %d %% / D %d %%", (int)(dw + 0.5f), (int)(dd + 0.5f)); 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; // Bei aktiver Cold Extraction heizt der Wasserkreis nicht - ein Aufheiz-Countdown // waere schlicht falsch (er liefe ab, ohne dass die Maschine warm wird). if (g_state.cxActive) g_heatUpLatched = false; else 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 (Karte, blendet weich ein; nach 8 s oder per Tipp weich aus) // ===================================================================================== static void shot_sum_hide_cb(lv_timer_t*) { if (g_shotSum) ui_fade_out_hide(g_shotSum, UI_FADE_MS); g_shotSumTimer = nullptr; } static void shot_sum_tap_cb(lv_event_t*) { if (g_shotSum) ui_fade_out_hide(g_shotSum, UI_FADE_MS); if (g_shotSumTimer) { lv_timer_del(g_shotSumTimer); g_shotSumTimer = nullptr; } } // Eine Wert-Spalte der Zusammenfassung (grosser Wert, gedimmte Beschriftung darunter) static lv_obj_t* sum_stat_col(lv_obj_t* parent, const char* caption, lv_obj_t** valOut) { lv_obj_t* col = lv_obj_create(parent); 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, 2, 0); lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_grow(col, 1); lv_obj_clear_flag(col, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(col, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt auf die Karte durch (schliessen) lv_obj_t* v = lv_label_create(col); lv_label_set_text(v, "--"); lv_obj_set_style_text_color(v, COL_TEXT, 0); lv_obj_set_style_text_font(v, &lv_font_maven_pro_40, 0); lv_obj_clear_flag(v, LV_OBJ_FLAG_CLICKABLE); lv_obj_t* c = lv_label_create(col); lv_label_set_text(c, caption); lv_obj_set_style_text_color(c, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(c, font_small(), 0); lv_obj_clear_flag(c, LV_OBJ_FLAG_CLICKABLE); if (valOut) *valOut = v; return col; } static void show_shot_summary(uint32_t durMs, float weight, bool haveScale) { if (!g_shotSum) return; float durS = durMs / 1000.0f; char b[24]; snprintf(b, sizeof(b), "%.1f s", durS); if (g_sumVal[0]) lv_label_set_text(g_sumVal[0], b); if (haveScale) { snprintf(b, sizeof(b), "%.1f g", weight); if (g_sumVal[1]) lv_label_set_text(g_sumVal[1], b); float avg = (durS > 0.1f) ? weight / durS : 0.0f; snprintf(b, sizeof(b), "%.1f g/s", avg); if (g_sumVal[2]) lv_label_set_text(g_sumVal[2], b); } for (int i = 1; i < 3; i++) { // Gewicht/Flow nur mit Waage if (!g_sumCol[i]) continue; if (haveScale) lv_obj_remove_flag(g_sumCol[i], LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_sumCol[i], LV_OBJ_FLAG_HIDDEN); } lv_obj_move_foreground(g_shotSum); ui_fade_in(g_shotSum, UI_FADE_MS); 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); } } // ===================================================================================== // Live-Bezugsschirm (Vollbild-Overlay waehrend eines Bezugs) // ===================================================================================== // Blendet sich beim Start eines echten Bezugs (kein Flush, kein Reinigungsassistent) // weich ein: grosse Bezugszeit, Live-Gewicht + Flow samt Verlaufskurve (nur mit Waage), // Fortschritt Richtung Zielgewicht (BBW) bzw. Zielzeit (BBT) und Pre-Infusion-Phase. // Ueber das X fuer den laufenden Bezug schliessbar; Ein/Aus auf der Info-Seite (NVS). // ------------------------------------------------------------------------------------- // Aktive Pre-Infusion-Phase (1..3): bevorzugt die echte S3-Statemachine (piState), // sonst zeitbasiert aus den gelatchten Phasendauern (aeltere S3-Firmware). static int pi_phase_now(uint32_t el) { if (g_piStateS3 >= 1 && g_piStateS3 <= 3) return (int)g_piStateS3; return (el < g_piDurMs) ? 1 : ((el < g_piDurMs + g_piPauseMs) ? 2 : 3); } static void brew_live_hide() { if (!g_brewLiveShown) return; g_brewLiveShown = false; if (g_brewScreen) ui_fade_out_hide(g_brewScreen, UI_FADE_MS); } static void brew_live_close_cb(lv_event_t*) { g_brewLiveClosed = true; // nur fuer den laufenden Bezug; naechster Bezug oeffnet wieder brew_live_hide(); } static void brew_live_show() { if (!g_brewScreen || g_brewLiveShown) return; bool scale = g_state.scaleEnabled && g_state.scaleConnected; // Ohne Waage keine Gewicht-/Flow-Bloecke und keine Kurve - der Timer traegt den Schirm if (g_bzWeightBlock) { if (scale) lv_obj_remove_flag(g_bzWeightBlock, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_bzWeightBlock, LV_OBJ_FLAG_HIDDEN); } if (g_bzFlowBlock) { if (scale) lv_obj_remove_flag(g_bzFlowBlock, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_bzFlowBlock, LV_OBJ_FLAG_HIDDEN); } if (g_bzChartWrap) { if (scale) lv_obj_remove_flag(g_bzChartWrap, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_bzChartWrap, LV_OBJ_FLAG_HIDDEN); } if (g_bzChart && g_bzSer) { lv_chart_set_all_value(g_bzChart, g_bzSer, LV_CHART_POINT_NONE); lv_chart_set_range(g_bzChart, LV_CHART_AXIS_PRIMARY_Y, 0, 200); // 0..2 g/s Startskala } g_bzFlowMax = 0.0f; g_bzTick = 0; if (g_bzPhase) lv_obj_add_flag(g_bzPhase, LV_OBJ_FLAG_HIDDEN); if (g_bzBarRow) lv_obj_add_flag(g_bzBarRow, LV_OBJ_FLAG_HIDDEN); g_brewLiveShown = true; ui_fade_in(g_brewScreen, 300); // etwas fixer als UI_FADE_MS - der Bezug laeuft schon } // 100-ms-Takt aus dash_timers_cb: Werte, Phase, Kurve und Zielfortschritt nachziehen static void brew_live_tick() { if (!g_brewLiveShown) return; char b[48]; float el = g_shotFrozenMs / 1000.0f; snprintf(b, sizeof(b), "%.1f s", el); label_set_if_changed(g_bzTimer, b); bool scale = g_state.scaleEnabled && g_state.scaleConnected; float w = g_state.weight; if (w > -0.05f && w <= 0.0f) w = 0.0f; // kein "-0.0 g" float f = g_state.flowRate; if (f < 0.0f) f = 0.0f; if (scale) { snprintf(b, sizeof(b), "%.1f g", w); label_set_if_changed(g_bzWeight, b); snprintf(b, sizeof(b), "%.1f g/s", f); label_set_if_changed(g_bzFlow, b); // Flow-Kurve alle 200 ms fuettern (150 Punkte = 30-s-Fenster, aeltere rutschen raus) if (g_bzChart && g_bzSer && (++g_bzTick & 1) == 0) { lv_chart_set_next_value(g_bzChart, g_bzSer, (int32_t)(f * 100.0f + 0.5f)); if (f > g_bzFlowMax) { g_bzFlowMax = f; int hi = (int)(g_bzFlowMax * 125.0f); // 25 % Luft ueber dem Maximum if (hi < 200) hi = 200; // mindestens 0..2 g/s lv_chart_set_range(g_bzChart, LV_CHART_AXIS_PRIMARY_Y, 0, hi); } } } // Pre-Infusion-Phase (nur bei PI-Bezug) if (g_bzPhase) { if (g_piShotMode) { int phase = pi_phase_now(g_shotFrozenMs); if (phase <= 2) { uint32_t endMs = (phase == 1) ? g_piDurMs : (g_piDurMs + g_piPauseMs); uint32_t remainMs = (g_shotFrozenMs < endMs) ? (endMs - g_shotFrozenMs) : 0; const char* pname = (phase == 1) ? (g_piColdMode ? "Vorbenetzung" : "Pre-Infusion") : "Pause"; snprintf(b, sizeof(b), "%s - noch %.1f s", pname, remainMs / 1000.0f); } else if (g_piColdMode && g_state.cxResting) { snprintf(b, sizeof(b), "Extraktion - Pumpenpause"); } else { snprintf(b, sizeof(b), "Extraktion"); } label_set_if_changed(g_bzPhase, b); lv_obj_remove_flag(g_bzPhase, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_bzPhase, LV_OBJ_FLAG_HIDDEN); } } // Zielfortschritt: Brew-by-Weight (Gewicht) vor Brew-by-Time (Zeit), sonst versteckt if (g_bzBarRow && g_bzBar && g_bzBarLbl) { // Ein kalter Bezug hat sein eigenes Ziel (cxTarget) und ersetzt Brew-by-Weight - // sonst stuende hier das Espresso-Ziel. const bool cxShotNow = g_state.cxShot && g_state.cxTarget > 0.0f; const float wTarget = cxShotNow ? g_state.cxTarget : g_state.bbwTarget; bool bbw = scale && (cxShotNow || (g_state.bbwEnabled && g_state.bbwTarget > 0.0f)); if (bbw || (g_shotBbtMode && g_bbtTarget > 0.0f)) { int pct; if (bbw) { pct = (int)(w / wTarget * 100.0f + 0.5f); snprintf(b, sizeof(b), "%.1f g / %.1f g", w, wTarget); } else { pct = (int)(el / g_bbtTarget * 100.0f + 0.5f); snprintf(b, sizeof(b), "%.1f s / %.1f s", el, g_bbtTarget); } if (pct < 0) pct = 0; if (pct > 100) pct = 100; lv_bar_set_value(g_bzBar, pct, LV_ANIM_OFF); label_set_if_changed(g_bzBarLbl, b); lv_obj_remove_flag(g_bzBarRow, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_bzBarRow, LV_OBJ_FLAG_HIDDEN); } } } // Beschriftete Wert-Zeile des Live-Bezugsschirms (Caption gedimmt, Wert gross darunter) static lv_obj_t* bz_stat_block(lv_obj_t* parent, const char* caption, lv_obj_t** valOut, const lv_font_t* font, lv_color_t col) { lv_obj_t* blk = lv_obj_create(parent); lv_obj_set_size(blk, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(blk, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(blk, 0, 0); lv_obj_set_style_pad_all(blk, 0, 0); lv_obj_set_style_pad_row(blk, 0, 0); lv_obj_set_flex_flow(blk, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(blk, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* c = lv_label_create(blk); lv_label_set_text(c, caption); lv_obj_set_style_text_color(c, COL_TEXT_MUT, 0); lv_obj_set_style_text_font(c, font_small(), 0); lv_obj_t* v = lv_label_create(blk); lv_label_set_text(v, "--"); lv_obj_set_style_text_color(v, col, 0); lv_obj_set_style_text_font(v, font, 0); if (valOut) *valOut = v; return blk; } static void build_brew_live_screen() { g_brewScreen = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_brewScreen, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_bg_color(g_brewScreen, COL_BG, 0); lv_obj_set_style_bg_opa(g_brewScreen, LV_OPA_COVER, 0); lv_obj_set_style_border_width(g_brewScreen, 0, 0); lv_obj_set_style_radius(g_brewScreen, 0, 0); lv_obj_set_style_pad_all(g_brewScreen, 16, 0); lv_obj_set_style_pad_row(g_brewScreen, 10, 0); lv_obj_set_flex_flow(g_brewScreen, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(g_brewScreen, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_brewScreen, LV_OBJ_FLAG_CLICKABLE); // schirmt die UI darunter ab // Kopfzeile: Titel links, Schliessen-X rechts lv_obj_t* top = lv_obj_create(g_brewScreen); lv_obj_set_size(top, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(top, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(top, 0, 0); lv_obj_set_style_pad_all(top, 0, 0); lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(top, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(top, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* title = lv_label_create(top); lv_label_set_text(title, "Bezug läuft"); lv_obj_set_style_text_color(title, COL_ACCENT, 0); lv_obj_set_style_text_font(title, font_title(), 0); lv_obj_t* closeBtn = th_button(top, LV_SYMBOL_CLOSE, COL_CARD2, COL_TEXT); lv_obj_set_size(closeBtn, 56, 40); lv_obj_add_event_cb(closeBtn, brew_live_close_cb, LV_EVENT_CLICKED, nullptr); // Hauptbereich: Wertespalte links, Flow-Kurve rechts lv_obj_t* main = lv_obj_create(g_brewScreen); lv_obj_set_width(main, LV_PCT(100)); lv_obj_set_flex_grow(main, 1); lv_obj_set_style_bg_opa(main, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(main, 0, 0); lv_obj_set_style_pad_all(main, 0, 0); lv_obj_set_style_pad_column(main, 16, 0); lv_obj_set_flex_flow(main, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(main, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* left = lv_obj_create(main); lv_obj_set_size(left, 300, LV_PCT(100)); lv_obj_set_style_bg_opa(left, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(left, 0, 0); lv_obj_set_style_pad_all(left, 0, 0); lv_obj_set_style_pad_row(left, 10, 0); lv_obj_set_flex_flow(left, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(left, LV_OBJ_FLAG_SCROLLABLE); bz_stat_block(left, "ZEIT", &g_bzTimer, &lv_font_maven_pro_48, COL_ACCENT); g_bzWeightBlock = bz_stat_block(left, "GEWICHT", &g_bzWeight, &lv_font_maven_pro_48, COL_TEXT); g_bzFlowBlock = bz_stat_block(left, "FLOW", &g_bzFlow, font_title(), COL_TEXT); g_bzPhase = lv_label_create(left); lv_label_set_text(g_bzPhase, ""); lv_obj_set_style_text_color(g_bzPhase, COL_ACCENT, 0); lv_obj_set_style_text_font(g_bzPhase, font_text(), 0); lv_obj_add_flag(g_bzPhase, LV_OBJ_FLAG_HIDDEN); g_bzChartWrap = lv_obj_create(main); lv_obj_set_height(g_bzChartWrap, LV_PCT(100)); lv_obj_set_flex_grow(g_bzChartWrap, 1); lv_obj_set_style_bg_opa(g_bzChartWrap, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_bzChartWrap, 0, 0); lv_obj_set_style_pad_all(g_bzChartWrap, 0, 0); lv_obj_set_style_pad_row(g_bzChartWrap, 4, 0); lv_obj_set_flex_flow(g_bzChartWrap, LV_FLEX_FLOW_COLUMN); lv_obj_clear_flag(g_bzChartWrap, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* chartCap = lv_label_create(g_bzChartWrap); lv_label_set_text(chartCap, "Flow-Verlauf (letzte 30 s)"); lv_obj_set_style_text_color(chartCap, COL_TEXT_MUT, 0); lv_obj_set_style_text_font(chartCap, font_small(), 0); g_bzChart = lv_chart_create(g_bzChartWrap); lv_obj_set_width(g_bzChart, LV_PCT(100)); lv_obj_set_flex_grow(g_bzChart, 1); lv_obj_set_style_bg_color(g_bzChart, COL_CARD, 0); lv_obj_set_style_border_color(g_bzChart, COL_BORDER, 0); lv_obj_set_style_border_width(g_bzChart, 1, 0); lv_obj_set_style_radius(g_bzChart, 8, 0); lv_obj_set_style_line_width(g_bzChart, 3, LV_PART_ITEMS); lv_obj_set_style_width(g_bzChart, 0, LV_PART_INDICATOR); // keine Punkt-Marker lv_obj_set_style_height(g_bzChart, 0, LV_PART_INDICATOR); lv_chart_set_type(g_bzChart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(g_bzChart, 150); // 150 * 200 ms = 30 s lv_chart_set_update_mode(g_bzChart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_div_line_count(g_bzChart, 4, 0); lv_chart_set_range(g_bzChart, LV_CHART_AXIS_PRIMARY_Y, 0, 200); // Flow * 100 (0..2 g/s Start) g_bzSer = lv_chart_add_series(g_bzChart, COL_ACCENT, LV_CHART_AXIS_PRIMARY_Y); lv_chart_set_all_value(g_bzChart, g_bzSer, LV_CHART_POINT_NONE); // Fusszeile: Fortschritt Richtung Zielgewicht/Zielzeit g_bzBarRow = lv_obj_create(g_brewScreen); lv_obj_set_size(g_bzBarRow, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(g_bzBarRow, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_bzBarRow, 0, 0); lv_obj_set_style_pad_all(g_bzBarRow, 0, 0); lv_obj_set_style_pad_row(g_bzBarRow, 4, 0); lv_obj_set_flex_flow(g_bzBarRow, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(g_bzBarRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(g_bzBarRow, LV_OBJ_FLAG_SCROLLABLE); g_bzBarLbl = lv_label_create(g_bzBarRow); lv_label_set_text(g_bzBarLbl, ""); lv_obj_set_style_text_color(g_bzBarLbl, COL_TEXT_DIM, 0); lv_obj_set_style_text_font(g_bzBarLbl, font_text(), 0); g_bzBar = lv_bar_create(g_bzBarRow); lv_obj_set_size(g_bzBar, LV_PCT(100), 14); lv_bar_set_range(g_bzBar, 0, 100); lv_obj_set_style_radius(g_bzBar, 7, 0); lv_obj_set_style_bg_color(g_bzBar, COL_CARD2, 0); lv_obj_set_style_radius(g_bzBar, 7, LV_PART_INDICATOR); lv_obj_set_style_bg_color(g_bzBar, COL_ACCENT, LV_PART_INDICATOR); lv_obj_add_flag(g_bzBarRow, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(g_brewScreen, LV_OBJ_FLAG_HIDDEN); } // ===================================================================================== // 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) { // Jede Funktion in einer eigenen Karte - vorher standen alle Zeilen unter blossen // Zwischenueberschriften untereinander und waren kaum auseinanderzuhalten. lv_obj_t* cPI = group_card(p, "Pre-Infusion", "Pumpe an - aus - an, vor dem eigentlichen Bezug"); swPI = add_switch(cPI, "Aktiv"); taPiDur = add_num(cPI, "Dauer (s)"); taPiPause = add_num(cPI, "Pause (s)"); lv_obj_t* cBBT = group_card(p, "Brew-by-Time", "Beendet den Bezug nach der Zielzeit"); swBBT = add_switch(cBBT, "Aktiv"); taBbtSecs = add_num(cBBT, "Zielzeit (s)"); lv_obj_t* cBBW = group_card(p, "Brew-by-Weight", "Beendet den Bezug beim Zielgewicht (braucht Waage)"); swBBW = add_switch(cBBW, "Aktiv"); taBbwTarget= add_num(cBBW, "Zielgewicht (g)"); taBbwOffset= add_num(cBBW, "Offset (g)"); lv_obj_t* cSBT = group_card(p, "Dampf-Timer", "Beendet den Dampfbezug automatisch"); swSBT = add_switch(cSBT, "Aktiv"); taSbtSecs = add_num(cSBT, "Zielzeit (s)"); #if JC_PANEL_TYPE == WS_PANEL_7H // Zwei Spalten: links, was den Bezug startet, rechts, was ihn beendet. page_grid(p, PAGE_COLS_2, PAGE_ROWS_2C); grid_at(cPI, 0, 0); grid_at(cBBW, 1, 0); grid_at(cBBT, 0, 1); grid_at(cSBT, 1, 1); // Speichern frei unter den Karten, nicht in einer davon: Der Knopf speichert die ganze // Seite und wuerde in einer einzelnen Karte so wirken, als gaelte er nur fuer diese. lv_obj_t* sb = th_accent_button(p, "Brühen speichern"); lv_obj_add_event_cb(sb, send_save_brew, LV_EVENT_CLICKED, nullptr); lv_obj_set_width(sb, LV_SIZE_CONTENT); lv_obj_set_grid_cell(sb, LV_GRID_ALIGN_END, 0, 2, LV_GRID_ALIGN_START, 2, 1); #else add_save_button(p, "Brühen speichern", send_save_brew); #endif } // ===================================================================================== // Cold Extraction (eigene Seite) // ===================================================================================== static void send_save_coldex(lv_event_t*) { if (!g_client) return; String f; f += "\"cxEnabled\":"; f += boolStr(sw_get(swCxEnabled)); f += ","; f += "\"cxMaxTemp\":"; f += String(ta_f(taCxMaxTemp), 1); f += ","; f += "\"cxPreInf\":"; f += String(ta_f(taCxPreInf), 1); f += ","; f += "\"cxPreInfDuty\":"; f += String(ta_f(taCxPreInfDuty), 1); f += ","; f += "\"cxPulseMs\":"; f += String((int)ta_f(taCxPulseMs)); f += ","; f += "\"cxDuty\":"; f += String(ta_f(taCxDuty), 1); f += ","; f += "\"cxTarget\":"; f += String(ta_f(taCxTarget), 1); f += ","; f += "\"cxMaxSecs\":"; f += String(ta_f(taCxMaxSecs), 1); f += ","; f += "\"cxPumpMaxRun\":"; f += String(ta_f(taCxPumpMaxRun), 1); f += ","; f += "\"cxPumpRest\":"; f += String(ta_f(taCxPumpRest), 1); f += ","; f += "\"cxAskOnWake\":"; f += boolStr(sw_get(swCxAskOnWake)); g_client->sendCommand("saveBrew", f); g_cxEditMs = millis(); showToast("Speichere Cold Extraction...", false); } static void build_coldex(lv_obj_t* p) { lv_obj_t* cMode = group_card(p, "Modus", "Schaltet die Wasserheizung ab. Auch im Standby und bei warmem Kessel schaltbar; " "bezogen werden kann erst unterhalb der Freigabetemperatur."); swColdEx = add_switch(cMode, "Cold Extraction aktiv"); lv_obj_add_event_cb(swColdEx, coldex_cb, LV_EVENT_VALUE_CHANGED, nullptr); lblColdExHint = lv_label_create(cMode); lv_label_set_long_mode(lblColdExHint, LV_LABEL_LONG_WRAP); lv_obj_set_width(lblColdExHint, LV_PCT(100)); lv_obj_set_style_text_color(lblColdExHint, COL_TEXT_DIM, 0); lv_label_set_text(lblColdExHint, ""); lv_obj_t* cBase = group_card(p, "Grundeinstellung"); swCxEnabled = add_switch(cBase, "Funktion freigeschaltet"); taCxMaxTemp = add_num(cBase, "Freigabe bis (°C)"); swCxAskOnWake = add_switch(cBase, "Beim Aufwecken fragen"); lv_obj_t* cPre = group_card(p, "Vorbenetzung", "Deutlich schwächer als der Hauptbezug: Kaltes Mehl quillt nicht, volle Leistung legt " "Kanäle an, die sich nie wieder schließen."); taCxPreInf = add_num(cPre, "Dauer (s)"); taCxPreInfDuty = add_num(cPre, "Pumpenleistung (%)"); lv_obj_t* cMain = group_card(p, "Hauptbezug", "Das kalte Kaffeebett ist viel durchlässiger als ein heißes - niedrige Werte sind normal."); taCxPulseMs = add_num(cMain, "Pulsperiode (ms)"); taCxDuty = add_num(cMain, "Pumpenleistung (%)"); lv_obj_t* cEnd = group_card(p, "Bezugsende", "Ohne Waage ist das Zielgewicht wirkungslos, dann zählt die maximale Dauer."); taCxTarget = add_num(cEnd, "Zielgewicht (g)"); taCxMaxSecs = add_num(cEnd, "Max. Dauer (s)"); lv_obj_t* cPump = group_card(p, "Pumpenschutz", "Gezählt wird die kumulierte Einschaltzeit der Pumpe, nicht die Bezugsdauer."); taCxPumpMaxRun = add_num(cPump, "Max. Laufzeit (s)"); taCxPumpRest = add_num(cPump, "Zwangspause (s)"); // Der Warnhinweis stand frueher als eigene Zeile am Seitenende - weit entfernt von dem // Schalter, auf den er sich bezieht. Er gehoert in die Modus-Karte. lblCxPageHint = lv_label_create(cMode); lv_label_set_long_mode(lblCxPageHint, LV_LABEL_LONG_WRAP); lv_obj_set_width(lblCxPageHint, LV_PCT(100)); lv_obj_set_style_text_color(lblCxPageHint, COL_WARN, 0); lv_label_set_text(lblCxPageHint, ""); lv_obj_add_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN); #if JC_PANEL_TYPE == WS_PANEL_7H // Drei Spalten, oben die Karten mit Erklaerungstext, darunter die knappen. page_grid(p, PAGE_COLS_3, PAGE_ROWS_2C); grid_at(cMode, 0, 0); grid_at(cPre, 1, 0); grid_at(cEnd, 2, 0); grid_at(cBase, 0, 1); grid_at(cMain, 1, 1); grid_at(cPump, 2, 1); lv_obj_t* sbCx = th_accent_button(p, "Cold Extraction speichern"); lv_obj_add_event_cb(sbCx, send_save_coldex, LV_EVENT_CLICKED, nullptr); lv_obj_set_width(sbCx, LV_SIZE_CONTENT); lv_obj_set_grid_cell(sbCx, LV_GRID_ALIGN_END, 0, 3, LV_GRID_ALIGN_START, 2, 1); #else add_save_button(p, "Cold Extraction speichern", send_save_coldex); #endif } // Felder nur beim Oeffnen der Seite fuellen - laufende State-Pushes duerfen eine // begonnene Eingabe nicht ueberschreiben (gleiches Muster wie populate_brew). static void populate_coldex(const MachineState& st) { if (!taCxMaxTemp) return; sw_set(swCxEnabled, st.cxEnabled); sw_set(swCxAskOnWake, st.cxAskOnWake); ta_setf(taCxMaxTemp, st.cxMaxTemp, 1); ta_setf(taCxPreInf, st.cxPreInfSec, 0); ta_setf(taCxPreInfDuty, st.cxPreInfDuty, 0); ta_setf(taCxPulseMs, (float)st.cxPulseMs, 0); ta_setf(taCxDuty, st.cxDuty, 0); ta_setf(taCxTarget, st.cxTarget, 0); ta_setf(taCxMaxSecs, st.cxMaxSecs, 0); ta_setf(taCxPumpMaxRun, st.cxPumpMaxRun, 0); ta_setf(taCxPumpRest, st.cxPumpRest, 0); } // ===================================================================================== // 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, font_title(), 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, font_text(), 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); scroll_ohne_nachlauf(g_clnCfg); 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, font_text(), 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); } // Schriftgroesse waehlen. Speichert und startet neu - die Schriften stecken in bereits // angelegten Bedienelementen und lassen sich nicht nachtraeglich umhaengen. static void font_pick_cb(lv_event_t* e) { font_step_save_and_restart((uint8_t)(intptr_t)lv_event_get_user_data(e)); } static void build_service(lv_obj_t* p) { #if JC_PANEL_TYPE == WS_PANEL_7H // Vier Karten in zwei Spalten. Frueher standen hier lose Zeilen unter blossen // Zwischenueberschriften, dazwischen freistehende Knoepfe - schwer auseinanderzuhalten // und laenger als der Bildschirm. Jede Funktion ist jetzt eine Karte, und der // zugehoerige Speichern-Knopf sitzt darin: Anders als bei Bruehen und Cold Extraction // speichert hier jede Karte etwas anderes, deshalb gehoert der Knopf zur Karte. lv_obj_t* cMaint = group_card(p, "Wartungszähler"); lblMaintStatus = lv_label_create(cMaint); lv_label_set_long_mode(lblMaintStatus, LV_LABEL_LONG_WRAP); 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); taMaintInterval = add_num(cMaint, "Intervall (Bezüge)"); lv_obj_t* rowM = row(cMaint, nullptr); lv_obj_t* rb = th_button(rowM, "Zähler zurücksetzen", COL_WARN, lv_color_hex(0x000000)); lv_obj_add_event_cb(rb, send_reset_maint, LV_EVENT_CLICKED, nullptr); lv_obj_t* sbM = th_accent_button(rowM, "Speichern"); lv_obj_add_event_cb(sbM, send_save_maint, LV_EVENT_CLICKED, nullptr); lv_obj_t* cFlush = group_card(p, "Flush-Zeiten", "Spüldauer für Wasser- und Dampfkreislauf"); taFlushDur = add_num(cFlush, "Wasser-Flush (s)"); taSteamFlushDur = add_num(cFlush, "Dampf-Flush (s)"); add_save_button(cFlush, "Speichern", send_save_flush); lv_obj_t* cLight = group_card(p, "Display-Helligkeit", "Gilt für dieses Touch-Display"); taBacklightActive = add_num(cLight, "Aktiv (%)"); taBacklightStandby = add_num(cLight, "Standby-Uhr (%)"); add_save_button(cLight, "Speichern", send_save_display); lv_obj_t* cCln = group_card(p, "Reinigung & Entkalkung", "Der Assistent führt Schritt für Schritt durch den Vorgang"); swMaint2 = add_switch(cCln, "Wartungsmodus"); lv_obj_add_event_cb(swMaint2, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_t* cb = th_accent_button(cCln, "Reinigungsassistent öffnen"); lv_obj_set_width(cb, LV_PCT(100)); lv_obj_add_event_cb(cb, cln_open, LV_EVENT_CLICKED, nullptr); page_grid(p, PAGE_COLS_2, PAGE_ROWS_2C); grid_at(cMaint, 0, 0); grid_at(cFlush, 1, 0); grid_at(cLight, 0, 1); grid_at(cCln, 1, 1); #else 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); taMaintInterval = add_num(p, "Intervall (Bezüge)"); add_save_button(p, "Intervall speichern", send_save_maint); 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); 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); 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); 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); section_title(p, "Wartungsmodus (Entkalkung)"); swMaint2 = add_switch(p, "Wartungsmodus"); lv_obj_add_event_cb(swMaint2, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr); #endif } // ===================================================================================== // 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, TH_SEL(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(); } // --- Profil-Schnellwahl: Overlay mit allen Profilen zum Direkt-Laden ------------------ // Geoeffnet ueber den Profil-Chip im Header; ein Tap laedt das Profil sofort (die // Bestaetigung uebernimmt der Toast + der aktualisierte Chip aus dem naechsten State). static void prof_picker_close() { if (!g_profPicker) return; lv_obj_delete(g_profPicker); g_profPicker = nullptr; g_profPickerList = nullptr; g_profPickerInfo = nullptr; } static void prof_picker_bg_cb(lv_event_t* e) { // nur ein Tap auf die Abdunkelung selbst schliesst (Karte bubbelt nicht) if (lv_event_get_target(e) == lv_event_get_current_target(e)) prof_picker_close(); } static void prof_picker_close_cb(lv_event_t*) { prof_picker_close(); } static void prof_picker_load_cb(lv_event_t* e) { lv_obj_t* btn = (lv_obj_t*)lv_event_get_current_target(e); lv_obj_t* lbl = lv_obj_get_child(btn, 0); // Kind 0 = Name (pur), Kind 1 = optionales Haekchen if (!lbl || !g_client) return; String name = lv_label_get_text(lbl); prof_picker_close(); if (name == g_state.activeProfile && !g_state.profileDirty) { showToast(("Profil ist bereits aktiv: " + name).c_str(), false); return; } g_client->sendLoadProfile(name); showToast(("Lade Profil: " + name).c_str(), false); } static void prof_picker_fill(const String profiles[], int count) { if (!g_profPickerList) return; lv_obj_clean(g_profPickerList); if (g_profPickerInfo) { if (count == 0) { lv_label_set_text(g_profPickerInfo, "Keine Profile gespeichert."); lv_obj_remove_flag(g_profPickerInfo, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_profPickerInfo, LV_OBJ_FLAG_HIDDEN); } } for (int i = 0; i < count; i++) { bool active = (profiles[i] == g_state.activeProfile); lv_obj_t* b = lv_button_create(g_profPickerList); lv_obj_set_width(b, LV_PCT(100)); lv_obj_set_style_bg_color(b, active ? COL_ACCENT : COL_CARD2, 0); lv_obj_set_style_radius(b, 10, 0); lv_obj_set_style_pad_ver(b, 12, 0); lv_obj_set_style_pad_hor(b, 14, 0); lv_obj_set_style_shadow_width(b, 0, 0); lv_obj_t* l = lv_label_create(b); // Kind 0: purer Profilname (prof_picker_load_cb liest ihn) lv_label_set_text(l, profiles[i].c_str()); lv_obj_set_style_text_color(l, active ? lv_color_hex(0x000000) : COL_TEXT, 0); lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); if (active) { lv_obj_t* chk = lv_label_create(b); lv_label_set_text(chk, LV_SYMBOL_OK); lv_obj_set_style_text_color(chk, lv_color_hex(0x000000), 0); lv_obj_align(chk, LV_ALIGN_RIGHT_MID, 0, 0); } lv_obj_add_event_cb(b, prof_picker_load_cb, LV_EVENT_CLICKED, nullptr); } } static void prof_picker_open() { if (g_profPicker) return; g_profPicker = lv_obj_create(lv_screen_active()); lv_obj_set_size(g_profPicker, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(g_profPicker, 0, 0); lv_obj_set_style_bg_color(g_profPicker, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(g_profPicker, LV_OPA_60, 0); lv_obj_set_style_border_width(g_profPicker, 0, 0); lv_obj_set_style_radius(g_profPicker, 0, 0); lv_obj_set_style_pad_all(g_profPicker, 0, 0); lv_obj_clear_flag(g_profPicker, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(g_profPicker, prof_picker_bg_cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* card = th_card(g_profPicker); lv_obj_set_size(card, 460, 400); lv_obj_center(card); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(card, 8, 0); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t* hdr = row(card, nullptr); lv_obj_t* t = lv_label_create(hdr); lv_label_set_text(t, "Profil laden"); lv_obj_set_style_text_color(t, COL_ACCENT, 0); lv_obj_set_style_text_font(t, font_text(), 0); lv_obj_t* x = th_button(hdr, LV_SYMBOL_CLOSE, COL_CARD2, COL_TEXT); lv_obj_add_event_cb(x, prof_picker_close_cb, LV_EVENT_CLICKED, nullptr); g_profPickerInfo = lv_label_create(card); lv_label_set_text(g_profPickerInfo, "Lade Profile..."); lv_obj_set_style_text_color(g_profPickerInfo, COL_TEXT_DIM, 0); g_profPickerList = lv_obj_create(card); lv_obj_set_width(g_profPickerList, LV_PCT(100)); lv_obj_set_flex_grow(g_profPickerList, 1); lv_obj_set_style_bg_opa(g_profPickerList, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_profPickerList, 0, 0); lv_obj_set_style_pad_all(g_profPickerList, 0, 0); lv_obj_set_style_pad_row(g_profPickerList, 6, 0); lv_obj_set_flex_flow(g_profPickerList, LV_FLEX_FLOW_COLUMN); if (g_client) g_client->sendListProfiles(); // Antwort fuellt via ui_set_profiles/prof_picker_fill } static void prof_chip_cb(lv_event_t*) { prof_picker_open(); } 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, TH_SEL(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, TH_SEL(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, font_text(), 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] = { "Gewichtet", "Verlauf", "Flächen", "Band" }; 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 } // Live-Bezugsschirm ein-/ausschalten (P4-lokal in NVS gespeichert) // Temperaturzahl einfaerben oder weiss lassen. Wirkt sofort - die Farbe wird bei jeder // Zustandsmeldung neu gesetzt, es muss nichts neu aufgebaut werden. // Groesse der Standby-Uhr waehlen. Wirkt sofort - es haengt an einem einzigen Textfeld. static void clock_size_cb(lv_event_t* e) { g_clockSize = (uint8_t)(intptr_t)lv_event_get_user_data(e); { Preferences prefs; prefs.begin("ui", false); prefs.putUChar("uhrgr", g_clockSize); prefs.end(); } clock_size_apply(); for (int i = 0; i < 3; i++) if (g_clockSizeBtn[i]) { bool an = (i == g_clockSize); lv_obj_set_style_bg_color(g_clockSizeBtn[i], an ? COL_ACCENT : COL_CARD2, 0); lv_obj_set_style_text_color(g_clockSizeBtn[i], an ? lv_color_hex(0x000000) : COL_TEXT, 0); } } static void temp_color_sw_cb(lv_event_t* e) { g_tempColored = sw_get((lv_obj_t*)lv_event_get_target(e)); { Preferences prefs; prefs.begin("ui", false); prefs.putBool("tempcol", g_tempColored); prefs.end(); } } static void brew_live_sw_cb(lv_event_t* e) { g_brewLiveEnabled = sw_get((lv_obj_t*)lv_event_get_target(e)); { Preferences prefs; prefs.begin("ui", false); prefs.putBool("brewlive", g_brewLiveEnabled); prefs.end(); } if (!g_brewLiveEnabled) brew_live_hide(); // laeuft gerade ein Bezug: sofort ausblenden } // Eigener Menuepunkt "Darstellung". Die Einstellungen sassen frueher oben auf der // Info-Seite; dort standen sie zwischen Firmware-Staenden und Verbindungsdaten, // mit denen sie nichts zu tun haben. static void build_display(lv_obj_t* p) { 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(); lv_obj_t* fl = lv_label_create(dcard); lv_label_set_text(fl, "Schriftgröße (Neustart des Displays)"); lv_obj_set_style_text_color(fl, COL_TEXT_DIM, 0); lv_obj_t* frow = row(dcard, nullptr); lv_obj_set_style_pad_column(frow, 8, 0); static const char* FONT_NAMEN[3] = { "Klein", "Mittel", "Groß" }; for (uint8_t i = 0; i < 3; i++) { lv_obj_t* fb = (i == g_fontStep) ? th_accent_button(frow, FONT_NAMEN[i]) : th_button(frow, FONT_NAMEN[i], COL_CARD2, COL_TEXT); lv_obj_set_flex_grow(fb, 1); lv_obj_add_event_cb(fb, font_pick_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); } lv_obj_t* cl = lv_label_create(dcard); lv_label_set_text(cl, "Uhrgröße im Standby"); lv_obj_set_style_text_color(cl, COL_TEXT_DIM, 0); lv_obj_t* crow = row(dcard, nullptr); lv_obj_set_style_pad_column(crow, 8, 0); static const char* UHR_NAMEN[3] = { "Klein", "Mittel", "Groß" }; for (uint8_t i = 0; i < 3; i++) { lv_obj_t* b = (i == g_clockSize) ? th_accent_button(crow, UHR_NAMEN[i]) : th_button(crow, UHR_NAMEN[i], COL_CARD2, COL_TEXT); lv_obj_set_flex_grow(b, 1); lv_obj_add_event_cb(b, clock_size_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); g_clockSizeBtn[i] = b; } lv_obj_t* swTempCol = add_switch(dcard, "Temperaturzahl in der Zustandsfarbe"); sw_set(swTempCol, g_tempColored); lv_obj_add_event_cb(swTempCol, temp_color_sw_cb, LV_EVENT_VALUE_CHANGED, nullptr); swBrewLive = add_switch(dcard, "Live-Bezugsschirm (Vollbild während des Bezugs)"); sw_set(swBrewLive, g_brewLiveEnabled); lv_obj_add_event_cb(swBrewLive, brew_live_sw_cb, LV_EVENT_VALUE_CHANGED, nullptr); } static void build_info(lv_obj_t* p) { 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..."); stat_list_finish(0); 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"); stat_list_build(p); } // ===================================================================================== // 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). // LVGL macht bei jedem Setzen den Bereich der Beschriftung ungueltig - auch dann, wenn sich // der Text gar nicht geaendert hat. Auf dem Dashboard werden im Sekundentakt Werte quer ueber // den ganzen Bildschirm gesetzt; LVGL fasst die ungueltigen Bereiche zu einem zusammen, und // das ist dann die gesamte Flaeche. Gemessen waren das 240 bis 300 ms pro Sekunde, in denen // die Oberflaeche mit nichts anderem beschaeftigt war. Deshalb wird ueberall dort, wo sich // Werte nur selten aendern, vorher verglichen. static void label_set_if_changed(lv_obj_t* lbl, const char* txt) { if (!lbl || !txt) return; const char* cur = lv_label_get_text(lbl); if (!cur || strcmp(cur, txt) != 0) lv_label_set_text(lbl, txt); } // Wie oben, aber mit Formatangabe. static void label_fmt_if_changed(lv_obj_t* lbl, const char* fmt, ...) { if (!lbl) return; char buf[96]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); label_set_if_changed(lbl, buf); } // Auch ein Farbwechsel macht den Bereich ungueltig. Gleiche Farbe -> nichts tun. static void label_color_if_changed(lv_obj_t* lbl, lv_color_t col) { if (!lbl) return; lv_color_t cur = lv_obj_get_style_text_color(lbl, LV_PART_MAIN); if (lv_color_to_u32(cur) != lv_color_to_u32(col)) lv_obj_set_style_text_color(lbl, col, 0); } // 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 = pi_phase_now(el); // 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* piNamesHot[3] = { "Pre-Infusion", "Pause", "Extraktion" }; static const char* piNamesCold[3] = { "Vorbenetzung", "Pause", "Extraktion" }; const char** piNames = g_piColdMode ? piNamesCold : piNamesHot; 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); } // Live-Bezugsschirm (Werte/Kurve/Zielfortschritt im 100-ms-Takt) brew_live_tick(); // Pre-Infusion-Phasenleiste (Sichtbarkeit/Phase/Restzeit) pi_bar_update(); // Statuszeile (Einblenden/Rotation der konsolidierten Meldungen) status_line_tick(); } // ===================================================================================== // Aufbau (Header + Schublade + Seiten) // ===================================================================================== // Erst true, wenn das Dashboard steht. Kam das Panel nicht hoch (siehe display_hal.cpp), // wird ui_init nie aufgerufen - dann duerfen die uebrigen Funktionen nichts anfassen, // sonst greifen sie auf nicht angelegte LVGL-Objekte zu. static bool g_uiReady = false; // ------------------------------------------------------------------------------------ // Zeitmessung fuer langsame Bildaufbauten // // Beim 7-Zoll-Panel dauern manche Vorgaenge spuerbar lange, und aus dem Quelltext allein // ist nicht zu sehen, welcher Teil die Zeit frisst. LVGL meldet Anfang und Ende jedes // Zeichenzyklus - hier wird die Dauer gemessen und nur dann ausgegeben, wenn sie eine // Schwelle ueberschreitet. Im Normalbetrieb bleibt die Konsole still. // ------------------------------------------------------------------------------------ #define REFR_WARN_MS 200 static uint32_t g_refrStart = 0; static uint32_t g_refrPixels = 0; static uint32_t g_refrAreas = 0; static lv_area_t g_refrArea[6]; static uint8_t g_refrAreaCnt = 0; // Beim Start eines Zeichenzyklus festhalten, WIE VIEL neu gezeichnet wird. Erst damit laesst // sich unterscheiden, ob LVGL nur kleine Bereiche anfasst - dann ist das Zeichnen an sich zu // langsam - oder ob jedes Mal der ganze Bildschirm neu entsteht, weil ihn etwas unnoetig // ungueltig macht. static void refr_start_cb(lv_event_t*) { g_refrStart = lv_tick_get(); g_refrPixels = 0; g_refrAreas = 0; g_refrAreaCnt = 0; lv_display_t* d = lv_display_get_default(); if (!d) return; for (uint16_t i = 0; i < d->inv_p; i++) { if (d->inv_area_joined[i]) continue; const lv_area_t* a = &d->inv_areas[i]; g_refrPixels += (uint32_t)(a->x2 - a->x1 + 1) * (uint32_t)(a->y2 - a->y1 + 1); if (g_refrAreaCnt < 6) g_refrArea[g_refrAreaCnt++] = *a; g_refrAreas++; } } static void refr_ready_cb(lv_event_t*) { uint32_t d = lv_tick_get() - g_refrStart; if (d < REFR_WARN_MS) return; unsigned pct = (unsigned)((uint64_t)g_refrPixels * 100ULL / ((uint64_t)LCD_H_RES * LCD_V_RES)); esp_rom_printf("[UI] Bildaufbau %u ms, %u Bereiche, %u %% der Flaeche\n", (unsigned)d, (unsigned)g_refrAreas, pct); // Lage der Bereiche mit ausgeben. Erst daran ist zu erkennen, WELCHER Teil der // Oberflaeche das Neuzeichnen ausloest - die Groesse allein sagt das nicht. for (uint8_t i = 0; i < g_refrAreaCnt; i++) esp_rom_printf(" Bereich %u: x %d..%d, y %d..%d\n", (unsigned)i, (int)g_refrArea[i].x1, (int)g_refrArea[i].x2, (int)g_refrArea[i].y1, (int)g_refrArea[i].y2); } // Meldet, wenn ein ganzer Container ungueltig gemacht wird. Damit laesst sich unterscheiden, // ob eine einzelne Beschriftung den Bildaufbau ausloest oder ob gleich der komplette // Inhaltsbereich neu gezeichnet werden soll - zwei verschiedene Ursachen. static void inval_watch_cb(lv_event_t* e) { const char* name = (const char*)lv_event_get_user_data(e); esp_rom_printf("[UI] %s wird komplett ungueltig\n", name); } static void refr_monitor_install() { lv_display_t* d = lv_display_get_default(); if (!d) return; lv_display_add_event_cb(d, refr_start_cb, LV_EVENT_REFR_START, nullptr); lv_display_add_event_cb(d, refr_ready_cb, LV_EVENT_REFR_READY, nullptr); } void ui_init(ProtocolClient* client) { g_client = client; // Gespeicherte Darstellungs-Einstellungen laden (P4-lokal; Design-Standard: Rundinstrumente) // Verlaufs-Historie (60 min à 1 s, ~29 KB) im PSRAM anlegen - wie die LVGL-Framebuffer. // Schlaegt die Allokation fehl, bleibt das Chart schlicht leer (alle Zugriffe geprueft). if (!g_hist) { g_hist = (ChartHist*)heap_caps_malloc(sizeof(ChartHist), MALLOC_CAP_SPIRAM); if (!g_hist) g_hist = (ChartHist*)heap_caps_malloc(sizeof(ChartHist), MALLOC_CAP_8BIT); } { Preferences prefs; prefs.begin("ui", false); // Vorgabe ist jetzt TD_BAND. Aeltere Staende haben hier Werte der frueheren fuenf // Darstellungen stehen - liegt der Wert ausserhalb, faellt er auf die Vorgabe zurueck. g_tempDesign = prefs.getUChar("tempdsgn", TD_BAND); if (g_tempDesign >= TD_DESIGN_COUNT) g_tempDesign = TD_BAND; g_brewLiveEnabled = prefs.getBool("brewlive", true); g_tempColored = prefs.getBool("tempcol", false); g_clockSize = prefs.getUChar("uhrgr", UHR_GROSS); if (g_clockSize > UHR_GROSS) g_clockSize = UHR_GROSS; g_chartWin = prefs.getUChar("chartwin", 0); if (g_chartWin > 3) g_chartWin = 0; g_chartDuty = prefs.getBool("chartduty", false); prefs.end(); } font_step_load(); // gewaehlte Schriftgroesse, bevor irgendetwas angelegt wird lv_obj_t* scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, COL_BG, 0); // Grundschrift am Bildschirm: Alles, was keine eigene Schrift setzt, erbt sie von hier. lv_obj_set_style_text_font(scr, font_small(), 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); // Der Knopf bleibt optisch klein, faengt Tipps aber auch knapp daneben ab. 48x36 Pixel // sind auf dem 7-Zoll-Panel ein sehr kleines Ziel; gemessen wurde ein Fehlgriff von // rund 10 Pixeln. Die Vergroesserung wirkt nur innerhalb der Kopfleiste - darunter // beschneidet sie die Leiste selbst, sodass dem Seiteninhalt nichts weggenommen wird. lv_obj_set_ext_click_area(burger, 20); 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, font_text(), 0); // Profil-Chip: zuletzt geladenes Profil ("*" = seitdem geaenderte Einstellungen). // Antippen oeffnet die Profil-Schnellwahl. Unsichtbar, solange die S3 kein Profil meldet. g_profChip = lv_label_create(header); lv_label_set_text(g_profChip, ""); lv_obj_set_style_bg_color(g_profChip, COL_CARD2, 0); lv_obj_set_style_bg_opa(g_profChip, LV_OPA_COVER, 0); lv_obj_set_style_text_color(g_profChip, COL_TEXT, 0); lv_obj_set_style_radius(g_profChip, 12, 0); lv_obj_set_style_pad_hor(g_profChip, 10, 0); lv_obj_set_style_pad_ver(g_profChip, 3, 0); lv_obj_set_style_margin_right(g_profChip, 10, 0); lv_obj_add_flag(g_profChip, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_ext_click_area(g_profChip, 8); lv_obj_add_event_cb(g_profChip, prof_chip_cb, LV_EVENT_CLICKED, nullptr); lv_obj_add_flag(g_profChip, LV_OBJ_FLAG_HIDDEN); 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, font_text(), 0); // Feste Masse, sonst rechnet LVGL beim Wechsel der Minute das Layout der Kopfleiste neu // und macht sie samt Menue-Knopf ungueltig (siehe tempField). lv_obj_set_width(g_clock, 130); lv_obj_set_height(g_clock, lv_font_get_line_height(font_text()) + 4); lv_obj_set_style_text_align(g_clock, LV_TEXT_ALIGN_RIGHT, 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), DISP_VER_RES - 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_COLDEX] = make_page(g_content); build_coldex(g_pages[PG_COLDEX]); 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_DISPLAY] = make_page(g_content); build_display(g_pages[PG_DISPLAY]); 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), DISP_VER_RES - 44); lv_obj_set_pos(g_drawer, 0, 44); // Bewusst durchscheinend: Der Vorhang legt sich ueber die ganze Seite, und der Inhalt // dahinter soll sichtbar bleiben. Ein Versuch mit deckendem Vorhang (gegen die // Zeichenlast) hat den Seiteninhalt verschwinden lassen und ist zurueckgenommen. 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 scroll_ohne_nachlauf(menu); 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(); // --- Live-Bezugsschirm (ueber dem Reinigungs-Screen, unter Tastatur/Standby/OTA) --- build_brew_live_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, clock_font(), 0); // Groesse einstellbar 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); #if JC_PANEL_TYPE == WS_PANEL_7H // Deckend statt halbtransparent. Ein durchscheinender Vorhang ueber den ganzen // Bildschirm zwingt LVGL, einen Vollbild-Zwischenpuffer anzulegen und die gesamte // Flaeche zu mischen - bei 1280x720 in 24 Bit dauerte das rund fuenf Sekunden, in // denen nach dem Fingertipp nur der schwarze Standby-Bildschirm zu sehen war. // Verloren geht dabei nichts: Dahinter liegt im Standby ohnehin nur Schwarz. lv_obj_set_style_bg_opa(g_wakeConfirm, LV_OPA_COVER, 0); #else lv_obj_set_style_bg_opa(g_wakeConfirm, LV_OPA_70, 0); #endif 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); // Breite waechst mit der gewaehlten Schriftgroesse mit. Bei fester Breite brach der // Text in der Stufe Gross an unguenstigen Stellen um. lv_obj_set_width(wakeCard, (g_fontStep == FONT_GROSS) ? 900 : (g_fontStep == FONT_MITTEL) ? 640 : 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); g_wakeMsg = lv_label_create(wakeCard); lv_obj_set_width(g_wakeMsg, LV_PCT(100)); lv_obj_set_style_text_align(g_wakeMsg, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_color(g_wakeMsg, COL_TEXT, 0); lv_obj_set_style_text_font(g_wakeMsg, font_text(), 0); lv_label_set_text(g_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); // Knopfbreite und -schrift wachsen mit der Schriftgroesse mit; mit fester Breite und // geerbter Grundschrift wirkten die Knoepfe neben dem groesseren Text verloren. const int32_t wakeBtnW = (g_fontStep == FONT_GROSS) ? 280 : (g_fontStep == FONT_MITTEL) ? 210 : 150; lv_obj_t* wakeNo = th_button(wakeBtns, "Abbrechen", COL_CARD2, COL_TEXT); lv_obj_set_width(wakeNo, wakeBtnW); lv_obj_set_style_text_font(wakeNo, font_text(), 0); lv_obj_add_event_cb(wakeNo, wake_no_cb, LV_EVENT_CLICKED, nullptr); g_wakeNoBtn = wakeNo; lv_obj_t* wakeYes = th_accent_button(wakeBtns, "Aufwecken"); lv_obj_set_width(wakeYes, wakeBtnW); lv_obj_set_style_text_font(wakeYes, font_text(), 0); lv_obj_add_event_cb(wakeYes, wake_yes_cb, LV_EVENT_CLICKED, nullptr); g_wakeYesLbl = lv_obj_get_child(wakeYes, 0); // Label des Knopfs (fuer "Espresso" im Abfrage-Modus) // Zweite Zeile: direkt in die kalte Extraktion aufwecken. Ohne diesen Weg muesste man // erst normal aufwecken (Maschine heizt an) und den Modus danach umschalten. // Nur sichtbar, wenn die S3 die Funktion freigeschaltet meldet (siehe ui_update). g_wakeColdBtn = th_button(wakeCard, "Cold Extraction (Wasser bleibt kalt)", COL_CARD2, COL_ACCENT); lv_obj_set_width(g_wakeColdBtn, LV_PCT(100)); lv_obj_set_style_text_font(g_wakeColdBtn, font_text(), 0); lv_obj_add_event_cb(g_wakeColdBtn, wake_cold_cb, LV_EVENT_CLICKED, nullptr); lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN); 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, font_title(), 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, font_text(), 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, font_text(), 0); lv_obj_add_flag(g_otaScreen, LV_OBJ_FLAG_HIDDEN); // Shot-Zusammenfassung (zentrierte Karte mit grossen Werten, antippbar zum Schliessen) g_shotSum = lv_obj_create(lv_layer_top()); lv_obj_set_size(g_shotSum, 560, LV_SIZE_CONTENT); // breit genug, damit die drei Werte nicht zusammenruecken 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, 14, 0); lv_obj_set_style_shadow_width(g_shotSum, 24, 0); // hebt die Karte vom Inhalt ab lv_obj_set_style_shadow_color(g_shotSum, lv_color_hex(0x000000), 0); lv_obj_set_style_shadow_opa(g_shotSum, LV_OPA_60, 0); lv_obj_set_style_pad_all(g_shotSum, 20, 0); lv_obj_set_style_pad_row(g_shotSum, 14, 0); lv_obj_set_flex_flow(g_shotSum, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(g_shotSum, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); 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, font_title(), 0); lv_obj_t* sumRow = lv_obj_create(g_shotSum); lv_obj_set_size(sumRow, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(sumRow, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(sumRow, 0, 0); lv_obj_set_style_pad_all(sumRow, 0, 0); lv_obj_set_style_pad_column(sumRow, 28, 0); // deutlicher Abstand zwischen Dauer/Gewicht/Flow lv_obj_set_flex_flow(sumRow, LV_FLEX_FLOW_ROW); lv_obj_clear_flag(sumRow, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(sumRow, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt auf die Karte durch g_sumCol[0] = sum_stat_col(sumRow, "Dauer", &g_sumVal[0]); g_sumCol[1] = sum_stat_col(sumRow, "Gewicht", &g_sumVal[1]); g_sumCol[2] = sum_stat_col(sumRow, "Mittl. Flow", &g_sumVal[2]); 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, font_text(), 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, font_small(), 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(); refr_monitor_install(); if (g_content) lv_obj_add_event_cb(g_content, inval_watch_cb, LV_EVENT_INVALIDATE_AREA, (void*)"Inhaltsbereich"); if (g_pages[PG_DASH]) lv_obj_add_event_cb(g_pages[PG_DASH], inval_watch_cb, LV_EVENT_INVALIDATE_AREA, (void*)"Dashboard-Seite"); g_uiReady = true; } // 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; } // Eine Live-Zeile der Tuning-Karte: Kreis, Laufzeit, Heiz-/Abkuehlphase und Schwingungshub // (Min/Max der Ist-Temperatur seit Tuning-Start; zeigt, wie stark der Kessel pendelt). static void appendTuneLive(String& out, const char* kreis, uint32_t elapsedMs, float duty, float tMin, float tMax) { if (out.length()) out += "\n"; uint32_t s = elapsedMs / 1000; char b[16]; snprintf(b, sizeof(b), "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60)); out += kreis; out += " · "; out += b; out += " min · "; out += (duty >= 50.0f) ? "heizt" : "kühlt ab"; float hub = tMax - tMin; if (hub >= 0.05f) { out += " · Hub " + tempStr(hub, 1) + " um " + tempStr((tMin + tMax) * 0.5f, 1); } } // ===================================================================================== // Live-Aktualisierung // ===================================================================================== // Zustandsband der Darstellung TD_BAND. Es beantwortet die haeufigste Frage - ist sie // bereit? - ohne dass eine Zahl gelesen werden muss. static void ready_band_update(const MachineState& st) { if (!g_readyBand || !g_readyBandLbl) return; const char* txt; uint32_t bg; lv_color_t fg; if (st.standbyActive) { txt = "Standby"; bg = 0x1e1e1e; fg = COL_TEXT_MUT; } else if (st.shotActive) { txt = "Bezug läuft"; bg = 0x3a2a05; fg = COL_ACCENT; } else if (st.cxActive) { txt = "Cold Extraction"; bg = 0x0d2b33; fg = lv_color_hex(0x80cbc4); } else { bool wOk = fabsf(st.tempW - st.setW) <= 1.0f; bool dOk = st.steamHeatDisabled || fabsf(st.tempD - st.setD) <= 3.0f; if (wOk && dOk) { txt = "Bereit — beide Kessel auf Temperatur"; bg = 0x11301a; fg = COL_OK; } else if (wOk) { txt = "Wasser bereit — Dampf heizt"; bg = 0x3a2a05; fg = COL_ACCENT; } else { txt = "Heizt auf"; bg = 0x3a2a05; fg = COL_ACCENT; } } // Das Band spannt sich ueber die volle Breite. Wuerde es bei jeder Zustandsmeldung neu // gesetzt, machte es allein schon einen Streifen ueber den ganzen Bildschirm ungueltig. label_set_if_changed(g_readyBandLbl, txt); label_color_if_changed(g_readyBandLbl, fg); if (lv_color_to_u32(lv_obj_get_style_bg_color(g_readyBand, LV_PART_MAIN)) != bg) { lv_obj_set_style_bg_color(g_readyBand, lv_color_hex(bg), 0); lv_obj_set_style_bg_opa(g_readyBand, LV_OPA_COVER, 0); } } // ------------------------------------------------------------------------------------ // Bezugsanzeige: die untere Zeile wechselt ihren Zustand // // Im Ruhezustand ist der Temperaturbereich das Wichtigste - die haeufigste Frage lautet // "ist sie bereit?". Waehrend eines Bezugs kehrt sich das um: Dann zaehlen Zeit und // Gewicht, und die Kesseltemperaturen sind Nebensache. Statt zweier getrennter Ansichten // verschiebt die Seite nur das Gewicht zwischen ihren beiden Bereichen und vergroessert // die beiden Zahlen, auf die es dann ankommt. // // Der Wechsel haelt nach dem Bezug noch kurz an. Ohne dieses Nachhalten wuerde die Seite // bei jeder kurzen Unterbrechung hin- und herspringen - das waere unruhiger als gar kein // Wechsel. // ------------------------------------------------------------------------------------ #define DASH_BREW_HOLD_MS 4000 static void dash_brew_mode(bool on) { if (on == g_dashBrewMode) return; // nur bei echtem Wechsel neu anordnen g_dashBrewMode = on; if (!g_tempRow || !g_infoRow) return; lv_obj_set_flex_grow(g_tempRow, on ? 1 : 3); lv_obj_set_flex_grow(g_infoRow, on ? 3 : 2); // Die Schrift von Shot-Timer und Waage bleibt unveraendert. Frueher wechselte sie // waehrend des Bezugs auf den grossen fetten Schnitt - das wirkte gegenueber dem // restlichen Dashboard zu laut. Der Bereich bekommt weiterhin mehr Platz, die Zahlen // stehen dadurch freier, aber in derselben Schrift wie sonst auch. } static void dash_brew_mode_tick(const MachineState& st) { static bool war = false; // schon einmal ein Bezug gesehen? static uint32_t zuletzt = 0; bool aktiv = st.shotActive || st.steamCircuitActive; uint32_t jetzt = lv_tick_get(); if (aktiv) { war = true; zuletzt = jetzt; } dash_brew_mode(aktiv || (war && (uint32_t)(jetzt - zuletzt) < DASH_BREW_HOLD_MS)); } // ------------------------------------------------------------------------------------ // Uhr läuft lokal weiter // // Die Uhrzeit kommt von der Hauptplatine und ist leer, solange dort keine Zeitquelle // synchronisiert ist. Bei einem erneuten Abgleich ist sie kurz weg - auf dem Display // verschwand die Uhr dann fuer ein paar Sekunden, was gerade im Standby unruhig wirkt. // // Deshalb merkt sich der P4 die zuletzt empfangene Zeit und zaehlt zwischen den Meldungen // selbst weiter. Er stellt nichts richtig: Sobald die Hauptplatine wieder eine Zeit // schickt, gilt deren Wert. Die lokale Uhr ueberbrueckt nur die Luecke. // ------------------------------------------------------------------------------------ static bool g_clockKnown = false; // schon einmal eine Zeit empfangen? static uint32_t g_clockMinute = 0; // zuletzt bekannte Uhrzeit in Minuten seit Mitternacht static uint32_t g_clockTick = 0; // Zeitpunkt (lv_tick), zu dem sie galt // "HH:MM" der Hauptplatine uebernehmen. Andere Formate werden nicht angetastet. static void clock_note(const String& hhmm) { if (hhmm.length() < 5 || hhmm.charAt(2) != ':') return; int h = hhmm.substring(0, 2).toInt(); int m = hhmm.substring(3, 5).toInt(); if (h < 0 || h > 23 || m < 0 || m > 59) return; g_clockMinute = (uint32_t)(h * 60 + m); g_clockTick = lv_tick_get(); g_clockKnown = true; } // Anzuzeigende Uhrzeit: die der Hauptplatine, solange sie eine schickt - sonst die lokal // weitergezaehlte. Leerer Rueckgabewert heisst: noch nie eine Zeit gehabt, nichts anzeigen. static String clock_text(const String& vonS3) { if (vonS3.length()) return vonS3; if (!g_clockKnown) return String(); uint32_t min = g_clockMinute + (uint32_t)((lv_tick_get() - g_clockTick) / 60000UL); char b[6]; snprintf(b, sizeof(b), "%02u:%02u", (unsigned)((min / 60) % 24), (unsigned)(min % 60)); return String(b); } void ui_update(const MachineState& st) { if (!g_uiReady) return; 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; label_color_if_changed(g_wifiIcon, wifiOk ? COL_OK : (st.apMode ? COL_WARN : COL_TEXT_MUT)); // Uhrzeit. Angezeigt wird die der Hauptplatine; faellt sie voruebergehend aus, zaehlt // der P4 die zuletzt bekannte Zeit selbst weiter (siehe clock_text). clock_note(st.timeStr); String zeit = clock_text(st.timeStr); if (g_clock) { if (zeit.length()) { label_set_if_changed(g_clock, zeit.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) { label_set_if_changed(g_standbyClock, zeit.length() ? zeit.c_str() : "--:--"); if (!g_standbyShown) { ui_fade_in(g_standbyScreen, UI_FADE_MS); // Einschlaf-Animation g_standbyShown = true; } // Frueher wurde die Uhr hier bei JEDER Zustandsmeldung nach vorn geholt. Das // ordnet die Ebenen neu und laesst LVGL den gesamten Bildschirm neu zeichnen - // einmal pro Sekunde, jedesmal 1280x720 in 24 Bit. Genau das war der Grund, // warum sich rund um den Standby alles um ein bis zwei Sekunden verzoegerte. // Nach vorn geholt wird die Uhr beim Einblenden, danach bleibt sie dort. } 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). // Bei offenem Auswahlfenster (cxWakeChoice) bleibt der Dialog stehen, obwohl der Standby // schon beendet ist - genau dafuer ist er da. if (!st.standbyActive && !st.cxWakeChoice) { 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); } // Cold-Extraction-Hinweis: erklaert dauerhaft, warum der Wasserkreis nicht heizt. // Transiente Meldung (abgewiesener Start / Abbruch) hat Vorrang vor dem Dauerzustand. // Ohne nutzbare Waage laeuft die kalte Extraktion rein zeitgesteuert (Zielgewicht und // Stillstands-Erkennung greifen nicht) - das wird dauerhaft mit angezeigt. const bool cxScaleOk = st.scaleEnabled && st.scaleConnected; // "Aufwecken mit Cold Extraction" nur anbieten, wenn die Funktion freigeschaltet und // umschaltbar ist und der Modus nicht ohnehin schon laeuft. // Abfrage-Modus: Der Aufweck-Dialog wird zur Auswahl "Espresso oder Cold Extraction". // Abschaltbar ueber die Einstellung cxAskOnWake, damit man sich den Zwischenschritt // sparen kann, wenn gerade keine kalten Bezuege anstehen. { // Zwei Faelle: Vorab-Abfrage beim Antippen im Standby (askMode) und das von der S3 // geoeffnete Auswahlfenster NACH dem Aufwecken (choiceOpen) - letzteres deckt auch // das Aufwecken per Schalter ab, wo es keine Vorab-Abfrage geben kann. // Kein "!st.cxActive": Der Modus ist persistent, die Auswahl muss in beide // Richtungen gehen - sonst haengt man nach dem ersten kalten Bezug dauerhaft fest. const bool choiceOpen = st.cxWakeChoice; const bool askMode = wake_choice_offered(); // g_state == st (oben zugewiesen) if (g_wakeColdBtn) { if (askMode) lv_obj_remove_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN); } // Im Auswahlfenster gibt es nichts abzubrechen - laeuft die Zeit ab, wird geheizt. if (g_wakeNoBtn) { if (choiceOpen) lv_obj_add_flag(g_wakeNoBtn, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(g_wakeNoBtn, LV_OBJ_FLAG_HIDDEN); } if (g_wakeMsg) { if (choiceOpen) { char b[128]; snprintf(b, sizeof(b), "Womit weiter?\nEspresso heizt auf, Cold Extraction bleibt kalt.\n" "Ohne Auswahl wird in %ld s geheizt.", st.cxWakeChoiceSec); label_set_if_changed(g_wakeMsg, b); } else { label_set_if_changed(g_wakeMsg, askMode ? "Die Maschine befindet sich im Standby.\nWomit aufwecken?" : "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?"); } } if (g_wakeYesLbl) { label_set_if_changed(g_wakeYesLbl, askMode ? "Espresso" : "Aufwecken"); } // Aktive Wahl hervorheben: Bei laufendem Modus ist "Cold Extraction" der Ist-Zustand, // "Espresso" schaltet ihn ab - andersherum genauso. if (g_wakeColdBtn && askMode) { lv_obj_set_style_bg_color(g_wakeColdBtn, st.cxActive ? COL_ACCENT : COL_CARD2, 0); lv_obj_t* coldLbl = lv_obj_get_child(g_wakeColdBtn, 0); if (coldLbl) { lv_obj_set_style_text_color(coldLbl, st.cxActive ? lv_color_hex(0x000000) : COL_ACCENT, 0); label_set_if_changed(coldLbl, st.cxActive ? "Cold Extraction (aktiv lassen)" : "Cold Extraction (Wasser bleibt kalt)"); } } // Auswahlfenster von der S3 -> Dialog von sich aus zeigen (kein Antippen noetig) if (choiceOpen && g_wakeConfirm && lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) { wake_confirm_show(); } else if (!choiceOpen && !st.standbyActive && g_wakeConfirm && !lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) { wake_confirm_hide(); // Fenster abgelaufen oder entschieden g_wakeDialogOpen = false; } } if (st.cxNotice == 2) { status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction beendet: kein Zulauf", COL_WARN, lv_color_hex(0x000000)); } else if (st.cxNotice == 3) { status_set(ST_COLDEX, true, "Cold Extraction: erst nach dem Bezug umschaltbar", COL_WARN, lv_color_hex(0x000000)); } else if (st.cxNotice == 1) { String t = "Cold Extraction nicht möglich - "; t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW); status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000)); } else if (st.cxNotice == 4) { status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction ohne Waage: Bezug endet nach Zeit", COL_WARN, lv_color_hex(0x000000)); } else if (st.cxActive && st.cxResting) { status_set(ST_COLDEX, true, "Cold Extraction: Pumpenpause (Pumpenschutz)", COL_ACCENT, lv_color_hex(0x000000)); } else if (st.cxActive && !st.cxAllowed) { String t = "Cold Extraction aktiv, Bezug gesperrt - "; t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW); status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000)); } else if (st.cxActive && !cxScaleOk) { // Dauerhinweis, solange ohne Waage gefahren wird: das Zielgewicht greift nicht, // der Bezug endet erst nach der maximalen Bezugsdauer. status_set(ST_COLDEX, true, "Cold Extraction aktiv - ohne Waage, Ende nach Zeit", COL_WARN, lv_color_hex(0x000000)); } else if (st.cxActive) { status_set(ST_COLDEX, true, "Cold Extraction aktiv - Wasserkreis heizt nicht", lv_color_hex(0x1976d2), COL_TEXT); } else { status_set(ST_COLDEX, 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: Statuszeile (antippbar -> Verlauf-Seite) + Live-Karte auf der Verlauf-Seite { uint32_t nowMs = millis(); // Steigende Flanke je Kreis: Startzeit latchen, Schwingungs-Min/Max zuruecksetzen if (!g_tunePrevW && st.autoTuneW) { g_tuneStartW = nowMs; g_tuneMinW = g_tuneMaxW = st.tempW; } if (!g_tunePrevD && st.autoTuneD) { g_tuneStartD = nowMs; g_tuneMinD = g_tuneMaxD = st.tempD; } if (st.autoTuneW) { if (st.tempW < g_tuneMinW) g_tuneMinW = st.tempW; if (st.tempW > g_tuneMaxW) g_tuneMaxW = st.tempW; } if (st.autoTuneD) { if (st.tempD < g_tuneMinD) g_tuneMinD = st.tempD; if (st.tempD > g_tuneMaxD) g_tuneMaxD = st.tempD; } // Fallende Flanke je Kreis erkennen -> Ergebnis ~30 s einblenden 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; // Ergebnistext des/der zuletzt beendeten Laufs/Laeufe (Statuszeile und Karte teilen ihn) String resTxt; int resSev = 0; if (showResW) appendTuneResult(resTxt, "Wasser", g_tuneResW, resSev); if (showResD) appendTuneResult(resTxt, "Dampf", g_tuneResD, resSev); 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 (resTxt.length()) { lv_color_t bg = (resSev >= 2) ? COL_DANGER : (resSev == 1 ? COL_WARN : COL_OK); lv_color_t fg = (resSev == 1) ? lv_color_hex(0x000000) : COL_TEXT; status_set(ST_TUNE, true, resTxt.c_str(), bg, fg); } else { status_set(ST_TUNE, false); } // Live-Karte auf der Verlauf-Seite: waehrend des Laufs Laufzeit/Phase/Schwingungshub, // nach dem Ende ~30 s das Ergebnis; erscheint und verschwindet weich (Fade) bool wantCard = st.autoTuneW || st.autoTuneD || resTxt.length() > 0; if (g_tuneCard) { if (st.autoTuneW || st.autoTuneD) { if (g_tuneCardSpin) lv_obj_remove_flag(g_tuneCardSpin, LV_OBJ_FLAG_HIDDEN); if (g_tuneCardTitle) { lv_label_set_text(g_tuneCardTitle, "PID-Tuning läuft..."); lv_obj_set_style_text_color(g_tuneCardTitle, COL_WARN, 0); } String b; if (st.autoTuneW) appendTuneLive(b, "Wasser", nowMs - g_tuneStartW, st.dutyW, g_tuneMinW, g_tuneMaxW); if (st.autoTuneD) appendTuneLive(b, "Dampf", nowMs - g_tuneStartD, st.dutyD, g_tuneMinD, g_tuneMaxD); if (g_tuneCardBody) label_set_if_changed(g_tuneCardBody, b.c_str()); } else if (wantCard) { if (g_tuneCardSpin) lv_obj_add_flag(g_tuneCardSpin, LV_OBJ_FLAG_HIDDEN); lv_color_t c = (resSev >= 2) ? COL_DANGER : (resSev == 1 ? COL_WARN : COL_OK); if (g_tuneCardTitle) { lv_label_set_text(g_tuneCardTitle, "PID-Tuning beendet"); lv_obj_set_style_text_color(g_tuneCardTitle, c, 0); } String b = resTxt; b.replace(" | ", "\n"); // Karte darf mehrzeilig sein if (g_tuneCardBody) label_set_if_changed(g_tuneCardBody, b.c_str()); } if (wantCard != g_tuneCardShown) { g_tuneCardShown = wantCard; if (wantCard) ui_fade_in(g_tuneCard, UI_FADE_MS); else ui_fade_out_hide(g_tuneCard, UI_FADE_MS); } } } // 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) { label_set_if_changed(lblTempW, LV_SYMBOL_WARNING); label_color_if_changed(lblTempW, COL_DANGER); label_set_if_changed(lblSetW, st.wasserSafetyShutdown ? "Sicherheitsabsch." : "Sensorfehler!"); label_color_if_changed(lblSetW, COL_DANGER); temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER); } else { label_set_if_changed(lblTempW, tempStr(st.tempW, 1).c_str()); if (st.cxActive) { // Cold Extraction: der Sollwert ist bedeutungslos, es wird nicht geheizt. // Statt eines Zielbands, das nie erreicht wird, den Grund anzeigen. label_color_if_changed(lblTempW, temp_value_color(lv_color_hex(0x1976d2))); label_set_if_changed(lblSetW, "Cold Extraction - Heizen aus"); label_color_if_changed(lblSetW, lv_color_hex(0x1976d2)); temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, lv_color_hex(0x1976d2)); } else { // Ist-Wert faerbt sich weich Richtung Soll (Blau -> Gold -> Gruen im Zielband) lv_color_t colW = temp_state_color(st.tempW, st.setW); label_color_if_changed(lblTempW, temp_value_color(colW)); label_set_if_changed(lblSetW, ("Soll " + tempStr(st.setW, 1)).c_str()); label_color_if_changed(lblSetW, COL_TEXT_DIM); // 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) { label_set_if_changed(lblTempD, LV_SYMBOL_WARNING); label_color_if_changed(lblTempD, COL_DANGER); label_set_if_changed(lblSetD, st.dampfSafetyShutdown ? "Sicherheitsabsch." : "Sensorfehler!"); label_color_if_changed(lblSetD, COL_DANGER); temp_progress_set(barDutyD, arcTempD, bulbTempD, 0, COL_DANGER); } else { label_set_if_changed(lblTempD, tempStr(st.tempD, 0).c_str()); lv_color_t colD = temp_state_color(st.tempD, st.setD); label_color_if_changed(lblTempD, temp_value_color(colD)); label_set_if_changed(lblSetD, ("Soll " + tempStr(st.setD, 0)).c_str()); label_color_if_changed(lblSetD, COL_TEXT_DIM); int pctD = (st.setD > 0.0f) ? (int)(st.tempD / st.setD * 100.0f + 0.5f) : 0; temp_progress_set(barDutyD, arcTempD, bulbTempD, pctD, colD); } ready_band_update(st); dash_brew_mode_tick(st); if (false) { } } // "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); } } // Profil-Chip im Header: Name des zuletzt geladenen Profils, "*" wenn seitdem // profil-relevante Einstellungen geaendert wurden. Lange Namen werden gekuerzt, // damit der Header nicht ueberlaeuft. Leer (alte S3-FW / kein Profil) -> versteckt. if (g_profChip) { if (st.activeProfile.length() > 0) { String t = st.activeProfile; if (t.length() > 18) { int cut = 17; // nicht mitten in einem UTF-8-Mehrbyte-Zeichen (Umlaut) schneiden while (cut > 0 && ((uint8_t)t[cut] & 0xC0) == 0x80) cut--; t = t.substring(0, cut) + "..."; } if (st.profileDirty) t += " *"; label_set_if_changed(g_profChip, t.c_str()); lv_obj_remove_flag(g_profChip, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(g_profChip, LV_OBJ_FLAG_HIDDEN); } } // 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) // Ein kalter Bezug hat seine eigene Phasenfolge (Vorbenetzung -> Extraktion, keine // Pause) und ignoriert die Pre-Infusion-Einstellungen komplett. g_piColdMode = st.cxShot; if (g_piColdMode) { g_piShotMode = (st.cxPreInfSec > 0.0f); g_piDurMs = (uint32_t)(st.cxPreInfSec * 1000.0f); g_piPauseMs = 0; } else { g_piShotMode = st.piEnabled; g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f); g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f); } g_piStateS3 = st.piState; // Live-Bezugsschirm einblenden (nur echte Bezuege; nicht im Reinigungsassistenten, // nicht im Standby und nicht, wenn der Nutzer ihn fuer diesen Bezug weggetippt hat) if (g_brewLiveEnabled && !g_brewLiveClosed && !g_brewLiveShown && !st.cleaningActive && !st.standbyActive) brew_live_show(); } else if (st.flushActive) { brew_live_hide(); // Sicherheitsnetz: Flush zeigt nie den Bezugsschirm 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_piColdMode = false; g_piStateS3 = 0; brew_live_hide(); // Bezug vorbei -> Overlay weich ausblenden g_brewLiveClosed = false; // Wegtipp-Sperre gilt nur fuer den laufenden Bezug } // 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) { // Bei kaltem Bezug zaehlt cxTarget, nicht das Brew-by-Weight-Ziel const bool cxShotNow = st.cxShot && st.cxTarget > 0.0f; const float shotTarget = cxShotNow ? st.cxTarget : st.bbwTarget; bool bbwShot = st.shotActive && (cxShotNow || (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) { label_set_if_changed(lblBezug, "--"); } else if (bbwShot) { // Waehrend des Bezugs mit Brew-by-Weight: Ist oben, Ziel darunter (zweizeilig) label_fmt_if_changed(lblBezug, "%.1f g\n/ %.1f g", wDisp, shotTarget); } else { label_fmt_if_changed(lblBezug, "%.1f g", wDisp); } // Fortschrittsbalken Gewicht/Ziel (nur waehrend BBW-Bezug) if (g_bbwBar) { if (bbwShot) { int pct = (int)(st.weight / shotTarget * 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); if (swColdEx) { // Zeile nur zeigen, wenn die S3 die Funktion freigeschaltet meldet (und sie kennt) lv_obj_t* rowCx = lv_obj_get_parent(swColdEx); if (st.cxEnabled) { lv_obj_remove_flag(rowCx, LV_OBJ_FLAG_HIDDEN); if (lblColdExHint) lv_obj_remove_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(rowCx, LV_OBJ_FLAG_HIDDEN); if (lblColdExHint) lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN); } sw_set(swColdEx, st.cxActive); // Umschalten haengt an cxArmable, NICHT an cxAllowed: Der Modus muss auch im Standby // und bei warmem Kessel scharf zu stellen sein. Waehrend eines kalten Bezugs gesperrt. bool usable = st.cxEnabled && st.cxArmable && !(st.cxActive && st.cxShot); if (usable) lv_obj_remove_state(swColdEx, LV_STATE_DISABLED); else lv_obj_add_state(swColdEx, LV_STATE_DISABLED); if (lblColdExHint && st.cxEnabled) { const bool scaleOk = st.scaleEnabled && st.scaleConnected; String hint; if (st.cxActive) { hint = "Wasserkreis heizt nicht (bleibt über Standby und Neustart aktiv). "; if (st.standbyActive) { hint += "Nach dem Aufwecken bleibt der Wasserkreis aus."; } else if (st.cxAllowed) { char b[96]; if (scaleOk) { snprintf(b, sizeof(b), "Bezug regulär starten - Ziel %.0f g, max %.0f s.", (double)st.cxTarget, (double)st.cxMaxSecs); } else { snprintf(b, sizeof(b), "Bezug regulär starten - Ende nach %.0f s.", (double)st.cxMaxSecs); } hint += b; } else { hint += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW); } } else if (!st.cxAllowed) { hint = coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW); } else { char b[112]; snprintf(b, sizeof(b), "Kalte Extraktion möglich (Wasser %.1f °C < %.1f °C). " "Solange aktiv, heizt der Wasserkreis nicht.", (double)st.tempW, (double)st.cxMaxTemp); hint = b; } if (!scaleOk) { hint += " Ohne Waage: Zielgewicht und Stillstands-Erkennung greifen nicht, " "der Bezug endet erst nach der maximalen Bezugsdauer."; } label_set_if_changed(lblColdExHint, hint.c_str()); } } if (lblCxPageHint) { // Warnhinweis auf der Cold-Extraction-Seite (Waage fehlt bzw. Funktion gesperrt) String h; if (st.cxEnabled && !cxScaleOk) { h = "Keine Waage aktiv/verbunden: Zielgewicht und Stillstands-Erkennung greifen nicht, " "der Bezug endet erst nach der maximalen Dauer."; } if (h.length() > 0) { label_set_if_changed(lblCxPageHint, h.c_str()); lv_obj_remove_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN); } } // 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) { if (g_uiReady) showToast(txt, error); } void ui_set_profiles(const String profiles[], int count) { if (!g_uiReady) return; // Schnellwahl-Overlay (falls offen) mit derselben Antwort befuellen prof_picker_fill(profiles, count); if (!profList) return; lv_obj_clean(profList); for (int i = 0; i < count; i++) { // aktives Profil markieren (Haekchen + Akzentfarbe statt Datei-Symbol) bool active = (profiles[i] == g_state.activeProfile); lv_obj_t* b = lv_list_add_button(profList, active ? LV_SYMBOL_OK : LV_SYMBOL_FILE, profiles[i].c_str()); lv_obj_set_style_bg_color(b, COL_CARD2, 0); lv_obj_set_style_text_color(b, active ? COL_ACCENT : 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 (!g_uiReady) return; 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_uiReady) return; if (!g_statSummary) return; JsonDocument doc; if (deserializeJson(doc, json)) { label_set_if_changed(g_statSummary, "Statistik konnte nicht gelesen werden."); stat_list_finish(0); return; } bool avail = doc["available"] | false; if (!avail) { label_set_if_changed(g_statSummary, "Noch keine Nutzungsdaten vorhanden."); stat_list_finish(0); return; } long total = doc["total"] | 0; long today = doc["today"] | 0; long week = doc["week"] | 0; bool partial = doc["partial"] | false; // Mittelwert der angezeigten Bezuege; die Liste fuellt stat_list_set() int n = 0; float durSum = 0.0f; int nDur = 0; // Anzahl der Bezuege, die in den Mittelwert eingehen (ohne kalte) for (JsonVariant v : doc["shots"].as()) { const char* dt = v["dt"] | ""; float d = v["d"] | 0.0f; float w = v["w"] | -1.0f; bool cold = v["cx"] | false; // kalte Extraktion (fehlt bei S3-FW < 5.3.0) stat_list_set(n, dt, d, w, cold); // Minutenlange kalte Bezuege wuerden den Mittelwert unbrauchbar machen -> raus // (gleiche Regel wie in der S3-Statistik) if (!cold) { durSum += d; nDur++; } 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 (nDur > 0) { s += "\nMittl. Dauer (letzte " + String(nDur) + "): " + String(durSum / nDur, 1) + " s"; if (nDur < n) s += " (ohne kalte)"; } label_set_if_changed(g_statSummary, s.c_str()); stat_list_finish(n); } void ui_set_wifi_networks(const String& json) { if (!g_uiReady) return; 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_uiReady) return; 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_uiReady) return; if (!g_otaBytes) return; lv_label_set_text_fmt(g_otaBytes, "%lu KB übertragen", (unsigned long)(bytes / 1024)); } void ui_ota_done() { if (!g_uiReady) return; 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_uiReady) return; 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); }