feat: eigene Cold-Extraction-Seite in Web-UI und Display (S3 5.4.0 / P4 1.2.0) #19

Merged
thomas merged 1 commits from feat/cold-extraction-eigene-seite into main 2026-08-12 00:09:50 +02:00
8 changed files with 412 additions and 143 deletions
Showing only changes of commit 2d8f70ae37 - Show all commits
+14
View File
@@ -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 %).
+12 -4
View File
@@ -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) |
+209 -89
View File
@@ -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 =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -2029,6 +2029,7 @@ static const char commonNav[] PROGMEM = R"rawliteral(
<a href="/Profile">Profile</a>
<a href="/Timer">Timer</a>
<a href="/Brew-Control">Brew Control</a>
<a href="/Cold-Extraction">Cold Extraction</a>
<a href="/Chart">Chart</a>
<a href="/Sensoren">Sensoren</a>
<a href="/Fast-Heat-Up">Fast-Heat-Up</a>
@@ -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(<h3>Cold Extraction (kalte Extraktion)</h3>)rawliteral";
// Cold Extraction hat eine eigene Seite (/Cold-Extraction); auf /Brew-Control steht nur der Verweis.
static const char brewControlCX_Link[] PROGMEM = R"rawliteral(
<h3>Cold Extraction (kalte Extraktion)</h3>
Die Einstellungen der kalten Extraktion stehen auf einer <a href="/Cold-Extraction">eigenen Seite</a>.<br><br>
)rawliteral";
static const char coldExHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><title>Cold Extraction</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char coldExHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
static const char coldExBodyStart[] PROGMEM = R"rawliteral(
<body><h1>Cold Extraction</h1>
<form action='/saveColdExtraction' method='POST'>
Kalte Extraktion: Bezug mit kaltem Wasser, gepulster Pumpe und Bezugszeiten im Minutenbereich.<br>
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&uuml;hrt.<br>
Ein- und ausgeschaltet wird der Modus auf dem Dashboard oder am Touch-Display - hier stehen nur die Parameter.<br><br>
)rawliteral";
static const char coldExCX_H3[] PROGMEM = R"rawliteral(<h3>Grundeinstellung</h3>)rawliteral";
static const char brewControlCX_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Freischalten:</span><label class="toggle-switch"><input type="checkbox" id="cxEnabled" name="cxEnabled" value="1")rawliteral";
static const char brewControlCX_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral";
static const char coldExFormEnd[] PROGMEM = R"rawliteral(
<input type='submit' value='Einstellungen speichern'>
</form>
</body></html>
)rawliteral";
static const char brewControlCX_Fields[] PROGMEM = R"rawliteral(
<label for='cxMaxTemp'>Freigabe bis Wassertemperatur (&deg;C):</label>
<input type='number' min='10.0' max='40.0' step='0.5' id='cxMaxTemp' name='cxMaxTemp' value='{CX_MAX_TEMP}' required>
<label for='cxPreInf'>Vorbenetzung (Sekunden):</label>
<small class="toggle-description-input">(Oberhalb dieser Temperatur bleibt der Modus zwar schaltbar, Bez&uuml;ge sind aber gesperrt.)</small>
{CX_STATUS_MSG}
<h3>Vorbenetzung</h3>
<label for='cxPreInf'>Dauer (Sekunden):</label>
<input type='number' min='0.0' max='120.0' step='1.0' id='cxPreInf' name='cxPreInf' value='{CX_PREINF}' required>
<label for='cxPreInfDuty'>Pumpenleistung Vorbenetzung (%):</label>
<label for='cxPreInfDuty'>Pumpenleistung (%):</label>
<input type='number' min='5.0' max='100.0' step='1.0' id='cxPreInfDuty' name='cxPreInfDuty' value='{CX_PREINF_DUTY}' required>
<small class="toggle-description-input">(Deutlich schw&auml;cher einstellen als den Hauptbezug: Kaltes Kaffeemehl quillt nicht und
ist schlecht benetzbar. Volle Leistung auf den trockenen Puck legt sofort Kan&auml;le an, die sich - anders als bei
hei&szlig;em Bezug - nie wieder schlie&szlig;en; der restliche Bezug l&auml;uft dann daran vorbei statt zu extrahieren.)</small>
<h3>Hauptbezug</h3>
<label for='cxPulseMs'>Pulsperiode (ms):</label>
<input type='number' min='200' max='5000' step='50' id='cxPulseMs' name='cxPulseMs' value='{CX_PULSE_MS}' required>
<label for='cxDuty'>Pumpenleistung Hauptbezug (%):</label>
<label for='cxDuty'>Pumpenleistung (%):</label>
<input type='number' min='5.0' max='100.0' step='1.0' id='cxDuty' name='cxDuty' value='{CX_DUTY}' required>
<small class="toggle-description-input">(Das kalte Kaffeebett ist erheblich durchl&auml;ssiger als ein hei&szlig;es - niedrige Werte
sind normal. L&auml;uft der Bezug zu schnell durch: Leistung senken und feiner mahlen.)</small>
<h3>Bezugsende</h3>
<label for='cxTarget'>Zielgewicht (Gramm, nur mit Waage wirksam):</label>
<input type='number' min='10.0' max='300.0' step='1.0' id='cxTarget' name='cxTarget' value='{CX_TARGET}' required>
<label for='cxMaxSecs'>Maximale Bezugsdauer (Sekunden):</label>
<input type='number' min='30.0' max='900.0' step='5.0' id='cxMaxSecs' name='cxMaxSecs' value='{CX_MAX_SECS}' required>
<label for='cxPumpMaxRun'>Pumpenschutz: max. Laufzeit (Sekunden):</label>
<small class="toggle-description-input">(Ohne Waage ist das Zielgewicht wirkungslos und die maximale Bezugsdauer wird zur Dosierung.)</small>
<h3>Pumpenschutz</h3>
<label for='cxPumpMaxRun'>Max. Laufzeit (Sekunden):</label>
<input type='number' min='10.0' max='300.0' step='5.0' id='cxPumpMaxRun' name='cxPumpMaxRun' value='{CX_PUMP_MAX_RUN}' required>
<label for='cxPumpRest'>Pumpenschutz: Zwangspause (Sekunden):</label>
<label for='cxPumpRest'>Zwangspause (Sekunden):</label>
<input type='number' min='0.0' max='120.0' step='1.0' id='cxPumpRest' name='cxPumpRest' value='{CX_PUMP_REST}' required>
<small class="toggle-description-input">(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&ouml;glich, praktisch also
aus dem kalten Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht - der Dampfkreis bleibt
unber&uuml;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&auml;le an, die sich (anders als bei hei&szlig;em Bezug) nie wieder schlie&szlig;en, und der
restliche Bezug l&auml;uft daran vorbei. Deshalb die Vorbenetzung deutlich schw&auml;cher einstellen als den
Hauptbezug. Das kalte Bett ist insgesamt viel durchl&auml;ssiger als ein hei&szlig;es, niedrige Werte sind
also normal. Die Pumpenlaufzeit wird kumuliert gez&auml;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 &uuml;ber Standby und Neustart hinweg aktiv, bis er dort wieder ausgeschaltet
wird - solange heizt der Wasserkreis nicht.)</small>
{CX_STATUS_MSG}
<small class="toggle-description-input">(Gez&auml;hlt wird die kumulierte Einschaltzeit der Pumpe, nicht die Bezugsdauer. Nach Ablauf
legt die Steuerung eine Zwangspause ein; der Bezug l&auml;uft dabei weiter.)</small>
<br>
)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 += "<br><b><small style='color: #FFCC00;'>Keine Waage aktiv/verbunden: Zielgewicht und "
"Stillstands-Erkennung greifen nicht. Der Bezug endet erst nach der maximalen "
"Bezugsdauer - diesen Wert dann als Dosierung verstehen.</small></b>";
}
String reason = coldExtractionBlockReason();
if (reason.length() > 0) {
cxStatus += "<br><b><small style='color: #FFCC00;'>" + reason + "</small></b>";
} else {
cxStatus += "<br><b><small style='color: #66CC66;'>Kalte Extraktion derzeit m&ouml;glich.</small></b>";
}
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 += "<br><b><small style='color: #FFCC00;'>Keine Waage aktiv/verbunden: Zielgewicht und "
"Stillstands-Erkennung greifen nicht. Der Bezug endet erst nach der maximalen "
"Bezugsdauer - diesen Wert dann als Dosierung verstehen.</small></b>";
}
String reason = coldExtractionBlockReason();
if (reason.length() > 0) {
cxStatus += "<br><b><small style='color: #FFCC00;'>" + reason + "</small></b>";
} else {
cxStatus += "<br><b><small style='color: #66CC66;'>Kalte Extraktion derzeit m&ouml;glich.</small></b>";
}
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;
}
+14
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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
+5
View File
@@ -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;
+151 -49
View File
@@ -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);
}
}