Files
Dual-PID/JC_Display_Firmware/ui.cpp
raw-designsandClaude Opus 5 92c1446824 feat(P4): 7-Zoll-Display JC1060P470C-I-W unterstützen (1.5.0)
Dieselbe Display-Firmware bedient jetzt wahlweise das bisherige 4,3-Zoll-Panel
JC4880P443C-I-W (ST7701, 480x800) oder das neue 7-Zoll-Panel JC1060P470C-I-W
(JD9165, 1024x600). Umgeschaltet wird über JC_PANEL_TYPE in config.h
(JC_PANEL_43 / JC_PANEL_70) oder per Compiler-Flag -DJC_PANEL_TYPE=70. Vorgabe
bleibt das 4,3-Zoll-Panel; dessen Verhalten ändert sich nicht.

- src/lcd/esp_lcd_jd9165.c/.h aus dem Hersteller-Paket übernommen;
  board_bringup.c legt je nach Panel DSI-Bus, DPI-Zeitbasis und Herstellertreiber an.
- pins_config.h liefert Auflösung, LCD-Reset (4,3": GPIO5, 7,0": GPIO27) und
  Ausrichtung panelabhängig. Das 7-Zoll-Panel ist nativ Querformat, deshalb
  entfallen dort die 270-Grad-Rotation und die PPA-Beschleunigung.
- lvgl_port_v9.h bezieht LVGL_PORT_H/V_RES aus pins_config.h statt fest 480/800.
- Der GT911 des 7-Zöllers meldet Rohkoordinaten im Raster 800x480 statt 1024x600.
  Die Umrechnung hängt am process_coordinates-Hook von esp_lcd_touch, damit die
  Herstellerdatei esp_lcd_touch_gt911.c unverändert bleibt.
- ui.cpp rechnet nicht mehr mit fest verdrahteten 480 Pixeln Höhe, sondern mit
  DISP_VER_RES. Layout und Schriften bleiben sonst unverändert.
- Hersteller-Paket des 7-Zöllers unter JC_Display_Firmware_7zoll/ abgelegt;
  Werkzeuge, Archive und mitgelieferte Fremdbibliotheken per .gitignore
  ausgeschlossen (587 MB im Original, 21 MB im Repo).

Kompiliertest ESP32-P4, beide Varianten:
  4,3": 1.173.768 B Flash (37 %), 322.880 B RAM (98 %)
  7,0": 1.138.752 B Flash (36 %), 322.600 B RAM (98 %)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 02:35:40 +02:00

