Compare commits

..
Author SHA1 Message Date
raw-designsandClaude Opus 5 7c1e485722 feat: Espresso/Cold-Auswahl bei jedem Aufwecken, auch per Schalter (S3 5.6.0 / P4 1.4.0)
Die Abfrage war eine reine Vorab-Abfrage des Displays und erschien nur beim
Antippen des Standby-Bildschirms. Beim Aufwecken über den Schalter ist der Standby
aber schon beendet, bevor das Display überhaupt fragen könnte — dieser Weg war nie
abgedeckt.

Die Steuerung hält jetzt nach dem Aufwecken ein Auswahlfenster offen (20 s): Der
Wasserkreis heizt in dieser Zeit nicht, das Display blendet die Auswahl von sich aus
ein und zeigt einen Countdown. Ohne Entscheidung wird danach normal geheizt, es geht
also nichts verloren, wenn niemand hinsieht.

Das Fenster öffnet nur mit angeschlossenem Display (sonst könnte niemand wählen),
bei freigeschalteter Funktion und aktiver Einstellung "Abfrage beim Aufwecken", und
nur wenn der Modus nicht ohnehin schon läuft. Es schließt bei Entscheidung,
Bezug/Spülen oder erneutem Standby.

Neue Aktion wakeEspresso (beendet Standby falls aktiv und schließt das Fenster) —
das Display sendet sie statt deactivateStandby, sobald die Auswahl relevant ist,
sonst würde direkt nach dem Aufwecken nochmal gefragt. Neue State-Felder
cxWakeChoice und cxWakeChoiceSec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:46:47 +02:00
thomas 7d14f94a72 Merge pull request 'feat: Abfrage Espresso oder Cold Extraction beim Aufwecken (S3 5.5.0 / P4 1.3.0)' (#21) from feat/cold-extraction-abfrage-beim-aufwecken into main 2026-08-12 01:02:58 +02:00
raw-designsandClaude Opus 5 11307d255b feat: Abfrage Espresso oder Cold Extraction beim Aufwecken (S3 5.5.0 / P4 1.3.0)
Der Aufweck-Dialog am Touch-Display wird zur Auswahl: "Womit aufwecken?" mit
"Espresso" (heizt normal auf) und "Cold Extraction (Wasser bleibt kalt)". Damit
lässt sich bei jedem Aufwecken neu entscheiden, ohne vorher die Web-UI zu bedienen.
Bei der kalten Variante wird der Modus scharf geschaltet, bevor der Standby endet —
der Wasserkreis heizt also gar nicht erst an.

Abschaltbar über die neue Einstellung "Abfrage beim Aufwecken" (EEPROM 982), zu
finden auf /Cold-Extraction und auf der Cold-Extraction-Seite des Displays. Ist sie
aus, erscheint wieder der bisherige Dialog. Ohne angeschlossenes Display oder bei
nicht freigeschalteter Funktion hat sie keine Wirkung. Neues Feld cxAskOnWake im
State und in saveBrew.

Dazu: Die Meldungstexte der kalten Extraktion sind jetzt mit echten Umlauten
geschrieben ("nicht möglich" statt "nicht moeglich"). Für das OLED übersetzt die
neue Hilfsfunktion oledUmlauts() sie beim Rendern nach CP437 — dort hätte UTF-8
sonst zwei Fehlzeichen je Umlaut ergeben, weshalb die Texte ursprünglich
transliteriert waren. Web-UI und Touch-Display stellen UTF-8 direkt dar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:02:37 +02:00
thomas f1a8d8c175 Merge pull request 'fix: Zielgewicht bei Cold Extraction ueberall korrekt anzeigen (S3 5.4.1 / P4 1.2.1)' (#20) from fix/cold-extraction-zielgewicht-anzeige into main 2026-08-12 00:52:07 +02:00
raw-designsandClaude Opus 5 334896b9cf fix: Zielgewicht bei Cold Extraction überall korrekt anzeigen (S3 5.4.1 / P4 1.2.1)
Während einer kalten Extraktion stand als Zielgewicht überall noch der
Brew-by-Weight-Wert des normalen Espresso-Bezugs. OLED, Web-Dashboard und
Touch-Display griffen alle direkt auf brewByWeightTargetGrams zu — die kalte
Extraktion ersetzt Brew-by-Weight aber und stoppt nach coldExtractionTargetGrams.

Neue Hilfsfunktion activeTargetWeightGrams() liefert das Ziel des aktuellen bzw.
nächsten Bezugs. Das UART-Feld targetWeight führt jetzt diesen Wert; bbwTarget
bleibt unverändert die Roh-Einstellung, damit die Brew-Control-Seite weiter das
Richtige anzeigt.

Zusätzlich hing die Gewichtsanzeige selbst an "Brew-by-Weight aktiv" — das ist bei
kalter Extraktion typischerweise aus, obwohl ein Ziel existiert. OLED, Waage-Kachel
und Live-Bezugsschirm zeigen Gewicht und Fortschritt jetzt auch beim kalten Bezug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:51:48 +02:00
thomas e8b1832fc1 Merge pull request 'feat: eigene Cold-Extraction-Seite in Web-UI und Display (S3 5.4.0 / P4 1.2.0)' (#19) from feat/cold-extraction-eigene-seite into main 2026-08-12 00:09:49 +02:00
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 741 additions and 186 deletions
+55
View File
@@ -1,3 +1,58 @@
Version 5.6.0:
- Die Abfrage „Espresso oder Cold Extraction" kommt jetzt bei JEDEM Weg aus dem Standby, also auch beim
Aufwecken über den Schalter. Bisher war sie eine reine Vorab-Abfrage des Touch-Displays und erschien nur
beim Antippen des Standby-Bildschirms - beim Schalter ist der Standby aber schon beendet, bevor das
Display überhaupt fragen könnte. Dieser Weg war damit nie abgedeckt.
- Dafür hält die Steuerung nach dem Aufwecken ein Auswahlfenster offen (20 s): Der Wasserkreis heizt in
dieser Zeit nicht, das Display zeigt die Auswahl mit Countdown. Ohne Entscheidung wird anschließend
normal geheizt - es geht also nichts verloren, wenn niemand hinsieht.
- Das Fenster öffnet nur mit angeschlossenem Touch-Display (ohne Display könnte niemand wählen), bei
freigeschalteter Funktion und aktivierter Einstellung „Abfrage beim Aufwecken", und nur wenn der
Cold-Extraction-Modus nicht ohnehin schon aktiv ist. Es schließt sofort bei einer Entscheidung, bei
Bezug/Spülen oder erneutem Standby.
- Neue Aktion „wakeEspresso": beendet den Standby (falls noch aktiv) und schließt das Auswahlfenster.
Das Display sendet sie statt „deactivateStandby", sobald die Auswahl relevant ist - sonst würde nach
dem Aufwecken über den Dialog gleich nochmal gefragt.
- Neue State-Felder „cxWakeChoice" (Fenster offen) und „cxWakeChoiceSec" (Restsekunden).
Version 5.5.0:
- Neu: Beim Aufwecken aus dem Standby fragt das Touch-Display, ob normal geheizt (Espresso) oder kalt
extrahiert werden soll. Damit lässt sich das bei jedem Aufwecken neu entscheiden, ohne vorher die
Web-UI zu bedienen. Bei „Cold Extraction" wird der Modus scharf geschaltet, BEVOR der Standby endet -
der Wasserkreis heizt also gar nicht erst an.
- Abschaltbar über die neue Einstellung „Abfrage beim Aufwecken" auf /Cold-Extraction bzw. auf der
Cold-Extraction-Seite des Touch-Displays. Ohne angeschlossenes Display oder bei nicht freigeschalteter
Funktion hat sie keine Wirkung; ist sie aus, bleibt der bisherige Dialog („Aufwecken" / „Abbrechen").
- Neues UART-Feld „cxAskOnWake" im State und als Feld in saveBrew.
- Die Meldungstexte der kalten Extraktion sind jetzt mit echten Umlauten geschrieben („nicht möglich"
statt „nicht moeglich"). Für das OLED werden sie beim Rendern nach CP437 übersetzt (neue Hilfsfunktion
oledUmlauts) - dort hätte UTF-8 sonst zwei Fehlzeichen je Umlaut ergeben. Web-UI und Touch-Display
stellen UTF-8 ohnehin direkt dar.
Version 5.4.1:
- Bug-Fix: Während einer kalten Extraktion stand als Zielgewicht überall noch der Brew-by-Weight-Wert
des normalen Espresso-Bezugs. Betroffen waren OLED, Web-Dashboard und Touch-Display, weil alle drei
direkt brewByWeightTargetGrams anzeigten - die kalte Extraktion ersetzt Brew-by-Weight aber und stoppt
nach coldExtractionTargetGrams. Neue Hilfsfunktion activeTargetWeightGrams() liefert jetzt das Ziel
des aktuellen bzw. nächsten Bezugs; das UART-Feld "targetWeight" führt diesen Wert (bbwTarget bleibt
unverändert die Roh-Einstellung).
- Auf dem OLED wird während eines kalten Bezugs jetzt überhaupt Gewicht angezeigt: Die Anzeige hing an
„Brew-by-Weight aktiv", das bei kalter Extraktion typischerweise aus ist - trotz vorhandenem Ziel.
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 %).
+16 -4
View File
@@ -322,6 +322,7 @@ Alle gültigen `action`-Strings aus `executeDashboardAction()` (Stand v4.8.1, Ze
| `startColdExtraction` | — | Modus aktivieren (Wasserkreis heizt nicht, Dampf bleibt möglich) |
| `stopColdExtraction` | — | Modus beenden |
| `toggleColdExtraction` | — | Modus umschalten |
| `wakeEspresso` | — | Standby beenden (falls aktiv) und das Auswahlfenster schließen, also „normal heizen" wählen (ab S3 5.6.0) |
Für das Bedienelement zählt `cxArmable`, **nicht** `cxAllowed`: Der Modus schaltet die
Wasserheizung ab und muss deshalb auch im Standby und bei noch warmem Kessel scharf zu
@@ -340,9 +341,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`, `cxAskOnWake` (bool). 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 +401,15 @@ 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) |
| `cxAskOnWake` | bool | Beim Aufwecken aus dem Standby nach Espresso/Cold Extraction fragen (ab S3 5.5.0) |
| `cxWakeChoice` | bool | Auswahlfenster nach dem Aufwecken offen — Wasserkreis heizt solange nicht (ab S3 5.6.0) |
| `cxWakeChoiceSec` | long | Restsekunden der Bedenkzeit |
| `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) |
+376 -111
View File
@@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
* Firmware-Informationen
************************************************************************************/
String version = "5.3.6";
String version = "5.6.0";
String versionHersteller = "Thomas M&uuml;ller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -790,6 +790,19 @@ float coldExtractionTargetGrams = 60.0f; // Zielgewicht (ca. 1:3 auf 18 g
float coldExtractionMaxSeconds = 300.0f; // Sicherheits-Timeout des Bezugs
float coldExtractionPumpMaxRunSec = 60.0f; // kumulierte Pumpenlaufzeit bis zur Zwangspause
float coldExtractionPumpRestSec = 20.0f; // Dauer der Zwangspause
// Beim Aufwecken aus dem Standby am Touch-Display fragen, ob normal geheizt oder kalt
// extrahiert werden soll. Nur wirksam bei angeschlossenem UART-Display und freigeschalteter
// Funktion; abschaltbar, wenn man den Zwischenschritt nicht mehr will.
bool coldExtractionAskOnWake = true;
// Auswahlfenster nach dem Aufwecken: Der Dialog am Display ist eine Vorab-Abfrage und kann
// den Schalter-Weg nicht abdecken - dort ist der Standby schon beendet, bevor das Display
// fragen koennte. Deshalb haelt die Steuerung den Wasserkreis nach dem Aufwecken kurz aus
// und wartet auf die Entscheidung; laeuft das Fenster ab, wird normal geheizt.
bool coldExtractionWakeChoicePending = false;
unsigned long coldExtractionWakeChoiceUntilMs = 0;
unsigned long coldExtractionWakeDecisionMs = 0; // Zeitpunkt der letzten expliziten Wahl
const unsigned long COLDEX_WAKE_CHOICE_MS = 20000UL; // Bedenkzeit
const unsigned long COLDEX_WAKE_DECISION_GRACE_MS = 5000UL; // frisch entschieden -> kein Fenster
const bool defaultColdExtractionEnabled = false;
const float defaultColdExtractionMaxTempC = 30.0f;
@@ -808,6 +821,7 @@ const float defaultColdExtractionTargetGrams = 60.0f;
const float defaultColdExtractionMaxSeconds = 300.0f;
const float defaultColdExtractionPumpMaxRunSec = 60.0f;
const float defaultColdExtractionPumpRestSec = 20.0f;
const bool defaultColdExtractionAskOnWake = true;
const float COLDEX_MAX_TEMP_MIN = 10.0f;
const float COLDEX_MAX_TEMP_MAX = 40.0f;
@@ -1073,7 +1087,8 @@ const int EEPROM_ADDR_COLDEX_PUMP_MAX_RUN = 969; // 4 Bytes (float) -> Ende
const int EEPROM_ADDR_COLDEX_PUMP_REST = 973; // 4 Bytes (float) -> Ende 976
const int EEPROM_ADDR_COLDEX_MODE_ACTIVE = 977; // 1 Byte (uint8_t) -> Ende 977
const int EEPROM_ADDR_COLDEX_PREINF_DUTY = 978; // 4 Bytes (float) -> Ende 981
// Naechste freie Adresse: 982 (Innerhalb EEPROM_SIZE=1024)
const int EEPROM_ADDR_COLDEX_ASK_ON_WAKE = 982; // 1 Byte (uint8_t) -> Ende 982
// Naechste freie Adresse: 983 (Innerhalb EEPROM_SIZE=1024)
/************************************************************************************
* Eco-Mode Variablen
@@ -2029,6 +2044,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>
@@ -3066,6 +3082,7 @@ void resetToDefaults() {
EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_MAX_RUN, defaultColdExtractionPumpMaxRunSec);
EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_REST, defaultColdExtractionPumpRestSec);
EEPROM.put(EEPROM_ADDR_COLDEX_MODE_ACTIVE, (uint8_t)0); // Werksreset: Modus immer aus
EEPROM.put(EEPROM_ADDR_COLDEX_ASK_ON_WAKE, (uint8_t)(defaultColdExtractionAskOnWake ? 1 : 0));
// Piezo auf Default im EEPROM schreiben
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled);
@@ -3154,6 +3171,7 @@ void resetToDefaults() {
coldExtractionMaxSeconds = defaultColdExtractionMaxSeconds;
coldExtractionPumpMaxRunSec = defaultColdExtractionPumpMaxRunSec;
coldExtractionPumpRestSec = defaultColdExtractionPumpRestSec;
coldExtractionAskOnWake = defaultColdExtractionAskOnWake;
coldExtractionModeActive = false;
coldExtractionShotActive = false;
steamByTimeEnabled = defaultSteamByTimeEnabled; steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds;
@@ -4059,6 +4077,42 @@ bool coldExtractionTemperatureOk() {
return (InputWasser < (double)limit);
}
// UTF-8-Umlaute in die Codepage der OLED-Schrift (CP437) uebersetzen. Meldungstexte werden
// mit echten Umlauten gehalten (Web-UI und Touch-Display koennen UTF-8); das OLED wuerde
// daraus zwei Fehlzeichen machen - vgl. "Startverz\x94gerung" an anderer Stelle.
String oledUmlauts(const String& in) {
String out;
out.reserve(in.length());
for (size_t i = 0; i < in.length(); i++) {
uint8_t c = (uint8_t)in[i];
if (c == 0xC3 && (i + 1) < in.length()) {
uint8_t d = (uint8_t)in[i + 1];
char mapped = 0;
switch (d) {
case 0xA4: mapped = (char)0x84; break; // ae
case 0xB6: mapped = (char)0x94; break; // oe
case 0xBC: mapped = (char)0x81; break; // ue
case 0x84: mapped = (char)0x8E; break; // Ae
case 0x96: mapped = (char)0x99; break; // Oe
case 0x9C: mapped = (char)0x9A; break; // Ue
case 0x9F: mapped = (char)0xE1; break; // ss
default: break;
}
if (mapped) { out += mapped; i++; continue; }
}
out += (char)c;
}
return out;
}
// Zielgewicht des aktuellen bzw. naechsten Bezugs. Bei aktiver Cold Extraction ist das
// coldExtractionTargetGrams - brewByWeightTargetGrams gilt dann nicht, weil die kalte
// Extraktion Brew-by-Weight ersetzt. Alle Anzeigen (OLED, Web, Display) muessen diesen
// Wert nehmen, sonst steht dort das Espresso-Ziel waehrend eines kalten Bezugs.
float activeTargetWeightGrams() {
return coldExtractionModeActive ? coldExtractionTargetGrams : brewByWeightTargetGrams;
}
// Steht eine nutzbare Waage bereit? Ohne sie laeuft die kalte Extraktion rein zeitgesteuert:
// Zielgewicht und Stillstands-Erkennung greifen nicht, es bleibt nur die maximale Bezugsdauer.
// Bewusst kein Sperrgrund - ein kurz abgemeldeter HX711 soll die Funktion nicht blockieren.
@@ -4096,23 +4150,23 @@ String coldExtractionBlockReason() {
return "Cold Extraction ist nicht freigeschaltet.";
}
if (standbyModeActive) {
return "Cold Extraction nicht moeglich: Standby aktiv.";
return "Cold Extraction nicht möglich: Standby aktiv.";
}
if (wartungsModusAktiv) {
return "Cold Extraction nicht moeglich: Wartungsmodus aktiv.";
return "Cold Extraction nicht möglich: Wartungsmodus aktiv.";
}
if (cleaningAssistantActive) {
return "Cold Extraction nicht moeglich: Reinigungsassistent aktiv.";
return "Cold Extraction nicht möglich: Reinigungsassistent aktiv.";
}
if (autoTuneWasserActive || autoTuneDampfActive) {
return "Cold Extraction nicht moeglich: PID-Tuning laeuft.";
return "Cold Extraction nicht möglich: PID-Tuning läuft.";
}
if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) {
return "Cold Extraction nicht moeglich: Wasser-Sensor liefert keinen gueltigen Wert.";
return "Cold Extraction nicht möglich: Wasser-Sensor liefert keinen gültigen Wert.";
}
if (!coldExtractionTemperatureOk()) {
char buf[96];
snprintf(buf, sizeof(buf), "Wasserkessel zu warm (%.1f C, benoetigt < %.1f C).",
snprintf(buf, sizeof(buf), "Wasserkessel zu warm (%.1f °C, benötigt < %.1f °C).",
(double)InputWasser, (double)coldExtractionMaxTempC);
return String(buf);
}
@@ -4133,7 +4187,7 @@ bool setColdExtractionMode(bool enable, String& message) {
return false;
}
if (shotActive || shotSoftwareActive || flushActive) {
message = "Cold Extraction nicht moeglich: Bezug oder Spuelen laeuft.";
message = "Cold Extraction nicht möglich: Bezug oder Spülen läuft.";
return false;
}
coldExtractionModeActive = true;
@@ -4145,7 +4199,7 @@ bool setColdExtractionMode(bool enable, String& message) {
} else if (!coldExtractionTemperatureOk()) {
// Scharfschalten bei warmem Kessel ist erlaubt (Heizung geht sofort aus), ein
// Bezug aber erst, wenn der Kessel unter die Schwelle gefallen ist.
message += " Der Kessel ist noch zu warm - er kuehlt jetzt ab, Bezuege sind bis dahin gesperrt.";
message += " Der Kessel ist noch zu warm - er kühlt jetzt ab, Bezüge sind bis dahin gesperrt.";
}
if (!coldExtractionScaleUsable()) {
// Ohne Waage faellt die Dosierung auf die maximale Bezugsdauer zurueck - das
@@ -4195,13 +4249,13 @@ String coldExtractionArmBlockReason() {
return "Cold Extraction ist nicht freigeschaltet.";
}
if (wartungsModusAktiv) {
return "Cold Extraction nicht moeglich: Wartungsmodus aktiv.";
return "Cold Extraction nicht möglich: Wartungsmodus aktiv.";
}
if (cleaningAssistantActive) {
return "Cold Extraction nicht moeglich: Reinigungsassistent aktiv.";
return "Cold Extraction nicht möglich: Reinigungsassistent aktiv.";
}
if (autoTuneWasserActive || autoTuneDampfActive) {
return "Cold Extraction nicht moeglich: PID-Tuning laeuft.";
return "Cold Extraction nicht möglich: PID-Tuning läuft.";
}
return "";
}
@@ -4744,6 +4798,11 @@ void setup() {
// Der Modus ist bewusst persistent: Wer nur kalte Bezuege machen will, soll die
// Maschine nicht nach jedem Standby/Neustart erst aufheizen lassen, um sie danach
// wieder abkuehlen zu muessen. Nur bei entzogener Freischaltung faellt er weg.
uint8_t storedColdExtractionAsk = 0;
EEPROM.get(EEPROM_ADDR_COLDEX_ASK_ON_WAKE, storedColdExtractionAsk);
if (storedColdExtractionAsk > 1) { storedColdExtractionAsk = (uint8_t)(defaultColdExtractionAskOnWake ? 1 : 0); }
coldExtractionAskOnWake = (storedColdExtractionAsk == 1);
uint8_t storedColdExtractionMode = 0;
EEPROM.get(EEPROM_ADDR_COLDEX_MODE_ACTIVE, storedColdExtractionMode);
if (storedColdExtractionMode > 1) { storedColdExtractionMode = 0; }
@@ -5175,6 +5234,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);
@@ -5384,6 +5445,24 @@ void loop() {
lastShotTime = millis();
resetOffsetCompensationReferences();
applySteamHeatDisabledOnStartupWakeIfConfigured();
// Auswahlfenster oeffnen: gilt fuer JEDEN Weg aus dem Standby (Schalter, Touch, Web,
// Timer). Nur sinnvoll mit angeschlossenem Display - ohne das koennte niemand waehlen,
// und die Maschine wuerde nur unnoetig spaeter heizen.
if (coldExtractionEnabled && coldExtractionAskOnWake && touchUartClientActive &&
!coldExtractionModeActive && coldExtractionArmable() &&
(coldExtractionWakeDecisionMs == 0 ||
(currentMillis - coldExtractionWakeDecisionMs) > COLDEX_WAKE_DECISION_GRACE_MS)) {
coldExtractionWakeChoicePending = true;
coldExtractionWakeChoiceUntilMs = currentMillis + COLDEX_WAKE_CHOICE_MS;
}
}
// Auswahlfenster beenden: Zeit abgelaufen, Standby, oder es wird ohnehin schon bezogen
if (coldExtractionWakeChoicePending) {
if (standbyModeActive || coldExtractionModeActive || shotActive || shotSoftwareActive ||
flushActive || !coldExtractionEnabled ||
(long)(currentMillis - coldExtractionWakeChoiceUntilMs) >= 0) {
coldExtractionWakeChoicePending = false;
}
}
if (standbyModeActive != lastStandbyModeActive) {
syncFullyKioskWithStandby(standbyModeActive);
@@ -5415,7 +5494,7 @@ void loop() {
// nach Software-Shots, Einstellungsänderungen mid-shot etc. immer konsistent ist.
// Bei Cold Extraction zusaetzlich einfrieren: der Wasserkreis heizt minutenlang nicht,
// ohne Freeze wuerde der I-Anteil aufladen und nach dem Modus stark ueberschwingen.
setPidWasserIntegralFrozen(coldExtractionModeActive ||
setPidWasserIntegralFrozen(coldExtractionModeActive || coldExtractionWakeChoicePending ||
(ffIFreezeWasser && (shotActive || shotSoftwareActive) && !wartungsModusAktiv));
// --- Angepasste Sicherheitsueberpruefung Wasser ---
@@ -5436,12 +5515,12 @@ void loop() {
} else {
if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK."); wasserSensorError = false;
if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich."); wasserSafetyShutdown = false;
bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && !coldExtractionModeActive && (InputWasser < (SetpointWasser * 0.95));
bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && !coldExtractionModeActive && !coldExtractionWakeChoicePending && (InputWasser < (SetpointWasser * 0.95));
if (activateBoost) { OutputWasser = windowSizeWasser; } else if (!autoTuneWasserActive) { pidWasser.Compute(); }
// Cold Extraction: Stellgroesse hart auf 0 - der Wasserkreis heizt nicht. Die PID
// laeuft weiter (D-Anteil/lastInput bleiben aktuell), nur der Output wird verworfen,
// damit auch die dutyW-Telemetrie fuer das Display die Wahrheit zeigt.
if (coldExtractionModeActive) { OutputWasser = 0; }
if (coldExtractionModeActive || coldExtractionWakeChoicePending) { OutputWasser = 0; }
}
yield();
@@ -5518,7 +5597,7 @@ void loop() {
recordResetCheckpoint(RESET_CP_LOOP_SSR);
unsigned long now = millis();
// Wasser SSR
if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive || coldExtractionModeActive) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive || coldExtractionModeActive || coldExtractionWakeChoicePending) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser = now; }
unsigned long effOutWasser = effectiveOutputWasserMs(); // PID-Output + Feed-Forward-Boost bei Bezug
bool pidWantsHeat = (effOutWasser > 0) && (now < (windowStartTimeWasser + effOutWasser));
@@ -6113,7 +6192,9 @@ void updateDisplay() {
display.println(F(""));
// Meldung auf 21 Zeichen pro Zeile umbrechen (128 px / 6 px Zeichenbreite)
const size_t lineLen = 21;
String rest = coldExtractionBlockMessage;
// Umlaute in die CP437-Codepage der OLED-Schrift wandeln; danach ist jeder Umlaut
// wieder ein Byte, der Zeilenumbruch unten zaehlt also richtig.
String rest = oledUmlauts(coldExtractionBlockMessage);
while (rest.length() > 0) {
if (rest.length() <= lineLen) {
display.println(rest);
@@ -6563,7 +6644,9 @@ if (scaleModeActive) {
// Wenn die Zustandsmaschine nicht aktiv ist, zeige den laufenden Shot oder die Temperaturen
else if (shotActive) {
// Priorität: Shot läuft *aktuell*?
if (brewByWeightEnabled && scaleConnected) {
// Auch ein kalter Bezug hat ein Gewichtsziel - unabhaengig von Brew-by-Weight,
// das die Cold Extraction ersetzt.
if ((brewByWeightEnabled || coldExtractionShotActive) && scaleConnected) {
display.setTextSize(1);
display.setCursor(0, 0);
display.println(F("Bezug aktiv:"));
@@ -6580,7 +6663,7 @@ if (scaleModeActive) {
netWeight = 0.0;
}
char weightBuffer[15];
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f/%.1fg", netWeight, brewByWeightTargetGrams);
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f/%.1fg", netWeight, activeTargetWeightGrams());
// --- ENDE ÄNDERUNG ---
display.setCursor(0, 30);
@@ -7216,41 +7299,78 @@ 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>Abfrage beim Aufwecken</h3>
<div class="toggle-switch-container"><span class="toggle-switch-label-text">Am Touch-Display fragen:</span>
<label class="toggle-switch"><input type="checkbox" id="cxAskOnWake" name="cxAskOnWake" value="1"{CX_ASK_CHECKED}>
<span class="toggle-slider"></span></label></div>
<small class="toggle-description-input">(Beim Aufwecken aus dem Standby fragt das Touch-Display, ob normal geheizt oder kalt
extrahiert werden soll - so entscheidet man das bei jedem Aufwecken neu, ohne vorher die Web-UI zu bedienen.
Ohne angeschlossenes Touch-Display oder bei nicht freigeschalteter Funktion hat die Einstellung keine Wirkung.)</small>
<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 +7485,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 ---
@@ -7492,6 +7567,8 @@ struct BrewControlUpdate {
float cxPumpMaxRun = 0.0f;
bool hasCXPumpRest = false;
float cxPumpRest = 0.0f;
bool hasCXAskOnWake = false;
bool cxAskOnWake = false;
};
bool applyBrewControlUpdate(const BrewControlUpdate& update) {
@@ -7689,6 +7766,11 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
coldExtractionConfigChanged = true;
}
}
if (update.hasCXAskOnWake && update.cxAskOnWake != coldExtractionAskOnWake) {
coldExtractionAskOnWake = update.cxAskOnWake;
EEPROM.put(EEPROM_ADDR_COLDEX_ASK_ON_WAKE, (uint8_t)(coldExtractionAskOnWake ? 1 : 0));
coldExtractionConfigChanged = true;
}
// "changed" umfasst hier nur noch profil-relevante Felder (BBT/BBW/PI/SBT);
// FlowGuard und Cold Extraction sind kein Bestandteil von TemperatureProfile und
@@ -7702,6 +7784,109 @@ 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);
cxFieldsHtml.replace("{CX_ASK_CHECKED}", coldExtractionAskOnWake ? " checked" : "");
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(); }
update.hasCXAskOnWake = true; // Checkbox: fehlt im POST = "aus"
update.cxAskOnWake = request->hasArg("cxAskOnWake");
applyBrewControlUpdate(update);
AsyncWebServerResponse *response = request->beginResponse(303);
response->addHeader("Location", "/Cold-Extraction");
request->send(response);
}
void handleSaveBrewControl(AsyncWebServerRequest *request) {
BrewControlUpdate update;
@@ -7737,27 +7922,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);
@@ -15518,6 +15685,29 @@ bool executeDashboardAction(const String& action, const String& value,
success = true;
}
}
// Gegenstueck zu startColdExtraction beim Aufwecken: "normal heizen" bewusst waehlen.
// Beendet den Standby, falls noch aktiv, und schliesst das Auswahlfenster - sonst
// wuerde nach dem Aufwecken noch einmal gefragt.
else if (action == "wakeEspresso") {
coldExtractionWakeDecisionMs = millis();
coldExtractionWakeChoicePending = false;
if (standbyModeActive) {
standbyModeActive = false;
startupTime = millis();
ecoForcedActive = false;
steamDelayOverridden = false;
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
ecoModeAktiv = false;
ecoModeActivatedTime = 0;
lastShotTime = millis();
applyLightOutput();
message = "Espresso: Standby aufgehoben, Kessel heizt auf.";
} else {
message = "Espresso: Kessel heizt auf.";
}
success = true;
}
else if (action == "activateStandby") {
if (standbyModeActive) {
message = "Standby ist bereits aktiv.";
@@ -15647,6 +15837,9 @@ bool executeDashboardAction(const String& action, const String& value,
} else {
target = !coldExtractionModeActive;
}
// Jede bewusste Wahl schliesst das Auswahlfenster nach dem Aufwecken
coldExtractionWakeDecisionMs = millis();
coldExtractionWakeChoicePending = false;
success = setColdExtractionMode(target, message);
if (!success) {
coldExtractionBlockMessage = message;
@@ -15668,7 +15861,7 @@ bool executeDashboardAction(const String& action, const String& value,
// Freigabe wird pro Bezug neu geprueft (der Dampfkreis darf weiter heizen und
// kann den Wasserkessel ueber die Zeit ueber die Schwelle bringen).
message = coldExtractionBlockReason();
if (message.length() == 0) { message = "Cold Extraction derzeit nicht moeglich."; }
if (message.length() == 0) { message = "Cold Extraction derzeit nicht möglich."; }
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionBlockMessage = message;
coldExtractionNotice = COLDEX_NOTICE_START_REJECTED;
@@ -16149,6 +16342,9 @@ void buildDashboardJson(char *buffer, size_t bufferSize) {
}
}
statusKey = "brewing";
} else if (coldExtractionWakeChoicePending) {
statusText = "Auswahl: Espresso oder Cold Extraction?";
statusKey = "coldextraction";
} else if (coldExtractionModeActive) {
statusText = coldExtractionScaleUsable()
? "Cold Extraction bereit (Wasserkreis heizt nicht)"
@@ -16256,7 +16452,7 @@ void buildDashboardJson(char *buffer, size_t bufferSize) {
// Plattformdaten
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
dtostrf(activeTargetWeightGrams(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf); // bei Cold Extraction das kalte Ziel
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleEnabled\":%s,", scaleEnabled ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleType\":%u,", (unsigned int)scaleType);
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleConnected\":%s,", scaleConnected ? "true" : "false");
@@ -16400,6 +16596,9 @@ static void getTouchUartStatus(String& statusText, String& statusKey) {
statusText = "Bezug aktiv...";
}
statusKey = "brewing";
} else if (coldExtractionWakeChoicePending) {
statusText = "Auswahl: Espresso oder Cold Extraction?";
statusKey = "coldextraction";
} else if (coldExtractionModeActive) {
statusText = coldExtractionScaleUsable() ? "Cold Extraction bereit"
: "Cold Extraction bereit (Zeitsteuerung)";
@@ -16541,7 +16740,7 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
}
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
dtostrf(activeTargetWeightGrams(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf); // bei Cold Extraction das kalte Ziel
// Endgewicht des letzten Bezugs inkl. Offset (identisch zur OLED: Netto beim Stopp + Offset).
// Nur bei gewichtsbasiertem Bezug aussagekraeftig -> lastShotByWeight signalisiert das dem P4.
dtostrf(lastShotFinalNetWeight + brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"lastShotWeight\":%s,", floatBuf);
@@ -16600,9 +16799,26 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
// cxArmable = Modus umschaltbar (auch im Standby und bei warmem Kessel); cxAllowed = Bezug moeglich
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxArmable\":%s,", coldExtractionArmable() ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxResting\":%s,", coldExtractionPumpResting ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxAskOnWake\":%s,", coldExtractionAskOnWake ? "true" : "false");
// Offenes Auswahlfenster nach dem Aufwecken (+ Restsekunden fuer den Countdown am Display)
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxWakeChoice\":%s,", coldExtractionWakeChoicePending ? "true" : "false");
{
long cxWakeSec = 0;
if (coldExtractionWakeChoicePending) {
long remainMs = (long)(coldExtractionWakeChoiceUntilMs - millis());
cxWakeSec = (remainMs > 0) ? (long)((remainMs + 999) / 1000) : 0;
}
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxWakeChoiceSec\":%ld,", cxWakeSec);
}
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 +17500,58 @@ 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 (jsonGetBoolValue(line, "cxAskOnWake", boolVal)) {
update.hasCXAskOnWake = true;
update.cxAskOnWake = boolVal;
}
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 && !update.hasCXAskOnWake) {
touchUartSendError(id, "Keine Brew-Felder");
return false;
}
+41
View File
@@ -1,3 +1,44 @@
Version 1.4.0:
- Die Auswahl „Espresso oder Cold Extraction" erscheint jetzt auch beim Aufwecken über den Schalter
(braucht S3-Firmware ab 5.6.0). Meldet die S3 ein offenes Auswahlfenster (cxWakeChoice), blendet das
Display den Dialog von sich aus ein - ohne Antippen - und zeigt die verbleibende Bedenkzeit als
Countdown. „Abbrechen" entfällt in diesem Fall, weil es nichts abzubrechen gibt: Läuft die Zeit ab,
heizt die Maschine normal auf.
- Der Espresso-Knopf sendet „wakeEspresso" statt „deactivateStandby", sobald die Auswahl relevant ist.
Damit wird die Entscheidung auf der S3 vermerkt und nicht direkt danach erneut gefragt.
- Der Cold-Extraction-Knopf beendet den Standby nur noch, wenn er überhaupt aktiv ist - im Auswahlfenster
nach dem Aufwecken schaltet er nur den Modus.
Version 1.3.0:
- Der Aufweck-Dialog wird zur Auswahl: „Womit aufwecken?" mit den Knöpfen „Espresso" (heizt normal auf)
und „Cold Extraction (Wasser bleibt kalt)". Braucht S3-Firmware ab 5.5.0.
Bei „Cold Extraction" wird erst der Modus scharf geschaltet und dann der Standby beendet, damit der
Wasserkreis nicht zwischendurch anheizt.
- Steuerbar über die neue Einstellung „Beim Aufwecken fragen" (Cold-Extraction-Seite, Abschnitt
Grundeinstellung; identisch zur Web-UI). Ist sie aus, erscheint wieder der bisherige Dialog mit
„Aufwecken" und „Abbrechen" - der Zwischenschritt entfällt dann komplett.
Version 1.2.1:
- Bug-Fix: Während einer kalten Extraktion zeigten Waage-Kachel und Live-Bezugsschirm das Zielgewicht
des normalen Espresso-Bezugs (bbwTarget) statt des Ziels der kalten Extraktion (cxTarget). Außerdem
erschienen Gewichtsanzeige und Fortschrittsbalken nur, wenn Brew-by-Weight aktiv war - bei kalter
Extraktion ist das typischerweise aus, obwohl ein Ziel existiert. Beide Anzeigen nutzen jetzt
cxTarget, sobald cxShot gesetzt ist.
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.4.0" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
// -------------------------------------------------------------------------------------
// Protokoll
+9
View File
@@ -78,6 +78,15 @@ 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
bool cxAskOnWake = false; // Beim Aufwecken aus dem Standby fragen (seit S3 5.5.0)
bool cxWakeChoice = false; // Auswahlfenster nach dem Aufwecken offen (seit S3 5.6.0)
long cxWakeChoiceSec = 0; // Restsekunden der Bedenkzeit
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
+8
View File
@@ -231,6 +231,14 @@ 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.cxAskOnWake = doc["cxAskOnWake"] | false;
s.cxWakeChoice = doc["cxWakeChoice"] | false;
s.cxWakeChoiceSec = doc["cxWakeChoiceSec"] | (long)0;
s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
s.cxBlock = doc["cxBlock"] | (uint8_t)0;
s.cxNotice = doc["cxNotice"] | (uint8_t)0;
+235 -70
View File
@@ -38,7 +38,10 @@ static lv_timer_t* g_toastTimer = nullptr;
static lv_obj_t* g_standbyScreen = nullptr; // Vollbild-Overlay im Standby (Uhr)
static lv_obj_t* g_standbyClock = nullptr;
static lv_obj_t* g_wakeConfirm = nullptr; // Bestaetigungsdialog "Aus dem Standby aufwecken?"
static lv_obj_t* g_wakeColdBtn = nullptr; // "Aufwecken mit Cold Extraction" (nur wenn freigeschaltet)
static lv_obj_t* g_wakeColdBtn = nullptr; // "Cold Extraction" im Aufweck-Dialog
static lv_obj_t* g_wakeMsg = nullptr; // Text des Aufweck-Dialogs (wechselt bei Abfrage-Modus)
static lv_obj_t* g_wakeYesLbl = nullptr; // Beschriftung des Bestaetigen-Knopfs
static lv_obj_t* g_wakeNoBtn = nullptr; // "Abbrechen" (entfaellt beim Auswahlfenster nach dem Aufwecken)
static bool g_wakeDialogOpen = false; // Dialog sichtbar -> Display hell schalten (lesbar)
static bool g_standbyShown = false; // Standby-Uhr-Overlay zuletzt als sichtbar geschaltet (fuer Ein-/Ausblend-Uebergang)
@@ -50,8 +53,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 +254,16 @@ 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* swCxAskOnWake = nullptr; // Abfrage beim Aufwecken aus dem Standby
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 +412,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 +465,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 +499,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); } }
@@ -571,7 +608,12 @@ static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 bee
wake_confirm_hide();
// g_wakeDialogOpen bleibt true, bis der Standby wirklich endet (ui_update) - sonst wuerde
// das Display in der Uebergangsphase kurz auf Standby-Helligkeit abdunkeln.
if (g_client) { g_client->sendAction("deactivateStandby"); showToast("Maschine wird aufgeweckt...", false); }
if (!g_client) return;
// "wakeEspresso" beendet den Standby UND schliesst das Auswahlfenster der S3. Ohne das
// wuerde nach dem Aufwecken noch einmal gefragt bzw. der Wasserkreis bliebe kurz aus.
const bool choiceRelevant = g_state.cxWakeChoice || (g_state.cxEnabled && g_state.cxAskOnWake);
g_client->sendAction(choiceRelevant ? "wakeEspresso" : "deactivateStandby");
showToast(g_state.standbyActive ? "Maschine wird aufgeweckt..." : "Espresso: Kessel heizt auf.", false);
}
// Aufwecken direkt in die kalte Extraktion. Reihenfolge ist wichtig: erst den Modus scharf
// stellen, dann den Standby beenden - sonst heizt der Wasserkreis in der Zwischenzeit an,
@@ -580,8 +622,8 @@ static void wake_cold_cb(lv_event_t*) {
wake_confirm_hide();
if (g_client) {
g_client->sendAction("startColdExtraction");
g_client->sendAction("deactivateStandby");
showToast("Aufwecken mit Cold Extraction...", false);
if (g_state.standbyActive) g_client->sendAction("deactivateStandby");
showToast("Cold Extraction aktiviert.", false);
}
}
bool ui_wake_dialog_open() { return g_wakeDialogOpen; } // fuer die Helligkeitssteuerung in onState
@@ -1327,16 +1369,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.
}
// =====================================================================================
@@ -1834,12 +1867,16 @@ static void brew_live_tick() {
}
// Zielfortschritt: Brew-by-Weight (Gewicht) vor Brew-by-Time (Zeit), sonst versteckt
if (g_bzBarRow && g_bzBar && g_bzBarLbl) {
bool bbw = scale && g_state.bbwEnabled && g_state.bbwTarget > 0.0f;
// Ein kalter Bezug hat sein eigenes Ziel (cxTarget) und ersetzt Brew-by-Weight -
// sonst stuende hier das Espresso-Ziel.
const bool cxShotNow = g_state.cxShot && g_state.cxTarget > 0.0f;
const float wTarget = cxShotNow ? g_state.cxTarget : g_state.bbwTarget;
bool bbw = scale && (cxShotNow || (g_state.bbwEnabled && g_state.bbwTarget > 0.0f));
if (bbw || (g_shotBbtMode && g_bbtTarget > 0.0f)) {
int pct;
if (bbw) {
pct = (int)(w / g_state.bbwTarget * 100.0f + 0.5f);
snprintf(b, sizeof(b), "%.1f g / %.1f g", w, g_state.bbwTarget);
pct = (int)(w / wTarget * 100.0f + 0.5f);
snprintf(b, sizeof(b), "%.1f g / %.1f g", w, wTarget);
} else {
pct = (int)(el / g_bbtTarget * 100.0f + 0.5f);
snprintf(b, sizeof(b), "%.1f s / %.1f s", el, g_bbtTarget);
@@ -2010,28 +2047,112 @@ 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); f += ",";
f += "\"cxAskOnWake\":"; f += boolStr(sw_get(swCxAskOnWake));
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)");
swCxAskOnWake = add_switch(cBase, "Beim Aufwecken fragen");
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);
sw_set(swCxAskOnWake, st.cxAskOnWake);
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 +3233,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]);
@@ -3214,12 +3336,12 @@ void ui_init(ProtocolClient* client) {
lv_obj_set_flex_align(wakeCard, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(wakeCard, 18, 0);
lv_obj_clear_flag(wakeCard, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* wakeMsg = lv_label_create(wakeCard);
lv_obj_set_width(wakeMsg, LV_PCT(100));
lv_obj_set_style_text_align(wakeMsg, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(wakeMsg, COL_TEXT, 0);
lv_obj_set_style_text_font(wakeMsg, &lv_font_maven_pro_18, 0);
lv_label_set_text(wakeMsg, "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?");
g_wakeMsg = lv_label_create(wakeCard);
lv_obj_set_width(g_wakeMsg, LV_PCT(100));
lv_obj_set_style_text_align(g_wakeMsg, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_wakeMsg, COL_TEXT, 0);
lv_obj_set_style_text_font(g_wakeMsg, &lv_font_maven_pro_18, 0);
lv_label_set_text(g_wakeMsg, "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?");
lv_obj_t* wakeBtns = lv_obj_create(wakeCard);
lv_obj_set_width(wakeBtns, LV_PCT(100));
lv_obj_set_height(wakeBtns, LV_SIZE_CONTENT);
@@ -3233,13 +3355,15 @@ void ui_init(ProtocolClient* client) {
lv_obj_t* wakeNo = th_button(wakeBtns, "Abbrechen", COL_CARD2, COL_TEXT);
lv_obj_set_width(wakeNo, 150);
lv_obj_add_event_cb(wakeNo, wake_no_cb, LV_EVENT_CLICKED, nullptr);
g_wakeNoBtn = wakeNo;
lv_obj_t* wakeYes = th_accent_button(wakeBtns, "Aufwecken");
lv_obj_set_width(wakeYes, 150);
lv_obj_add_event_cb(wakeYes, wake_yes_cb, LV_EVENT_CLICKED, nullptr);
g_wakeYesLbl = lv_obj_get_child(wakeYes, 0); // Label des Knopfs (fuer "Espresso" im Abfrage-Modus)
// Zweite Zeile: direkt in die kalte Extraktion aufwecken. Ohne diesen Weg muesste man
// erst normal aufwecken (Maschine heizt an) und den Modus danach umschalten.
// Nur sichtbar, wenn die S3 die Funktion freigeschaltet meldet (siehe ui_update).
g_wakeColdBtn = th_button(wakeCard, "Aufwecken mit Cold Extraction", COL_CARD2, COL_ACCENT);
g_wakeColdBtn = th_button(wakeCard, "Cold Extraction (Wasser bleibt kalt)", COL_CARD2, COL_ACCENT);
lv_obj_set_width(g_wakeColdBtn, LV_PCT(100));
lv_obj_add_event_cb(g_wakeColdBtn, wake_cold_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
@@ -3438,7 +3562,9 @@ void ui_update(const MachineState& st) {
}
// Aufweck-Ablauf: nach beendetem Standby Dialog schliessen und Helligkeits-Flag loeschen
// (auch wenn der Dialog schon ausgeblendet ist, weil "Aufwecken" das Flag bewusst haelt).
if (!st.standbyActive) {
// Bei offenem Auswahlfenster (cxWakeChoice) bleibt der Dialog stehen, obwohl der Standby
// schon beendet ist - genau dafuer ist er da.
if (!st.standbyActive && !st.cxWakeChoice) {
g_wakeDialogOpen = false;
if (g_wakeConfirm && !lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN))
lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN);
@@ -3470,9 +3596,49 @@ void ui_update(const MachineState& st) {
const bool cxScaleOk = st.scaleEnabled && st.scaleConnected;
// "Aufwecken mit Cold Extraction" nur anbieten, wenn die Funktion freigeschaltet und
// umschaltbar ist und der Modus nicht ohnehin schon laeuft.
if (g_wakeColdBtn) {
if (st.cxEnabled && st.cxArmable && !st.cxActive) lv_obj_remove_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
// Abfrage-Modus: Der Aufweck-Dialog wird zur Auswahl "Espresso oder Cold Extraction".
// Abschaltbar ueber die Einstellung cxAskOnWake, damit man sich den Zwischenschritt
// sparen kann, wenn gerade keine kalten Bezuege anstehen.
{
// Zwei Faelle: Vorab-Abfrage beim Antippen im Standby (askMode) und das von der S3
// geoeffnete Auswahlfenster NACH dem Aufwecken (choiceOpen) - letzteres deckt auch
// das Aufwecken per Schalter ab, wo es keine Vorab-Abfrage geben kann.
const bool choiceOpen = st.cxWakeChoice;
const bool askMode = choiceOpen ||
(st.cxEnabled && st.cxArmable && !st.cxActive && st.cxAskOnWake);
if (g_wakeColdBtn) {
if (askMode) lv_obj_remove_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
}
// Im Auswahlfenster gibt es nichts abzubrechen - laeuft die Zeit ab, wird geheizt.
if (g_wakeNoBtn) {
if (choiceOpen) lv_obj_add_flag(g_wakeNoBtn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_remove_flag(g_wakeNoBtn, LV_OBJ_FLAG_HIDDEN);
}
if (g_wakeMsg) {
if (choiceOpen) {
char b[128];
snprintf(b, sizeof(b),
"Womit weiter?\nEspresso heizt auf, Cold Extraction bleibt kalt.\n"
"Ohne Auswahl wird in %ld s geheizt.", st.cxWakeChoiceSec);
label_set_if_changed(g_wakeMsg, b);
} else {
label_set_if_changed(g_wakeMsg, askMode
? "Die Maschine befindet sich im Standby.\nWomit aufwecken?"
: "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?");
}
}
if (g_wakeYesLbl) {
label_set_if_changed(g_wakeYesLbl, askMode ? "Espresso" : "Aufwecken");
}
// Auswahlfenster von der S3 -> Dialog von sich aus zeigen (kein Antippen noetig)
if (choiceOpen && g_wakeConfirm && lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) {
wake_confirm_show();
} else if (!choiceOpen && !st.standbyActive && g_wakeConfirm &&
!lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) {
wake_confirm_hide(); // Fenster abgelaufen oder entschieden
g_wakeDialogOpen = false;
}
}
if (st.cxNotice == 2) {
status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction beendet: kein Zulauf",
@@ -3809,7 +3975,10 @@ void ui_update(const MachineState& st) {
else lv_obj_add_flag(cellBezug, LV_OBJ_FLAG_HIDDEN);
}
if (lblBezug) {
bool bbwShot = st.shotActive && st.bbwEnabled && st.bbwTarget > 0.0f;
// Bei kaltem Bezug zaehlt cxTarget, nicht das Brew-by-Weight-Ziel
const bool cxShotNow = st.cxShot && st.cxTarget > 0.0f;
const float shotTarget = cxShotNow ? st.cxTarget : st.bbwTarget;
bool bbwShot = st.shotActive && (cxShotNow || (st.bbwEnabled && st.bbwTarget > 0.0f));
// Winzige negative Messwerte (z.B. -0.04 g) oder negatives Float-Null wuerden von
// "%.1f" als "-0.0" dargestellt -> auf 0.0 normalisieren (kein fuehrendes Minus).
float wDisp = st.weight;
@@ -3818,14 +3987,14 @@ void ui_update(const MachineState& st) {
lv_label_set_text(lblBezug, "--");
} else if (bbwShot) {
// Waehrend des Bezugs mit Brew-by-Weight: Ist oben, Ziel darunter (zweizeilig)
lv_label_set_text_fmt(lblBezug, "%.1f g\n/ %.1f g", wDisp, st.bbwTarget);
lv_label_set_text_fmt(lblBezug, "%.1f g\n/ %.1f g", wDisp, shotTarget);
} else {
lv_label_set_text_fmt(lblBezug, "%.1f g", wDisp);
}
// Fortschrittsbalken Gewicht/Ziel (nur waehrend BBW-Bezug)
if (g_bbwBar) {
if (bbwShot) {
int pct = (int)(st.weight / st.bbwTarget * 100.0f + 0.5f);
int pct = (int)(st.weight / shotTarget * 100.0f + 0.5f);
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
lv_bar_set_value(g_bbwBar, pct, LV_ANIM_OFF);
lv_obj_remove_flag(g_bbwBar, LV_OBJ_FLAG_HIDDEN);
@@ -3917,22 +4086,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);
}
}