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) {