4355 lines
226 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_heap_caps.h> // Verlaufs-Historie im PSRAM allozieren (interner RAM ist knapp)
#include "ui.h"
#include "theme.h"
#include "config.h"
#include "display_hal.h" // hal_backlight() - Helligkeit fuer den Aufweck-Dialog
// -------------------------------------------------------------------------------------
// Zustand / Referenzen
// -------------------------------------------------------------------------------------
static ProtocolClient* g_client = nullptr;
static MachineState g_state;
static lv_obj_t* g_kb = nullptr; // gemeinsame Tastatur (auf der Top-Layer)
static lv_obj_t* g_kbTarget = nullptr; // aktuell editiertes Textfeld
static lv_obj_t* g_toast = nullptr; // globale Rueckmeldung (oben, ueber allen Tabs)
static lv_timer_t* g_toastTimer = nullptr;
static lv_obj_t* g_standbyScreen = nullptr; // Vollbild-Overlay im Standby (Uhr)
static lv_obj_t* g_standbyClock = nullptr;
static lv_obj_t* g_wakeConfirm = nullptr; // Bestaetigungsdialog "Aus dem Standby aufwecken?"
static 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_INFO, PG_COUNT };
static const char* PAGE_NAMES[PG_COUNT] = { "Dashboard", "Verlauf", "Statistik", "Temperatur", "Brew-Control", "Cold Extraction", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Info" };
static lv_obj_t* g_content = nullptr;
static lv_obj_t* g_pages[PG_COUNT] = { nullptr };
static lv_obj_t* g_drawer = nullptr;
static lv_obj_t* g_hdrTitle = nullptr;
static lv_obj_t* g_clock = nullptr;
static lv_obj_t* g_wifiIcon = nullptr;
static int g_curPage = PG_DASH;
// Dashboard
static lv_obj_t* g_maintIcon = nullptr; // Warnsymbol im Header (auf allen Seiten sichtbar)
static lv_obj_t* g_errIcon = nullptr; // Fehlersymbol im Header (rot, auf allen Seiten sichtbar)
// Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner.
// Jeder Hinweis belegt einen Slot (Reihenfolge = Prioritaet, Rot vor Gelb vor Blau);
// mehrere aktive Meldungen rotieren alle 4 s weich durch (siehe status_line_tick).
// 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_CARDS = 0, TD_GAUGE, TD_MINIMAL, TD_NEO, TD_THERMO, TD_DESIGN_COUNT };
static uint8_t g_tempDesign = TD_GAUGE;
static lv_obj_t* g_tempRow = nullptr; // Container; Inhalt wird je Design (neu) aufgebaut
static lv_obj_t* lblTempW; static lv_obj_t* lblSetW; static lv_obj_t* barDutyW; // barDuty*: lv_bar (Karten/Minimal/Neo/Thermo-Saeule)
static lv_obj_t* lblTempD; static lv_obj_t* lblSetD; static lv_obj_t* barDutyD;
static lv_obj_t* arcTempW = nullptr; static lv_obj_t* arcTempD = nullptr; // Rundinstrument-Bogen
static lv_obj_t* bulbTempW = nullptr; static lv_obj_t* bulbTempD = nullptr; // Thermometer-Kolben
static lv_obj_t* g_designBtn[TD_DESIGN_COUNT] = {}; // Umschalter auf der Info-Seite
static lv_obj_t* g_readyPill = nullptr; // "BEREIT"-Pille im Header (Kessel im Zielband)
static bool g_readyState = false; // mit Hysterese, gegen Flackern an der Bandgrenze
// 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* 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)
static lv_obj_t* g_statList = nullptr; // Liste der letzten Einzelbezuege
// =====================================================================================
// Hilfen
// =====================================================================================
static float ta_f(lv_obj_t* ta) { return atof(lv_textarea_get_text(ta)); }
static void ta_setf(lv_obj_t* ta, float v, int dec) {
if (ta == g_kbTarget) return; // nicht ueberschreiben waehrend Eingabe
char b[24]; dtostrf(v, 0, dec, b); lv_textarea_set_text(ta, b);
}
static const char* boolStr(bool b) { return b ? "true" : "false"; }
static const char* onoffStr(bool b) { return b ? "an" : "aus"; }
// Temperatur als deutsches "93,0°C" (Komma-Dezimaltrennung, kein Leerzeichen vor °C).
static String tempStr(float v, int dec) {
String s(v, (unsigned int)dec); // unsigned int -> eindeutig String(float, decimalPlaces)
s.replace('.', ',');
s += "°C";
return s;
}
static void sw_set(lv_obj_t* sw, bool on) {
if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); else lv_obj_remove_state(sw, LV_STATE_CHECKED);
}
static bool sw_get(lv_obj_t* sw) { return lv_obj_has_state(sw, LV_STATE_CHECKED); }
// =====================================================================================
// Globaler Toast
// =====================================================================================
static void toast_hide_cb(lv_timer_t* t) {
(void)t;
if (g_toast) lv_obj_add_flag(g_toast, LV_OBJ_FLAG_HIDDEN);
g_toastTimer = nullptr;
}
static void showToast(const char* txt, bool error) {
if (!g_toast) return;
lv_label_set_text(g_toast, txt);
lv_obj_set_style_bg_color(g_toast, error ? COL_DANGER : COL_OK, 0);
lv_obj_remove_flag(g_toast, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(g_toast);
if (g_toastTimer) {
lv_timer_reset(g_toastTimer);
} else {
g_toastTimer = lv_timer_create(toast_hide_cb, 3500, nullptr);
lv_timer_set_repeat_count(g_toastTimer, 1);
}
}
// =====================================================================================
// On-Screen-Tastatur
// =====================================================================================
// QWERTZ-Layout (deutsche Tastatur): wie das LVGL-Standardlayout, aber Y<->Z getauscht.
// Die ctrl-Map entspricht exakt dem Default (gleiche Tastenbreiten/-flags) und wird
// daher fuer Klein- und Grossschreibung gemeinsam verwendet.
#define KB_BTN(w) (LV_BUTTONMATRIX_CTRL_POPOVER | (w))
#define KB_C(x) ((lv_buttonmatrix_ctrl_t)(x)) // C++: enum|int ergibt int -> zurueckcasten
static const char* const kb_map_lc[] = {
"1#", "q", "w", "e", "r", "t", "z", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "y", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n",
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
};
static const char* const kb_map_uc[] = {
"1#", "Q", "W", "E", "R", "T", "Z", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "Y", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n",
LV_SYMBOL_CLOSE, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
};
static const lv_buttonmatrix_ctrl_t kb_ctrl[] = {
KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 5), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(KB_BTN(4)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 7),
KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 6), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(KB_BTN(3)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 7),
KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | KB_BTN(1)),
KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 2), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 2), KB_C(6), KB_C(LV_BUTTONMATRIX_CTRL_CHECKED | 2), KB_C(LV_KEYBOARD_CTRL_BUTTON_FLAGS | 2)
};
static void kb_event_cb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) {
lv_obj_add_flag(g_kb, LV_OBJ_FLAG_HIDDEN);
g_kbTarget = nullptr;
}
}
static void ta_focus_cb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e);
if (code == LV_EVENT_FOCUSED) {
g_kbTarget = ta;
bool isText = (intptr_t)lv_obj_get_user_data(ta) == 1;
lv_keyboard_set_mode(g_kb, isText ? LV_KEYBOARD_MODE_TEXT_LOWER : LV_KEYBOARD_MODE_NUMBER);
lv_keyboard_set_textarea(g_kb, ta);
lv_obj_remove_flag(g_kb, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(g_kb);
}
}
static void attach_kb(lv_obj_t* ta, bool isText) {
lv_obj_set_user_data(ta, (void*)(intptr_t)(isText ? 1 : 0));
lv_obj_add_event_cb(ta, ta_focus_cb, LV_EVENT_FOCUSED, nullptr);
}
// =====================================================================================
// Generische Bausteine
// =====================================================================================
static lv_obj_t* row(lv_obj_t* parent, const char* label) {
lv_obj_t* r = lv_obj_create(parent);
lv_obj_set_width(r, LV_PCT(100));
lv_obj_set_height(r, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(r, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(r, 0, 0);
lv_obj_set_style_pad_ver(r, 4, 0);
lv_obj_set_style_pad_hor(r, 2, 0);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(r, LV_OBJ_FLAG_SCROLLABLE);
if (label) {
lv_obj_t* l = lv_label_create(r);
lv_label_set_text(l, label);
lv_obj_set_style_text_color(l, COL_TEXT, 0);
}
return r;
}
static lv_obj_t* add_switch(lv_obj_t* parent, const char* label) {
lv_obj_t* r = row(parent, label);
lv_obj_t* sw = lv_switch_create(r);
lv_obj_set_style_bg_color(sw, COL_ACCENT, 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, &lv_font_maven_pro_18, 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)
static lv_obj_t* make_page(lv_obj_t* parent) {
lv_obj_t* p = lv_obj_create(parent);
lv_obj_set_size(p, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(p, 0, 0);
lv_obj_set_style_bg_opa(p, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(p, 0, 0);
lv_obj_set_style_pad_all(p, 8, 0);
lv_obj_set_style_pad_row(p, 6, 0);
lv_obj_set_flex_flow(p, LV_FLEX_FLOW_COLUMN);
return p;
}
// =====================================================================================
// Navigation (Hamburger-Schublade)
// =====================================================================================
// Brew-Control-Felder einmalig aus dem Zustand fuellen (nur beim Oeffnen der Seite,
// damit laufende State-Pushes die Eingaben nicht vor dem Speichern ueberschreiben).
static void populate_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.
static const uint32_t UI_FADE_MS = 450; // ruhige, unaufdringliche Dauer
static void ui_fade_exec_cb(void* obj, int32_t v) {
lv_obj_set_style_opa((lv_obj_t*)obj, (lv_opa_t)v, 0);
}
// Nach dem Ausblenden verstecken (ein unsichtbares Vollbild-Overlay wuerde sonst weiter
// Beruehrungen abfangen) und Opazitaet fuer das naechste Einblenden zuruecksetzen.
static void ui_fade_hide_done_cb(lv_anim_t* a) {
lv_obj_t* obj = (lv_obj_t*)a->var;
lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_style_opa(obj, LV_OPA_COVER, 0);
}
// Blendet 'obj' weich ein (sichtbar schalten, Opazitaet 0 -> voll).
static void ui_fade_in(lv_obj_t* obj, uint32_t ms) {
if (!obj) return;
lv_anim_delete(obj, ui_fade_exec_cb); // evtl. laufende Ausblendung abbrechen
lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN);
lv_anim_t a; lv_anim_init(&a);
lv_anim_set_var(&a, obj);
lv_anim_set_exec_cb(&a, ui_fade_exec_cb);
lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER);
lv_anim_set_duration(&a, ms);
lv_anim_set_path_cb(&a, lv_anim_path_ease_out);
lv_anim_start(&a);
}
// Blendet 'obj' weich aus und versteckt es danach.
static void ui_fade_out_hide(lv_obj_t* obj, uint32_t ms) {
if (!obj || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return;
lv_anim_delete(obj, ui_fade_exec_cb);
lv_anim_t a; lv_anim_init(&a);
lv_anim_set_var(&a, obj);
lv_anim_set_exec_cb(&a, ui_fade_exec_cb);
lv_anim_set_values(&a, lv_obj_get_style_opa(obj, LV_PART_MAIN), LV_OPA_TRANSP);
lv_anim_set_duration(&a, ms);
lv_anim_set_path_cb(&a, lv_anim_path_ease_in);
lv_anim_set_completed_cb(&a, ui_fade_hide_done_cb);
lv_anim_start(&a);
}
// Loescht 'obj' (inkl. Kinder/Anims) nach dem Ausblenden -> gibt LVGL-Pool-Speicher frei.
// Nur fuer Einweg-Overlays (Boot-Splash), die nie wieder gezeigt werden.
static void ui_splash_done_cb(lv_anim_t* a) {
lv_obj_delete((lv_obj_t*)a->var);
g_splash = nullptr;
}
static void ui_fade_out_delete(lv_obj_t* obj, uint32_t ms) {
if (!obj) return;
lv_anim_delete(obj, ui_fade_exec_cb);
lv_anim_t a; lv_anim_init(&a);
lv_anim_set_var(&a, obj);
lv_anim_set_exec_cb(&a, ui_fade_exec_cb);
lv_anim_set_values(&a, lv_obj_get_style_opa(obj, LV_PART_MAIN), LV_OPA_TRANSP);
lv_anim_set_duration(&a, ms);
lv_anim_set_path_cb(&a, lv_anim_path_ease_in);
lv_anim_set_completed_cb(&a, ui_splash_done_cb);
lv_anim_start(&a);
}
// --- Standby-Aufwecken (Bestaetigungsdialog) ---
// Helligkeit, die im Standby ohne Dialog gilt (Uhr gedimmt bzw. Display aus).
static int standby_backlight() {
return g_state.standbyShowClock ? (int)g_state.backlightStandbyClock : 0;
}
static void wake_confirm_show() {
if (!g_wakeConfirm) return;
g_wakeDialogOpen = true;
// Display auf normale Helligkeit, damit der Dialog im Dunkeln lesbar ist (sofort, ohne auf
// den naechsten State-Push zu warten; onState bestaetigt das ueber ui_wake_dialog_open()).
int bl = g_state.backlightActive ? (int)g_state.backlightActive : 100;
hal_backlight(bl);
lv_obj_remove_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(g_wakeConfirm);
}
static void wake_confirm_hide() { if (g_wakeConfirm) lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); }
// Einzige Quelle der Wahrheit dafuer, ob der Aufweck-Dialog die Auswahl "Espresso oder Cold
// Extraction" wirklich anbietet. Wird an ZWEI Stellen gebraucht: beim Aufbau des Dialogs und
// beim Absenden. Standen dort verschiedene Bedingungen, konnte der Dialog nur "Aufwecken"
// zeigen, aber trotzdem "wakeEspresso" senden - und damit das Auswahlfenster der S3
// schliessen. Dann war die Frage auf beiden Seiten weg (der Schalterweg hat diese Luecke
// nicht, weil er allein am S3 haengt).
static bool wake_choice_offered() {
return g_state.cxWakeChoice ||
(g_state.cxEnabled && g_state.cxArmable && g_state.cxAskOnWake);
}
static void standby_screen_cb(lv_event_t*) { wake_confirm_show(); } // Tipp auf Standby-Uhr -> nachfragen
static void wake_no_cb(lv_event_t*) { // Abbrechen -> zurueck in die Standby-Helligkeit
wake_confirm_hide();
g_wakeDialogOpen = false;
if (g_state.standbyActive) hal_backlight(standby_backlight());
}
static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 beendet den Standby
wake_confirm_hide();
// g_wakeDialogOpen bleibt true, bis der Standby wirklich endet (ui_update) - sonst wuerde
// das Display in der Uebergangsphase kurz auf Standby-Helligkeit abdunkeln.
if (!g_client) 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_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) {
lv_bar_set_value(bar, pct, LV_ANIM_OFF);
if (g_tempDesign != TD_CARDS) lv_obj_set_style_bg_color(bar, col, LV_PART_INDICATOR);
}
if (arc) {
lv_arc_set_value(arc, pct);
lv_obj_set_style_arc_color(arc, col, LV_PART_INDICATOR);
}
if (bulb) lv_obj_set_style_bg_color(bulb, col, 0);
}
static void tempCard(lv_obj_t* parent, const char* title, lv_color_t accent,
lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar) {
lv_obj_t* card = th_card(parent);
lv_obj_set_size(card, LV_PCT(49), LV_PCT(100));
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(card, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite
lv_obj_add_event_cb(card, temp_card_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* t = lv_label_create(card);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
*big = lv_label_create(card);
lv_label_set_text(*big, "--.-");
lv_obj_set_style_text_color(*big, COL_TEXT, 0);
lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0);
*setp = lv_label_create(card);
lv_label_set_text(*setp, "Soll --.-");
lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0);
*bar = lv_bar_create(card);
lv_obj_set_size(*bar, LV_PCT(92), 12);
lv_obj_set_style_bg_color(*bar, lv_color_hex(0x3a3a3a), 0); // sichtbarer Track (auch bei 0 %)
lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(*bar, COL_BORDER, 0);
lv_obj_set_style_border_width(*bar, 1, 0);
lv_obj_set_style_radius(*bar, 6, 0);
lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR);
lv_obj_set_style_radius(*bar, 6, LV_PART_INDICATOR);
lv_bar_set_range(*bar, 0, 100);
lv_bar_set_value(*bar, 0, LV_ANIM_OFF);
}
// Rundinstrument im Manometer-Look (Design TD_GAUGE): freistehend ohne Kartenrahmen.
// Leichte 3D-Wirkung ueber einen "Metall"-Bezel (vertikaler Verlauf hell -> dunkel) mit
// weichem Schlagschatten und ein leicht aufgehelltes Zifferblatt darunter. Der 270-Grad-
// Bogen zeigt Ist/Soll in % und faerbt sich mit der Zustandsfarbe des Ist-Werts.
static void tempGauge(lv_obj_t* parent, const char* title, lv_color_t accent,
lv_obj_t** big, lv_obj_t** setp, lv_obj_t** arc) {
lv_obj_t* holder = lv_obj_create(parent);
lv_obj_set_size(holder, LV_PCT(49), LV_PCT(100));
lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(holder, 0, 0);
lv_obj_set_style_pad_all(holder, 0, 0);
lv_obj_clear_flag(holder, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(holder, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite
lv_obj_add_event_cb(holder, temp_card_cb, LV_EVENT_CLICKED, nullptr);
// Bezel-Ring: Verlauf von hell (oben) nach dunkel (unten) + Schatten = Metall-Optik
lv_obj_t* bezel = lv_obj_create(holder);
lv_obj_set_size(bezel, 188, 188);
lv_obj_center(bezel);
lv_obj_set_style_radius(bezel, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(bezel, lv_color_hex(0x5c5c5c), 0);
lv_obj_set_style_bg_grad_color(bezel, lv_color_hex(0x141414), 0);
lv_obj_set_style_bg_grad_dir(bezel, LV_GRAD_DIR_VER, 0);
lv_obj_set_style_border_width(bezel, 0, 0);
lv_obj_set_style_pad_all(bezel, 0, 0);
lv_obj_set_style_shadow_width(bezel, 16, 0);
lv_obj_set_style_shadow_offset_y(bezel, 5, 0);
lv_obj_set_style_shadow_color(bezel, lv_color_hex(0x000000), 0);
lv_obj_set_style_shadow_opa(bezel, LV_OPA_60, 0);
lv_obj_clear_flag(bezel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(bezel, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt zum Holder durch
// Zifferblatt: leicht "vertieft" (Verlauf dunkel oben -> etwas heller unten)
lv_obj_t* face = lv_obj_create(bezel);
lv_obj_set_size(face, 174, 174);
lv_obj_center(face);
lv_obj_set_style_radius(face, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(face, lv_color_hex(0x0c0c0c), 0);
lv_obj_set_style_bg_grad_color(face, lv_color_hex(0x232323), 0);
lv_obj_set_style_bg_grad_dir(face, LV_GRAD_DIR_VER, 0);
lv_obj_set_style_border_width(face, 0, 0);
lv_obj_set_style_pad_all(face, 0, 0);
lv_obj_clear_flag(face, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(face, LV_OBJ_FLAG_CLICKABLE);
// 270-Grad-Bogen (Luecke unten): reine Anzeige, kein Griff, nicht bedienbar
*arc = lv_arc_create(face);
lv_obj_set_size(*arc, 158, 158);
lv_obj_center(*arc);
lv_arc_set_rotation(*arc, 135);
lv_arc_set_bg_angles(*arc, 0, 270);
lv_arc_set_range(*arc, 0, 100);
lv_arc_set_value(*arc, 0);
lv_obj_remove_style(*arc, nullptr, LV_PART_KNOB);
lv_obj_clear_flag(*arc, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_arc_width(*arc, 12, LV_PART_MAIN);
lv_obj_set_style_arc_color(*arc, lv_color_hex(0x2e2e2e), LV_PART_MAIN);
lv_obj_set_style_arc_width(*arc, 12, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(*arc, accent, LV_PART_INDICATOR);
lv_obj_set_style_arc_rounded(*arc, true, LV_PART_INDICATOR);
lv_obj_t* t = lv_label_create(face);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_14, 0);
lv_obj_align(t, LV_ALIGN_CENTER, 0, -48);
*big = lv_label_create(face);
lv_label_set_text(*big, "--.-");
lv_obj_set_style_text_color(*big, COL_TEXT, 0);
lv_obj_set_style_text_font(*big, &lv_font_maven_pro_40, 0);
lv_obj_align(*big, LV_ALIGN_CENTER, 0, -2);
// Soll-Wert sitzt in der Bogen-Luecke unten (dort ist die volle Breite frei)
*setp = lv_label_create(face);
lv_label_set_text(*setp, "Soll --.-");
lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_14, 0);
lv_obj_align(*setp, LV_ALIGN_CENTER, 0, 62);
}
// Minimal-Design (TD_MINIMAL): komplett rahmenlos - nur grosser Ist-Wert in der
// Zustandsfarbe, darunter eine duenne Ist/Soll-Linie und der Soll-Wert. Sehr ruhig.
static void tempMinimal(lv_obj_t* parent, const char* title, lv_color_t accent,
lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar) {
lv_obj_t* holder = lv_obj_create(parent);
lv_obj_set_size(holder, LV_PCT(49), LV_PCT(100));
lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(holder, 0, 0);
lv_obj_set_style_pad_all(holder, 0, 0);
lv_obj_set_style_pad_row(holder, 10, 0);
lv_obj_set_flex_flow(holder, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(holder, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(holder, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(holder, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite
lv_obj_add_event_cb(holder, temp_card_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* t = lv_label_create(holder);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
*big = lv_label_create(holder);
lv_label_set_text(*big, "--.-");
lv_obj_set_style_text_color(*big, COL_TEXT, 0);
lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0);
*bar = lv_bar_create(holder); // duenne Ist/Soll-Linie
lv_obj_set_size(*bar, LV_PCT(64), 4);
lv_obj_set_style_bg_color(*bar, lv_color_hex(0x2a2a2a), 0);
lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(*bar, 0, 0);
lv_obj_set_style_radius(*bar, 2, 0);
lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); // folgt spaeter der Zustandsfarbe
lv_obj_set_style_radius(*bar, 2, LV_PART_INDICATOR);
lv_bar_set_range(*bar, 0, 100);
lv_bar_set_value(*bar, 0, LV_ANIM_OFF);
*setp = lv_label_create(holder);
lv_label_set_text(*setp, "Soll --.-");
lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0);
}
// Soft-3D/Neumorph (TD_NEO): Flaeche fast im Hintergrundton, wirkt herausgedrueckt.
// LVGL kann nur einen Schatten je Objekt, daher zwei verschachtelte Objekte:
// aussen dunkler Schatten unten rechts, innen heller "Lichtschein" oben links.
static void tempNeo(lv_obj_t* parent, const char* title, lv_color_t accent,
lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar) {
lv_obj_t* shell = lv_obj_create(parent);
lv_obj_set_size(shell, LV_PCT(48), LV_PCT(100));
lv_obj_set_style_radius(shell, 20, 0);
lv_obj_set_style_bg_color(shell, lv_color_hex(0x171717), 0);
lv_obj_set_style_bg_opa(shell, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(shell, 0, 0);
lv_obj_set_style_pad_all(shell, 0, 0);
lv_obj_set_style_shadow_width(shell, 14, 0);
lv_obj_set_style_shadow_offset_x(shell, 6, 0);
lv_obj_set_style_shadow_offset_y(shell, 6, 0);
lv_obj_set_style_shadow_color(shell, lv_color_hex(0x000000), 0);
lv_obj_set_style_shadow_opa(shell, LV_OPA_70, 0);
lv_obj_clear_flag(shell, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(shell, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite
lv_obj_add_event_cb(shell, temp_card_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* inner = lv_obj_create(shell);
lv_obj_set_size(inner, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_radius(inner, 20, 0);
lv_obj_set_style_bg_color(inner, lv_color_hex(0x1e1e1e), 0); // leicht gewoelbt: oben etwas heller
lv_obj_set_style_bg_grad_color(inner, lv_color_hex(0x121212), 0);
lv_obj_set_style_bg_grad_dir(inner, LV_GRAD_DIR_VER, 0);
lv_obj_set_style_border_width(inner, 0, 0);
lv_obj_set_style_shadow_width(inner, 14, 0);
lv_obj_set_style_shadow_offset_x(inner, -6, 0);
lv_obj_set_style_shadow_offset_y(inner, -6, 0);
lv_obj_set_style_shadow_color(inner, lv_color_hex(0x3c3c3c), 0);
lv_obj_set_style_shadow_opa(inner, LV_OPA_50, 0);
lv_obj_set_style_pad_row(inner, 8, 0);
lv_obj_set_flex_flow(inner, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(inner, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(inner, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(inner, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt zur Shell durch
lv_obj_t* t = lv_label_create(inner);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
*big = lv_label_create(inner);
lv_label_set_text(*big, "--.-");
lv_obj_set_style_text_color(*big, COL_TEXT, 0);
lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0);
*setp = lv_label_create(inner);
lv_label_set_text(*setp, "Soll --.-");
lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0);
*bar = lv_bar_create(inner); // "eingelassener" Balken: Track dunkler als Flaeche
lv_obj_set_size(*bar, LV_PCT(78), 10);
lv_obj_set_style_bg_color(*bar, lv_color_hex(0x0a0a0a), 0);
lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(*bar, 0, 0);
lv_obj_set_style_radius(*bar, 5, 0);
lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); // folgt spaeter der Zustandsfarbe
lv_obj_set_style_radius(*bar, 5, LV_PART_INDICATOR);
lv_bar_set_range(*bar, 0, 100);
lv_bar_set_value(*bar, 0, LV_ANIM_OFF);
}
// Thermometer-Saeulen (TD_THERMO): vertikale Saeule (lv_bar waechst bei Hochformat von
// unten), Glanzstreifen als Glas-Effekt, runder Kolben unten - alles rahmenlos.
static void tempThermo(lv_obj_t* parent, const char* title, lv_color_t accent,
lv_obj_t** big, lv_obj_t** setp, lv_obj_t** bar, lv_obj_t** bulb) {
lv_obj_t* holder = lv_obj_create(parent);
lv_obj_set_size(holder, LV_PCT(49), LV_PCT(100));
lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(holder, 0, 0);
lv_obj_set_style_pad_all(holder, 0, 0);
lv_obj_set_style_pad_column(holder, 14, 0);
lv_obj_set_flex_flow(holder, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(holder, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(holder, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(holder, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Temperatur-Seite
lv_obj_add_event_cb(holder, temp_card_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* th = lv_obj_create(holder); // Saeule + Kolben (feste Groesse)
lv_obj_set_size(th, 48, 190);
lv_obj_set_style_bg_opa(th, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(th, 0, 0);
lv_obj_set_style_pad_all(th, 0, 0);
lv_obj_clear_flag(th, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(th, LV_OBJ_FLAG_CLICKABLE);
*bar = lv_bar_create(th); // hochkant -> fuellt sich von unten
lv_obj_set_size(*bar, 20, 158);
lv_obj_align(*bar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_bg_color(*bar, lv_color_hex(0x262626), 0);
lv_obj_set_style_bg_opa(*bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(*bar, 0, 0);
lv_obj_set_style_radius(*bar, 10, 0);
lv_obj_set_style_bg_color(*bar, accent, LV_PART_INDICATOR); // folgt spaeter der Zustandsfarbe
lv_obj_set_style_radius(*bar, 10, LV_PART_INDICATOR);
lv_bar_set_range(*bar, 0, 100);
lv_bar_set_value(*bar, 0, LV_ANIM_OFF);
lv_obj_t* gloss = lv_obj_create(th); // Glanzstreifen (Glas-Effekt)
lv_obj_set_size(gloss, 4, 138);
lv_obj_align(gloss, LV_ALIGN_TOP_MID, -5, 8);
lv_obj_set_style_bg_color(gloss, lv_color_hex(0xffffff), 0);
lv_obj_set_style_bg_opa(gloss, LV_OPA_20, 0);
lv_obj_set_style_border_width(gloss, 0, 0);
lv_obj_set_style_radius(gloss, 2, 0);
lv_obj_clear_flag(gloss, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(gloss, LV_OBJ_FLAG_CLICKABLE);
*bulb = lv_obj_create(th); // Kolben (ueberlappt die Saeule leicht)
lv_obj_set_size(*bulb, 36, 36);
lv_obj_align(*bulb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_radius(*bulb, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(*bulb, accent, 0); // folgt spaeter der Zustandsfarbe
lv_obj_set_style_bg_opa(*bulb, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(*bulb, 0, 0);
lv_obj_set_style_shadow_width(*bulb, 10, 0);
lv_obj_set_style_shadow_offset_y(*bulb, 3, 0);
lv_obj_set_style_shadow_color(*bulb, lv_color_hex(0x000000), 0);
lv_obj_set_style_shadow_opa(*bulb, LV_OPA_40, 0);
lv_obj_clear_flag(*bulb, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(*bulb, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* col = lv_obj_create(holder); // Textspalte neben der Saeule
lv_obj_set_size(col, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(col, 0, 0);
lv_obj_set_style_pad_all(col, 0, 0);
lv_obj_set_style_pad_row(col, 6, 0);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(col, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(col, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* t = lv_label_create(col);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
*big = lv_label_create(col);
lv_label_set_text(*big, "--.-");
lv_obj_set_style_text_color(*big, COL_TEXT, 0);
lv_obj_set_style_text_font(*big, &lv_font_maven_pro_48, 0);
*setp = lv_label_create(col);
lv_label_set_text(*setp, "Soll --.-");
lv_obj_set_style_text_color(*setp, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(*setp, &lv_font_maven_pro_18, 0);
}
// Temperaturzeile gemaess aktivem Design (neu) befuellen - auch beim Umschalten zur Laufzeit
static void build_temp_row_content() {
if (!g_tempRow) return;
lv_obj_clean(g_tempRow);
lblTempW = lblSetW = barDutyW = nullptr;
lblTempD = lblSetD = barDutyD = nullptr;
arcTempW = arcTempD = nullptr;
bulbTempW = bulbTempD = nullptr;
// Nur Soft-3D braucht Innenabstand, damit die weichen Schatten nicht am Rand abgeschnitten werden
lv_obj_set_style_pad_hor(g_tempRow, (g_tempDesign == TD_NEO) ? 12 : 0, 0);
lv_obj_set_style_pad_ver(g_tempRow, (g_tempDesign == TD_NEO) ? 12 : 0, 0);
const lv_color_t teal = lv_color_hex(0x80cbc4);
switch (g_tempDesign) {
case TD_GAUGE:
tempGauge(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &arcTempW);
tempGauge(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &arcTempD);
break;
case TD_MINIMAL:
tempMinimal(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW);
tempMinimal(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD);
break;
case TD_NEO:
tempNeo(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW);
tempNeo(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD);
break;
case TD_THERMO:
tempThermo(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW, &bulbTempW);
tempThermo(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD, &bulbTempD);
break;
default:
tempCard(g_tempRow, "WASSER", COL_ACCENT, &lblTempW, &lblSetW, &barDutyW);
tempCard(g_tempRow, "DAMPF", teal, &lblTempD, &lblSetD, &barDutyD);
break;
}
}
static lv_obj_t* infoCell(lv_obj_t* parent, const char* title, lv_obj_t** val, lv_color_t accent, lv_obj_t** titleOut = nullptr) {
lv_obj_t* card = th_card(parent);
lv_obj_set_height(card, LV_PCT(100));
lv_obj_set_flex_grow(card, 1); // alle Zellen teilen sich die Breite gleichmaessig
lv_obj_set_style_pad_all(card, 6, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(card);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
if (titleOut) *titleOut = t;
*val = lv_label_create(card);
lv_label_set_text(*val, "--");
lv_obj_set_style_text_color(*val, COL_TEXT, 0);
lv_obj_set_style_text_font(*val, &lv_font_maven_pro_28, 0);
lv_obj_set_style_text_align(*val, LV_TEXT_ALIGN_CENTER, 0); // fuer mehrzeilige Werte
return card;
}
// Tipp auf die Statuszeile: 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);
lv_obj_set_width(ir, LV_PCT(100));
lv_obj_set_flex_grow(ir, 2);
lv_obj_set_style_bg_opa(ir, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(ir, 0, 0);
lv_obj_set_style_pad_all(ir, 0, 0); lv_obj_set_style_pad_column(ir, 6, 0);
lv_obj_set_flex_flow(ir, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ir, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(ir, LV_OBJ_FLAG_SCROLLABLE);
cellShot = infoCell(ir, "SHOT-TIMER", &lblShotTimer, COL_ACCENT);
// Brew-by-Time-Fortschrittsbalken (unter dem Shot-Timer; nur waehrend BBT-Bezug sichtbar)
g_bbtBar = lv_bar_create(cellShot);
lv_obj_set_width(g_bbtBar, LV_PCT(90));
lv_obj_set_height(g_bbtBar, 8);
lv_obj_set_style_bg_color(g_bbtBar, lv_color_hex(0x3a3a3a), 0);
lv_obj_set_style_bg_opa(g_bbtBar, LV_OPA_COVER, 0);
lv_obj_set_style_radius(g_bbtBar, 4, 0);
lv_obj_set_style_bg_color(g_bbtBar, COL_OK, LV_PART_INDICATOR);
lv_obj_set_style_radius(g_bbtBar, 4, LV_PART_INDICATOR);
lv_bar_set_range(g_bbtBar, 0, 100);
lv_bar_set_value(g_bbtBar, 0, LV_ANIM_OFF);
lv_obj_add_flag(g_bbtBar, LV_OBJ_FLAG_HIDDEN);
cellBezug = infoCell(ir, "WAAGE", &lblBezug, COL_ACCENT);
lv_obj_add_flag(cellBezug, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Brew-Control
lv_obj_add_event_cb(cellBezug, brew_cell_cb, LV_EVENT_CLICKED, nullptr);
// Brew-by-Weight-Fortschrittsbalken (unter dem Gewichtswert; nur waehrend BBW-Bezug sichtbar)
g_bbwBar = lv_bar_create(cellBezug);
lv_obj_set_width(g_bbwBar, LV_PCT(90));
lv_obj_set_height(g_bbwBar, 8);
lv_obj_set_style_bg_color(g_bbwBar, lv_color_hex(0x3a3a3a), 0);
lv_obj_set_style_bg_opa(g_bbwBar, LV_OPA_COVER, 0);
lv_obj_set_style_radius(g_bbwBar, 4, 0);
lv_obj_set_style_bg_color(g_bbwBar, COL_OK, LV_PART_INDICATOR);
lv_obj_set_style_radius(g_bbwBar, 4, LV_PART_INDICATOR);
lv_bar_set_range(g_bbwBar, 0, 100);
lv_bar_set_value(g_bbwBar, 0, LV_ANIM_OFF);
lv_obj_add_flag(g_bbwBar, LV_OBJ_FLAG_HIDDEN);
cellFlow = infoCell(ir, "FLOW", &lblFlow, lv_color_hex(0x64b5f6));
lv_obj_add_flag(cellFlow, LV_OBJ_FLAG_CLICKABLE); // antippbar -> Brew-Control
lv_obj_add_event_cb(cellFlow, brew_cell_cb, LV_EVENT_CLICKED, nullptr);
cellSteam = infoCell(ir, "DAMPF-TIMER", &lblSteamTimer, lv_color_hex(0x80cbc4));
// Steam-by-Time-Fortschrittsbalken (unter dem Dampf-Timer; nur waehrend SBT-Dampfbezug sichtbar)
g_sbtBar = lv_bar_create(cellSteam);
lv_obj_set_width(g_sbtBar, LV_PCT(90));
lv_obj_set_height(g_sbtBar, 8);
lv_obj_set_style_bg_color(g_sbtBar, lv_color_hex(0x3a3a3a), 0);
lv_obj_set_style_bg_opa(g_sbtBar, LV_OPA_COVER, 0);
lv_obj_set_style_radius(g_sbtBar, 4, 0);
lv_obj_set_style_bg_color(g_sbtBar, COL_OK, LV_PART_INDICATOR);
lv_obj_set_style_radius(g_sbtBar, 4, LV_PART_INDICATOR);
lv_bar_set_range(g_sbtBar, 0, 100);
lv_bar_set_value(g_sbtBar, 0, LV_ANIM_OFF);
lv_obj_add_flag(g_sbtBar, LV_OBJ_FLAG_HIDDEN);
// Kombi-Zelle: zeigt Eco-Info und/oder Gehaeusetemperatur (abwechselnd, siehe refresh_eco_case)
cellEco = infoCell(ir, "ECO", &lblEco, COL_OK, &lblEcoTitle);
lv_obj_add_flag(cellFlow, LV_OBJ_FLAG_HIDDEN); // nur waehrend Brew-by-Weight-Bezug (+ Nachhalten)
lv_obj_add_flag(cellEco, LV_OBJ_FLAG_HIDDEN); // erst zeigen, wenn Eco-Info und/oder Gehaeusesensor aktiv
}
// =====================================================================================
// Temperatur-Seite (radiale Drehregler + Feinjustage + Toggles)
// =====================================================================================
// Solltemperaturen NICHT bei jeder Wertaenderung senden: setTempW/setTempD schreiben auf
// der S3 ins EEPROM - eine Drag-Geste am Drehregler wuerde die S3 mit Kommandos und
// Schreibzugriffen fluten (gleiche Begruendung wie beim Aufheizzeit-Debounce). Deshalb
// entprellt: Anzeige sofort, gesendet wird nach kurzer Bedienpause bzw. sofort beim
// Loslassen des Reglers oder beim Verlassen der Seite (flush_targ()).
static lv_obj_t* g_arcW = nullptr; static lv_obj_t* g_arcD = nullptr;
static bool g_targWDirty = false, g_targDDirty = false;
static lv_timer_t* g_targSendTimer = nullptr;
static void send_targ_now() {
if (g_targWDirty) { g_targWDirty = false; if (g_client) g_client->sendAction("setTempW", String(g_targW, 1)); }
if (g_targDDirty) { g_targDDirty = false; if (g_client) g_client->sendAction("setTempD", String(g_targD, 1)); }
g_targEditMs = millis(); // State-Echo kurz nicht ueberschreiben lassen
}
static void targ_send_timer_cb(lv_timer_t*) {
g_targSendTimer = nullptr; // Einmal-Timer wird von LVGL nach diesem Callback selbst geloescht
send_targ_now();
}
static void schedule_targ_send() {
g_targEditMs = millis(); // verhindert, dass der State-Push den Wert waehrend der Eingabe ueberschreibt
if (g_targSendTimer) { lv_timer_reset(g_targSendTimer); return; }
g_targSendTimer = lv_timer_create(targ_send_timer_cb, 400, nullptr);
lv_timer_set_repeat_count(g_targSendTimer, 1); // Einmal-Timer (loescht sich selbst)
}
static void flush_targ() {
// explizit (Loslassen/Seitenwechsel): laufenden Timer abbrechen und sofort senden
if (g_targSendTimer) { lv_timer_del(g_targSendTimer); g_targSendTimer = nullptr; }
send_targ_now();
}
// Wert lokal uebernehmen: Label + Arc synchron halten, Senden entprellt anstossen
static void targ_w_set(float v) {
if (v < 0) v = 0; if (v > 135) v = 135;
g_targW = v;
if (lblTargW) lv_label_set_text(lblTargW, tempStr(g_targW, 0).c_str());
if (g_arcW) lv_arc_set_value(g_arcW, (int32_t)(g_targW + 0.5f));
g_targWDirty = true;
schedule_targ_send();
}
static void targ_d_set(float v) {
if (v < 0) v = 0; if (v > 200) v = 200;
g_targD = v;
if (lblTargD) lv_label_set_text(lblTargD, tempStr(g_targD, 0).c_str());
if (g_arcD) lv_arc_set_value(g_arcD, (int32_t)(g_targD + 0.5f));
g_targDDirty = true;
schedule_targ_send();
}
static void arc_w_cb(lv_event_t* e) { targ_w_set((float)lv_arc_get_value((lv_obj_t*)lv_event_get_target(e))); }
static void arc_d_cb(lv_event_t* e) { targ_d_set((float)lv_arc_get_value((lv_obj_t*)lv_event_get_target(e))); }
static void arc_released_cb(lv_event_t*) { flush_targ(); } // Loslassen -> Wert sofort uebernehmen
static void tw_minus(lv_event_t*) { targ_w_set(g_targW - 1.0f); }
static void tw_plus (lv_event_t*) { targ_w_set(g_targW + 1.0f); }
static void td_minus(lv_event_t*) { targ_d_set(g_targD - 1.0f); }
static void td_plus (lv_event_t*) { targ_d_set(g_targD + 1.0f); }
// Karte mit radialem Drehregler (Arc) + Feinjustage-Tasten fuer eine Solltemperatur.
// Liefert das Wert-Label (mittig im Regler) zurueck.
static lv_obj_t* build_temp_arc(lv_obj_t* parent, const char* title, lv_color_t accent, int maxVal,
lv_obj_t** arcOut, lv_event_cb_t arcCb,
lv_event_cb_t minusCb, lv_event_cb_t plusCb) {
lv_obj_t* card = th_card(parent);
lv_obj_set_size(card, LV_PCT(49), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(card, 6, 0);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(card);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, accent, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
lv_obj_t* arc = lv_arc_create(card);
lv_obj_set_size(arc, 180, 180);
lv_arc_set_bg_angles(arc, 135, 45); // klassischer 270-Grad-Regler (Oeffnung unten)
lv_arc_set_range(arc, 0, maxVal);
lv_arc_set_value(arc, 0);
lv_obj_set_style_arc_width(arc, 14, LV_PART_MAIN);
lv_obj_set_style_arc_color(arc, lv_color_hex(0x3a3a3a), LV_PART_MAIN);
lv_obj_set_style_arc_width(arc, 14, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(arc, accent, LV_PART_INDICATOR);
lv_obj_set_style_arc_rounded(arc, true, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(arc, accent, LV_PART_KNOB);
lv_obj_set_style_pad_all(arc, 7, LV_PART_KNOB); // groesserer Griff (Touch-Ziel)
lv_obj_add_flag(arc, LV_OBJ_FLAG_ADV_HITTEST); // nur Ring/Griff reagieren -> kein Wertsprung bei Tipp in die Mitte
lv_obj_add_event_cb(arc, arcCb, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_add_event_cb(arc, arc_released_cb, LV_EVENT_RELEASED, nullptr);
lv_obj_t* val = lv_label_create(arc); // Wert mittig im Regler
lv_label_set_text(val, "--°C");
lv_obj_set_style_text_color(val, COL_TEXT, 0);
lv_obj_set_style_text_font(val, &lv_font_maven_pro_40, 0);
lv_obj_center(val);
lv_obj_t* grp = lv_obj_create(card); // Feinjustage: [-] [+]
lv_obj_set_size(grp, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(grp, 0, 0);
lv_obj_set_style_pad_all(grp, 0, 0); lv_obj_set_style_pad_column(grp, 24, 0);
lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW);
lv_obj_clear_flag(grp, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* bm = th_button(grp, "-", COL_CARD2, COL_TEXT);
lv_obj_set_size(bm, 96, 48);
lv_obj_add_event_cb(bm, minusCb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* bp = th_button(grp, "+", COL_CARD2, COL_TEXT);
lv_obj_set_size(bp, 96, 48);
lv_obj_add_event_cb(bp, plusCb, LV_EVENT_CLICKED, nullptr);
*arcOut = arc;
return val;
}
static void eco_cb(lv_event_t* e) { if (g_client) g_client->sendAction(sw_get((lv_obj_t*)lv_event_get_target(e)) ? "startEco" : "stopEco"); }
static void maint_cb(lv_event_t* e) { if (g_client) g_client->sendAction("toggleMaintenance", sw_get((lv_obj_t*)lv_event_get_target(e)) ? "true" : "false"); }
// 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, &lv_font_maven_pro_28, 0);
}
// =====================================================================================
// Verlauf (Chart): Wasser/Dampf + Zusatzsensor (Gehaeuse/Tassenablage), ~2 min,
// mit automatisch skalierter Temperatur-Achse und Y-Skala-Beschriftung links
// =====================================================================================
// 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), &lv_font_maven_pro_14, 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), &lv_font_maven_pro_14, 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, &lv_font_maven_pro_18, 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], &lv_font_maven_pro_14, 0);
lv_label_set_text(ax[i], "--");
}
// Legende mit Live-Werten
lv_obj_t* leg = row(p, nullptr);
g_legW = lv_label_create(leg); lv_label_set_text(g_legW, "Wasser --");
lv_obj_set_style_text_color(g_legW, COL_ACCENT, 0);
g_legD = lv_label_create(leg); lv_label_set_text(g_legD, "Dampf --");
lv_obj_set_style_text_color(g_legD, lv_color_hex(0x80cbc4), 0);
g_legC = lv_label_create(leg); lv_label_set_text(g_legC, "");
lv_obj_set_style_text_color(g_legC, COL_CASE, 0);
lv_obj_add_flag(g_legC, LV_OBJ_FLAG_HIDDEN); // nur wenn Zusatzsensor aktiv
g_legDuty = lv_label_create(leg); lv_label_set_text(g_legDuty, "Leistung --");
lv_obj_set_style_text_color(g_legDuty, COL_TEXT_DIM, 0);
if (!g_chartDuty) lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN);
}
// Zeichnet die 120 Chart-Punkte aus der 1-s-Historie (je Fenster dezimiert), skaliert die
// Y-Achse ueber das sichtbare Fenster (Werte < 30 Grad = kalter/inaktiver Sensor werden
// fuer die Skala ignoriert) und positioniert die gestrichelten Soll-Linien.
static void chart_redraw() {
if (!g_chart || !g_hist) return;
const int stride = (int)CHART_WIN_MIN[g_chartWin] * 60 / 120; // Sekunden je Chart-Punkt
int32_t lo = INT32_MAX, hi = INT32_MIN; // Zehntelgrad
bool anyW = false, anyD = false;
for (int i = 0; i < 120; i++) {
int ageStart = (119 - i) * stride; // juengstes Alter im Bucket (Sekunden)
int32_t vW = LV_CHART_POINT_NONE, vD = LV_CHART_POINT_NONE, vC = LV_CHART_POINT_NONE;
int32_t dW = LV_CHART_POINT_NONE, dD = LV_CHART_POINT_NONE;
if (ageStart < (int)g_histFill) {
// Ueber den ganzen Bucket mitteln statt ein Einzelsample zu picken: das
// Abtast-Raster haengt an g_histPos und wandert sonst mit jedem Sekundentick,
// wodurch schnell schaltende Werte (Duty-PWM) sichtbar zwischen zwei
// Kurvenbildern hin- und herspringen. Der Mittelwert ist zudem bei der
// Heizleistung die ehrlichere Aussage als ein zufaelliger Momentwert.
int32_t sW = 0, sD = 0, sC = 0, sDW = 0, sDD = 0;
int nT = 0, nC = 0;
for (int s = 0; s < stride; s++) {
int age = ageStart + s;
if (age >= (int)g_histFill) break;
int idx = (int)g_histPos - 1 - age;
if (idx < 0) idx += CHART_HIST_MAX;
sW += g_hist->w[idx];
sD += g_hist->d[idx];
sDW += g_hist->dutyW[idx];
sDD += g_hist->dutyD[idx];
nT++;
if (g_hist->c[idx] != CHART_HIST_NONE) { sC += g_hist->c[idx]; nC++; }
}
if (nT > 0) {
vW = (sW + nT / 2) / nT;
vD = (sD + nT / 2) / nT;
dW = (sDW + nT / 2) / nT;
dD = (sDD + nT / 2) / nT;
if (nC > 0) vC = (sC + nC / 2) / nC;
if (vW > 300) { anyW = true; if (vW < lo) lo = vW; if (vW > hi) hi = vW; }
if (vD > 300) { anyD = true; if (vD < lo) lo = vD; if (vD > hi) hi = vD; }
if (vC != LV_CHART_POINT_NONE && vC > 300) { if (vC < lo) lo = vC; if (vC > hi) hi = vC; }
}
}
lv_chart_set_value_by_id(g_chart, g_serW, i, vW);
lv_chart_set_value_by_id(g_chart, g_serD, i, vD);
lv_chart_set_value_by_id(g_chart, g_serC, i, vC);
lv_chart_set_value_by_id(g_chart, g_serDutyW, i, dW);
lv_chart_set_value_by_id(g_chart, g_serDutyD, i, dD);
}
// Soll-Werte in die Skala einbeziehen, damit die Soll-Linien im Bild liegen -
// aber nur fuer Kreise, deren Ist-Kurve im Fenster ueberhaupt sichtbar ist
int32_t setWt = (int32_t)(g_state.setW * 10.0f + 0.5f);
int32_t setDt = (int32_t)(g_state.setD * 10.0f + 0.5f);
bool showLineW = anyW && setWt > 300;
bool showLineD = anyD && setDt > 300;
if (showLineW) { if (setWt < lo) lo = setWt; if (setWt > hi) hi = setWt; }
if (showLineD) { if (setDt < lo) lo = setDt; if (setDt > hi) hi = setDt; }
if (hi >= lo) {
int loDeg = ((int)(lo / 10) - 3) / 5 * 5; // auf 5 Grad abrunden (mit Rand)
int hiDeg = ((int)(hi / 10) + 7) / 5 * 5; // auf 5 Grad aufrunden (mit Rand)
if (hiDeg - loDeg < 10) hiDeg = loDeg + 10; // Mindestspanne 10 Grad
lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, loDeg * 10, hiDeg * 10);
if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hiDeg);
if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (loDeg + hiDeg) / 2);
if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", loDeg);
// Soll-Linien positionieren (horizontale Linie ueber die volle Plotbreite)
int32_t w = lv_obj_get_content_width(g_chart);
int32_t h = lv_obj_get_content_height(g_chart);
struct { lv_obj_t* line; lv_point_precise_t* pts; int32_t val; bool show; } L[2] = {
{ g_lineSetW, g_lineSetPts[0], setWt, showLineW },
{ g_lineSetD, g_lineSetPts[1], setDt, showLineD },
};
for (int i = 0; i < 2; i++) {
if (!L[i].line) continue;
int32_t span = (hiDeg - loDeg) * 10;
if (L[i].show && w > 0 && h > 0 && span > 0 &&
L[i].val >= loDeg * 10 && L[i].val <= hiDeg * 10) {
int32_t y = (int32_t)((int64_t)h * (hiDeg * 10 - L[i].val) / span);
L[i].pts[0].x = 0; L[i].pts[0].y = 0;
L[i].pts[1].x = w; L[i].pts[1].y = 0;
lv_line_set_points(L[i].line, L[i].pts, 2);
lv_obj_align(L[i].line, LV_ALIGN_TOP_LEFT, 0, y);
lv_obj_remove_flag(L[i].line, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(L[i].line, LV_OBJ_FLAG_HIDDEN);
}
}
}
lv_chart_refresh(g_chart);
}
static void chart_sample_cb(lv_timer_t*) {
if (!g_chart || !g_hist) return;
// 1-s-Probe in die 60-min-Historie (Ringpuffer); Anzeige zeichnet chart_redraw
bool caseOn = g_state.caseSensorEnabled && g_state.hasCaseTemp;
g_hist->w[g_histPos] = (int16_t)(g_state.tempW * 10.0f + 0.5f);
g_hist->d[g_histPos] = (int16_t)(g_state.tempD * 10.0f + 0.5f);
g_hist->c[g_histPos] = caseOn ? (int16_t)(g_state.caseTemp * 10.0f + 0.5f) : CHART_HIST_NONE;
float dw = g_state.dutyW, dd = g_state.dutyD;
g_hist->dutyW[g_histPos] = (uint8_t)(dw < 0.0f ? 0 : (dw > 100.0f ? 100 : (int)(dw + 0.5f)));
g_hist->dutyD[g_histPos] = (uint8_t)(dd < 0.0f ? 0 : (dd > 100.0f ? 100 : (int)(dd + 0.5f)));
g_histPos = (uint16_t)((g_histPos + 1) % CHART_HIST_MAX);
if (g_histFill < CHART_HIST_MAX) g_histFill++;
chart_redraw();
if (g_legDuty && g_chartDuty)
lv_label_set_text_fmt(g_legDuty, "Leistung W %d %% / D %d %%",
(int)(dw + 0.5f), (int)(dd + 0.5f));
if (g_legW) lv_label_set_text(g_legW, ("Wasser " + tempStr(g_state.tempW, 1)).c_str());
if (g_legD) lv_label_set_text(g_legD, ("Dampf " + tempStr(g_state.tempD, 0)).c_str());
if (g_legC) {
if (caseOn) {
const char* name = (g_state.caseSensorType == 1) ? "Tassenablage " : "Gehäuse ";
lv_label_set_text(g_legC, (String(name) + tempStr(g_state.caseTemp, 1)).c_str());
lv_obj_remove_flag(g_legC, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(g_legC, LV_OBJ_FLAG_HIDDEN);
}
}
// --- Aufheiz-Countdown: konfigurierte Aufheizzeit ab Start (vom S3 als heatUpRemainSec) ---
// 40-Grad-Regel ist nur ein KALTSTART-Gate: einmal "kalt gestartet" gelatcht, laeuft der
// Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird.
{
long rs = g_state.heatUpRemainSec;
// Bei aktiver Cold Extraction heizt der Wasserkreis nicht - ein Aufheiz-Countdown
// waere schlicht falsch (er liefe ab, ohne dass die Maschine warm wird).
if (g_state.cxActive) g_heatUpLatched = false;
else if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen
else if (g_state.tempW < 40.0f) g_heatUpLatched = true; // Kaltstart erkannt -> ganzen Countdown zeigen
if (g_heatUpLatched) {
char b[56];
if (rs >= 60) snprintf(b, sizeof(b), LV_SYMBOL_UP " Aufheizen - bereit in ~%ld:%02ld", rs / 60, rs % 60);
else snprintf(b, sizeof(b), LV_SYMBOL_UP " Aufheizen - bereit in ~%ld s", rs);
status_set(ST_HEAT, true, b, lv_color_hex(0x1976d2), COL_TEXT);
} else {
status_set(ST_HEAT, false);
}
}
}
// =====================================================================================
// Shot-Zusammenfassung (Karte, blendet weich ein; nach 8 s oder per Tipp weich aus)
// =====================================================================================
static void shot_sum_hide_cb(lv_timer_t*) {
if (g_shotSum) ui_fade_out_hide(g_shotSum, UI_FADE_MS);
g_shotSumTimer = nullptr;
}
static void shot_sum_tap_cb(lv_event_t*) {
if (g_shotSum) ui_fade_out_hide(g_shotSum, UI_FADE_MS);
if (g_shotSumTimer) { lv_timer_del(g_shotSumTimer); g_shotSumTimer = nullptr; }
}
// Eine Wert-Spalte der Zusammenfassung (grosser Wert, gedimmte Beschriftung darunter)
static lv_obj_t* sum_stat_col(lv_obj_t* parent, const char* caption, lv_obj_t** valOut) {
lv_obj_t* col = lv_obj_create(parent);
lv_obj_set_size(col, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(col, 0, 0);
lv_obj_set_style_pad_all(col, 0, 0);
lv_obj_set_style_pad_row(col, 2, 0);
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_flex_grow(col, 1);
lv_obj_clear_flag(col, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(col, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt auf die Karte durch (schliessen)
lv_obj_t* v = lv_label_create(col);
lv_label_set_text(v, "--");
lv_obj_set_style_text_color(v, COL_TEXT, 0);
lv_obj_set_style_text_font(v, &lv_font_maven_pro_40, 0);
lv_obj_clear_flag(v, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* c = lv_label_create(col);
lv_label_set_text(c, caption);
lv_obj_set_style_text_color(c, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(c, &lv_font_maven_pro_14, 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, &lv_font_maven_pro_14, 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, &lv_font_maven_pro_28, 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, &lv_font_maven_pro_28, 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, &lv_font_maven_pro_18, 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, &lv_font_maven_pro_14, 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, &lv_font_maven_pro_18, 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)");
add_save_button(p, "Brühen speichern", send_save_brew);
}
// =====================================================================================
// 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)");
lblCxPageHint = lv_label_create(p);
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);
add_save_button(p, "Cold Extraction speichern", send_save_coldex);
}
// 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, &lv_font_maven_pro_28, 0);
lv_obj_t* bp = th_button(grp, "+", COL_CARD2, COL_TEXT);
lv_obj_set_size(bp, 64, 56);
lv_obj_add_event_cb(bp, plus, LV_EVENT_CLICKED, nullptr);
return val;
}
static void cln_refresh() {
if (!g_clnScreen || lv_obj_has_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN)) return;
const MachineState& st = g_state;
if (st.cleaningActive) {
if (g_clnCfg) lv_obj_add_flag(g_clnCfg, LV_OBJ_FLAG_HIDDEN);
if (g_clnProgress) lv_obj_remove_flag(g_clnProgress, LV_OBJ_FLAG_HIDDEN);
String s;
if (st.cleaningWaiting) {
s = "Heizt auf 93 °C vor...\n\n";
s += "Wasser: " + tempStr(st.tempW, 0) + " / 93°C\n\n";
s += "Die Zyklen starten automatisch,\nsobald die Temperatur erreicht ist.";
} else {
s = "Zyklus " + String(st.cleaningCycle) + " / " + String(st.cleaningCycles) + "\n\n";
s += "Phase: " + String(st.cleaningBrewPhase ? "Bezug" : "Pause") + "\n";
s += "Rest: " + String(st.cleaningPhaseRemainSec) + " s\n\n";
s += "Wasser: " + tempStr(st.tempW, 0) + " / 93°C\n";
s += "Dampfheizung aus";
}
if (g_clnProgLbl) label_set_if_changed(g_clnProgLbl, s.c_str());
} else {
if (g_clnProgress) lv_obj_add_flag(g_clnProgress, LV_OBJ_FLAG_HIDDEN);
if (g_clnCfg) lv_obj_remove_flag(g_clnCfg, LV_OBJ_FLAG_HIDDEN);
}
}
static void cln_close(lv_event_t*) { if (g_clnScreen) lv_obj_add_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN); }
static void cln_open(lv_event_t*) {
if (!g_clnScreen) return;
if (!g_state.cleaningActive) { // Parameter aus dem Zustand vorbelegen
if (g_state.cleaningCycles) g_clnCycles = g_state.cleaningCycles;
if (g_state.cleaningBrewSeconds) g_clnBrew = g_state.cleaningBrewSeconds;
if (g_state.cleaningPauseSeconds) g_clnPause = g_state.cleaningPauseSeconds;
cln_set_labels();
}
lv_obj_remove_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN); // bleibt unter Toast/Standby/OTA (Erstellreihenfolge)
cln_refresh();
}
static void cln_start_cb(lv_event_t*) {
if (!g_client) return;
String f = "\"cycles\":" + String(g_clnCycles) +
",\"brewSeconds\":" + String(g_clnBrew) +
",\"pauseSeconds\":" + String(g_clnPause);
g_client->sendCommand("startCleaning", f);
}
static void cln_stop_cb(lv_event_t*) {
if (g_client) g_client->sendCommand("stopCleaning");
}
static void build_cleaning_screen() {
g_clnScreen = lv_obj_create(lv_layer_top());
lv_obj_set_size(g_clnScreen, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(g_clnScreen, COL_BG, 0);
lv_obj_set_style_bg_opa(g_clnScreen, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(g_clnScreen, 0, 0);
lv_obj_set_style_radius(g_clnScreen, 0, 0);
lv_obj_set_style_pad_all(g_clnScreen, 10, 0);
lv_obj_set_style_pad_row(g_clnScreen, 8, 0);
lv_obj_set_flex_flow(g_clnScreen, LV_FLEX_FLOW_COLUMN);
lv_obj_clear_flag(g_clnScreen, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_clnScreen, LV_OBJ_FLAG_HIDDEN);
// Kopfzeile: Zurueck + Titel
lv_obj_t* hdr = lv_obj_create(g_clnScreen);
lv_obj_set_width(hdr, LV_PCT(100));
lv_obj_set_height(hdr, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(hdr, 0, 0);
lv_obj_set_style_pad_all(hdr, 0, 0); lv_obj_set_style_pad_column(hdr, 12, 0);
lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* back = th_button(hdr, LV_SYMBOL_LEFT " Zurück", COL_CARD2, COL_TEXT);
lv_obj_add_event_cb(back, cln_close, LV_EVENT_CLICKED, nullptr);
lv_obj_t* title = lv_label_create(hdr);
lv_label_set_text(title, "Reinigungsassistent");
lv_obj_set_style_text_color(title, COL_ACCENT, 0);
lv_obj_set_style_text_font(title, &lv_font_maven_pro_18, 0);
// --- Konfigurationsbereich (sichtbar, wenn inaktiv) ---
g_clnCfg = lv_obj_create(g_clnScreen);
lv_obj_set_width(g_clnCfg, LV_PCT(100));
lv_obj_set_flex_grow(g_clnCfg, 1);
lv_obj_set_style_bg_opa(g_clnCfg, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_clnCfg, 0, 0);
lv_obj_set_style_pad_all(g_clnCfg, 0, 0); lv_obj_set_style_pad_row(g_clnCfg, 8, 0);
lv_obj_set_flex_flow(g_clnCfg, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(g_clnCfg, LV_DIR_VER);
lv_obj_set_scrollbar_mode(g_clnCfg, LV_SCROLLBAR_MODE_AUTO);
lv_obj_t* hint = lv_label_create(g_clnCfg);
lv_obj_set_width(hint, LV_PCT(100));
lv_label_set_text(hint, "Heizt auf 93 °C vor und führt dann automatisch die eingestellten Spül-Zyklen aus (Siebträger mit Blindsieb einsetzen).");
lv_obj_set_style_text_color(hint, COL_TEXT_DIM, 0);
g_clnCyclesVal = cln_stepper(g_clnCfg, "Zyklen", clnCyc_m, clnCyc_p);
g_clnBrewVal = cln_stepper(g_clnCfg, "Bezug", clnBrew_m, clnBrew_p);
g_clnPauseVal = cln_stepper(g_clnCfg, "Pause", clnPause_m, clnPause_p);
cln_set_labels();
lv_obj_t* startBtn = th_accent_button(g_clnCfg, LV_SYMBOL_PLAY " Reinigung starten");
lv_obj_set_width(startBtn, LV_PCT(100));
lv_obj_add_event_cb(startBtn, cln_start_cb, LV_EVENT_CLICKED, nullptr);
// --- Fortschrittsbereich (sichtbar, wenn aktiv) ---
g_clnProgress = lv_obj_create(g_clnScreen);
lv_obj_set_width(g_clnProgress, LV_PCT(100));
lv_obj_set_flex_grow(g_clnProgress, 1);
lv_obj_set_style_bg_opa(g_clnProgress, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(g_clnProgress, 0, 0);
lv_obj_set_style_pad_all(g_clnProgress, 0, 0); lv_obj_set_style_pad_row(g_clnProgress, 12, 0);
lv_obj_set_flex_flow(g_clnProgress, LV_FLEX_FLOW_COLUMN);
lv_obj_clear_flag(g_clnProgress, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_clnProgress, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* pcard = th_card(g_clnProgress);
lv_obj_set_width(pcard, LV_PCT(100));
lv_obj_set_flex_grow(pcard, 1);
lv_obj_clear_flag(pcard, LV_OBJ_FLAG_SCROLLABLE);
g_clnProgLbl = lv_label_create(pcard);
lv_obj_set_width(g_clnProgLbl, LV_PCT(100));
lv_label_set_text(g_clnProgLbl, "Reinigung läuft...");
lv_obj_set_style_text_color(g_clnProgLbl, COL_TEXT, 0);
lv_obj_set_style_text_font(g_clnProgLbl, &lv_font_maven_pro_18, 0);
lv_obj_t* stopBtn = th_button(g_clnProgress, LV_SYMBOL_STOP " Reinigung abbrechen", COL_DANGER, COL_TEXT);
lv_obj_set_width(stopBtn, LV_PCT(100));
lv_obj_add_event_cb(stopBtn, cln_stop_cb, LV_EVENT_CLICKED, nullptr);
}
// =====================================================================================
// Reinigung/Wartung
// =====================================================================================
static void send_save_maint(lv_event_t*) {
if (!g_client) return;
int iv = (int)(ta_f(taMaintInterval) + 0.5f);
if (iv < 0) iv = 0;
g_client->sendCommand("saveService", "\"maintenanceInterval\":" + String(iv));
showToast("Intervall gespeichert", false);
}
static void send_save_flush(lv_event_t*) {
if (!g_client) return;
int fw = (int)(ta_f(taFlushDur) + 0.5f);
int fs = (int)(ta_f(taSteamFlushDur) + 0.5f);
if (fw < 1) fw = 1; if (fw > 30) fw = 30; // S3 klemmt ebenso auf 1..30
if (fs < 1) fs = 1; if (fs > 30) fs = 30;
g_client->sendCommand("saveService",
"\"flushDurationSeconds\":" + String(fw) +
",\"steamFlushDurationSeconds\":" + String(fs));
showToast("Flush-Zeiten gespeichert", false);
}
static void send_save_display(lv_event_t*) {
if (!g_client) return;
int ba = (int)(ta_f(taBacklightActive) + 0.5f);
int bs = (int)(ta_f(taBacklightStandby) + 0.5f);
if (ba < 5) ba = 5; if (ba > 100) ba = 100; // S3 klemmt ebenso (aktiv 5..100)
if (bs < 0) bs = 0; if (bs > 100) bs = 100; // Standby-Uhr 0..100
g_client->sendCommand("saveDisplay",
"\"backlightActive\":" + String(ba) +
",\"backlightStandbyClock\":" + String(bs));
showToast("Helligkeit gespeichert", false);
}
static void send_reset_maint(lv_event_t*) {
if (!g_client) return;
g_client->sendCommand("saveService", "\"resetMaintenanceCounter\":true");
showToast("Wartungszähler zurückgesetzt", false);
}
static void build_service(lv_obj_t* p) {
section_title(p, "Reinigung & Wartung");
// Live-Status (Zaehler / Intervall / Faelligkeit) - aktualisiert ui_update
lv_obj_t* sc = th_card(p);
lv_obj_set_width(sc, LV_PCT(100));
lv_obj_set_height(sc, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(sc, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(sc, 2, 0);
lv_obj_clear_flag(sc, LV_OBJ_FLAG_SCROLLABLE);
lblMaintStatus = lv_label_create(sc);
lv_obj_set_width(lblMaintStatus, LV_PCT(100));
lv_label_set_text(lblMaintStatus, "Bezüge seit Reset: --");
lv_obj_set_style_text_color(lblMaintStatus, COL_TEXT, 0);
// Intervall einstellen + speichern
taMaintInterval = add_num(p, "Intervall (Bezüge)");
add_save_button(p, "Intervall speichern", send_save_maint);
// Zaehler zuruecksetzen
lv_obj_t* rb = th_button(p, "Wartungszähler zurücksetzen", COL_WARN, lv_color_hex(0x000000));
lv_obj_set_width(rb, LV_PCT(100));
lv_obj_add_event_cb(rb, send_reset_maint, LV_EVENT_CLICKED, nullptr);
// Flush-Zeiten (Wasser- und Dampfkreislauf), 1..30 s - wie in der Web-UI der S3
section_title(p, "Flush-Zeiten");
taFlushDur = add_num(p, "Wasser-Flush (s)");
taSteamFlushDur = add_num(p, "Dampf-Flush (s)");
add_save_button(p, "Flush-Zeiten speichern", send_save_flush);
// Display-Helligkeit dieses Touch-Displays (aktiv 5..100 %, Standby-Uhr 0..100 %)
section_title(p, "Display-Helligkeit");
taBacklightActive = add_num(p, "Aktiv (%)");
taBacklightStandby = add_num(p, "Standby-Uhr (%)");
add_save_button(p, "Helligkeit speichern", send_save_display);
// Reinigungsassistent (eigener Sub-Screen mit Parametern + Live-Fortschritt)
section_title(p, "Reinigungsassistent");
lv_obj_t* cb = th_accent_button(p, "Reinigungsassistent öffnen");
lv_obj_set_width(cb, LV_PCT(100));
lv_obj_add_event_cb(cb, cln_open, LV_EVENT_CLICKED, nullptr);
// Wartungsmodus-Toggle (gleiche Aktion wie auf der Temperatur-Seite)
section_title(p, "Wartungsmodus (Entkalkung)");
swMaint2 = add_switch(p, "Wartungsmodus");
lv_obj_add_event_cb(swMaint2, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr);
}
// =====================================================================================
// WiFi
// =====================================================================================
// Scan-Button-Beschriftung setzen (Label ist Kind 0 des th_-Buttons)
static void wifi_scan_set_label(const char* txt) {
if (!g_btnScan) return;
lv_obj_t* l = lv_obj_get_child(g_btnScan, 0);
if (l) lv_label_set_text(l, txt);
}
// Scan-Zustand zuruecksetzen (Button wieder freigeben) - bei Ergebnis ODER Timeout
static void wifi_scan_done() {
g_wifiScanPending = false;
if (g_wifiScanTimer) { lv_timer_del(g_wifiScanTimer); g_wifiScanTimer = nullptr; }
if (g_btnScan) lv_obj_remove_state(g_btnScan, LV_STATE_DISABLED);
wifi_scan_set_label(LV_SYMBOL_REFRESH " Netzwerke suchen");
}
static void wifi_scan_timeout_cb(lv_timer_t*) { g_wifiScanTimer = nullptr; wifi_scan_done(); }
static void wifi_scan_cb(lv_event_t*) {
if (!g_client) return;
if (g_wifiScanPending) return; // laufenden Scan nicht erneut anstossen
g_wifiScanPending = true;
if (g_btnScan) lv_obj_add_state(g_btnScan, LV_STATE_DISABLED);
wifi_scan_set_label(LV_SYMBOL_REFRESH " Sucht...");
g_wifiScanTimer = lv_timer_create(wifi_scan_timeout_cb, 16000, nullptr); // Sicherheits-Timeout
lv_timer_set_repeat_count(g_wifiScanTimer, 1);
g_client->sendScanWifi();
showToast("Suche Netzwerke...", false);
}
static void wifi_pick_cb(lv_event_t* e) {
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
const char* txt = lv_list_get_button_text(wifiList, btn);
if (!txt) return;
// Eintrag ist "SSID (-xx dBm) [Schloss]" -> SSID bis zum doppelten Leerzeichen
String s = txt; int cut = s.indexOf(" ");
if (cut > 0) s = s.substring(0, cut);
s.trim();
g_wifiSel = s;
if (lblWifiSel) lv_label_set_text_fmt(lblWifiSel, "Netz: %s", s.c_str());
}
static void wifi_connect_cb(lv_event_t*) {
if (!g_client) return;
if (g_wifiSel.length() == 0) { showToast("Erst ein Netzwerk wählen", true); return; }
String pass = taWifiPass ? String(lv_textarea_get_text(taWifiPass)) : String("");
g_client->sendSetWifi(g_wifiSel, pass);
showToast(("Verbinde mit " + g_wifiSel + "...").c_str(), false);
}
static void build_wifi(lv_obj_t* p) {
lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE);
// Statuskarte
lv_obj_t* sc = th_card(p);
lv_obj_set_width(sc, LV_PCT(100));
lv_obj_set_height(sc, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(sc, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(sc, 2, 0);
lv_obj_clear_flag(sc, LV_OBJ_FLAG_SCROLLABLE);
wifiStatusLbl = lv_label_create(sc);
lv_label_set_text(wifiStatusLbl, "Status: --");
lv_obj_set_style_text_color(wifiStatusLbl, COL_TEXT, 0);
wifiHintLbl = lv_label_create(sc);
lv_obj_set_width(wifiHintLbl, LV_PCT(100)); // feste Breite -> LVGL 9 umbricht standardmaessig
lv_label_set_text(wifiHintLbl, "Erweiterte Konfiguration im Webinterface.");
lv_obj_set_style_text_color(wifiHintLbl, COL_TEXT_DIM, 0);
// Aktionszeile: Scan + gewaehltes Netz
lv_obj_t* ar = row(p, nullptr);
g_btnScan = th_accent_button(ar, LV_SYMBOL_REFRESH " Netzwerke suchen");
lv_obj_set_style_bg_opa(g_btnScan, LV_OPA_40, 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, &lv_font_maven_pro_18, 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, &lv_font_maven_pro_18, 0);
lv_obj_t* v = lv_label_create(card);
lv_obj_set_width(v, LV_PCT(100));
lv_label_set_text(v, initial);
lv_obj_set_style_text_color(v, COL_TEXT, 0);
if (valOut) *valOut = v;
}
// --- Darstellung: Dashboard-Temperaturdesign umschalten (P4-lokal, in NVS gespeichert) ---
static const char* TEMP_DESIGN_NAMES[TD_DESIGN_COUNT] = { "Klassisch", "Instrumente", "Minimal", "Soft-3D", "Thermometer" };
static void design_btns_refresh() {
for (int i = 0; i < TD_DESIGN_COUNT; i++) {
if (!g_designBtn[i]) continue;
bool sel = (g_tempDesign == i);
lv_obj_set_style_bg_color(g_designBtn[i], sel ? COL_ACCENT : COL_CARD2, 0);
lv_obj_set_style_text_color(lv_obj_get_child(g_designBtn[i], 0),
sel ? lv_color_hex(0x000000) : COL_TEXT, 0);
}
}
static void design_btn_cb(lv_event_t* e) {
int d = (int)(intptr_t)lv_event_get_user_data(e);
if (d == (int)g_tempDesign) return;
g_tempDesign = (uint8_t)d;
{ Preferences prefs; prefs.begin("ui", false); prefs.putUChar("tempdsgn", g_tempDesign); prefs.end(); }
design_btns_refresh();
build_temp_row_content();
ui_update(g_state); // neu aufgebaute Anzeige sofort mit den aktuellen Werten fuellen
}
// Live-Bezugsschirm ein-/ausschalten (P4-lokal in NVS gespeichert)
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
}
static void build_info(lv_obj_t* p) {
section_title(p, "Darstellung");
lv_obj_t* dcard = th_card(p);
lv_obj_set_width(dcard, LV_PCT(100));
lv_obj_set_height(dcard, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(dcard, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(dcard, 8, 0);
lv_obj_clear_flag(dcard, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* dl = lv_label_create(dcard);
lv_label_set_text(dl, "Dashboard-Temperaturanzeige");
lv_obj_set_style_text_color(dl, COL_TEXT_DIM, 0);
lv_obj_t* drow = row(dcard, nullptr);
lv_obj_set_style_pad_column(drow, 8, 0);
for (int i = 0; i < TD_DESIGN_COUNT; i++) {
g_designBtn[i] = th_button(drow, TEMP_DESIGN_NAMES[i], COL_CARD2, COL_TEXT);
lv_obj_set_flex_grow(g_designBtn[i], 1);
lv_obj_add_event_cb(g_designBtn[i], design_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
design_btns_refresh();
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);
section_title(p, "Firmware-Stände & Verbindung");
lv_obj_t* card = th_card(p);
lv_obj_set_width(card, LV_PCT(100));
lv_obj_set_height(card, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(card, 10, 0);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
// Display (P4) - feste Werte aus config.h
char p4buf[64];
snprintf(p4buf, sizeof(p4buf), "Firmware: %s\nProtokoll (erwartet): v%d", DISPLAY_FW_VERSION, PROTO_EXPECTED_VERSION);
info_block(card, "Display (ESP32-P4)", nullptr, p4buf);
// Steuerung (S3) - live aus dem State-Push
info_block(card, "Steuerung (ESP32-S3)", &lblInfoS3, "Firmware: --\nProtokoll: --");
// Verbindung - live
info_block(card, "Verbindung", &lblInfoLink, "UART: --");
}
// =====================================================================================
// Statistik (Nutzungsverlauf) - Daten kommen vom S3 als 'usageStats' (asynchron, bounded)
// =====================================================================================
static void stats_request() {
if (!g_client) return;
if (g_statSummary) label_set_if_changed(g_statSummary, "Lade Statistik...");
if (g_statList) label_set_if_changed(g_statList, "");
g_client->sendGetUsageStats();
}
static void stats_refresh_cb(lv_event_t*) { stats_request(); }
static void build_stats(lv_obj_t* p) {
lv_obj_set_scroll_dir(p, LV_DIR_VER);
lv_obj_set_scrollbar_mode(p, LV_SCROLLBAR_MODE_AUTO);
// Kopfzeile mit Aktualisieren-Button
lv_obj_t* hdr = row(p, nullptr);
section_title(hdr, "Nutzungsstatistik");
lv_obj_t* rf = th_button(hdr, LV_SYMBOL_REFRESH " Aktualisieren", COL_CARD, COL_TEXT);
lv_obj_add_event_cb(rf, stats_refresh_cb, LV_EVENT_CLICKED, nullptr);
// Aggregat-Karte (Heute/Woche/Gesamt)
lv_obj_t* sc = th_card(p);
lv_obj_set_width(sc, LV_PCT(100));
lv_obj_set_height(sc, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(sc, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(sc, 2, 0);
lv_obj_clear_flag(sc, LV_OBJ_FLAG_SCROLLABLE);
g_statSummary = lv_label_create(sc);
lv_obj_set_width(g_statSummary, LV_PCT(100));
lv_label_set_text(g_statSummary, "Lade Statistik...");
lv_obj_set_style_text_color(g_statSummary, COL_TEXT, 0);
// Letzte Einzelbezuege
section_title(p, "Letzte Bezüge");
lv_obj_t* lc = th_card(p);
lv_obj_set_width(lc, LV_PCT(100));
lv_obj_set_height(lc, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(lc, LV_FLEX_FLOW_COLUMN);
lv_obj_clear_flag(lc, LV_OBJ_FLAG_SCROLLABLE);
g_statList = lv_label_create(lc);
lv_obj_set_width(g_statList, LV_PCT(100));
lv_label_set_text(g_statList, "");
lv_obj_set_style_text_color(g_statList, COL_TEXT_DIM, 0);
}
// =====================================================================================
// Kombi-Zelle Eco / Gehaeuse
// - nur Eco aktiv -> Eco-Info
// - nur Gehaeuse aktiv -> Gehaeusetemperatur (an Stelle der Eco-Anzeige)
// - beide aktiv -> Wechsel alle 5 s (eco_case_timer_cb togglet g_ecoCasePhase)
// =====================================================================================
static void refresh_eco_case() {
if (!cellEco) return;
bool ecoShow = g_state.ecoShowInfo;
bool caseShow = g_state.hasCaseTemp;
if (!ecoShow && !caseShow) { lv_obj_add_flag(cellEco, LV_OBJ_FLAG_HIDDEN); return; }
lv_obj_remove_flag(cellEco, LV_OBJ_FLAG_HIDDEN);
bool showCase = caseShow && (!ecoShow || g_ecoCasePhase);
if (showCase) {
// Sensorart 1 = Tassenablage, sonst Gehaeuse
lv_label_set_text(lblEcoTitle, g_state.caseSensorType == 1 ? "TASSENABLAGE" : "GEHÄUSE");
lv_obj_set_style_text_color(lblEcoTitle, lv_color_hex(0xffb74d), 0);
lv_label_set_text(lblEco, tempStr(g_state.caseTemp, 1).c_str());
lv_obj_set_style_text_color(lblEco, COL_TEXT, 0);
} else {
lv_obj_set_style_text_color(lblEcoTitle, COL_OK, 0);
if (g_state.ecoActive) {
lv_label_set_text(lblEcoTitle, "ECO");
lv_label_set_text(lblEco, "AKTIV");
lv_obj_set_style_text_color(lblEco, COL_OK, 0);
} else if (g_state.ecoCountdownSec >= 0) {
lv_label_set_text(lblEcoTitle, "ECO IN");
long s = g_state.ecoCountdownSec;
if (s >= 60) lv_label_set_text_fmt(lblEco, "%ld min", s / 60);
else lv_label_set_text_fmt(lblEco, "%ld s", s);
lv_obj_set_style_text_color(lblEco, COL_TEXT, 0);
} else {
lv_label_set_text(lblEcoTitle, "ECO");
lv_label_set_text(lblEco, "AUS");
lv_obj_set_style_text_color(lblEco, COL_TEXT_DIM, 0);
}
}
}
static void eco_case_timer_cb(lv_timer_t*) {
g_ecoCasePhase = !g_ecoCasePhase;
refresh_eco_case();
}
// =====================================================================================
// Dashboard-Timer (100 ms): laeuft displayseitig weiter und haelt 2 s nach dem Ende
// =====================================================================================
// Setzt den Labeltext nur, wenn er sich aendert -> verhindert unnoetige Invalidierung
// (im Full-Refresh-Modus loest jede Aenderung ein komplettes Neuzeichnen aus).
static void label_set_if_changed(lv_obj_t* lbl, const char* txt) {
const char* cur = lv_label_get_text(lbl);
if (!cur || strcmp(cur, txt) != 0) lv_label_set_text(lbl, txt);
}
// Pre-Infusion-Phasenleiste aktualisieren (100-ms-Takt aus dash_timers_cb).
// Die aktive Phase kommt bevorzugt von der S3 (piState = echte Statemachine, Quelle der
// Wahrheit; Push-Latenz max. 250 ms). Sendet eine aeltere S3-Firmware das Feld noch nicht
// (piState bleibt 0), wird die Phase zeitbasiert aus den gelatchten Phasendauern abgeleitet.
static void pi_bar_update() {
if (!g_piRow) return;
bool show = g_shotActive && !g_shotWasFlush && g_piShotMode;
if (!show) {
if (!lv_obj_has_flag(g_piRow, LV_OBJ_FLAG_HIDDEN)) lv_obj_add_flag(g_piRow, LV_OBJ_FLAG_HIDDEN);
g_piLastPhase = -1;
return;
}
uint32_t el = g_shotFrozenMs;
int phase = 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)
// =====================================================================================
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);
g_tempDesign = prefs.getUChar("tempdsgn", TD_GAUGE);
g_brewLiveEnabled = prefs.getBool("brewlive", true);
g_chartWin = prefs.getUChar("chartwin", 0);
if (g_chartWin > 3) g_chartWin = 0;
g_chartDuty = prefs.getBool("chartduty", false);
prefs.end();
if (g_tempDesign >= TD_DESIGN_COUNT) g_tempDesign = TD_GAUGE; }
lv_obj_t* scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, COL_BG, 0);
lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
// --- Kopfleiste ---
lv_obj_t* header = lv_obj_create(scr);
lv_obj_set_size(header, LV_PCT(100), 44);
lv_obj_set_pos(header, 0, 0);
lv_obj_set_style_bg_color(header, COL_BG2, 0);
lv_obj_set_style_border_width(header, 0, 0);
lv_obj_set_style_radius(header, 0, 0);
lv_obj_set_style_pad_hor(header, 8, 0);
lv_obj_set_style_pad_ver(header, 0, 0);
lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(header, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* burger = th_button(header, LV_SYMBOL_LIST, COL_CARD2, COL_TEXT);
lv_obj_set_size(burger, 48, 36);
lv_obj_add_event_cb(burger, hamburger_cb, LV_EVENT_CLICKED, nullptr);
g_hdrTitle = lv_label_create(header);
lv_label_set_text(g_hdrTitle, PAGE_NAMES[PG_DASH]);
lv_obj_set_style_text_color(g_hdrTitle, COL_TEXT, 0);
lv_obj_set_style_pad_left(g_hdrTitle, 10, 0);
lv_obj_set_flex_grow(g_hdrTitle, 1);
lv_obj_set_style_text_font(g_hdrTitle, &lv_font_maven_pro_18, 0);
// 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, &lv_font_maven_pro_18, 0);
g_wifiIcon = lv_label_create(header);
lv_label_set_text(g_wifiIcon, LV_SYMBOL_WIFI);
lv_obj_set_style_text_color(g_wifiIcon, COL_TEXT_MUT, 0);
lv_obj_add_flag(g_wifiIcon, LV_OBJ_FLAG_CLICKABLE); // antippbar -> WiFi-Seite
lv_obj_set_ext_click_area(g_wifiIcon, 12); // groesserer Trefferbereich fuer den Finger
lv_obj_add_event_cb(g_wifiIcon, wifi_icon_cb, LV_EVENT_CLICKED, nullptr);
// --- Content-Bereich (Seiten gestapelt) ---
g_content = lv_obj_create(scr);
lv_obj_set_size(g_content, LV_PCT(100), 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_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);
lv_obj_set_style_bg_color(g_drawer, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(g_drawer, LV_OPA_50, 0);
lv_obj_set_style_border_width(g_drawer, 0, 0);
lv_obj_set_style_pad_all(g_drawer, 0, 0);
lv_obj_set_style_radius(g_drawer, 0, 0);
lv_obj_clear_flag(g_drawer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_CLICKABLE); // Scrim faengt Tipps ab (schliessen + abschirmen)
lv_obj_add_event_cb(g_drawer, scrim_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* menu = lv_obj_create(g_drawer);
lv_obj_set_size(menu, 240, LV_PCT(100));
lv_obj_set_pos(menu, 0, 0);
lv_obj_set_style_bg_color(menu, COL_BG2, 0);
lv_obj_set_style_border_width(menu, 0, 0);
lv_obj_set_style_radius(menu, 0, 0);
lv_obj_set_style_pad_all(menu, 8, 0);
lv_obj_set_style_pad_row(menu, 6, 0);
lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(menu, LV_DIR_VER); // bei vielen Eintraegen vertikal scrollbar
lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_AUTO);
for (int i = 0; i < PG_COUNT; i++) {
lv_obj_t* mb = th_button(menu, PAGE_NAMES[i], COL_CARD, COL_TEXT);
lv_obj_set_width(mb, LV_PCT(100));
lv_obj_set_height(mb, 52);
lv_obj_set_style_flex_grow(mb, 0, 0); // feste Hoehe, nicht stauchen
lv_obj_clear_flag(mb, LV_OBJ_FLAG_SCROLLABLE); // Button selbst faengt keine Scrolls ab
lv_obj_add_event_cb(mb, nav_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
// --- Reinigungsassistent-Sub-Screen (oberste Ebene, unter Tastatur/Toast/Overlays) ---
build_cleaning_screen();
// --- 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, &lv_font_clock_240, 0); // grosse Uhr im Standby
lv_obj_center(g_standbyClock);
lv_obj_add_event_cb(g_standbyScreen, standby_screen_cb, LV_EVENT_CLICKED, nullptr); // Tippen -> Aufweck-Abfrage
lv_obj_t* standbyHint = lv_label_create(g_standbyScreen); // dezenter Hinweis am unteren Rand
lv_label_set_text(standbyHint, "Zum Aufwecken tippen");
lv_obj_set_style_text_color(standbyHint, COL_TEXT_MUT, 0);
lv_obj_align(standbyHint, LV_ALIGN_BOTTOM_MID, 0, -24);
lv_obj_add_flag(g_standbyScreen, LV_OBJ_FLAG_HIDDEN);
// Aufweck-Bestaetigung (halbtransparenter Scrim + zentrierte Karte), zunaechst versteckt
g_wakeConfirm = lv_obj_create(lv_layer_top());
lv_obj_set_size(g_wakeConfirm, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(g_wakeConfirm, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(g_wakeConfirm, LV_OPA_70, 0);
lv_obj_set_style_border_width(g_wakeConfirm, 0, 0);
lv_obj_set_style_radius(g_wakeConfirm, 0, 0);
lv_obj_clear_flag(g_wakeConfirm, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_CLICKABLE); // Scrim faengt Tipps ab
lv_obj_t* wakeCard = th_card(g_wakeConfirm);
lv_obj_set_width(wakeCard, 420);
lv_obj_set_height(wakeCard, LV_SIZE_CONTENT);
lv_obj_center(wakeCard);
lv_obj_set_flex_flow(wakeCard, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wakeCard, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(wakeCard, 18, 0);
lv_obj_clear_flag(wakeCard, LV_OBJ_FLAG_SCROLLABLE);
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, &lv_font_maven_pro_18, 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);
lv_obj_t* wakeNo = th_button(wakeBtns, "Abbrechen", COL_CARD2, COL_TEXT);
lv_obj_set_width(wakeNo, 150);
lv_obj_add_event_cb(wakeNo, wake_no_cb, LV_EVENT_CLICKED, nullptr);
g_wakeNoBtn = wakeNo;
lv_obj_t* wakeYes = th_accent_button(wakeBtns, "Aufwecken");
lv_obj_set_width(wakeYes, 150);
lv_obj_add_event_cb(wakeYes, wake_yes_cb, LV_EVENT_CLICKED, nullptr);
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_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, &lv_font_maven_pro_28, 0);
g_otaStatus = lv_label_create(g_otaScreen);
lv_label_set_text(g_otaStatus, "Bitte warten...");
lv_obj_set_style_text_color(g_otaStatus, COL_TEXT, 0);
lv_obj_set_style_text_font(g_otaStatus, &lv_font_maven_pro_18, 0);
g_otaBytes = lv_label_create(g_otaScreen);
lv_label_set_text(g_otaBytes, "");
lv_obj_set_style_text_color(g_otaBytes, COL_TEXT_DIM, 0);
lv_obj_set_style_text_font(g_otaBytes, &lv_font_maven_pro_18, 0);
lv_obj_add_flag(g_otaScreen, LV_OBJ_FLAG_HIDDEN);
// Shot-Zusammenfassung (zentrierte Karte 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, &lv_font_maven_pro_28, 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, &lv_font_maven_pro_18, 0);
lv_obj_t* splashSpin = lv_spinner_create(g_splash);
lv_spinner_set_anim_params(splashSpin, 1000, 60);
lv_obj_set_size(splashSpin, 48, 48);
lv_obj_set_style_arc_color(splashSpin, COL_CARD2, LV_PART_MAIN);
lv_obj_set_style_arc_color(splashSpin, COL_ACCENT, LV_PART_INDICATOR);
lv_obj_set_style_margin_top(splashSpin, 8, 0);
lv_obj_t* splashVer = lv_label_create(g_splash);
lv_label_set_text(splashVer, "Firmware " DISPLAY_FW_VERSION);
lv_obj_set_style_text_color(splashVer, COL_TEXT_MUT, 0);
lv_obj_set_style_text_font(splashVer, &lv_font_maven_pro_14, 0);
g_splashActive = true;
ui_fade_in(g_splash, UI_FADE_MS); // weiches Erscheinen beim Boot
lv_timer_create(dash_timers_cb, 100, nullptr); // Timer-Anzeige fortlaufend aktualisieren
lv_timer_create(eco_case_timer_cb, 5000, nullptr); // Eco/Gehaeuse alle 5 s wechseln
lv_timer_create(chart_sample_cb, 1000, nullptr); // Verlauf jede Sekunde abtasten
show_page(PG_DASH);
if (g_client) g_client->sendListProfiles();
}
// Haengt den Ergebnis-/Abbruchtext eines beendeten Tuning-Laufs an 'out' an.
// 'sev' wird auf die hoechste vorkommende Schwere gesetzt (0=ok, 1=warnung, 2=fehler).
static void appendTuneResult(String& out, const char* kreis, uint8_t status, int& sev) {
const char* msg = nullptr;
int s = 0;
switch (status) {
case 2: msg = ": Tuning erfolgreich"; s = 0; break; // AT_SUCCESS
case 3: msg = ": Tuning beendet (Failsafe, evtl. ungenau)"; s = 1; break; // AT_FAILSAFE_PEAKS
case 4: msg = ": abgebrochen - keine Schwingung"; s = 2; break; // AT_ABORT_DEGENERATE
case 5: msg = ": abgebrochen - Sensorfehler"; s = 2; break; // AT_ABORT_SENSOR
case 6: msg = ": abgebrochen - Sicherheitsabsch."; s = 2; break; // AT_ABORT_SAFETY
case 7: msg = ": manuell abgebrochen"; s = 1; break; // AT_ABORT_MANUAL
default: return; // 0=Idle, 1=laeuft -> hier nichts anzeigen
}
if (out.length()) out += " | "; // Statuszeile ist einzeilig -> nebeneinander statt Umbruch
out += kreis;
out += msg;
if (s > sev) sev = s;
}
// 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
// =====================================================================================
void ui_update(const MachineState& st) {
g_state = st;
// Boot-Splash: erster State = Verbindung zur Maschine steht -> Start-Animation ausblenden
// und den Splash danach freigeben (wird nie wieder gebraucht).
if (g_splashActive) {
g_splashActive = false;
ui_fade_out_delete(g_splash, UI_FADE_MS);
}
// WLAN-Symbol
bool wifiOk = st.wifiConnected && !st.apMode;
if (g_wifiIcon) lv_obj_set_style_text_color(g_wifiIcon, wifiOk ? COL_OK : (st.apMode ? COL_WARN : COL_TEXT_MUT), 0);
// Uhrzeit (nur wenn NTP synchronisiert)
if (g_clock) {
if (st.timeStr.length()) {
lv_label_set_text(g_clock, st.timeStr.c_str());
lv_obj_remove_flag(g_clock, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(g_clock, LV_OBJ_FLAG_HIDDEN);
}
}
// Standby-Uhr-Overlay (Backlight wird in onState gesteuert). Uebergaenge werden weich
// gefadet: Einschlafen blendet die Uhr ein, Aufwecken blendet sie aus. Nur bei echtem
// Statuswechsel animieren (g_standbyShown), sonst nur Uhrzeit/Vordergrund aktualisieren.
if (g_standbyScreen) {
bool shouldShow = st.standbyActive && st.standbyShowClock;
if (shouldShow) {
lv_label_set_text(g_standbyClock, st.timeStr.length() ? st.timeStr.c_str() : "--:--");
if (!g_standbyShown) {
ui_fade_in(g_standbyScreen, UI_FADE_MS); // Einschlaf-Animation
g_standbyShown = true;
} else {
lv_obj_move_foreground(g_standbyScreen);
}
} else if (g_standbyShown) {
ui_fade_out_hide(g_standbyScreen, UI_FADE_MS); // Aufweck-Animation
g_standbyShown = false;
}
}
// Aufweck-Ablauf: nach beendetem Standby Dialog schliessen und Helligkeits-Flag loeschen
// (auch wenn der Dialog schon ausgeblendet ist, weil "Aufwecken" das Flag bewusst haelt).
// 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) {
lv_label_set_text(lblTempW, LV_SYMBOL_WARNING);
lv_obj_set_style_text_color(lblTempW, COL_DANGER, 0);
lv_label_set_text(lblSetW, st.wasserSafetyShutdown ? "Sicherheitsabsch." : "Sensorfehler!");
lv_obj_set_style_text_color(lblSetW, COL_DANGER, 0);
temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER);
} else {
lv_label_set_text(lblTempW, tempStr(st.tempW, 1).c_str());
if (st.cxActive) {
// Cold Extraction: der Sollwert ist bedeutungslos, es wird nicht geheizt.
// Statt eines Zielbands, das nie erreicht wird, den Grund anzeigen.
lv_obj_set_style_text_color(lblTempW, lv_color_hex(0x1976d2), 0);
label_set_if_changed(lblSetW, "Cold Extraction - Heizen aus");
lv_obj_set_style_text_color(lblSetW, lv_color_hex(0x1976d2), 0);
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);
lv_obj_set_style_text_color(lblTempW, colW, 0);
lv_label_set_text(lblSetW, ("Soll " + tempStr(st.setW, 1)).c_str());
lv_obj_set_style_text_color(lblSetW, COL_TEXT_DIM, 0);
// Fortschritt = Ist/Soll in %, gedeckelt auf 100 (Ist >= Soll -> voll)
int pctW = (st.setW > 0.0f) ? (int)(st.tempW / st.setW * 100.0f + 0.5f) : 0;
temp_progress_set(barDutyW, arcTempW, bulbTempW, pctW, colW);
}
}
if (st.dampfSafetyShutdown || st.dampfSensorError) {
lv_label_set_text(lblTempD, LV_SYMBOL_WARNING);
lv_obj_set_style_text_color(lblTempD, COL_DANGER, 0);
lv_label_set_text(lblSetD, st.dampfSafetyShutdown ? "Sicherheitsabsch." : "Sensorfehler!");
lv_obj_set_style_text_color(lblSetD, COL_DANGER, 0);
temp_progress_set(barDutyD, arcTempD, bulbTempD, 0, COL_DANGER);
} else {
lv_label_set_text(lblTempD, tempStr(st.tempD, 0).c_str());
lv_color_t colD = temp_state_color(st.tempD, st.setD);
lv_obj_set_style_text_color(lblTempD, colD, 0);
lv_label_set_text(lblSetD, ("Soll " + tempStr(st.setD, 0)).c_str());
lv_obj_set_style_text_color(lblSetD, COL_TEXT_DIM, 0);
int pctD = (st.setD > 0.0f) ? (int)(st.tempD / st.setD * 100.0f + 0.5f) : 0;
temp_progress_set(barDutyD, arcTempD, bulbTempD, pctD, colD);
}
}
// "BEREIT"-Pille im Header: Wasser im Zielband (und Dampf, sofern er heizt), kein
// Fehler/Standby/Eco und kein laufender Aufheiz-Countdown. Mit Hysterese gegen
// Flackern an der Bandgrenze; erscheint und verschwindet weich (Fade).
if (g_readyPill) {
bool dampfRelevant = !st.steamHeatDisabled && !st.steamDelayActive &&
!st.dampfSafetyShutdown && !st.dampfSensorError && st.setD > 0.0f;
float devW = fabsf(st.tempW - st.setW);
float devD = dampfRelevant ? fabsf(st.tempD - st.setD) : 0.0f;
float band = g_readyState ? 1.8f : 1.0f; // Eintritt enger als Austritt
bool ready = !anyErr && !st.standbyActive && !st.ecoActive && !g_heatUpLatched &&
st.setW > 0.0f && devW <= band && devD <= band;
if (ready != g_readyState) {
g_readyState = ready;
if (ready) ui_fade_in(g_readyPill, UI_FADE_MS);
else ui_fade_out_hide(g_readyPill, UI_FADE_MS);
}
}
// 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) {
lv_label_set_text(lblBezug, "--");
} else if (bbwShot) {
// Waehrend des Bezugs mit Brew-by-Weight: Ist oben, Ziel darunter (zweizeilig)
lv_label_set_text_fmt(lblBezug, "%.1f g\n/ %.1f g", wDisp, shotTarget);
} else {
lv_label_set_text_fmt(lblBezug, "%.1f g", wDisp);
}
// Fortschrittsbalken Gewicht/Ziel (nur waehrend BBW-Bezug)
if (g_bbwBar) {
if (bbwShot) {
int pct = (int)(st.weight / 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) { showToast(txt, error); }
void ui_set_profiles(const String profiles[], int count) {
// 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 (!profDetailLbl) return;
JsonDocument doc;
if (deserializeJson(doc, json)) {
lv_label_set_text(profDetailLbl, "Profilwerte konnten nicht gelesen werden.");
lv_obj_set_style_text_color(profDetailLbl, COL_DANGER, 0);
return;
}
if (profDetailTitle) {
const char* pname = doc["profile"] | "";
if (pname[0]) lv_label_set_text(profDetailTitle, pname);
}
String t;
t += "Wasser Soll "; t += tempStr((float)(doc["setW"] | 0.0f), 1);
t += " Offset "; t += tempStr((float)(doc["offW"] | 0.0f), 1); t += "\n";
t += "Dampf Soll "; t += tempStr((float)(doc["setD"] | 0.0f), 1);
t += " Offset "; t += tempStr((float)(doc["offD"] | 0.0f), 1); t += "\n\n";
t += "PID Wasser ";
t += String((float)(doc["kpW"] | 0.0f), 2); t += " / ";
t += String((float)(doc["kiW"] | 0.0f), 2); t += " / ";
t += String((float)(doc["kdW"] | 0.0f), 2); t += "\n";
t += "PID Dampf ";
t += String((float)(doc["kpD"] | 0.0f), 2); t += " / ";
t += String((float)(doc["kiD"] | 0.0f), 2); t += " / ";
t += String((float)(doc["kdD"] | 0.0f), 2); t += "\n\n";
t += "Pre-Infusion: "; t += onoffStr(doc["piEnabled"] | false);
t += " ("; t += String((float)(doc["piDurSecs"] | 0.0f), 1);
t += "s / "; t += String((float)(doc["piPauseSecs"] | 0.0f), 1); t += "s Pause)\n";
t += "Brew-by-Time: "; t += onoffStr(doc["bbtEnabled"] | false);
t += " ("; t += String((float)(doc["bbtSecs"] | 0.0f), 1); t += "s)\n";
t += "Brew-by-Weight: "; t += onoffStr(doc["bbwEnabled"] | false);
t += " ("; t += String((float)(doc["bbwTarget"] | 0.0f), 1);
t += "g, Offset "; t += String((float)(doc["bbwOffset"] | 0.0f), 1); t += "g)\n";
t += "Steam-by-Time: "; t += onoffStr(doc["sbtEnabled"] | false);
t += " ("; t += String((float)(doc["sbtSecs"] | 0.0f), 1); t += "s)\n\n";
t += "Eco: "; t += String((int)(doc["ecoMin"] | 0)); t += " min W ";
t += String((int)(doc["ecoTempW"] | 0)); t += "°C / D ";
t += String((int)(doc["ecoTempD"] | 0)); t += "°C\n";
t += "Fast-Heat-Up: "; t += onoffStr(doc["fastHeatUp"] | false);
t += " Piezo: "; t += onoffStr(doc["piezoEnabled"] | false);
lv_label_set_text(profDetailLbl, t.c_str());
lv_obj_set_style_text_color(profDetailLbl, COL_TEXT, 0);
}
void ui_set_usage_stats(const String& json) {
if (!g_statSummary) return;
JsonDocument doc;
if (deserializeJson(doc, json)) {
label_set_if_changed(g_statSummary, "Statistik konnte nicht gelesen werden.");
if (g_statList) label_set_if_changed(g_statList, "");
return;
}
bool avail = doc["available"] | false;
if (!avail) {
label_set_if_changed(g_statSummary, "Noch keine Nutzungsdaten vorhanden.");
if (g_statList) label_set_if_changed(g_statList, "");
return;
}
long total = doc["total"] | 0;
long today = doc["today"] | 0;
long week = doc["week"] | 0;
bool partial = doc["partial"] | false;
// Liste + Mittelwert der angezeigten Bezuege aufbauen
String l;
int n = 0;
float durSum = 0.0f;
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)
if (n > 0) l += "\n";
l += String(dt) + " " + String(d, 1) + " s";
if (w >= 0.0f) l += " " + String(w, 1) + " g";
if (cold) l += " (kalt)";
// 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());
if (g_statList) {
if (n == 0) l = "Keine Einzelbezüge vorhanden.";
label_set_if_changed(g_statList, l.c_str());
}
}
void ui_set_wifi_networks(const String& json) {
wifi_scan_done(); // Scan abgeschlossen: Button wieder freigeben
if (!wifiList) return;
// 1) Netze in kompakte lokale Labels parsen und den JSON-Speicher wieder freigeben,
// BEVOR die LVGL-Listenobjekte alloziert werden (haelt den LVGL-Pool-Peak klein).
static const int MAXW = 12; // Liste begrenzen
String labels[MAXW];
int n = 0;
{
JsonDocument doc;
if (deserializeJson(doc, json)) { showToast("Netzliste fehlerhaft", true); return; }
for (JsonVariant v : doc["networks"].as<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_otaScreen) return;
lv_label_set_text(g_otaStatus, "Update läuft - bitte nicht ausschalten!");
lv_obj_set_style_text_color(g_otaStatus, COL_TEXT, 0);
lv_label_set_text(g_otaBytes, "0 KB");
lv_obj_remove_flag(g_otaScreen, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(g_otaScreen);
}
void ui_ota_progress(uint32_t bytes) {
if (!g_otaBytes) return;
lv_label_set_text_fmt(g_otaBytes, "%lu KB übertragen", (unsigned long)(bytes / 1024));
}
void ui_ota_done() {
if (!g_otaStatus) return;
lv_label_set_text(g_otaStatus, "Fertig - Neustart...");
lv_obj_set_style_text_color(g_otaStatus, COL_OK, 0);
}
void ui_ota_fail(const char* msg) {
if (!g_otaStatus) return;
lv_label_set_text_fmt(g_otaStatus, "Fehlgeschlagen: %s", msg);
lv_obj_set_style_text_color(g_otaStatus, COL_DANGER, 0);
}