Compare commits

..
Author SHA1 Message Date
raw-designsandClaude Opus 5 2d8f70ae37 feat: eigene Cold-Extraction-Seite in Web-UI und Display (S3 5.4.0 / P4 1.2.0)
Cold Extraction bekommt in beiden Oberflächen einen eigenen Menüpunkt statt eines
angehängten Blocks auf der Brew-Control-Seite. Die Parameter sind damit erstmals
auch am Touch-Display änderbar.

S3: neue Seite /Cold-Extraction, gegliedert in Grundeinstellung, Vorbenetzung,
Hauptbezug, Bezugsende und Pumpenschutz, jeder Abschnitt mit kurzer Begründung.
/Brew-Control verweist nur noch darauf. Das UART-Kommando saveBrew akzeptiert jetzt
die cx-Felder, und das State-JSON liefert die restlichen Parameter mit, damit das
Display seine Felder vorbelegen kann.

Wichtig: Der Speicher-Handler von /Brew-Control wertet die cx-Felder nicht mehr aus.
Er tat es bisher, und nach dem Verschieben der Checkbox hätte ein Speichern dort die
Freischaltung abgeschaltet, weil das Feld im Formular fehlt.

P4: neue Seite "Cold Extraction" mit allen Parametern; der Modus-Schalter ist von der
Temperatur-Seite dorthin umgezogen, damit es nur eine Stelle gibt. Die Brew-Control-
Seite ist neu gegliedert: jede Funktion in einer eigenen Karte mit Titel und einer
Zeile, die erklärt was sie tut, statt einer durchlaufenden Liste unter
Zwischenüberschriften. Neues Hilfsmakro group_card() für beide Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:09:23 +02:00
thomas 537f1d4c20 Merge pull request 'fix(S3): Cold Extraction benetzt sanft statt mit voller Leistung (5.3.6)' (#18) from fix/cold-extraction-sanfte-vorbenetzung into main 2026-08-11 23:41:43 +02:00
8 changed files with 412 additions and 143 deletions
+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: Version 5.3.6:
- Cold Extraction: Die Vorbenetzung läuft nicht mehr mit voller Pumpenleistung, sondern gepulst mit - Cold Extraction: Die Vorbenetzung läuft nicht mehr mit voller Pumpenleistung, sondern gepulst mit
eigener, niedriger Leistung (neue Einstellung „Pumpenleistung Vorbenetzung", Standard 10 %). 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, Benutzer, warum die Maschine nicht heizt. Ein laufender Bezug wird nie abgebrochen,
`stopColdExtraction` schlägt dann fehl. `stopColdExtraction` schlägt dann fehl.
Die Parameter der kalten Extraktion (Schwelle, Vorbenetzung, Pulsperiode, Pumpenleistung, Die Parameter der kalten Extraktion sind ab S3 5.4.0 über `saveBrew` einstellbar (zusätzlich
Zielgewicht, Timeout, Pumpenschutz) sind bewusst **nicht** über `saveBrew` erreichbar, sondern zur Web-Seite `/Cold-Extraction`). Zulässige Felder: `cxEnabled` (bool), `cxMaxTemp`,
nur über die Web-Seite `/Brew-Control`. Das Display schaltet den Modus, konfiguriert ihn nicht. `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** **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) | | `cxResting` | bool | Zwangspause des Pumpenschutzes läuft (Bezug läuft weiter) |
| `cxMaxTemp` | float | Freigabeschwelle in °C (für eigene Hinweistexte am Display) | | `cxMaxTemp` | float | Freigabeschwelle in °C (für eigene Hinweistexte am Display) |
| `cxTarget` | float | Zielgewicht in g | | `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 | | `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s |
| `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei | | `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei |
| `cxNotice` | uint8 | transiente Meldung, `0` = keine (läuft nach ~6 s selbst ab) | | `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 * Firmware-Informationen
************************************************************************************/ ************************************************************************************/
String version = "5.3.6"; String version = "5.4.0";
String versionHersteller = "Thomas M&uuml;ller"; String versionHersteller = "Thomas M&uuml;ller";
String versionHerstellerMail = String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>"; "<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="/Profile">Profile</a>
<a href="/Timer">Timer</a> <a href="/Timer">Timer</a>
<a href="/Brew-Control">Brew Control</a> <a href="/Brew-Control">Brew Control</a>
<a href="/Cold-Extraction">Cold Extraction</a>
<a href="/Chart">Chart</a> <a href="/Chart">Chart</a>
<a href="/Sensoren">Sensoren</a> <a href="/Sensoren">Sensoren</a>
<a href="/Fast-Heat-Up">Fast-Heat-Up</a> <a href="/Fast-Heat-Up">Fast-Heat-Up</a>
@@ -5175,6 +5176,8 @@ void setup() {
// Routen für Brew Control // Routen für Brew Control
asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl); asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl);
asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl); asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl);
asyncServer.on("/Cold-Extraction", HTTP_GET, handleColdExtraction);
asyncServer.on("/saveColdExtraction", HTTP_POST, handleSaveColdExtraction);
dashboardWs.onEvent(onDashboardWsEvent); dashboardWs.onEvent(onDashboardWsEvent);
asyncServer.addHandler(&dashboardWs); asyncServer.addHandler(&dashboardWs);
@@ -7216,41 +7219,70 @@ static const char brewControlPI_Fields[] PROGMEM = R"rawliteral(
)rawliteral"; )rawliteral";
// --- Cold Extraction (CX) Chunks --- // --- 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_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 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( static const char brewControlCX_Fields[] PROGMEM = R"rawliteral(
<label for='cxMaxTemp'>Freigabe bis Wassertemperatur (&deg;C):</label> <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> <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> <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> <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> <label for='cxPulseMs'>Pulsperiode (ms):</label>
<input type='number' min='200' max='5000' step='50' id='cxPulseMs' name='cxPulseMs' value='{CX_PULSE_MS}' required> <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> <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> <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> <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> <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> <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> <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> <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 <small class="toggle-description-input">(Gez&auml;hlt wird die kumulierte Einschaltzeit der Pumpe, nicht die Bezugsdauer. Nach Ablauf
Kessel am Puck an. Deshalb ist die kalte Extraktion nur unterhalb der Freigabetemperatur m&ouml;glich, praktisch also legt die Steuerung eine Zwangspause ein; der Bezug l&auml;uft dabei weiter.)</small>
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}
<br> <br>
)rawliteral"; )rawliteral";
@@ -7365,53 +7397,8 @@ void handleBrewControl(AsyncWebServerRequest *request) {
response->print(piFieldsHtml); response->print(piFieldsHtml);
yield(); yield();
// --- Cold Extraction Sektion --- // Cold Extraction hat eine eigene Seite (/Cold-Extraction) - hier nur der Verweis
response->print(FPSTR(brewControlCX_H3)); response->print(FPSTR(brewControlCX_Link));
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);
yield(); yield();
// --- Dampf-Timer Sektion --- // --- Dampf-Timer Sektion ---
@@ -7702,6 +7689,106 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
return changed || flowGuardConfigChanged || coldExtractionConfigChanged; 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) { void handleSaveBrewControl(AsyncWebServerRequest *request) {
BrewControlUpdate update; BrewControlUpdate update;
@@ -7737,27 +7824,9 @@ void handleSaveBrewControl(AsyncWebServerRequest *request) {
update.hasSBTSecs = request->hasArg("sbtSecs"); update.hasSBTSecs = request->hasArg("sbtSecs");
if (update.hasSBTSecs) { update.sbtSecs = request->arg("sbtSecs").toFloat(); } if (update.hasSBTSecs) { update.sbtSecs = request->arg("sbtSecs").toFloat(); }
update.hasCXEnabled = true; // Cold Extraction wird hier bewusst NICHT angefasst - die Felder stehen auf
update.cxEnabled = request->hasArg("cxEnabled"); // /Cold-Extraction. Wuerde man sie hier weiter auswerten, wuerde ein Speichern auf
update.hasCXMaxTemp = request->hasArg("cxMaxTemp"); // dieser Seite die Freischaltung abschalten (Checkbox fehlt im Formular = "aus").
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(); }
applyBrewControlUpdate(update); applyBrewControlUpdate(update);
AsyncWebServerResponse *response = request->beginResponse(303); 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(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(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(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); 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 // Sperrgrund + transiente Meldung als Codes (kein Freitext - die State-Zeile muss kurz
// bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst). // bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst).
@@ -17284,9 +17359,54 @@ static bool touchUartHandleCommandLine(const String& line) {
update.sbtSecs = floatVal; 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 && if (!update.hasBBTEnabled && !update.hasBBTSecs && !update.hasBBWEnabled && !update.hasBBWTarget &&
!update.hasBBWOffset && !update.hasPIEnabled && !update.hasPIDurSecs && !update.hasPIPauseSecs && !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"); touchUartSendError(id, "Keine Brew-Felder");
return false; 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: Version 1.1.3:
- Aufweck-Dialog hat einen dritten Knopf: „Aufwecken mit Cold Extraction" (braucht S3 ab 5.3.5). - 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 Er stellt den Modus scharf und beendet erst danach den Standby - in dieser Reihenfolge, damit
+1 -1
View File
@@ -65,7 +65,7 @@
// ------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------
// Firmware // 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 // Protokoll
+6
View File
@@ -78,6 +78,12 @@ struct MachineState {
float cxMaxTemp = 0; // Freigabeschwelle in C float cxMaxTemp = 0; // Freigabeschwelle in C
float cxTarget = 0; // Zielgewicht in g float cxTarget = 0; // Zielgewicht in g
float cxPreInfSec = 0; // Vorbenetzung in s (fuer die Phasenleiste des kalten Bezugs) 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 float cxMaxSecs = 0; // Sicherheits-Timeout des Bezugs in s
// Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent, // Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent,
// 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm // 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.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp;
s.cxTarget = doc["cxTarget"] | s.cxTarget; s.cxTarget = doc["cxTarget"] | s.cxTarget;
s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec; 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.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
s.cxBlock = doc["cxBlock"] | (uint8_t)0; s.cxBlock = doc["cxBlock"] | (uint8_t)0;
s.cxNotice = doc["cxNotice"] | (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; static lv_obj_t* g_otaBytes = nullptr;
// Navigation // 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 }; 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", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Info" }; 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_content = nullptr;
static lv_obj_t* g_pages[PG_COUNT] = { nullptr }; static lv_obj_t* g_pages[PG_COUNT] = { nullptr };
static lv_obj_t* g_drawer = 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* 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* 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* 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 // WiFi
static lv_obj_t* wifiStatusLbl; static lv_obj_t* wifiHintLbl; static lv_obj_t* wifiList; 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); attach_kb(ta, false);
return ta; 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) { static lv_obj_t* section_title(lv_obj_t* parent, const char* txt) {
lv_obj_t* l = lv_label_create(parent); lv_obj_t* l = lv_label_create(parent);
lv_label_set_text(l, txt); 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, // Brew-Control-Felder einmalig aus dem Zustand fuellen (nur beim Oeffnen der Seite,
// damit laufende State-Pushes die Eingaben nicht vor dem Speichern ueberschreiben). // 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) { static void populate_brew(const MachineState& st) {
if (!swPI) return; if (!swPI) return;
sw_set(swPI, st.piEnabled); ta_setf(taPiDur, st.piDurSecs, 1); ta_setf(taPiPause, st.piPauseSecs, 1); 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_PROFILE && g_client) g_client->sendListProfiles();
if (idx == PG_STATS) stats_request(); // Nutzungsstatistik beim Oeffnen frisch anfordern 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_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); if (idx == PG_SERVICE) populate_service(g_state);
} }
static void drawer_open() { if (g_drawer) { lv_obj_remove_flag(g_drawer, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_drawer); } } static void drawer_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); lv_obj_add_event_cb(swEco, eco_cb, LV_EVENT_VALUE_CHANGED, nullptr);
swMaint = add_switch(p, "Wartungsmodus"); swMaint = add_switch(p, "Wartungsmodus");
lv_obj_add_event_cb(swMaint, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr); lv_obj_add_event_cb(swMaint, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr);
swColdEx = add_switch(p, "Cold Extraction"); // Cold Extraction hat eine eigene Seite (PG_COLDEX) - Schalter und Parameter stehen dort.
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);
} }
// ===================================================================================== // =====================================================================================
@@ -2010,28 +2034,109 @@ static void send_save_brew(lv_event_t*) {
showToast("Speichere Brühen...", false); showToast("Speichere Brühen...", false);
} }
static void build_brew(lv_obj_t* p) { static void build_brew(lv_obj_t* p) {
section_title(p, "Pre-Infusion"); // Jede Funktion in einer eigenen Karte - vorher standen alle Zeilen unter blossen
swPI = add_switch(p, "Aktiv"); // Zwischenueberschriften untereinander und waren kaum auseinanderzuhalten.
taPiDur = add_num(p, "Dauer (s)"); lv_obj_t* cPI = group_card(p, "Pre-Infusion", "Pumpe an - aus - an, vor dem eigentlichen Bezug");
taPiPause = add_num(p, "Pause (s)"); swPI = add_switch(cPI, "Aktiv");
section_title(p, "Brew-by-Time / Weight / Steam-by-Time"); taPiDur = add_num(cPI, "Dauer (s)");
swBBT = add_switch(p, "Brew-by-Time aktiv"); taPiPause = add_num(cPI, "Pause (s)");
taBbtSecs = add_num(p, "Zielzeit (s)");
swBBW = add_switch(p, "Brew-by-Weight aktiv"); lv_obj_t* cBBT = group_card(p, "Brew-by-Time", "Beendet den Bezug nach der Zielzeit");
taBbwTarget= add_num(p, "Zielgewicht (g)"); swBBT = add_switch(cBBT, "Aktiv");
taBbwOffset= add_num(p, "Offset (g)"); taBbtSecs = add_num(cBBT, "Zielzeit (s)");
swSBT = add_switch(p, "Steam-by-Time aktiv");
taSbtSecs = add_num(p, "Dampf-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); 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); // Cold Extraction (eigene Seite)
lv_label_set_long_mode(lblBrewColdEx, LV_LABEL_LONG_WRAP); // =====================================================================================
lv_obj_set_width(lblBrewColdEx, LV_PCT(100)); static void send_save_coldex(lv_event_t*) {
lv_obj_set_style_text_color(lblBrewColdEx, COL_TEXT_DIM, 0); if (!g_client) return;
lv_label_set_text(lblBrewColdEx, ""); String f;
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); 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_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_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_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_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_WIFI] = make_page(g_content); build_wifi(g_pages[PG_WIFI]);
g_pages[PG_PROFILE] = make_page(g_content); build_profiles(g_pages[PG_PROFILE]); g_pages[PG_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()); label_set_if_changed(lblColdExHint, hint.c_str());
} }
} }
if (lblBrewColdEx) { if (lblCxPageHint) {
if (st.cxEnabled) { // Warnhinweis auf der Cold-Extraction-Seite (Waage fehlt bzw. Funktion gesperrt)
char b[320]; // Grundtext ~205 Zeichen (das ° zaehlt als 2 Bytes) + Waagen-Hinweis String h;
snprintf(b, sizeof(b), if (st.cxEnabled && !cxScaleOk) {
"Cold Extraction (nur Anzeige, Einstellung in der Web-UI):\n" h = "Keine Waage aktiv/verbunden: Zielgewicht und Stillstands-Erkennung greifen nicht, "
"Freigabe < %.1f °C | Vorbenetzung %.0f s | Ziel %.0f g | max. %.0f s\n" "der Bezug endet erst nach der maximalen Dauer.";
"Geschaltet wird der Modus auf der Seite \"Temperaturen\" unter \"Modi\".%s", }
(double)st.cxMaxTemp, (double)st.cxPreInfSec, if (h.length() > 0) {
(double)st.cxTarget, (double)st.cxMaxSecs, label_set_if_changed(lblCxPageHint, h.c_str());
(st.scaleEnabled && st.scaleConnected) lv_obj_remove_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN);
? ""
: "\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);
} else { } else {
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN);
} }
} }