diff --git a/Changelog.txt b/Changelog.txt index fc4f78a..b9c7cee 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,3 +1,12 @@ +Version 5.3.2: +- Cold Extraction sendet den Sperrgrund jetzt als Code („cxBlock") statt als Klartext („cxMessage"), + dazu eine transiente Meldung als Code („cxNotice") und die Vorbenetzungsdauer („cxPreInf"). + Hintergrund: Die State-Zeile ans Touch-Display liegt real bereits bei rund 2,1 KB, und der P4 + verwirft eingehende Zeilen ab einer Obergrenze komplett - ein bis zu 110 Byte langer Klartext + hätte die Zeile über diese Grenze gedrückt und damit die ganze Telemetrie unbrauchbar gemacht. + Die Texte rendert jetzt das Display, analog zur bestehenden Trennung statusKey/statusText. +- Der Klartext bleibt unverändert auf dem OLED und in den Web-/UART-Antworten (ack-message). + Version 5.3.1: - Cold Extraction ist jetzt persistent: Der Modus wird im EEPROM gespeichert und überlebt sowohl den Standby als auch einen Neustart. Zuvor wurde er beim Standby beendet und war nach dem Aufwachen aus - diff --git a/Doku/JC-Display_UART-Protokoll.md b/Doku/JC-Display_UART-Protokoll.md index 2fad6fa..f2f4a06 100644 --- a/Doku/JC-Display_UART-Protokoll.md +++ b/Doku/JC-Display_UART-Protokoll.md @@ -387,8 +387,20 @@ Aus `getTouchUartStatus()`. Für sprach-unabhängige UI-Logik/Icons auf dem P4 v | `cxResting` | bool | Zwangspause des Pumpenschutzes läuft (Bezug läuft weiter) | | `cxMaxTemp` | float | Freigabeschwelle in °C (für eigene Hinweistexte am Display) | | `cxTarget` | float | Zielgewicht in g | +| `cxPreInf` | float | Vorbenetzung in s (für die Phasenleiste des kalten Bezugs) | | `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s | -| `cxMessage` | string | Sperr-/Abbruchmeldung, leer = keine (läuft nach ~6 s selbst ab) | +| `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei | +| `cxNotice` | uint8 | transiente Meldung, `0` = keine (läuft nach ~6 s selbst ab) | + +`cxBlock`: `0` frei, `1` nicht freigeschaltet, `2` Standby, `3` Wartungsmodus, +`4` Reinigungsassistent, `5` PID-Tuning, `6` Sensorfehler, `7` Wasserkessel zu warm. + +`cxNotice`: `0` keine, `1` Start abgewiesen (Grund steht in `cxBlock`), `2` Bezug wegen +fehlenden Zulaufs beendet, `3` Umschalten während eines Bezugs abgelehnt. + +Bewusst **Codes statt Klartext**: Die State-Zeile muss unter dem Zeilenlimit des P4 bleiben +(`PROTO_RX_LINE_MAX`; sie liegt real bereits bei ~2,1 KB), und die Texte gehören ins Display — +dieselbe Trennung wie bei `statusKey`/`statusText`. Während `cxShot` folgt die Phase weiterhin `piState`: `1` = Vorbenetzung, `3` = Hauptbezug (gepulst). Eine Pause-Phase (`2`) gibt es bei der kalten Extraktion nicht. diff --git a/Dual-PID.ino b/Dual-PID.ino index f1127b9..e70e34a 100644 --- a/Dual-PID.ino +++ b/Dual-PID.ino @@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire); * Firmware-Informationen ************************************************************************************/ -String version = "5.3.1"; +String version = "5.3.2"; String versionHersteller = "Thomas Müller"; String versionHerstellerMail = "thomas@mueller.black"; @@ -832,11 +832,36 @@ unsigned long coldExtractionPumpSampleMs = 0; float coldExtractionStallLastWeight = NAN; unsigned long coldExtractionStallSinceMs = 0; bool lastShotWasColdExtraction = false; // fuer Anzeige/Log des letzten Bezugs -// Sperrmeldung fuer OLED und Touch-Display, wenn ein kalter Bezug abgewiesen wurde +// Sperrmeldung fuer das OLED, wenn ein kalter Bezug abgewiesen bzw. abgebrochen wurde. +// Der Klartext bleibt S3-intern; ans Touch-Display gehen nur die Codes unten, damit die +// State-Zeile kurz bleibt (der P4 hat ein Zeilenlimit) und das Display seine eigenen +// Texte rendern kann - dieselbe Trennung wie bei statusKey/statusText. const unsigned long COLDEX_BLOCK_MESSAGE_MS = 6000UL; String coldExtractionBlockMessage = ""; unsigned long coldExtractionBlockMessageUntilMs = 0; +// Sperrgrund (cxBlock): 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, +// 4=Reinigungsassistent, 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm +enum ColdExtractionBlock : uint8_t { + COLDEX_BLOCK_NONE = 0, + COLDEX_BLOCK_NOT_ENABLED = 1, + COLDEX_BLOCK_STANDBY = 2, + COLDEX_BLOCK_MAINTENANCE = 3, + COLDEX_BLOCK_CLEANING = 4, + COLDEX_BLOCK_TUNING = 5, + COLDEX_BLOCK_SENSOR = 6, + COLDEX_BLOCK_TOO_WARM = 7 +}; +// Transiente Meldung (cxNotice): 0=keine, 1=Start abgewiesen, 2=Bezug wegen fehlenden +// Zulaufs beendet, 3=Umschalten waehrend eines Bezugs abgelehnt +enum ColdExtractionNotice : uint8_t { + COLDEX_NOTICE_NONE = 0, + COLDEX_NOTICE_START_REJECTED = 1, + COLDEX_NOTICE_NO_FLOW = 2, + COLDEX_NOTICE_BUSY = 3 +}; +uint8_t coldExtractionNotice = COLDEX_NOTICE_NONE; + /************************************************************************************ * Standardwerte und Default-Einstellungen ************************************************************************************/ @@ -4032,6 +4057,19 @@ bool coldExtractionConditionsOk() { coldExtractionTemperatureOk(); } +// Sperrgrund als Code fuer das Touch-Display (0 = frei). Gleiche Reihenfolge wie +// coldExtractionBlockReason(), damit Text und Code immer dieselbe Ursache nennen. +uint8_t coldExtractionBlockCode() { + if (!coldExtractionEnabled) return COLDEX_BLOCK_NOT_ENABLED; + if (standbyModeActive) return COLDEX_BLOCK_STANDBY; + if (wartungsModusAktiv) return COLDEX_BLOCK_MAINTENANCE; + if (cleaningAssistantActive) return COLDEX_BLOCK_CLEANING; + if (autoTuneWasserActive || autoTuneDampfActive) return COLDEX_BLOCK_TUNING; + if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) return COLDEX_BLOCK_SENSOR; + if (!coldExtractionTemperatureOk()) return COLDEX_BLOCK_TOO_WARM; + return COLDEX_BLOCK_NONE; +} + // Klartext-Grund, warum gerade keine kalte Extraktion moeglich ist ("" = alles frei). String coldExtractionBlockReason() { if (!coldExtractionEnabled) { @@ -5253,6 +5291,7 @@ void loop() { // kalten Bezug wieder abkuehlen zu muessen. Nur die Laufzeitdaten werden verworfen. resetColdExtractionRuntime(); coldExtractionBlockMessage = ""; + coldExtractionNotice = COLDEX_NOTICE_NONE; } else if (lastStandbyModeActive) { startupTime = currentMillis; ecoForcedActive = false; @@ -5484,6 +5523,7 @@ void loop() { stopForTime = true; coldExtractionBlockMessage = "Cold Extraction beendet: kein Zulauf (Puck dicht oder Pumpenschutz?)."; coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; + coldExtractionNotice = COLDEX_NOTICE_NO_FLOW; } } @@ -5985,6 +6025,7 @@ void updateDisplay() { if (coldExtractionBlockMessage.length() > 0) { if ((long)(millis() - coldExtractionBlockMessageUntilMs) >= 0) { coldExtractionBlockMessage = ""; + coldExtractionNotice = COLDEX_NOTICE_NONE; } else if (!standbyModeActive) { display.clearDisplay(); display.setTextSize(1); @@ -15491,6 +15532,8 @@ bool executeDashboardAction(const String& action, const String& value, if (!success) { coldExtractionBlockMessage = message; coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; + coldExtractionNotice = (shotActive || shotSoftwareActive) ? COLDEX_NOTICE_BUSY + : COLDEX_NOTICE_START_REJECTED; } } else if (action == "startShot") { if (standbyModeActive) { @@ -15509,6 +15552,7 @@ bool executeDashboardAction(const String& action, const String& value, if (message.length() == 0) { message = "Cold Extraction derzeit nicht moeglich."; } coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; coldExtractionBlockMessage = message; + coldExtractionNotice = COLDEX_NOTICE_START_REJECTED; beepShort(); success = false; } else { @@ -16432,15 +16476,21 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) { offset += snprintf(buffer + offset, bufferSize - offset, "\"cxResting\":%s,", coldExtractionPumpResting ? "true" : "false"); dtostrf(coldExtractionMaxTempC, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxTemp\":%s,", floatBuf); dtostrf(coldExtractionTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxTarget\":%s,", floatBuf); + dtostrf(coldExtractionPreInfusionSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPreInf\":%s,", floatBuf); dtostrf(coldExtractionMaxSeconds, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxSecs\":%s,", floatBuf); - // Sperrmeldung nach abgewiesenem Start (leer = keine); laeuft nach COLDEX_BLOCK_MESSAGE_MS ab. - // Die Texte stammen ausschliesslich aus dieser Firmware und enthalten keine JSON-Sonderzeichen. + // Sperrgrund + transiente Meldung als Codes (kein Freitext - die State-Zeile muss kurz + // bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst). + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxBlock\":%u,", (unsigned int)coldExtractionBlockCode()); { - const char* cxMsg = ""; - if (coldExtractionBlockMessage.length() > 0 && (long)(millis() - coldExtractionBlockMessageUntilMs) < 0) { - cxMsg = coldExtractionBlockMessage.c_str(); + uint8_t notice = COLDEX_NOTICE_NONE; + if (coldExtractionNotice != COLDEX_NOTICE_NONE) { + if ((long)(millis() - coldExtractionBlockMessageUntilMs) < 0) { + notice = coldExtractionNotice; + } else { + coldExtractionNotice = COLDEX_NOTICE_NONE; + } } - offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMessage\":\"%s\",", cxMsg); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxNotice\":%u,", (unsigned int)notice); } dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwTarget\":%s,", floatBuf); dtostrf(brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwOffset\":%s,", floatBuf); diff --git a/JC_Display_Firmware/Changelog.txt b/JC_Display_Firmware/Changelog.txt index d3df445..9d16d08 100644 --- a/JC_Display_Firmware/Changelog.txt +++ b/JC_Display_Firmware/Changelog.txt @@ -1,3 +1,32 @@ +Version 1.1.0: +- Cold Extraction am Display bedienbar (braucht S3-Firmware ab 5.3.2): + - Neuer Schalter „Cold Extraction" auf der Seite „Temperaturen" unter „Modi", darunter eine + Erklärzeile mit dem aktuellen Zustand bzw. dem Sperrgrund. Schalter und Zeile bleiben + unsichtbar, solange die S3 die Funktion nicht freigeschaltet meldet (auch bei älterer + S3-Firmware, die die Felder gar nicht sendet). + - Einschalten ist nur bei erteilter Freigabe möglich (Wasserkessel kalt genug, kein Standby / + Wartung / Tuning); ausschalten immer, außer während eines laufenden kalten Bezugs. Gesendet + wird gezielt startColdExtraction/stopColdExtraction statt eines Toggles, damit ein + abgewiesener Befehl den Schalter nicht in einen nie bestätigten Zustand bringt. + - Statuszeile: eigener Slot direkt hinter der Sicherheitsmeldung. Zeigt dauerhaft + „Cold Extraction aktiv - Wasserkreis heizt nicht", bei fehlender Freigabe den Grund, + während der Pumpen-Zwangspause den Pumpenschutz und transient einen abgewiesenen Start + bzw. einen Abbruch wegen fehlenden Zulaufs. + - Wasser-Kachel im Dashboard zeigt bei aktivem Modus „Cold Extraction - Heizen aus" statt + eines Sollwerts, der nicht erreicht wird; der Aufheiz-Countdown ist dann unterdrückt. + - Phasenleiste und Live-Bezugsschirm benennen die erste Phase eines kalten Bezugs + „Vorbenetzung" (keine Pause-Phase) und zeigen die Pumpenpause an. + - Brew-Seite: Cold-Extraction-Parameter als Anzeige (Freigabeschwelle, Vorbenetzung, + Zielgewicht, Timeout) mit Hinweis, dass sie in der Web-UI eingestellt werden. + - Statistik-Seite: kalte Bezüge sind mit „(kalt)" markiert und gehen nicht in die mittlere + Bezugsdauer ein - gleiche Regel wie in der S3-Statistik. +- Bug-Fix: Das Limit für eingehende UART-Zeilen (PROTO_RX_LINE_MAX) lag bei 2048 Bytes, während + die State-Zeile der S3 bereits rund 2,1 KB erreicht - mit langem Status-, Profil- oder + SSID-Text konnte sie darüber liegen. In dem Fall wurde die GANZE Zeile verworfen und das + Display fror auf dem letzten Stand ein, ohne dass die Verbindung als tot erkannt wurde. + Das Limit ist jetzt 4096 Bytes; der RX-Puffer selbst bleibt bei 2 KB reserviert, weil der + interne RAM knapp ist und eine Arduino-String nicht ins PSRAM alloziert werden kann. + Version 1.0.13: - Profil-Schnellwahl + Anzeige des aktiven Profils (braucht S3-Firmware ab 5.1.1): - Neuer Profil-Chip im Header (auf allen Seiten sichtbar): zeigt das zuletzt geladene bzw. diff --git a/JC_Display_Firmware/config.h b/JC_Display_Firmware/config.h index 016c105..1ea6025 100644 --- a/JC_Display_Firmware/config.h +++ b/JC_Display_Firmware/config.h @@ -65,13 +65,16 @@ // ------------------------------------------------------------------------------------- // Firmware // ------------------------------------------------------------------------------------- -#define DISPLAY_FW_VERSION "1.0.13" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) +#define DISPLAY_FW_VERSION "1.1.0" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) // ------------------------------------------------------------------------------------- // Protokoll // ------------------------------------------------------------------------------------- #define PROTO_EXPECTED_VERSION 2 // muss zur S3-Firmware passen (TOUCH_UART_PROTOCOL_VERSION) -#define PROTO_RX_LINE_MAX 2048 // max. eingehende Zeilenlaenge (Bytes) +// Die State-Zeile der S3 kann mit langem Status-/Profil-/SSID-Text ueber 2 KB gehen. Wird +// das Limit erreicht, verwirft der Client die GANZE Zeile und das Display friert auf dem +// letzten Stand ein -> Reserve deutlich groesser als der reale Bedarf (~2,3 KB). +#define PROTO_RX_LINE_MAX 4096 // max. eingehende Zeilenlaenge (Bytes) #define PROTO_HEARTBEAT_MS 5000 // Intervall fuer 'ping' (S3-Timeout = 15 s) #define PROTO_HELLO_RETRY_MS 2000 // erneuter 'hello'-Versuch, solange nicht verbunden #define PROTO_LINK_TIMEOUT_MS 8000 // ohne empfangene Nachricht -> Verbindung gilt als tot diff --git a/JC_Display_Firmware/machine_state.h b/JC_Display_Firmware/machine_state.h index 54c4c7b..ec10384 100644 --- a/JC_Display_Firmware/machine_state.h +++ b/JC_Display_Firmware/machine_state.h @@ -68,6 +68,23 @@ struct MachineState { float bbwTarget = 0, bbwOffset = 0; uint8_t piState = 0; // Pre-Infusion-Phase (0=inaktiv,1=Pre-Infusion,2=Pause,3=Hauptbezug; seit S3 5.0.12) + // --- Cold Extraction (kalte Extraktion; seit S3 5.3.0) --- + bool cxEnabled = false; // Funktion auf der S3 freigeschaltet (sonst Bedienelement ausblenden) + bool cxActive = false; // Modus scharf: Wasserkreis heizt nicht (persistent, seit S3 5.3.1) + bool cxShot = false; // der laufende Bezug ist eine kalte Extraktion + bool cxAllowed = false; // Freigabe jetzt (Wasserkessel kalt genug, kein Standby/Wartung/Tuning) + bool cxResting = false; // Zwangspause des Pumpenschutzes laeuft (Bezug laeuft weiter) + float cxMaxTemp = 0; // Freigabeschwelle in C + float cxTarget = 0; // Zielgewicht in g + float cxPreInfSec = 0; // Vorbenetzung in s (fuer die Phasenleiste des kalten Bezugs) + float cxMaxSecs = 0; // Sicherheits-Timeout des Bezugs in s + // Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent, + // 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm + uint8_t cxBlock = 0; + // Transiente Meldung: 0=keine, 1=Start abgewiesen, 2=Bezug ohne Zulauf beendet, + // 3=Umschalten waehrend eines Bezugs abgelehnt + uint8_t cxNotice = 0; + // --- Gehaeusesensor --- bool caseSensorEnabled = false; bool caseTempDashboard = false; diff --git a/JC_Display_Firmware/protocol_client.cpp b/JC_Display_Firmware/protocol_client.cpp index cb22bd0..ee58043 100644 --- a/JC_Display_Firmware/protocol_client.cpp +++ b/JC_Display_Firmware/protocol_client.cpp @@ -16,6 +16,10 @@ void ProtocolClient::begin() { DISPLAY_UART_PORT.setRxBufferSize(2048); DISPLAY_UART_PORT.begin(DISPLAY_UART_BAUDRATE, SERIAL_8N1, DISPLAY_UART_RX_PIN, DISPLAY_UART_TX_PIN); + // Bewusst NICHT auf PROTO_RX_LINE_MAX (4 KB) reserviert: Der interne RAM ist knapp + // (statisch ~98 %), und eine Arduino-String kann nicht ins PSRAM alloziert werden. + // 2 KB deckt die reale State-Zeile (~2,1 KB) fast vollstaendig ab; der Rest waechst + // in kleinen Schritten nach. Das Limit dient nur als Obergrenze gegen Muell/Overflow. _rxBuf.reserve(2048); _lastHelloMs = 0; _lastHeartbeatMs = 0; @@ -216,6 +220,19 @@ void ProtocolClient::applyState(const String& json) { s.bbwTarget = doc["bbwTarget"] | s.bbwTarget; s.bbwOffset = doc["bbwOffset"] | s.bbwOffset; + // Cold Extraction (fehlt bei S3-FW < 5.3.0 -> cxEnabled bleibt false, UI blendet alles aus) + s.cxEnabled = doc["cxEnabled"] | false; + s.cxActive = doc["cxActive"] | false; + s.cxShot = doc["cxShot"] | false; + s.cxAllowed = doc["cxAllowed"] | false; + s.cxResting = doc["cxResting"] | false; + s.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp; + s.cxTarget = doc["cxTarget"] | s.cxTarget; + s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec; + s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs; + s.cxBlock = doc["cxBlock"] | (uint8_t)0; + s.cxNotice = doc["cxNotice"] | (uint8_t)0; + s.caseSensorEnabled = doc["caseSensorEnabled"] | false; s.caseTempDashboard = doc["caseTempDashboard"] | false; s.caseSensorType = doc["caseSensorType"] | 0; diff --git a/JC_Display_Firmware/ui.cpp b/JC_Display_Firmware/ui.cpp index 143cf10..bc92abf 100644 --- a/JC_Display_Firmware/ui.cpp +++ b/JC_Display_Firmware/ui.cpp @@ -65,7 +65,9 @@ static lv_obj_t* g_errIcon = nullptr; // Fehlersymbol im Header (rot, auf // Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner. // Jeder Hinweis belegt einen Slot (Reihenfolge = Prioritaet, Rot vor Gelb vor Blau); // mehrere aktive Meldungen rotieren alle 4 s weich durch (siehe status_line_tick). -enum StatusSlot { ST_SAFETY = 0, ST_DAMPF, ST_CLEAN, ST_TUNE, ST_MAINT, ST_HEAT, ST_COUNT }; +// 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] = ""; @@ -205,6 +207,7 @@ static lv_obj_t* g_piRow = nullptr; // Containe 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) @@ -219,6 +222,8 @@ 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) @@ -245,6 +250,7 @@ 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; +static lv_obj_t* lblBrewColdEx = nullptr; // Cold-Extraction-Parameter (nur Anzeige) // WiFi static lv_obj_t* wifiStatusLbl; static lv_obj_t* wifiHintLbl; static lv_obj_t* wifiList; @@ -1195,6 +1201,32 @@ static lv_obj_t* build_temp_arc(lv_obj_t* parent, const char* title, lv_color_t 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) { @@ -1283,6 +1315,16 @@ static void build_temp(lv_obj_t* p) { 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); + swColdEx = add_switch(p, "Cold Extraction"); + lv_obj_add_event_cb(swColdEx, coldex_cb, LV_EVENT_VALUE_CHANGED, nullptr); + lblColdExHint = lv_label_create(p); + 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, ""); + // Beide Zeilen bleiben versteckt, solange die S3 die Funktion nicht freigeschaltet meldet + lv_obj_add_flag(lv_obj_get_parent(swColdEx), LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN); } // ===================================================================================== @@ -1607,7 +1649,10 @@ static void chart_sample_cb(lv_timer_t*) { // Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird. { long rs = g_state.heatUpRemainSec; - if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen + // 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]; @@ -1761,8 +1806,11 @@ static void brew_live_tick() { if (phase <= 2) { uint32_t endMs = (phase == 1) ? g_piDurMs : (g_piDurMs + g_piPauseMs); uint32_t remainMs = (g_shotFrozenMs < endMs) ? (endMs - g_shotFrozenMs) : 0; - snprintf(b, sizeof(b), "%s - noch %.1f s", - (phase == 1) ? "Pre-Infusion" : "Pause", remainMs / 1000.0f); + 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"); } @@ -1963,6 +2011,15 @@ static void build_brew(lv_obj_t* p) { swSBT = add_switch(p, "Steam-by-Time aktiv"); taSbtSecs = add_num(p, "Dampf-Zielzeit (s)"); add_save_button(p, "Brühen speichern", send_save_brew); + // Cold Extraction: nur Anzeige. Die Parameter liegen bewusst nur in der Web-UI der S3 + // (maschinenabhaengig, nicht ueber saveBrew erreichbar); geschaltet wird der Modus + // auf der Temperatur-Seite unter "Modi". + lblBrewColdEx = lv_label_create(p); + lv_label_set_long_mode(lblBrewColdEx, LV_LABEL_LONG_WRAP); + lv_obj_set_width(lblBrewColdEx, LV_PCT(100)); + lv_obj_set_style_text_color(lblBrewColdEx, COL_TEXT_DIM, 0); + lv_label_set_text(lblBrewColdEx, ""); + lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); } // ===================================================================================== @@ -2761,7 +2818,9 @@ static void pi_bar_update() { } // Texte: erledigte Schritte mit Haken, aktive zeitgesteuerte Phase mit Restzeit - static const char* piNames[3] = { "Pre-Infusion", "Pause", "Extraktion" }; + 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; @@ -3385,6 +3444,32 @@ void ui_update(const MachineState& st) { 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. + 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.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) { + 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); @@ -3523,6 +3608,14 @@ void ui_update(const MachineState& st) { 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); @@ -3531,6 +3624,7 @@ void ui_update(const MachineState& st) { // 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); @@ -3596,10 +3690,19 @@ void ui_update(const MachineState& st) { g_shotBbtMode = st.bbtEnabled && st.bbtSecs > 0.0f; // Bezug mit Brew-by-Time? if (g_shotBbtMode) g_bbtTarget = st.bbtSecs; // Pre-Infusion-Phasenleiste: Modus, S3-Phase und Phasendauern latchen (pi_bar_update) - g_piShotMode = st.piEnabled; + // 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; - g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f); - g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f); // 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 && @@ -3623,6 +3726,7 @@ void ui_update(const MachineState& st) { } 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 @@ -3725,6 +3829,60 @@ void ui_update(const MachineState& st) { 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); + // Einschalten nur bei Freigabe; Ausschalten immer - ausser waehrend eines kalten Bezugs + bool usable = st.cxEnabled && (st.cxActive ? !st.cxShot : st.cxAllowed); + if (usable) lv_obj_remove_state(swColdEx, LV_STATE_DISABLED); + else lv_obj_add_state(swColdEx, LV_STATE_DISABLED); + if (lblColdExHint && st.cxEnabled) { + String hint; + if (st.cxActive) { + hint = "Wasserkreis heizt nicht (bleibt über Standby und Neustart aktiv). "; + if (st.cxAllowed) { + char b[96]; + snprintf(b, sizeof(b), "Bezug regulär starten - Ziel %.0f g, max %.0f s.", + (double)st.cxTarget, (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; + } + label_set_if_changed(lblColdExHint, hint.c_str()); + } + } + if (lblBrewColdEx) { + if (st.cxEnabled) { + char b[192]; + snprintf(b, sizeof(b), + "Cold Extraction (nur Anzeige, Einstellung in der Web-UI):\n" + "Freigabe < %.1f °C | Vorbenetzung %.0f s | Ziel %.0f g | max. %.0f s\n" + "Geschaltet wird der Modus auf der Seite \"Temperaturen\" unter \"Modi\".", + (double)st.cxMaxTemp, (double)st.cxPreInfSec, + (double)st.cxTarget, (double)st.cxMaxSecs); + label_set_if_changed(lblBrewColdEx, b); + lv_obj_remove_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); + } + } // Licht-Button spiegeln (gold = an) if (btnLight) { @@ -3857,14 +4015,19 @@ void ui_set_usage_stats(const String& json) { 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()) { 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"; - durSum += d; + 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++; } @@ -3873,7 +4036,10 @@ void ui_set_usage_stats(const String& json) { s += "Diese Woche: " + String(week) + "\n"; // total ist exakt, solange die ganze Datei ins Fenster passte (sonst Fenster-Untergrenze) s += "Gesamt: " + (partial ? String("min. ") : String("")) + String(total) + " Bezüge"; - if (n > 0) s += "\nMittl. Dauer (letzte " + String(n) + "): " + String(durSum / n, 1) + " s"; + 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) {