Files
Dual-PID/P4_Display_Firmware/ui.cpp
T
raw-designsandClaude Opus 5 bcbeea2a17 fix(P4): Temperaturdelle in den Verlaufskurven nach dem Standby (1.10.3)
Direkt nach dem Aufwachen aus dem Standby zog die Wasserkurve eine kurze
Delle nach unten. Ursache ist die Messung, nicht der Kessel: In genau
diesem Moment schalten die Heizungen, und der Wassersensor haengt als NTC
am ADC. Ein Kessel kann binnen einer Sekunde nicht um Grade einbrechen.

Die Verlaufskurven halten deshalb rund um jeden Standby-Wechsel sechs
Sekunden lang ihren letzten Wert, statt die gestoerte Probe einzutragen.
Das gilt fuer die kleinen Kurven im Dashboard und fuer die grosse
Verlaufsseite. Die angezeigten Temperaturzahlen bleiben unveraendert.

Ausserdem: DISPLAY_FW_VERSION stand seit dem Ruecknehmen von 1.6.11 auf
1.6.10 fest und wurde bei keinem der folgenden Staende mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe8KxS47iPmuByJPs5RvEw
2026-09-04 09:16:18 +02:00

5099 lines
261 KiB
C++

// =====================================================================================
// 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 <lvgl.h>
#include <Arduino.h>
#include <ArduinoJson.h>
#include <Preferences.h> // Dashboard-Design P4-lokal merken (NVS)
#include <esp_memory_utils.h> // esp_ptr_external_ram(): welcher Speicher einen Puffer traegt
#include <esp_heap_caps.h> // Verlaufs-Historie im PSRAM allozieren (interner RAM ist knapp)
#include "ui.h"
#include <Preferences.h>
#include "esp_system.h"
#include "display_hal.h"
#include "esp_rom_sys.h"
#include "lvgl_private.h"
#include <stdarg.h>
// ------------------------------------------------------------------------------------
// 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
// Kleinster Ausschnitt der Verlaufskurve in Zehntelgrad. Ohne Untergrenze blaeht ein
// ruhig geregelter Kessel jedes Zehntelgrad zum vollen Ausschlag auf.
#define DASH_SPARK_MIN_SPAN 40 // 4,0 Grad
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);
}
// Der Nachlauf beim Wischen war auf dem Waveshare-Panel eine Zeit lang abgeschaltet, weil
// jedes Einzelbild waehrend des Schwungs den ganzen Schirm neu zeichnete. Seit die
// Anzeigewerte feste Masse haben, faellt das weg - das Menue laeuft mit Nachlauf fluessig.
static void scroll_ohne_nachlauf(lv_obj_t* o) { (void)o; }
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*) { // Tipp auf Standby-Uhr -> nachfragen
wake_confirm_show();
}
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.
// Gemeinsamer Ausschnitt fuer BEIDE Verlaufskurven.
//
// Zwei Anlaeufe waren vorher daneben. Erst hing der Ausschnitt am Sollwert - dann lag beim
// Aufheizen jeder Messwert unterhalb des Fensters und wurde auf den unteren Rand geklemmt.
// Danach zoomte jede Kurve auf ihre eigenen Werte - dann sass jede mittig in ihrem eigenen
// Fenster, und Wasser bei 70 Grad lag genauso hoch wie Dampf bei 0. Die Hoehe sagte nichts.
//
// Jetzt teilen sich beide Kurven eine Skala, die sich nach den Werten beider zusammen
// richtet. Damit ist die Hoehe wieder vergleichbar - der waermere Kessel liegt sichtbar
// hoeher -, und die Skala bleibt so eng, wie es die Daten zulassen: Stehen beide Kessel
// dicht beieinander, greift die Mindestspanne und schon Zehntelgrade sind zu sehen.
static int32_t g_sparkMin = INT32_MIN; // zuletzt gesetzter Ausschnitt, fuer beide gleich
static int32_t g_sparkMax = INT32_MIN;
// Kurz nach einem Standby-Wechsel sackt die Wassertemperatur fuer einen Sekundenbruchteil
// ab. In genau diesem Moment schalten die Heizungen, und der Wassersensor ist ein NTC am
// ADC - ein Kessel kann binnen einer Sekunde nicht um Grade einbrechen, die Delle ist also
// eine Stoerung der Messung, kein Vorgang an der Maschine. Fuer die Dauer des Uebergangs
// schreiben die Verlaufskurven deshalb den letzten Wert von vor dem Wechsel weiter. Die
// angezeigten Zahlen bleiben unangetastet - was gemessen wird, steht dort weiter drin.
#define KURVEN_SPERRE_MS 6000
static uint32_t g_kurvenSperreBis = 0; // millis(); bis dahin Werte halten
static float g_haltW = 0.0f;
static float g_haltD = 0.0f;
static void spark_scan(lv_obj_t* ch, lv_chart_series_t* ser, int32_t* mn, int32_t* mx) {
if (!ch || !ser) return;
int32_t* pts = lv_chart_get_y_array(ch, ser);
if (!pts) return;
uint32_t n = lv_chart_get_point_count(ch);
for (uint32_t i = 0; i < n; i++) {
if (pts[i] == LV_CHART_POINT_NONE) continue;
if (pts[i] < *mn) *mn = pts[i];
if (pts[i] > *mx) *mx = pts[i];
}
}
static void spark_ranges_fit() {
int32_t mn = INT32_MAX, mx = INT32_MIN;
spark_scan(g_sparkW, g_sparkSerW, &mn, &mx);
spark_scan(g_sparkD, g_sparkSerD, &mn, &mx);
if (mn > mx) return; // noch kein einziger Wert
if (mx - mn < DASH_SPARK_MIN_SPAN) { // beide dicht beieinander: mittig halten
int32_t mitte = (mn + mx) / 2;
mn = mitte - DASH_SPARK_MIN_SPAN / 2;
mx = mitte + DASH_SPARK_MIN_SPAN / 2;
} else { // sonst etwas Luft nach oben und unten
int32_t luft = (mx - mn) / 8;
mn -= luft;
mx += luft;
}
// Nur bei echter Aenderung setzen - lv_chart_set_range macht das Diagramm sonst bei
// jeder Zustandsmeldung ungueltig, auch wenn sich der Ausschnitt gar nicht ruehrt.
// Abfragen laesst sich der gesetzte Ausschnitt bei LVGL nicht, also wird er gemerkt.
if (mn == g_sparkMin && mx == g_sparkMax) return;
g_sparkMin = mn;
g_sparkMax = mx;
if (g_sparkW) lv_chart_set_range(g_sparkW, LV_CHART_AXIS_PRIMARY_Y, mn, mx);
if (g_sparkD) lv_chart_set_range(g_sparkD, LV_CHART_AXIS_PRIMARY_Y, mn, mx);
}
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);
// Die Kurve nimmt den Platz unterhalb der Zahlen ein, statt als schmaler Streifen in
// der Mitte zu haengen. Sie beginnt damit weiter unten auf der Karte und hat deutlich
// mehr Hoehe - Temperaturaenderungen sind so ueberhaupt erst zu erkennen.
lv_obj_set_width(ch, LV_PCT(96));
lv_obj_set_height(ch, LV_PCT(100));
lv_obj_set_flex_grow(ch, 1);
lv_obj_set_style_min_height(ch, 90, 0);
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);
// 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.
(void)soll;
lv_chart_set_all_value(ch, *ser, (int32_t)(jetzt * 10.0f));
return ch; // Ausschnitt setzt spark_ranges_fit(), sobald beide Kurven stehen
}
// 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_sparkMin = g_sparkMax = INT32_MIN; // gemerkter Ausschnitt gilt fuer die alten Kurven
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);
spark_ranges_fit();
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();
}
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*) {
// Verlaufskurven des Dashboards (Darstellung "Verlauf") fortschreiben. Das stand
// frueher am Ende von chart_duty_btn_cb - also im Callback eines Knopfes auf der
// Verlauf-SEITE. Die Kurven wurden dadurch nur fortgeschrieben, wenn jemand genau
// diesen Knopf drueckte; sonst zeigten sie unveraendert den Wert, der beim Aufbau
// der Darstellung gerade anlag. Beide Linien lagen deshalb flach und identisch.
//
// Bewusst vor dem Ausstieg unten: Die Kurven im Dashboard haengen nicht daran, ob
// die Verlauf-Seite mit ihrem grossen Diagramm schon aufgebaut ist.
// Sperrfrist nach einem Standby-Wechsel: letzten Wert halten statt der frischen Probe.
bool sperre = g_kurvenSperreBis != 0 && (int32_t)(millis() - g_kurvenSperreBis) < 0;
if (!sperre) { g_haltW = g_state.tempW; g_haltD = g_state.tempD; }
const float probeW = sperre ? g_haltW : g_state.tempW;
const float probeD = sperre ? g_haltD : g_state.tempD;
if (g_sparkSerW && g_sparkW)
lv_chart_set_next_value(g_sparkW, g_sparkSerW, (int32_t)(probeW * 10.0f));
if (g_sparkSerD && g_sparkD)
lv_chart_set_next_value(g_sparkD, g_sparkSerD, (int32_t)(probeD * 10.0f));
spark_ranges_fit(); // gemeinsame Skala, damit die Hoehen vergleichbar bleiben
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)(probeW * 10.0f + 0.5f);
g_hist->d[g_histPos] = (int16_t)(probeD * 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] %u ms: Bildaufbau %u ms, %u Bereiche, %u %% der Flaeche\n", (unsigned)millis(),
(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);
}
// LVGL legt fuer JEDE Textglyphe einen eigenen Zwischenpuffer an - Breite mal Hoehe, ein
// Byte je Bildpunkt. Bei der grossen Standby-Uhr sind das 190 x 256 = rund 48 KB pro Ziffer,
// und mit zwei parallel arbeitenden Zeicheneinheiten fast 100 KB gleichzeitig. Der interne
// LVGL-Speicher fasst nur 245 KB, wovon rund 98 KB am Stueck frei sind - es reichte knapp
// nicht. Schlaegt die Anforderung fehl, haelt LVGL per Zusicherung in einer Endlosschleife
// an: kein Absturz, keine Meldung, das Display steht einfach. Genau das war der Stillstand
// beim Wechsel in den Standby.
//
// Diese Puffer wandern daher in den externen Speicher (PSRAM, 32 MB frei). Kleine Glyphen
// bleiben im internen Speicher, weil der schneller ist und Text sonst ueberall langsamer
// wuerde; nur die grossen gehen nach aussen.
#define FONT_BUF_EXTERN_AB 16384 // ab dieser Groesse in den externen Speicher
static void* font_buf_malloc(size_t size, lv_color_format_t cf) {
LV_UNUSED(cf);
size += LV_DRAW_BUF_ALIGN - 1;
if (size >= FONT_BUF_EXTERN_AB) {
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (p) return p;
}
return lv_malloc(size);
}
static void font_buf_free(void* buf) {
// Der externe Speicher liegt ausserhalb des LVGL-Bereichs; daran ist zu erkennen,
// welcher der beiden Wege den Puffer vergeben hat.
if (esp_ptr_external_ram(buf)) heap_caps_free(buf);
else lv_free(buf);
}
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();
// Glyphenpuffer umlenken, bevor der erste Text gezeichnet wird.
lv_draw_buf_handlers_t* fh = lv_draw_buf_get_font_handlers();
fh->buf_malloc_cb = font_buf_malloc;
fh->buf_free_cb = font_buf_free;
#if WS7_PERF_MONITOR
refr_monitor_install(); // nur zum Einmessen, siehe WS7_PERF_MONITOR
#endif
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;
// Die Zeilenhoehen bleiben unveraendert. Frueher bekam die untere Zeile waehrend des
// Bezugs deutlich mehr Platz - die kleinen Kacheln wurden dadurch enorm hoch und das
// Dashboard sprang bei jedem Bezugsbeginn sichtbar um. Der Bezug hebt sich jetzt allein
// ueber seine Inhalte hervor, nicht ueber die Groesse.
(void)on;
// Die Schrift von Shot-Timer und Waage bleibt ebenfalls unveraendert. Frueher wechselte
// sie waehrend des Bezugs auf den grossen fetten Schnitt - das wirkte gegenueber dem
// restlichen Dashboard zu laut.
}
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.
// Standby-Wechsel merken: die Verlaufskurven halten danach kurz ihren letzten Wert,
// damit die Messstoerung beim Schalten der Heizungen keine Delle in die Linie zieht.
static bool letzterStandby = false;
if (st.standbyActive != letzterStandby) {
letzterStandby = st.standbyActive;
g_haltW = st.tempW;
g_haltD = st.tempD;
g_kurvenSperreBis = millis() + KURVEN_SPERRE_MS;
}
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<JsonArray>()) {
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<JsonArray>()) {
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);
}