Die Abfrage war eine reine Vorab-Abfrage des Displays und erschien nur beim Antippen des Standby-Bildschirms. Beim Aufwecken über den Schalter ist der Standby aber schon beendet, bevor das Display überhaupt fragen könnte — dieser Weg war nie abgedeckt. Die Steuerung hält jetzt nach dem Aufwecken ein Auswahlfenster offen (20 s): Der Wasserkreis heizt in dieser Zeit nicht, das Display blendet die Auswahl von sich aus ein und zeigt einen Countdown. Ohne Entscheidung wird danach normal geheizt, es geht also nichts verloren, wenn niemand hinsieht. Das Fenster öffnet nur mit angeschlossenem Display (sonst könnte niemand wählen), bei freigeschalteter Funktion und aktiver Einstellung "Abfrage beim Aufwecken", und nur wenn der Modus nicht ohnehin schon läuft. Es schließt bei Entscheidung, Bezug/Spülen oder erneutem Standby. Neue Aktion wakeEspresso (beendet Standby falls aktiv und schließt das Fenster) — das Display sendet sie statt deactivateStandby, sobald die Auswahl relevant ist, sonst würde direkt nach dem Aufwecken nochmal gefragt. Neue State-Felder cxWakeChoice und cxWakeChoiceSec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4331 lines
225 KiB
C++
4331 lines
225 KiB
C++
// =====================================================================================
|
|
// ui.cpp - LVGL-Oberflaeche (Querformat 800x480), Design an die Web-UI angelehnt
|
|
// =====================================================================================
|
|
// Navigation ueber ein Hamburger-Menue (Icon oben links -> Schublade von links).
|
|
// Seiten:
|
|
// Dashboard - nur Anzeige (Temperaturen, Shot-/Dampf-Timer, Bezug)
|
|
// Temperatur - Wasser-/Dampf-Soll per Drehregler (Arc) + Feinjustage, Toggles Eco + Wartung
|
|
// Brew-Control - alle Brew-Control-Einstellungen
|
|
// WiFi - Netzwerk scannen/verbinden, Status, Hinweis aufs Webinterface
|
|
// Profile - Profile ansehen/laden/speichern
|
|
// Oben rechts ein WLAN-Symbol (verbunden/getrennt); Antippen oeffnet die WiFi-Seite.
|
|
// Eingaben ueber eine gemeinsame
|
|
// On-Screen-Tastatur. Alle Aktionen laufen ueber den ProtocolClient zur Hauptplatine.
|
|
// =====================================================================================
|
|
|
|
#include <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 (800x480) allozieren
|
|
// und koennte den LVGL-Speicherpool sprengen. So bleibt das Fade speicher-neutral.
|
|
static const uint32_t UI_FADE_MS = 450; // ruhige, unaufdringliche Dauer
|
|
|
|
static void ui_fade_exec_cb(void* obj, int32_t v) {
|
|
lv_obj_set_style_opa((lv_obj_t*)obj, (lv_opa_t)v, 0);
|
|
}
|
|
// Nach dem Ausblenden verstecken (ein unsichtbares Vollbild-Overlay wuerde sonst weiter
|
|
// Beruehrungen abfangen) und Opazitaet fuer das naechste Einblenden zuruecksetzen.
|
|
static void ui_fade_hide_done_cb(lv_anim_t* a) {
|
|
lv_obj_t* obj = (lv_obj_t*)a->var;
|
|
lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_set_style_opa(obj, LV_OPA_COVER, 0);
|
|
}
|
|
// Blendet 'obj' weich ein (sichtbar schalten, Opazitaet 0 -> voll).
|
|
static void ui_fade_in(lv_obj_t* obj, uint32_t ms) {
|
|
if (!obj) return;
|
|
lv_anim_delete(obj, ui_fade_exec_cb); // evtl. laufende Ausblendung abbrechen
|
|
lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0);
|
|
lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN);
|
|
lv_anim_t a; lv_anim_init(&a);
|
|
lv_anim_set_var(&a, obj);
|
|
lv_anim_set_exec_cb(&a, ui_fade_exec_cb);
|
|
lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER);
|
|
lv_anim_set_duration(&a, ms);
|
|
lv_anim_set_path_cb(&a, lv_anim_path_ease_out);
|
|
lv_anim_start(&a);
|
|
}
|
|
// Blendet 'obj' weich aus und versteckt es danach.
|
|
static void ui_fade_out_hide(lv_obj_t* obj, uint32_t ms) {
|
|
if (!obj || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return;
|
|
lv_anim_delete(obj, ui_fade_exec_cb);
|
|
lv_anim_t a; lv_anim_init(&a);
|
|
lv_anim_set_var(&a, obj);
|
|
lv_anim_set_exec_cb(&a, ui_fade_exec_cb);
|
|
lv_anim_set_values(&a, lv_obj_get_style_opa(obj, LV_PART_MAIN), LV_OPA_TRANSP);
|
|
lv_anim_set_duration(&a, ms);
|
|
lv_anim_set_path_cb(&a, lv_anim_path_ease_in);
|
|
lv_anim_set_completed_cb(&a, ui_fade_hide_done_cb);
|
|
lv_anim_start(&a);
|
|
}
|
|
// Loescht 'obj' (inkl. Kinder/Anims) nach dem Ausblenden -> gibt LVGL-Pool-Speicher frei.
|
|
// Nur fuer Einweg-Overlays (Boot-Splash), die nie wieder gezeigt werden.
|
|
static void ui_splash_done_cb(lv_anim_t* a) {
|
|
lv_obj_delete((lv_obj_t*)a->var);
|
|
g_splash = nullptr;
|
|
}
|
|
static void ui_fade_out_delete(lv_obj_t* obj, uint32_t ms) {
|
|
if (!obj) return;
|
|
lv_anim_delete(obj, ui_fade_exec_cb);
|
|
lv_anim_t a; lv_anim_init(&a);
|
|
lv_anim_set_var(&a, obj);
|
|
lv_anim_set_exec_cb(&a, ui_fade_exec_cb);
|
|
lv_anim_set_values(&a, lv_obj_get_style_opa(obj, LV_PART_MAIN), LV_OPA_TRANSP);
|
|
lv_anim_set_duration(&a, ms);
|
|
lv_anim_set_path_cb(&a, lv_anim_path_ease_in);
|
|
lv_anim_set_completed_cb(&a, ui_splash_done_cb);
|
|
lv_anim_start(&a);
|
|
}
|
|
|
|
// --- Standby-Aufwecken (Bestaetigungsdialog) ---
|
|
// Helligkeit, die im Standby ohne Dialog gilt (Uhr gedimmt bzw. Display aus).
|
|
static int standby_backlight() {
|
|
return g_state.standbyShowClock ? (int)g_state.backlightStandbyClock : 0;
|
|
}
|
|
static void wake_confirm_show() {
|
|
if (!g_wakeConfirm) return;
|
|
g_wakeDialogOpen = true;
|
|
// Display auf normale Helligkeit, damit der Dialog im Dunkeln lesbar ist (sofort, ohne auf
|
|
// den naechsten State-Push zu warten; onState bestaetigt das ueber ui_wake_dialog_open()).
|
|
int bl = g_state.backlightActive ? (int)g_state.backlightActive : 100;
|
|
hal_backlight(bl);
|
|
lv_obj_remove_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_move_foreground(g_wakeConfirm);
|
|
}
|
|
static void wake_confirm_hide() { if (g_wakeConfirm) lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN); }
|
|
static void standby_screen_cb(lv_event_t*) { wake_confirm_show(); } // Tipp auf Standby-Uhr -> nachfragen
|
|
static void wake_no_cb(lv_event_t*) { // Abbrechen -> zurueck in die Standby-Helligkeit
|
|
wake_confirm_hide();
|
|
g_wakeDialogOpen = false;
|
|
if (g_state.standbyActive) hal_backlight(standby_backlight());
|
|
}
|
|
static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 beendet den Standby
|
|
wake_confirm_hide();
|
|
// g_wakeDialogOpen bleibt true, bis der Standby wirklich endet (ui_update) - sonst wuerde
|
|
// das Display in der Uebergangsphase kurz auf Standby-Helligkeit abdunkeln.
|
|
if (!g_client) return;
|
|
// "wakeEspresso" beendet den Standby UND schliesst das Auswahlfenster der S3. Ohne das
|
|
// wuerde nach dem Aufwecken noch einmal gefragt bzw. der Wasserkreis bliebe kurz aus.
|
|
const bool choiceRelevant = g_state.cxWakeChoice || (g_state.cxEnabled && g_state.cxAskOnWake);
|
|
g_client->sendAction(choiceRelevant ? "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), 480 - 44);
|
|
lv_obj_set_pos(g_content, 0, 44);
|
|
lv_obj_set_style_bg_opa(g_content, LV_OPA_TRANSP, 0);
|
|
lv_obj_set_style_border_width(g_content, 0, 0);
|
|
lv_obj_set_style_pad_all(g_content, 0, 0);
|
|
lv_obj_clear_flag(g_content, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
g_pages[PG_DASH] = make_page(g_content); build_dashboard(g_pages[PG_DASH]);
|
|
g_pages[PG_CHART] = make_page(g_content); build_chart(g_pages[PG_CHART]);
|
|
g_pages[PG_STATS] = make_page(g_content); build_stats(g_pages[PG_STATS]);
|
|
g_pages[PG_TEMP] = make_page(g_content); build_temp(g_pages[PG_TEMP]);
|
|
g_pages[PG_LIGHT] = make_page(g_content); build_light(g_pages[PG_LIGHT]);
|
|
g_pages[PG_BREW] = make_page(g_content); build_brew(g_pages[PG_BREW]);
|
|
g_pages[PG_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), 480 - 44);
|
|
lv_obj_set_pos(g_drawer, 0, 44);
|
|
lv_obj_set_style_bg_color(g_drawer, lv_color_hex(0x000000), 0);
|
|
lv_obj_set_style_bg_opa(g_drawer, LV_OPA_50, 0);
|
|
lv_obj_set_style_border_width(g_drawer, 0, 0);
|
|
lv_obj_set_style_pad_all(g_drawer, 0, 0);
|
|
lv_obj_set_style_radius(g_drawer, 0, 0);
|
|
lv_obj_clear_flag(g_drawer, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_add_flag(g_drawer, LV_OBJ_FLAG_CLICKABLE); // Scrim faengt Tipps ab (schliessen + abschirmen)
|
|
lv_obj_add_event_cb(g_drawer, scrim_cb, LV_EVENT_CLICKED, nullptr);
|
|
|
|
lv_obj_t* menu = lv_obj_create(g_drawer);
|
|
lv_obj_set_size(menu, 240, LV_PCT(100));
|
|
lv_obj_set_pos(menu, 0, 0);
|
|
lv_obj_set_style_bg_color(menu, COL_BG2, 0);
|
|
lv_obj_set_style_border_width(menu, 0, 0);
|
|
lv_obj_set_style_radius(menu, 0, 0);
|
|
lv_obj_set_style_pad_all(menu, 8, 0);
|
|
lv_obj_set_style_pad_row(menu, 6, 0);
|
|
lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_scroll_dir(menu, LV_DIR_VER); // bei vielen Eintraegen vertikal scrollbar
|
|
lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_AUTO);
|
|
for (int i = 0; i < PG_COUNT; i++) {
|
|
lv_obj_t* mb = th_button(menu, PAGE_NAMES[i], COL_CARD, COL_TEXT);
|
|
lv_obj_set_width(mb, LV_PCT(100));
|
|
lv_obj_set_height(mb, 52);
|
|
lv_obj_set_style_flex_grow(mb, 0, 0); // feste Hoehe, nicht stauchen
|
|
lv_obj_clear_flag(mb, LV_OBJ_FLAG_SCROLLABLE); // Button selbst faengt keine Scrolls ab
|
|
lv_obj_add_event_cb(mb, nav_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
|
|
}
|
|
|
|
// --- Reinigungsassistent-Sub-Screen (oberste Ebene, unter Tastatur/Toast/Overlays) ---
|
|
build_cleaning_screen();
|
|
|
|
// --- 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.
|
|
const bool choiceOpen = st.cxWakeChoice;
|
|
const bool askMode = choiceOpen ||
|
|
(st.cxEnabled && st.cxArmable && !st.cxActive && st.cxAskOnWake);
|
|
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");
|
|
}
|
|
// 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);
|
|
}
|