diff --git a/Changelog.txt b/Changelog.txt index a606486..b151506 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,3 +1,17 @@ +Version 5.4.0: +- Cold Extraction hat eine eigene Seite: /Cold-Extraction, eigener Eintrag im Menü. Auf /Brew-Control + steht nur noch ein Verweis darauf - die Seite war mit dem angehängten Block unübersichtlich geworden. +- Die neue Seite ist in Abschnitte gegliedert (Grundeinstellung, Vorbenetzung, Hauptbezug, Bezugsende, + Pumpenschutz), jeder mit einer kurzen Erklärung, warum der Wert so eingestellt gehört. +- Wichtig für bestehende Installationen: Ein Speichern auf /Brew-Control fasst die Cold-Extraction- + Einstellungen nicht mehr an. Vorher wertete dieser Handler die cx-Felder mit aus; nach dem Verschieben + hätte ein Speichern dort die Freischaltung abgeschaltet, weil die Checkbox im Formular fehlt. +- Das UART-Kommando "saveBrew" akzeptiert jetzt zusätzlich die Cold-Extraction-Felder (cxEnabled, + cxMaxTemp, cxPreInf, cxPreInfDuty, cxPulseMs, cxDuty, cxTarget, cxMaxSecs, cxPumpMaxRun, cxPumpRest) - + damit lässt sich die Funktion auch am Touch-Display vollständig einstellen. +- Das State-JSON liefert die restlichen Parameter mit (cxPreInfDuty, cxDuty, cxPulseMs, cxPumpMaxRun, + cxPumpRest), damit das Display seine Eingabefelder vorbelegen kann. + Version 5.3.6: - Cold Extraction: Die Vorbenetzung läuft nicht mehr mit voller Pumpenleistung, sondern gepulst mit eigener, niedriger Leistung (neue Einstellung „Pumpenleistung Vorbenetzung", Standard 10 %). diff --git a/Doku/JC-Display_UART-Protokoll.md b/Doku/JC-Display_UART-Protokoll.md index 2ef8f4b..192a58a 100644 --- a/Doku/JC-Display_UART-Protokoll.md +++ b/Doku/JC-Display_UART-Protokoll.md @@ -340,9 +340,12 @@ Das P4-UI sollte `cxActive` deshalb dauerhaft sichtbar spiegeln — sonst wunder Benutzer, warum die Maschine nicht heizt. Ein laufender Bezug wird nie abgebrochen, `stopColdExtraction` schlägt dann fehl. -Die Parameter der kalten Extraktion (Schwelle, Vorbenetzung, Pulsperiode, Pumpenleistung, -Zielgewicht, Timeout, Pumpenschutz) sind bewusst **nicht** über `saveBrew` erreichbar, sondern -nur über die Web-Seite `/Brew-Control`. Das Display schaltet den Modus, konfiguriert ihn nicht. +Die Parameter der kalten Extraktion sind ab S3 5.4.0 über `saveBrew` einstellbar (zusätzlich +zur Web-Seite `/Cold-Extraction`). Zulässige Felder: `cxEnabled` (bool), `cxMaxTemp`, +`cxPreInf`, `cxPreInfDuty`, `cxPulseMs`, `cxDuty`, `cxTarget`, `cxMaxSecs`, `cxPumpMaxRun`, +`cxPumpRest`. Es werden nur die tatsächlich gesendeten Felder übernommen; Grenzwerte prüft +die S3. Die aktuellen Werte stehen alle im `state`, damit das Display seine Eingabefelder +vorbelegen kann. **Persistente Toggles** @@ -397,7 +400,12 @@ 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) | +| `cxPreInf` | float | Vorbenetzung in s (auch für die Phasenleiste des kalten Bezugs) | +| `cxPreInfDuty` | float | Pumpenleistung Vorbenetzung in % (ab S3 5.4.0) | +| `cxDuty` | float | Pumpenleistung Hauptbezug in % (ab S3 5.4.0) | +| `cxPulseMs` | uint16 | Pulsperiode in ms (ab S3 5.4.0) | +| `cxPumpMaxRun` | float | Pumpenschutz: max. kumulierte Laufzeit in s (ab S3 5.4.0) | +| `cxPumpRest` | float | Pumpenschutz: Zwangspause in s (ab S3 5.4.0) | | `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s | | `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei | | `cxNotice` | uint8 | transiente Meldung, `0` = keine (läuft nach ~6 s selbst ab) | diff --git a/Dual-PID.ino b/Dual-PID.ino index bc7d8c7..196e928 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.6"; +String version = "5.4.0"; String versionHersteller = "Thomas Müller"; String versionHerstellerMail = "thomas@mueller.black"; @@ -2029,6 +2029,7 @@ static const char commonNav[] PROGMEM = R"rawliteral( Profile Timer Brew Control + Cold Extraction Chart Sensoren Fast-Heat-Up @@ -5175,6 +5176,8 @@ void setup() { // Routen für Brew Control asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl); asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl); + asyncServer.on("/Cold-Extraction", HTTP_GET, handleColdExtraction); + asyncServer.on("/saveColdExtraction", HTTP_POST, handleSaveColdExtraction); dashboardWs.onEvent(onDashboardWsEvent); asyncServer.addHandler(&dashboardWs); @@ -7216,41 +7219,70 @@ static const char brewControlPI_Fields[] PROGMEM = R"rawliteral( )rawliteral"; // --- Cold Extraction (CX) Chunks --- -static const char brewControlCX_H3[] PROGMEM = R"rawliteral(

Cold Extraction (kalte Extraktion)

)rawliteral"; +// Cold Extraction hat eine eigene Seite (/Cold-Extraction); auf /Brew-Control steht nur der Verweis. +static const char brewControlCX_Link[] PROGMEM = R"rawliteral( +

Cold Extraction (kalte Extraktion)

+Die Einstellungen der kalten Extraktion stehen auf einer eigenen Seite.

+)rawliteral"; + +static const char coldExHead[] PROGMEM = R"rawliteral( +Cold Extraction +)rawliteral"; +static const char coldExHeadEnd[] PROGMEM = R"rawliteral()rawliteral"; +static const char coldExBodyStart[] PROGMEM = R"rawliteral( +

Cold Extraction

+
+Kalte Extraktion: Bezug mit kaltem Wasser, gepulster Pumpe und Bezugszeiten im Minutenbereich.
+Die Maschine hat keinen Bypass um den Wasserkessel - kaltes Wasser kommt nur bei kaltem Kessel am Puck an. +Deshalb ist die Funktion nur unterhalb der Freigabetemperatur beziehbar, praktisch also aus dem kalten +Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht; der Dampfkreis bleibt unberührt.
+Ein- und ausgeschaltet wird der Modus auf dem Dashboard oder am Touch-Display - hier stehen nur die Parameter.

+)rawliteral"; +static const char coldExCX_H3[] PROGMEM = R"rawliteral(

Grundeinstellung

)rawliteral"; static const char brewControlCX_ToggleContainerStart[] PROGMEM = R"rawliteral(
Freischalten:
)rawliteral"; +static const char coldExFormEnd[] PROGMEM = R"rawliteral( + +
+ +)rawliteral"; static const char brewControlCX_Fields[] PROGMEM = R"rawliteral( - + (Oberhalb dieser Temperatur bleibt der Modus zwar schaltbar, Bezüge sind aber gesperrt.) + {CX_STATUS_MSG} + +

Vorbenetzung

+ - + + (Deutlich schwächer einstellen als den Hauptbezug: Kaltes Kaffeemehl quillt nicht und + ist schlecht benetzbar. Volle Leistung auf den trockenen Puck legt sofort Kanäle an, die sich - anders als bei + heißem Bezug - nie wieder schließen; der restliche Bezug läuft dann daran vorbei statt zu extrahieren.) + +

Hauptbezug

- + + (Das kalte Kaffeebett ist erheblich durchlässiger als ein heißes - niedrige Werte + sind normal. Läuft der Bezug zu schnell durch: Leistung senken und feiner mahlen.) + +

Bezugsende

- + (Ohne Waage ist das Zielgewicht wirkungslos und die maximale Bezugsdauer wird zur Dosierung.) + +

Pumpenschutz

+ - + - (Die Maschine hat keinen Bypass um den Wasserkessel: Kaltes Wasser kommt nur bei kaltem - Kessel am Puck an. Deshalb ist die kalte Extraktion nur unterhalb der Freigabetemperatur möglich, praktisch also - aus dem kalten Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht - der Dampfkreis bleibt - unberührt und darf weiter heizen. Vorbenetzung und Hauptbezug pulsen beide, nur mit unterschiedlicher - Leistung: Kaltes Kaffeemehl quillt nicht und ist schlecht benetzbar - volle Leistung auf den trockenen Puck - legt sofort Kanäle an, die sich (anders als bei heißem Bezug) nie wieder schließen, und der - restliche Bezug läuft daran vorbei. Deshalb die Vorbenetzung deutlich schwächer einstellen als den - Hauptbezug. Das kalte Bett ist insgesamt viel durchlässiger als ein heißes, niedrige Werte sind - also normal. Die Pumpenlaufzeit wird kumuliert gezählt; nach der eingestellten - Laufzeit legt die Steuerung eine Zwangspause ein. Der Modus selbst wird auf dem Dashboard bzw. am Touch-Display - ein- und ausgeschaltet und bleibt über Standby und Neustart hinweg aktiv, bis er dort wieder ausgeschaltet - wird - solange heizt der Wasserkreis nicht.) - {CX_STATUS_MSG} + (Gezählt wird die kumulierte Einschaltzeit der Pumpe, nicht die Bezugsdauer. Nach Ablauf + legt die Steuerung eine Zwangspause ein; der Bezug läuft dabei weiter.)
)rawliteral"; @@ -7365,53 +7397,8 @@ void handleBrewControl(AsyncWebServerRequest *request) { response->print(piFieldsHtml); yield(); - // --- Cold Extraction Sektion --- - response->print(FPSTR(brewControlCX_H3)); - response->print(FPSTR(brewControlCX_ToggleContainerStart)); - if (coldExtractionEnabled) { - response->print(F(" checked")); - } - response->print(FPSTR(brewControlCX_ToggleContainerEnd)); - - String cxFieldsHtml = FPSTR(brewControlCX_Fields); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxTempC); - cxFieldsHtml.replace("{CX_MAX_TEMP}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfusionSeconds); - cxFieldsHtml.replace("{CX_PREINF}", buffer); - snprintf(buffer, sizeof(buffer), "%u", (unsigned int)coldExtractionPulsePeriodMs); - cxFieldsHtml.replace("{CX_PULSE_MS}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionDutyPercent); - cxFieldsHtml.replace("{CX_DUTY}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfDutyPercent); - cxFieldsHtml.replace("{CX_PREINF_DUTY}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionTargetGrams); - cxFieldsHtml.replace("{CX_TARGET}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxSeconds); - cxFieldsHtml.replace("{CX_MAX_SECS}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpMaxRunSec); - cxFieldsHtml.replace("{CX_PUMP_MAX_RUN}", buffer); - snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpRestSec); - cxFieldsHtml.replace("{CX_PUMP_REST}", buffer); - if (coldExtractionEnabled) { - String cxStatus; - // Waagen-Hinweis zuerst: er gilt unabhaengig von der Temperatur-Freigabe und - // erklaert, warum das Zielgewicht oben wirkungslos ist. - if (!coldExtractionScaleUsable()) { - cxStatus += "
Keine Waage aktiv/verbunden: Zielgewicht und " - "Stillstands-Erkennung greifen nicht. Der Bezug endet erst nach der maximalen " - "Bezugsdauer - diesen Wert dann als Dosierung verstehen."; - } - String reason = coldExtractionBlockReason(); - if (reason.length() > 0) { - cxStatus += "
" + reason + ""; - } else { - cxStatus += "
Kalte Extraktion derzeit möglich."; - } - cxFieldsHtml.replace("{CX_STATUS_MSG}", cxStatus); - } else { - cxFieldsHtml.replace("{CX_STATUS_MSG}", ""); - } - response->print(cxFieldsHtml); + // Cold Extraction hat eine eigene Seite (/Cold-Extraction) - hier nur der Verweis + response->print(FPSTR(brewControlCX_Link)); yield(); // --- Dampf-Timer Sektion --- @@ -7702,6 +7689,106 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) { return changed || flowGuardConfigChanged || coldExtractionConfigChanged; } +/************************************************************************************ + * Cold Extraction: eigene Seite (GET /Cold-Extraction, POST /saveColdExtraction) + * Bewusst getrennt von /Brew-Control - die Parameter gehoeren logisch zusammen und die + * Brew-Control-Seite war mit dem Block darunter unuebersichtlich geworden. + ************************************************************************************/ +void handleColdExtraction(AsyncWebServerRequest *request) { + char buffer[16]; + AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8"); + + response->print(FPSTR(coldExHead)); + response->print(FPSTR(commonStyle)); + response->print(FPSTR(coldExHeadEnd)); + response->print(FPSTR(commonNav)); + response->print(FPSTR(coldExBodyStart)); + yield(); + + response->print(FPSTR(coldExCX_H3)); + response->print(FPSTR(brewControlCX_ToggleContainerStart)); + if (coldExtractionEnabled) { + response->print(F(" checked")); + } + response->print(FPSTR(brewControlCX_ToggleContainerEnd)); + + String cxFieldsHtml = FPSTR(brewControlCX_Fields); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxTempC); + cxFieldsHtml.replace("{CX_MAX_TEMP}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfusionSeconds); + cxFieldsHtml.replace("{CX_PREINF}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfDutyPercent); + cxFieldsHtml.replace("{CX_PREINF_DUTY}", buffer); + snprintf(buffer, sizeof(buffer), "%u", (unsigned int)coldExtractionPulsePeriodMs); + cxFieldsHtml.replace("{CX_PULSE_MS}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionDutyPercent); + cxFieldsHtml.replace("{CX_DUTY}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionTargetGrams); + cxFieldsHtml.replace("{CX_TARGET}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxSeconds); + cxFieldsHtml.replace("{CX_MAX_SECS}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpMaxRunSec); + cxFieldsHtml.replace("{CX_PUMP_MAX_RUN}", buffer); + snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpRestSec); + cxFieldsHtml.replace("{CX_PUMP_REST}", buffer); + + if (coldExtractionEnabled) { + String cxStatus; + // Waagen-Hinweis zuerst: er gilt unabhaengig von der Temperatur-Freigabe und + // erklaert, warum das Zielgewicht weiter unten wirkungslos ist. + if (!coldExtractionScaleUsable()) { + cxStatus += "
Keine Waage aktiv/verbunden: Zielgewicht und " + "Stillstands-Erkennung greifen nicht. Der Bezug endet erst nach der maximalen " + "Bezugsdauer - diesen Wert dann als Dosierung verstehen."; + } + String reason = coldExtractionBlockReason(); + if (reason.length() > 0) { + cxStatus += "
" + reason + ""; + } else { + cxStatus += "
Kalte Extraktion derzeit möglich."; + } + cxFieldsHtml.replace("{CX_STATUS_MSG}", cxStatus); + } else { + cxFieldsHtml.replace("{CX_STATUS_MSG}", ""); + } + response->print(cxFieldsHtml); + yield(); + + response->print(FPSTR(coldExFormEnd)); + request->send(response); +} + +void handleSaveColdExtraction(AsyncWebServerRequest *request) { + BrewControlUpdate update; // nur die cx-Felder setzen, alles andere bleibt unangetastet + + update.hasCXEnabled = true; + update.cxEnabled = request->hasArg("cxEnabled"); + update.hasCXMaxTemp = request->hasArg("cxMaxTemp"); + if (update.hasCXMaxTemp) { update.cxMaxTemp = request->arg("cxMaxTemp").toFloat(); } + update.hasCXPreInf = request->hasArg("cxPreInf"); + if (update.hasCXPreInf) { update.cxPreInf = request->arg("cxPreInf").toFloat(); } + update.hasCXPreInfDuty = request->hasArg("cxPreInfDuty"); + if (update.hasCXPreInfDuty) { update.cxPreInfDuty = request->arg("cxPreInfDuty").toFloat(); } + update.hasCXPulseMs = request->hasArg("cxPulseMs"); + if (update.hasCXPulseMs) { update.cxPulseMs = (uint16_t)request->arg("cxPulseMs").toInt(); } + update.hasCXDuty = request->hasArg("cxDuty"); + if (update.hasCXDuty) { update.cxDuty = request->arg("cxDuty").toFloat(); } + update.hasCXTarget = request->hasArg("cxTarget"); + if (update.hasCXTarget) { update.cxTarget = request->arg("cxTarget").toFloat(); } + update.hasCXMaxSecs = request->hasArg("cxMaxSecs"); + if (update.hasCXMaxSecs) { update.cxMaxSecs = request->arg("cxMaxSecs").toFloat(); } + update.hasCXPumpMaxRun = request->hasArg("cxPumpMaxRun"); + if (update.hasCXPumpMaxRun) { update.cxPumpMaxRun = request->arg("cxPumpMaxRun").toFloat(); } + update.hasCXPumpRest = request->hasArg("cxPumpRest"); + if (update.hasCXPumpRest) { update.cxPumpRest = request->arg("cxPumpRest").toFloat(); } + + applyBrewControlUpdate(update); + + AsyncWebServerResponse *response = request->beginResponse(303); + response->addHeader("Location", "/Cold-Extraction"); + request->send(response); +} + void handleSaveBrewControl(AsyncWebServerRequest *request) { BrewControlUpdate update; @@ -7737,27 +7824,9 @@ void handleSaveBrewControl(AsyncWebServerRequest *request) { update.hasSBTSecs = request->hasArg("sbtSecs"); if (update.hasSBTSecs) { update.sbtSecs = request->arg("sbtSecs").toFloat(); } - update.hasCXEnabled = true; - update.cxEnabled = request->hasArg("cxEnabled"); - update.hasCXMaxTemp = request->hasArg("cxMaxTemp"); - if (update.hasCXMaxTemp) { update.cxMaxTemp = request->arg("cxMaxTemp").toFloat(); } - update.hasCXPreInf = request->hasArg("cxPreInf"); - if (update.hasCXPreInf) { update.cxPreInf = request->arg("cxPreInf").toFloat(); } - update.hasCXPulseMs = request->hasArg("cxPulseMs"); - if (update.hasCXPulseMs) { update.cxPulseMs = (uint16_t)request->arg("cxPulseMs").toInt(); } - update.hasCXDuty = request->hasArg("cxDuty"); - if (update.hasCXDuty) { update.cxDuty = request->arg("cxDuty").toFloat(); } - update.hasCXPreInfDuty = request->hasArg("cxPreInfDuty"); - if (update.hasCXPreInfDuty) { update.cxPreInfDuty = request->arg("cxPreInfDuty").toFloat(); } - update.hasCXTarget = request->hasArg("cxTarget"); - if (update.hasCXTarget) { update.cxTarget = request->arg("cxTarget").toFloat(); } - update.hasCXMaxSecs = request->hasArg("cxMaxSecs"); - if (update.hasCXMaxSecs) { update.cxMaxSecs = request->arg("cxMaxSecs").toFloat(); } - update.hasCXPumpMaxRun = request->hasArg("cxPumpMaxRun"); - if (update.hasCXPumpMaxRun) { update.cxPumpMaxRun = request->arg("cxPumpMaxRun").toFloat(); } - update.hasCXPumpRest = request->hasArg("cxPumpRest"); - if (update.hasCXPumpRest) { update.cxPumpRest = request->arg("cxPumpRest").toFloat(); } - + // Cold Extraction wird hier bewusst NICHT angefasst - die Felder stehen auf + // /Cold-Extraction. Wuerde man sie hier weiter auswerten, wuerde ein Speichern auf + // dieser Seite die Freischaltung abschalten (Checkbox fehlt im Formular = "aus"). applyBrewControlUpdate(update); AsyncWebServerResponse *response = request->beginResponse(303); @@ -16603,6 +16672,12 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) { 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); + // Restliche Parameter fuer die Einstellseite am Display (seit S3 5.4.0) + dtostrf(coldExtractionPreInfDutyPercent, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPreInfDuty\":%s,", floatBuf); + dtostrf(coldExtractionDutyPercent, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxDuty\":%s,", floatBuf); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPulseMs\":%u,", (unsigned int)coldExtractionPulsePeriodMs); + dtostrf(coldExtractionPumpMaxRunSec, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPumpMaxRun\":%s,", floatBuf); + dtostrf(coldExtractionPumpRestSec, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPumpRest\":%s,", floatBuf); dtostrf(coldExtractionMaxSeconds, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxSecs\":%s,", floatBuf); // Sperrgrund + transiente Meldung als Codes (kein Freitext - die State-Zeile muss kurz // bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst). @@ -17284,9 +17359,54 @@ static bool touchUartHandleCommandLine(const String& line) { update.sbtSecs = floatVal; } + // --- Cold Extraction (seit S3 5.4.0 auch ueber saveBrew einstellbar) --- + if (jsonGetBoolValue(line, "cxEnabled", boolVal)) { + update.hasCXEnabled = true; + update.cxEnabled = boolVal; + } + if (jsonGetFloatValue(line, "cxMaxTemp", floatVal)) { + update.hasCXMaxTemp = true; + update.cxMaxTemp = floatVal; + } + if (jsonGetFloatValue(line, "cxPreInf", floatVal)) { + update.hasCXPreInf = true; + update.cxPreInf = floatVal; + } + if (jsonGetFloatValue(line, "cxPreInfDuty", floatVal)) { + update.hasCXPreInfDuty = true; + update.cxPreInfDuty = floatVal; + } + if (jsonGetFloatValue(line, "cxPulseMs", floatVal)) { + update.hasCXPulseMs = true; + update.cxPulseMs = (uint16_t)floatVal; + } + if (jsonGetFloatValue(line, "cxDuty", floatVal)) { + update.hasCXDuty = true; + update.cxDuty = floatVal; + } + if (jsonGetFloatValue(line, "cxTarget", floatVal)) { + update.hasCXTarget = true; + update.cxTarget = floatVal; + } + if (jsonGetFloatValue(line, "cxMaxSecs", floatVal)) { + update.hasCXMaxSecs = true; + update.cxMaxSecs = floatVal; + } + if (jsonGetFloatValue(line, "cxPumpMaxRun", floatVal)) { + update.hasCXPumpMaxRun = true; + update.cxPumpMaxRun = floatVal; + } + if (jsonGetFloatValue(line, "cxPumpRest", floatVal)) { + update.hasCXPumpRest = true; + update.cxPumpRest = floatVal; + } + if (!update.hasBBTEnabled && !update.hasBBTSecs && !update.hasBBWEnabled && !update.hasBBWTarget && !update.hasBBWOffset && !update.hasPIEnabled && !update.hasPIDurSecs && !update.hasPIPauseSecs && - !update.hasSBTEnabled && !update.hasSBTSecs) { + !update.hasSBTEnabled && !update.hasSBTSecs && + !update.hasCXEnabled && !update.hasCXMaxTemp && !update.hasCXPreInf && !update.hasCXPreInfDuty && + !update.hasCXPulseMs && !update.hasCXDuty && !update.hasCXTarget && !update.hasCXMaxSecs && + !update.hasCXPumpMaxRun && !update.hasCXPumpRest) { touchUartSendError(id, "Keine Brew-Felder"); return false; } diff --git a/JC_Display_Firmware/Changelog.txt b/JC_Display_Firmware/Changelog.txt index 33aa0e8..bf85ddc 100644 --- a/JC_Display_Firmware/Changelog.txt +++ b/JC_Display_Firmware/Changelog.txt @@ -1,3 +1,17 @@ +Version 1.2.0: +- Neue Seite „Cold Extraction" mit eigenem Menüeintrag (braucht S3-Firmware ab 5.4.0). Dort stehen jetzt + alle Parameter der kalten Extraktion und lassen sich direkt am Display ändern: Freigabetemperatur, + Vorbenetzung (Dauer + Pumpenleistung), Hauptbezug (Pulsperiode + Pumpenleistung), Zielgewicht, + maximale Bezugsdauer sowie der Pumpenschutz. Gespeichert wird über das erweiterte saveBrew-Kommando. +- Der Modus-Schalter ist von der Temperatur-Seite auf die neue Seite umgezogen (eine Stelle statt zwei). + Der Knopf „Aufwecken mit Cold Extraction" im Standby-Dialog bleibt unverändert. +- Brew-Control-Seite neu gegliedert: Statt einer durchlaufenden Liste unter Zwischenüberschriften liegt + jede Funktion (Pre-Infusion, Brew-by-Time, Brew-by-Weight, Dampf-Timer) jetzt in einer eigenen Karte + mit Titel und einer Zeile, die erklärt, was sie tut. Die neue Cold-Extraction-Seite nutzt dieselbe + Gliederung. +- Eingabefelder werden nur beim Öffnen der Seite aus dem Zustand gefüllt, damit laufende State-Pushes + eine begonnene Eingabe nicht überschreiben (gleiches Verhalten wie auf der Brew-Control-Seite). + Version 1.1.3: - Aufweck-Dialog hat einen dritten Knopf: „Aufwecken mit Cold Extraction" (braucht S3 ab 5.3.5). Er stellt den Modus scharf und beendet erst danach den Standby - in dieser Reihenfolge, damit diff --git a/JC_Display_Firmware/config.h b/JC_Display_Firmware/config.h index 5a36f36..20d9d12 100644 --- a/JC_Display_Firmware/config.h +++ b/JC_Display_Firmware/config.h @@ -65,7 +65,7 @@ // ------------------------------------------------------------------------------------- // Firmware // ------------------------------------------------------------------------------------- -#define DISPLAY_FW_VERSION "1.1.3" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) +#define DISPLAY_FW_VERSION "1.2.0" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) // ------------------------------------------------------------------------------------- // Protokoll diff --git a/JC_Display_Firmware/machine_state.h b/JC_Display_Firmware/machine_state.h index d6e10aa..8e6a33b 100644 --- a/JC_Display_Firmware/machine_state.h +++ b/JC_Display_Firmware/machine_state.h @@ -78,6 +78,12 @@ struct MachineState { float cxMaxTemp = 0; // Freigabeschwelle in C float cxTarget = 0; // Zielgewicht in g float cxPreInfSec = 0; // Vorbenetzung in s (fuer die Phasenleiste des kalten Bezugs) + // Restliche Parameter fuer die Einstellseite am Display (seit S3 5.4.0) + float cxPreInfDuty = 0; // Pumpenleistung Vorbenetzung in % + float cxDuty = 0; // Pumpenleistung Hauptbezug in % + uint16_t cxPulseMs = 0; // Pulsperiode in ms + float cxPumpMaxRun = 0; // Pumpenschutz: max. kumulierte Laufzeit in s + float cxPumpRest = 0; // Pumpenschutz: Zwangspause in s 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 diff --git a/JC_Display_Firmware/protocol_client.cpp b/JC_Display_Firmware/protocol_client.cpp index 7bed323..920f124 100644 --- a/JC_Display_Firmware/protocol_client.cpp +++ b/JC_Display_Firmware/protocol_client.cpp @@ -231,6 +231,11 @@ void ProtocolClient::applyState(const String& json) { s.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp; s.cxTarget = doc["cxTarget"] | s.cxTarget; s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec; + s.cxPreInfDuty = doc["cxPreInfDuty"] | s.cxPreInfDuty; + s.cxDuty = doc["cxDuty"] | s.cxDuty; + s.cxPulseMs = doc["cxPulseMs"] | s.cxPulseMs; + s.cxPumpMaxRun = doc["cxPumpMaxRun"] | s.cxPumpMaxRun; + s.cxPumpRest = doc["cxPumpRest"] | s.cxPumpRest; s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs; s.cxBlock = doc["cxBlock"] | (uint8_t)0; s.cxNotice = doc["cxNotice"] | (uint8_t)0; diff --git a/JC_Display_Firmware/ui.cpp b/JC_Display_Firmware/ui.cpp index 77f0fc5..d424bc0 100644 --- a/JC_Display_Firmware/ui.cpp +++ b/JC_Display_Firmware/ui.cpp @@ -50,8 +50,8 @@ 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_SERVICE, PG_PROFILE, PG_LIGHT, PG_WIFI, PG_INFO, PG_COUNT }; -static const char* PAGE_NAMES[PG_COUNT] = { "Dashboard", "Verlauf", "Statistik", "Temperatur", "Brew-Control", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Info" }; +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; @@ -251,7 +251,15 @@ 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) +// 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* 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; @@ -400,6 +408,29 @@ static lv_obj_t* add_num(lv_obj_t* parent, const char* label) { 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); @@ -430,6 +461,7 @@ static lv_obj_t* make_page(lv_obj_t* parent) { // ===================================================================================== // 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); @@ -463,6 +495,7 @@ static void show_page(int 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); } } @@ -1327,16 +1360,7 @@ 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); + // Cold Extraction hat eine eigene Seite (PG_COLDEX) - Schalter und Parameter stehen dort. } // ===================================================================================== @@ -2010,28 +2034,109 @@ static void send_save_brew(lv_event_t*) { showToast("Speichere Brühen...", false); } static void build_brew(lv_obj_t* p) { - section_title(p, "Pre-Infusion"); - swPI = add_switch(p, "Aktiv"); - taPiDur = add_num(p, "Dauer (s)"); - taPiPause = add_num(p, "Pause (s)"); - section_title(p, "Brew-by-Time / Weight / Steam-by-Time"); - swBBT = add_switch(p, "Brew-by-Time aktiv"); - taBbtSecs = add_num(p, "Zielzeit (s)"); - swBBW = add_switch(p, "Brew-by-Weight aktiv"); - taBbwTarget= add_num(p, "Zielgewicht (g)"); - taBbwOffset= add_num(p, "Offset (g)"); - swSBT = add_switch(p, "Steam-by-Time aktiv"); - taSbtSecs = add_num(p, "Dampf-Zielzeit (s)"); + // 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: 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); +} + +// ===================================================================================== +// 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); + 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)"); + + 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); + 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); } // ===================================================================================== @@ -3112,6 +3217,7 @@ void ui_init(ProtocolClient* client) { 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]); @@ -3917,22 +4023,18 @@ void ui_update(const MachineState& st) { label_set_if_changed(lblColdExHint, hint.c_str()); } } - if (lblBrewColdEx) { - if (st.cxEnabled) { - char b[320]; // Grundtext ~205 Zeichen (das ° zaehlt als 2 Bytes) + Waagen-Hinweis - 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\".%s", - (double)st.cxMaxTemp, (double)st.cxPreInfSec, - (double)st.cxTarget, (double)st.cxMaxSecs, - (st.scaleEnabled && st.scaleConnected) - ? "" - : "\nOhne Waage ist das Zielgewicht wirkungslos - der Bezug endet nach der max. Dauer."); - label_set_if_changed(lblBrewColdEx, b); - lv_obj_remove_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); + 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(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN); } }