Compare commits

...
Author SHA1 Message Date
raw-designsandClaude Opus 5 e8edc8eb30 feat(P4): Cold Extraction am Touch-Display + RX-Zeilenlimit-Fix (1.1.0 / S3 5.3.2)
Display-Seite der kalten Extraktion: Schalter unter "Modi" auf der Temperatur-Seite
mit Erklärzeile, eigener Slot in der Statuszeile direkt hinter der
Sicherheitsmeldung, "Cold Extraction - Heizen aus" in der Wasser-Kachel statt eines
unerreichbaren Sollwerts, unterdrückter Aufheiz-Countdown, "Vorbenetzung" als erste
Phase des kalten Bezugs, Parameter-Anzeige auf der Brew-Seite und "(kalt)"-Markierung
in der Statistik (kalte Bezüge zählen nicht in die mittlere Dauer).

Bug-Fix P4: PROTO_RX_LINE_MAX lag bei 2048 Bytes, während die State-Zeile der S3
bereits rund 2,1 KB erreicht. Mit langem Status-, Profil- oder SSID-Text lag sie
darüber — dann wurde die ganze Zeile verworfen und das Display fror auf dem letzten
Stand ein, ohne dass die Verbindung als tot erkannt wurde. Limit jetzt 4096 Bytes;
der Puffer bleibt bei 2 KB reserviert, weil der interne RAM knapp ist und eine
Arduino-String nicht ins PSRAM alloziert werden kann.

S3 5.3.2: Sperrgrund und transiente Meldung gehen als Codes (cxBlock/cxNotice) statt
als Klartext ans Display, dazu cxPreInf für die Phasenleiste. Der bis zu 110 Byte
lange Freitext hätte die State-Zeile über das Zeilenlimit gedrückt; die Texte rendert
jetzt das Display, analog zur Trennung statusKey/statusText. Auf dem OLED und in den
ack-Antworten bleibt der Klartext unverändert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:56:17 +02:00
thomas 57b7969f8b Merge pull request 'feat(S3): Cold Extraction ueberlebt Standby und Neustart (5.3.1)' (#12) from feat/cold-extraction-persistent into main 2026-08-10 21:33:40 +02:00
raw-designsandClaude Opus 5 c697e65a01 feat(S3): Cold Extraction überlebt Standby und Neustart (5.3.1)
Der Modus wird im EEPROM (Adresse 977) gespeichert und beim Start wieder
übernommen, sofern die Funktion auf /Brew-Control freigeschaltet ist. Standby
beendet ihn nicht mehr, sondern verwirft nur die Laufzeitdaten.

Grund: Wer nur kalte Bezüge machen will, musste den Modus nach jedem Standby
neu einschalten. Bis dahin heizte die Maschine bereits auf und war für die
nächste kalte Extraktion zu warm — man musste auf das Abkühlen warten, also
genau die Situation, die der Modus vermeiden soll.

Der Modus endet nur noch durch explizites Ausschalten, durch Entzug der
Freischaltung oder durch einen Werksreset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:31:59 +02:00
thomas d7a1a8dee2 Merge pull request 'feat(S3): Cold Extraction (kalte Extraktion) mit Temperatur-Freigabe (5.3.0)' (#11) from feat/cold-extraction into main 2026-08-10 21:04:17 +02:00
8 changed files with 366 additions and 35 deletions
+19
View File
@@ -1,3 +1,22 @@
Version 5.3.2:
- Cold Extraction sendet den Sperrgrund jetzt als Code („cxBlock") statt als Klartext („cxMessage"),
dazu eine transiente Meldung als Code („cxNotice") und die Vorbenetzungsdauer („cxPreInf").
Hintergrund: Die State-Zeile ans Touch-Display liegt real bereits bei rund 2,1 KB, und der P4
verwirft eingehende Zeilen ab einer Obergrenze komplett - ein bis zu 110 Byte langer Klartext
hätte die Zeile über diese Grenze gedrückt und damit die ganze Telemetrie unbrauchbar gemacht.
Die Texte rendert jetzt das Display, analog zur bestehenden Trennung statusKey/statusText.
- Der Klartext bleibt unverändert auf dem OLED und in den Web-/UART-Antworten (ack-message).
Version 5.3.1:
- Cold Extraction ist jetzt persistent: Der Modus wird im EEPROM gespeichert und überlebt sowohl den
Standby als auch einen Neustart. Zuvor wurde er beim Standby beendet und war nach dem Aufwachen aus -
die Maschine heizte dann erst auf, obwohl der nächste kalte Bezug einen kalten Kessel braucht, und man
musste auf das Abkühlen warten. Wer nur kalte Bezüge machen will, schaltet den Modus damit einmal ein
und wieder aus, wenn er fertig ist.
- Der Modus fällt nur weg, wenn auf /Brew-Control die Freischaltung entzogen wird oder ein Werksreset
läuft. Solange er aktiv ist, heizt der Wasserkreis nicht - Dashboard, OLED („(COLD)" in der Wasserzeile)
und Touch-Display zeigen das durchgehend an.
Version 5.3.0: Version 5.3.0:
- Neu: Cold Extraction (kalte Extraktion). Die Maschine hat keinen Bypass um den Wasserkessel, kaltes Wasser - Neu: Cold Extraction (kalte Extraktion). Die Maschine hat keinen Bypass um den Wasserkessel, kaltes Wasser
kommt also nur bei kaltem Kessel am Puck an. Die Funktion wird deshalb über eine Temperaturschwelle kommt also nur bei kaltem Kessel am Puck an. Die Funktion wird deshalb über eine Temperaturschwelle
+19 -4
View File
@@ -324,9 +324,12 @@ Alle gültigen `action`-Strings aus `executeDashboardAction()` (Stand v4.8.1, Ze
| `toggleColdExtraction` | — | Modus umschalten | | `toggleColdExtraction` | — | Modus umschalten |
Alle drei Aktionen werden abgewiesen (`ok:false` + `message`), solange die Bedingungen nicht Alle drei Aktionen werden abgewiesen (`ok:false` + `message`), solange die Bedingungen nicht
erfüllt sind — Hauptfall ist ein zu warmer Wasserkessel (`cxAllowed:false`). Der Modus wird erfüllt sind — Hauptfall ist ein zu warmer Wasserkessel (`cxAllowed:false`). Der Modus ist
**nicht** persistiert und ist nach jedem Neustart aus; Standby beendet ihn. Ein laufender **persistent** (EEPROM) und überlebt Standby und Neustart; er endet nur durch explizites
Bezug wird nie abgebrochen, `stopColdExtraction` schlägt dann fehl. Ausschalten, durch Entzug der Freischaltung auf `/Brew-Control` oder durch einen Werksreset.
Das P4-UI sollte `cxActive` deshalb dauerhaft sichtbar spiegeln — sonst wundert sich der
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, Die Parameter der kalten Extraktion (Schwelle, Vorbenetzung, Pulsperiode, Pumpenleistung,
Zielgewicht, Timeout, Pumpenschutz) sind bewusst **nicht** über `saveBrew` erreichbar, sondern Zielgewicht, Timeout, Pumpenschutz) sind bewusst **nicht** über `saveBrew` erreichbar, sondern
@@ -384,8 +387,20 @@ Aus `getTouchUartStatus()`. Für sprach-unabhängige UI-Logik/Icons auf dem P4 v
| `cxResting` | bool | Zwangspause des Pumpenschutzes läuft (Bezug läuft weiter) | | `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) |
| `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s | | `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s |
| `cxMessage` | string | Sperr-/Abbruchmeldung, leer = keine (läuft nach ~6 s selbst ab) | | `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei |
| `cxNotice` | uint8 | transiente Meldung, `0` = keine (läuft nach ~6 s selbst ab) |
`cxBlock`: `0` frei, `1` nicht freigeschaltet, `2` Standby, `3` Wartungsmodus,
`4` Reinigungsassistent, `5` PID-Tuning, `6` Sensorfehler, `7` Wasserkessel zu warm.
`cxNotice`: `0` keine, `1` Start abgewiesen (Grund steht in `cxBlock`), `2` Bezug wegen
fehlenden Zulaufs beendet, `3` Umschalten während eines Bezugs abgelehnt.
Bewusst **Codes statt Klartext**: Die State-Zeile muss unter dem Zeilenlimit des P4 bleiben
(`PROTO_RX_LINE_MAX`; sie liegt real bereits bei ~2,1 KB), und die Texte gehören ins Display —
dieselbe Trennung wie bei `statusKey`/`statusText`.
Während `cxShot` folgt die Phase weiterhin `piState`: `1` = Vorbenetzung, `3` = Hauptbezug Während `cxShot` folgt die Phase weiterhin `piState`: `1` = Vorbenetzung, `3` = Hauptbezug
(gepulst). Eine Pause-Phase (`2`) gibt es bei der kalten Extraktion nicht. (gepulst). Eine Pause-Phase (`2`) gibt es bei der kalten Extraktion nicht.
+83 -18
View File
@@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
* Firmware-Informationen * Firmware-Informationen
************************************************************************************/ ************************************************************************************/
String version = "5.3.0"; String version = "5.3.2";
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>";
@@ -832,11 +832,36 @@ unsigned long coldExtractionPumpSampleMs = 0;
float coldExtractionStallLastWeight = NAN; float coldExtractionStallLastWeight = NAN;
unsigned long coldExtractionStallSinceMs = 0; unsigned long coldExtractionStallSinceMs = 0;
bool lastShotWasColdExtraction = false; // fuer Anzeige/Log des letzten Bezugs bool lastShotWasColdExtraction = false; // fuer Anzeige/Log des letzten Bezugs
// Sperrmeldung fuer OLED und Touch-Display, wenn ein kalter Bezug abgewiesen wurde // Sperrmeldung fuer das OLED, wenn ein kalter Bezug abgewiesen bzw. abgebrochen wurde.
// Der Klartext bleibt S3-intern; ans Touch-Display gehen nur die Codes unten, damit die
// State-Zeile kurz bleibt (der P4 hat ein Zeilenlimit) und das Display seine eigenen
// Texte rendern kann - dieselbe Trennung wie bei statusKey/statusText.
const unsigned long COLDEX_BLOCK_MESSAGE_MS = 6000UL; const unsigned long COLDEX_BLOCK_MESSAGE_MS = 6000UL;
String coldExtractionBlockMessage = ""; String coldExtractionBlockMessage = "";
unsigned long coldExtractionBlockMessageUntilMs = 0; unsigned long coldExtractionBlockMessageUntilMs = 0;
// Sperrgrund (cxBlock): 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung,
// 4=Reinigungsassistent, 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm
enum ColdExtractionBlock : uint8_t {
COLDEX_BLOCK_NONE = 0,
COLDEX_BLOCK_NOT_ENABLED = 1,
COLDEX_BLOCK_STANDBY = 2,
COLDEX_BLOCK_MAINTENANCE = 3,
COLDEX_BLOCK_CLEANING = 4,
COLDEX_BLOCK_TUNING = 5,
COLDEX_BLOCK_SENSOR = 6,
COLDEX_BLOCK_TOO_WARM = 7
};
// Transiente Meldung (cxNotice): 0=keine, 1=Start abgewiesen, 2=Bezug wegen fehlenden
// Zulaufs beendet, 3=Umschalten waehrend eines Bezugs abgelehnt
enum ColdExtractionNotice : uint8_t {
COLDEX_NOTICE_NONE = 0,
COLDEX_NOTICE_START_REJECTED = 1,
COLDEX_NOTICE_NO_FLOW = 2,
COLDEX_NOTICE_BUSY = 3
};
uint8_t coldExtractionNotice = COLDEX_NOTICE_NONE;
/************************************************************************************ /************************************************************************************
* Standardwerte und Default-Einstellungen * Standardwerte und Default-Einstellungen
************************************************************************************/ ************************************************************************************/
@@ -1036,7 +1061,8 @@ const int EEPROM_ADDR_COLDEX_TARGET_GRAMS = 961; // 4 Bytes (float) -> Ende
const int EEPROM_ADDR_COLDEX_MAX_SECONDS = 965; // 4 Bytes (float) -> Ende 968 const int EEPROM_ADDR_COLDEX_MAX_SECONDS = 965; // 4 Bytes (float) -> Ende 968
const int EEPROM_ADDR_COLDEX_PUMP_MAX_RUN = 969; // 4 Bytes (float) -> Ende 972 const int EEPROM_ADDR_COLDEX_PUMP_MAX_RUN = 969; // 4 Bytes (float) -> Ende 972
const int EEPROM_ADDR_COLDEX_PUMP_REST = 973; // 4 Bytes (float) -> Ende 976 const int EEPROM_ADDR_COLDEX_PUMP_REST = 973; // 4 Bytes (float) -> Ende 976
// Naechste freie Adresse: 977 (Innerhalb EEPROM_SIZE=1024) const int EEPROM_ADDR_COLDEX_MODE_ACTIVE = 977; // 1 Byte (uint8_t) -> Ende 977
// Naechste freie Adresse: 978 (Innerhalb EEPROM_SIZE=1024)
/************************************************************************************ /************************************************************************************
* Eco-Mode Variablen * Eco-Mode Variablen
@@ -3027,6 +3053,7 @@ void resetToDefaults() {
EEPROM.put(EEPROM_ADDR_COLDEX_MAX_SECONDS, defaultColdExtractionMaxSeconds); EEPROM.put(EEPROM_ADDR_COLDEX_MAX_SECONDS, defaultColdExtractionMaxSeconds);
EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_MAX_RUN, defaultColdExtractionPumpMaxRunSec); EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_MAX_RUN, defaultColdExtractionPumpMaxRunSec);
EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_REST, defaultColdExtractionPumpRestSec); EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_REST, defaultColdExtractionPumpRestSec);
EEPROM.put(EEPROM_ADDR_COLDEX_MODE_ACTIVE, (uint8_t)0); // Werksreset: Modus immer aus
// Piezo auf Default im EEPROM schreiben // Piezo auf Default im EEPROM schreiben
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled); EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled);
@@ -3988,6 +4015,12 @@ void applyFlowGuardPumpControl(unsigned long currentShotTimeMillis, float netWei
* Cold Extraction (kalte Extraktion) * Cold Extraction (kalte Extraktion)
************************************************************************************/ ************************************************************************************/
// Modus-Zustand ins EEPROM schreiben (persistent ueber Standby und Neustart hinweg).
void persistColdExtractionMode() {
EEPROM.put(EEPROM_ADDR_COLDEX_MODE_ACTIVE, (uint8_t)(coldExtractionModeActive ? 1 : 0));
EEPROM.commit();
}
void resetColdExtractionRuntime() { void resetColdExtractionRuntime() {
coldExtractionShotActive = false; coldExtractionShotActive = false;
coldExtractionPumpResting = false; coldExtractionPumpResting = false;
@@ -4024,6 +4057,19 @@ bool coldExtractionConditionsOk() {
coldExtractionTemperatureOk(); coldExtractionTemperatureOk();
} }
// Sperrgrund als Code fuer das Touch-Display (0 = frei). Gleiche Reihenfolge wie
// coldExtractionBlockReason(), damit Text und Code immer dieselbe Ursache nennen.
uint8_t coldExtractionBlockCode() {
if (!coldExtractionEnabled) return COLDEX_BLOCK_NOT_ENABLED;
if (standbyModeActive) return COLDEX_BLOCK_STANDBY;
if (wartungsModusAktiv) return COLDEX_BLOCK_MAINTENANCE;
if (cleaningAssistantActive) return COLDEX_BLOCK_CLEANING;
if (autoTuneWasserActive || autoTuneDampfActive) return COLDEX_BLOCK_TUNING;
if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) return COLDEX_BLOCK_SENSOR;
if (!coldExtractionTemperatureOk()) return COLDEX_BLOCK_TOO_WARM;
return COLDEX_BLOCK_NONE;
}
// Klartext-Grund, warum gerade keine kalte Extraktion moeglich ist ("" = alles frei). // Klartext-Grund, warum gerade keine kalte Extraktion moeglich ist ("" = alles frei).
String coldExtractionBlockReason() { String coldExtractionBlockReason() {
if (!coldExtractionEnabled) { if (!coldExtractionEnabled) {
@@ -4072,7 +4118,8 @@ bool setColdExtractionMode(bool enable, String& message) {
} }
coldExtractionModeActive = true; coldExtractionModeActive = true;
resetColdExtractionRuntime(); resetColdExtractionRuntime();
message = "Cold Extraction aktiviert - Wasserkreis heizt nicht."; persistColdExtractionMode();
message = "Cold Extraction aktiviert - Wasserkreis heizt nicht (bleibt auch nach Standby/Neustart aktiv).";
return true; return true;
} }
@@ -4082,6 +4129,7 @@ bool setColdExtractionMode(bool enable, String& message) {
} }
coldExtractionModeActive = false; coldExtractionModeActive = false;
resetColdExtractionRuntime(); resetColdExtractionRuntime();
persistColdExtractionMode();
message = "Cold Extraction beendet - Wasserkreis heizt wieder."; message = "Cold Extraction beendet - Wasserkreis heizt wieder.";
return true; return true;
} }
@@ -4615,9 +4663,13 @@ void setup() {
if (!isfinite(coldExtractionPumpRestSec) || coldExtractionPumpRestSec < COLDEX_PUMP_REST_SEC_MIN || coldExtractionPumpRestSec > COLDEX_PUMP_REST_SEC_MAX) { if (!isfinite(coldExtractionPumpRestSec) || coldExtractionPumpRestSec < COLDEX_PUMP_REST_SEC_MIN || coldExtractionPumpRestSec > COLDEX_PUMP_REST_SEC_MAX) {
coldExtractionPumpRestSec = defaultColdExtractionPumpRestSec; coldExtractionPumpRestSec = defaultColdExtractionPumpRestSec;
} }
// Der Modus selbst wird bewusst NICHT persistiert: nach einem Neustart startet die // Der Modus ist bewusst persistent: Wer nur kalte Bezuege machen will, soll die
// Maschine immer im normalen Heizbetrieb. // Maschine nicht nach jedem Standby/Neustart erst aufheizen lassen, um sie danach
coldExtractionModeActive = false; // wieder abkuehlen zu muessen. Nur bei entzogener Freischaltung faellt er weg.
uint8_t storedColdExtractionMode = 0;
EEPROM.get(EEPROM_ADDR_COLDEX_MODE_ACTIVE, storedColdExtractionMode);
if (storedColdExtractionMode > 1) { storedColdExtractionMode = 0; }
coldExtractionModeActive = (storedColdExtractionMode == 1) && coldExtractionEnabled;
coldExtractionShotActive = false; coldExtractionShotActive = false;
// Piezo-Status laden // Piezo-Status laden
@@ -5234,12 +5286,12 @@ void loop() {
if (wartungsModusAktiv) { if (wartungsModusAktiv) {
wartungsModusAktiv = false; wartungsModusAktiv = false;
} }
// Cold Extraction beim Wechsel in den Standby beenden (analog Eco/Wartung) // Cold Extraction bleibt beim Standby bewusst aktiv (anders als Eco/Wartung): Die
if (coldExtractionModeActive) { // Maschine soll nach dem Aufwachen nicht erst aufheizen, nur um fuer den naechsten
coldExtractionModeActive = false; // kalten Bezug wieder abkuehlen zu muessen. Nur die Laufzeitdaten werden verworfen.
resetColdExtractionRuntime(); resetColdExtractionRuntime();
}
coldExtractionBlockMessage = ""; coldExtractionBlockMessage = "";
coldExtractionNotice = COLDEX_NOTICE_NONE;
} else if (lastStandbyModeActive) { } else if (lastStandbyModeActive) {
startupTime = currentMillis; startupTime = currentMillis;
ecoForcedActive = false; ecoForcedActive = false;
@@ -5471,6 +5523,7 @@ void loop() {
stopForTime = true; stopForTime = true;
coldExtractionBlockMessage = "Cold Extraction beendet: kein Zulauf (Puck dicht oder Pumpenschutz?)."; coldExtractionBlockMessage = "Cold Extraction beendet: kein Zulauf (Puck dicht oder Pumpenschutz?).";
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionNotice = COLDEX_NOTICE_NO_FLOW;
} }
} }
@@ -5972,6 +6025,7 @@ void updateDisplay() {
if (coldExtractionBlockMessage.length() > 0) { if (coldExtractionBlockMessage.length() > 0) {
if ((long)(millis() - coldExtractionBlockMessageUntilMs) >= 0) { if ((long)(millis() - coldExtractionBlockMessageUntilMs) >= 0) {
coldExtractionBlockMessage = ""; coldExtractionBlockMessage = "";
coldExtractionNotice = COLDEX_NOTICE_NONE;
} else if (!standbyModeActive) { } else if (!standbyModeActive) {
display.clearDisplay(); display.clearDisplay();
display.setTextSize(1); display.setTextSize(1);
@@ -7109,7 +7163,8 @@ static const char brewControlCX_Fields[] PROGMEM = R"rawliteral(
aus dem kalten Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht - der Dampfkreis bleibt aus dem kalten Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht - der Dampfkreis bleibt
unber&uuml;hrt und darf weiter heizen. Die Pumpenlaufzeit wird kumuliert gez&auml;hlt; nach der eingestellten unber&uuml;hrt und darf weiter heizen. 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 Laufzeit legt die Steuerung eine Zwangspause ein. Der Modus selbst wird auf dem Dashboard bzw. am Touch-Display
ein- und ausgeschaltet.)</small> 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} {CX_STATUS_MSG}
<br> <br>
)rawliteral"; )rawliteral";
@@ -7461,6 +7516,7 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
if (!coldExtractionEnabled && coldExtractionModeActive && !shotActive && !shotSoftwareActive) { if (!coldExtractionEnabled && coldExtractionModeActive && !shotActive && !shotSoftwareActive) {
coldExtractionModeActive = false; // Freischaltung entzogen -> Modus beenden coldExtractionModeActive = false; // Freischaltung entzogen -> Modus beenden
resetColdExtractionRuntime(); resetColdExtractionRuntime();
EEPROM.put(EEPROM_ADDR_COLDEX_MODE_ACTIVE, (uint8_t)0); // Commit erfolgt am Ende der Funktion
} }
} }
if (update.hasCXMaxTemp) { if (update.hasCXMaxTemp) {
@@ -15476,6 +15532,8 @@ bool executeDashboardAction(const String& action, const String& value,
if (!success) { if (!success) {
coldExtractionBlockMessage = message; coldExtractionBlockMessage = message;
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionNotice = (shotActive || shotSoftwareActive) ? COLDEX_NOTICE_BUSY
: COLDEX_NOTICE_START_REJECTED;
} }
} else if (action == "startShot") { } else if (action == "startShot") {
if (standbyModeActive) { if (standbyModeActive) {
@@ -15494,6 +15552,7 @@ bool executeDashboardAction(const String& action, const String& value,
if (message.length() == 0) { message = "Cold Extraction derzeit nicht moeglich."; } if (message.length() == 0) { message = "Cold Extraction derzeit nicht moeglich."; }
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionBlockMessage = message; coldExtractionBlockMessage = message;
coldExtractionNotice = COLDEX_NOTICE_START_REJECTED;
beepShort(); beepShort();
success = false; success = false;
} else { } else {
@@ -16417,15 +16476,21 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxResting\":%s,", coldExtractionPumpResting ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxResting\":%s,", coldExtractionPumpResting ? "true" : "false");
dtostrf(coldExtractionMaxTempC, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxTemp\":%s,", floatBuf); dtostrf(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(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);
// Sperrmeldung nach abgewiesenem Start (leer = keine); laeuft nach COLDEX_BLOCK_MESSAGE_MS ab. // Sperrgrund + transiente Meldung als Codes (kein Freitext - die State-Zeile muss kurz
// Die Texte stammen ausschliesslich aus dieser Firmware und enthalten keine JSON-Sonderzeichen. // bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst).
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxBlock\":%u,", (unsigned int)coldExtractionBlockCode());
{ {
const char* cxMsg = ""; uint8_t notice = COLDEX_NOTICE_NONE;
if (coldExtractionBlockMessage.length() > 0 && (long)(millis() - coldExtractionBlockMessageUntilMs) < 0) { if (coldExtractionNotice != COLDEX_NOTICE_NONE) {
cxMsg = coldExtractionBlockMessage.c_str(); if ((long)(millis() - coldExtractionBlockMessageUntilMs) < 0) {
notice = coldExtractionNotice;
} else {
coldExtractionNotice = COLDEX_NOTICE_NONE;
} }
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMessage\":\"%s\",", cxMsg); }
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxNotice\":%u,", (unsigned int)notice);
} }
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwTarget\":%s,", floatBuf); dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwTarget\":%s,", floatBuf);
dtostrf(brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwOffset\":%s,", floatBuf); dtostrf(brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwOffset\":%s,", floatBuf);
+29
View File
@@ -1,3 +1,32 @@
Version 1.1.0:
- Cold Extraction am Display bedienbar (braucht S3-Firmware ab 5.3.2):
- Neuer Schalter „Cold Extraction" auf der Seite „Temperaturen" unter „Modi", darunter eine
Erklärzeile mit dem aktuellen Zustand bzw. dem Sperrgrund. Schalter und Zeile bleiben
unsichtbar, solange die S3 die Funktion nicht freigeschaltet meldet (auch bei älterer
S3-Firmware, die die Felder gar nicht sendet).
- Einschalten ist nur bei erteilter Freigabe möglich (Wasserkessel kalt genug, kein Standby /
Wartung / Tuning); ausschalten immer, außer während eines laufenden kalten Bezugs. Gesendet
wird gezielt startColdExtraction/stopColdExtraction statt eines Toggles, damit ein
abgewiesener Befehl den Schalter nicht in einen nie bestätigten Zustand bringt.
- Statuszeile: eigener Slot direkt hinter der Sicherheitsmeldung. Zeigt dauerhaft
„Cold Extraction aktiv - Wasserkreis heizt nicht", bei fehlender Freigabe den Grund,
während der Pumpen-Zwangspause den Pumpenschutz und transient einen abgewiesenen Start
bzw. einen Abbruch wegen fehlenden Zulaufs.
- Wasser-Kachel im Dashboard zeigt bei aktivem Modus „Cold Extraction - Heizen aus" statt
eines Sollwerts, der nicht erreicht wird; der Aufheiz-Countdown ist dann unterdrückt.
- Phasenleiste und Live-Bezugsschirm benennen die erste Phase eines kalten Bezugs
„Vorbenetzung" (keine Pause-Phase) und zeigen die Pumpenpause an.
- Brew-Seite: Cold-Extraction-Parameter als Anzeige (Freigabeschwelle, Vorbenetzung,
Zielgewicht, Timeout) mit Hinweis, dass sie in der Web-UI eingestellt werden.
- Statistik-Seite: kalte Bezüge sind mit „(kalt)" markiert und gehen nicht in die mittlere
Bezugsdauer ein - gleiche Regel wie in der S3-Statistik.
- Bug-Fix: Das Limit für eingehende UART-Zeilen (PROTO_RX_LINE_MAX) lag bei 2048 Bytes, während
die State-Zeile der S3 bereits rund 2,1 KB erreicht - mit langem Status-, Profil- oder
SSID-Text konnte sie darüber liegen. In dem Fall wurde die GANZE Zeile verworfen und das
Display fror auf dem letzten Stand ein, ohne dass die Verbindung als tot erkannt wurde.
Das Limit ist jetzt 4096 Bytes; der RX-Puffer selbst bleibt bei 2 KB reserviert, weil der
interne RAM knapp ist und eine Arduino-String nicht ins PSRAM alloziert werden kann.
Version 1.0.13: Version 1.0.13:
- Profil-Schnellwahl + Anzeige des aktiven Profils (braucht S3-Firmware ab 5.1.1): - Profil-Schnellwahl + Anzeige des aktiven Profils (braucht S3-Firmware ab 5.1.1):
- Neuer Profil-Chip im Header (auf allen Seiten sichtbar): zeigt das zuletzt geladene bzw. - Neuer Profil-Chip im Header (auf allen Seiten sichtbar): zeigt das zuletzt geladene bzw.
+5 -2
View File
@@ -65,13 +65,16 @@
// ------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------
// Firmware // Firmware
// ------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------
#define DISPLAY_FW_VERSION "1.0.13" // Firmware-Stand der P4-Display-Steuerung (Info-Seite) #define DISPLAY_FW_VERSION "1.1.0" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
// ------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------
// Protokoll // Protokoll
// ------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------
#define PROTO_EXPECTED_VERSION 2 // muss zur S3-Firmware passen (TOUCH_UART_PROTOCOL_VERSION) #define PROTO_EXPECTED_VERSION 2 // muss zur S3-Firmware passen (TOUCH_UART_PROTOCOL_VERSION)
#define PROTO_RX_LINE_MAX 2048 // max. eingehende Zeilenlaenge (Bytes) // Die State-Zeile der S3 kann mit langem Status-/Profil-/SSID-Text ueber 2 KB gehen. Wird
// das Limit erreicht, verwirft der Client die GANZE Zeile und das Display friert auf dem
// letzten Stand ein -> Reserve deutlich groesser als der reale Bedarf (~2,3 KB).
#define PROTO_RX_LINE_MAX 4096 // max. eingehende Zeilenlaenge (Bytes)
#define PROTO_HEARTBEAT_MS 5000 // Intervall fuer 'ping' (S3-Timeout = 15 s) #define PROTO_HEARTBEAT_MS 5000 // Intervall fuer 'ping' (S3-Timeout = 15 s)
#define PROTO_HELLO_RETRY_MS 2000 // erneuter 'hello'-Versuch, solange nicht verbunden #define PROTO_HELLO_RETRY_MS 2000 // erneuter 'hello'-Versuch, solange nicht verbunden
#define PROTO_LINK_TIMEOUT_MS 8000 // ohne empfangene Nachricht -> Verbindung gilt als tot #define PROTO_LINK_TIMEOUT_MS 8000 // ohne empfangene Nachricht -> Verbindung gilt als tot
+17
View File
@@ -68,6 +68,23 @@ struct MachineState {
float bbwTarget = 0, bbwOffset = 0; float bbwTarget = 0, bbwOffset = 0;
uint8_t piState = 0; // Pre-Infusion-Phase (0=inaktiv,1=Pre-Infusion,2=Pause,3=Hauptbezug; seit S3 5.0.12) uint8_t piState = 0; // Pre-Infusion-Phase (0=inaktiv,1=Pre-Infusion,2=Pause,3=Hauptbezug; seit S3 5.0.12)
// --- Cold Extraction (kalte Extraktion; seit S3 5.3.0) ---
bool cxEnabled = false; // Funktion auf der S3 freigeschaltet (sonst Bedienelement ausblenden)
bool cxActive = false; // Modus scharf: Wasserkreis heizt nicht (persistent, seit S3 5.3.1)
bool cxShot = false; // der laufende Bezug ist eine kalte Extraktion
bool cxAllowed = false; // Freigabe jetzt (Wasserkessel kalt genug, kein Standby/Wartung/Tuning)
bool cxResting = false; // Zwangspause des Pumpenschutzes laeuft (Bezug laeuft weiter)
float cxMaxTemp = 0; // Freigabeschwelle in C
float cxTarget = 0; // Zielgewicht in g
float cxPreInfSec = 0; // Vorbenetzung in s (fuer die Phasenleiste des kalten Bezugs)
float cxMaxSecs = 0; // Sicherheits-Timeout des Bezugs in s
// Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent,
// 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm
uint8_t cxBlock = 0;
// Transiente Meldung: 0=keine, 1=Start abgewiesen, 2=Bezug ohne Zulauf beendet,
// 3=Umschalten waehrend eines Bezugs abgelehnt
uint8_t cxNotice = 0;
// --- Gehaeusesensor --- // --- Gehaeusesensor ---
bool caseSensorEnabled = false; bool caseSensorEnabled = false;
bool caseTempDashboard = false; bool caseTempDashboard = false;
+17
View File
@@ -16,6 +16,10 @@ void ProtocolClient::begin() {
DISPLAY_UART_PORT.setRxBufferSize(2048); DISPLAY_UART_PORT.setRxBufferSize(2048);
DISPLAY_UART_PORT.begin(DISPLAY_UART_BAUDRATE, SERIAL_8N1, DISPLAY_UART_PORT.begin(DISPLAY_UART_BAUDRATE, SERIAL_8N1,
DISPLAY_UART_RX_PIN, DISPLAY_UART_TX_PIN); DISPLAY_UART_RX_PIN, DISPLAY_UART_TX_PIN);
// Bewusst NICHT auf PROTO_RX_LINE_MAX (4 KB) reserviert: Der interne RAM ist knapp
// (statisch ~98 %), und eine Arduino-String kann nicht ins PSRAM alloziert werden.
// 2 KB deckt die reale State-Zeile (~2,1 KB) fast vollstaendig ab; der Rest waechst
// in kleinen Schritten nach. Das Limit dient nur als Obergrenze gegen Muell/Overflow.
_rxBuf.reserve(2048); _rxBuf.reserve(2048);
_lastHelloMs = 0; _lastHelloMs = 0;
_lastHeartbeatMs = 0; _lastHeartbeatMs = 0;
@@ -216,6 +220,19 @@ void ProtocolClient::applyState(const String& json) {
s.bbwTarget = doc["bbwTarget"] | s.bbwTarget; s.bbwTarget = doc["bbwTarget"] | s.bbwTarget;
s.bbwOffset = doc["bbwOffset"] | s.bbwOffset; s.bbwOffset = doc["bbwOffset"] | s.bbwOffset;
// Cold Extraction (fehlt bei S3-FW < 5.3.0 -> cxEnabled bleibt false, UI blendet alles aus)
s.cxEnabled = doc["cxEnabled"] | false;
s.cxActive = doc["cxActive"] | false;
s.cxShot = doc["cxShot"] | false;
s.cxAllowed = doc["cxAllowed"] | false;
s.cxResting = doc["cxResting"] | false;
s.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp;
s.cxTarget = doc["cxTarget"] | s.cxTarget;
s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec;
s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
s.cxBlock = doc["cxBlock"] | (uint8_t)0;
s.cxNotice = doc["cxNotice"] | (uint8_t)0;
s.caseSensorEnabled = doc["caseSensorEnabled"] | false; s.caseSensorEnabled = doc["caseSensorEnabled"] | false;
s.caseTempDashboard = doc["caseTempDashboard"] | false; s.caseTempDashboard = doc["caseTempDashboard"] | false;
s.caseSensorType = doc["caseSensorType"] | 0; s.caseSensorType = doc["caseSensorType"] | 0;
+174 -8
View File
@@ -65,7 +65,9 @@ static lv_obj_t* g_errIcon = nullptr; // Fehlersymbol im Header (rot, auf
// Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner. // Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner.
// Jeder Hinweis belegt einen Slot (Reihenfolge = Prioritaet, Rot vor Gelb vor Blau); // Jeder Hinweis belegt einen Slot (Reihenfolge = Prioritaet, Rot vor Gelb vor Blau);
// mehrere aktive Meldungen rotieren alle 4 s weich durch (siehe status_line_tick). // mehrere aktive Meldungen rotieren alle 4 s weich durch (siehe status_line_tick).
enum StatusSlot { ST_SAFETY = 0, ST_DAMPF, ST_CLEAN, ST_TUNE, ST_MAINT, ST_HEAT, ST_COUNT }; // ST_COLDEX steht direkt hinter der Sicherheitsmeldung: Solange der Modus laeuft, heizt der
// Wasserkreis nicht - das muss der Benutzer sehen, sonst wartet er auf Temperatur, die nicht kommt.
enum StatusSlot { ST_SAFETY = 0, ST_COLDEX, ST_DAMPF, ST_CLEAN, ST_TUNE, ST_MAINT, ST_HEAT, ST_COUNT };
struct StatusMsg { struct StatusMsg {
bool active = false; bool active = false;
char text[120] = ""; char text[120] = "";
@@ -205,6 +207,7 @@ static lv_obj_t* g_piRow = nullptr; // Containe
static lv_obj_t* g_piChip[3] = { nullptr, nullptr, nullptr }; // Chip je Schritt static lv_obj_t* g_piChip[3] = { nullptr, nullptr, nullptr }; // Chip je Schritt
static lv_obj_t* g_piChipLbl[3] = { nullptr, nullptr, nullptr }; static lv_obj_t* g_piChipLbl[3] = { nullptr, nullptr, nullptr };
static bool g_piShotMode = false; // laufender Bezug nutzt Pre-Infusion static bool g_piShotMode = false; // laufender Bezug nutzt Pre-Infusion
static bool g_piColdMode = false; // laufender Bezug ist eine kalte Extraktion (andere Phasennamen)
static uint8_t g_piStateS3 = 0; // Phasenstatus der S3 (0=inaktiv,1=PI,2=Pause,3=Extraktion) static uint8_t g_piStateS3 = 0; // Phasenstatus der S3 (0=inaktiv,1=PI,2=Pause,3=Extraktion)
static uint32_t g_piDurMs = 0, g_piPauseMs = 0; // beim Bezug gelatchte Phasendauern static uint32_t g_piDurMs = 0, g_piPauseMs = 0; // beim Bezug gelatchte Phasendauern
static int g_piLastPhase = -1; // zuletzt gestylte Phase (Styles nur bei Wechsel anfassen) static int g_piLastPhase = -1; // zuletzt gestylte Phase (Styles nur bei Wechsel anfassen)
@@ -219,6 +222,8 @@ static float g_sbtTarget = 0.0f; // Dampf-Zielzeit (s)
// Temperatur // Temperatur
static lv_obj_t* lblTargW; static lv_obj_t* lblTargD; static lv_obj_t* swEco; static lv_obj_t* swMaint; static lv_obj_t* lblTargW; static lv_obj_t* lblTargD; static lv_obj_t* swEco; static lv_obj_t* swMaint;
static lv_obj_t* swColdEx = nullptr; // Cold Extraction (nur bei S3-FW >= 5.3.0 sichtbar)
static lv_obj_t* lblColdExHint = nullptr; // Erklaerung/Sperrgrund unter dem Schalter
static float g_targW = 0, g_targD = 0; static unsigned long g_targEditMs = 0; static float g_targW = 0, g_targD = 0; static unsigned long g_targEditMs = 0;
static lv_obj_t* lblHeatUp = nullptr; static int g_heatUp = 0; static unsigned long g_heatUpEditMs = 0; static lv_obj_t* lblHeatUp = nullptr; static int g_heatUp = 0; static unsigned long g_heatUpEditMs = 0;
static bool g_heatUpDirty = false; // ungesendete Aufheizzeit-Aenderung (entprellt senden) static bool g_heatUpDirty = false; // ungesendete Aufheizzeit-Aenderung (entprellt senden)
@@ -245,6 +250,7 @@ static lv_obj_t* swPI; static lv_obj_t* taPiDur; static lv_obj_t* taPiPause;
static lv_obj_t* swBBT; static lv_obj_t* taBbtSecs; static lv_obj_t* 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)
// 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;
@@ -1195,6 +1201,32 @@ static lv_obj_t* build_temp_arc(lv_obj_t* parent, const char* title, lv_color_t
static void eco_cb(lv_event_t* e) { if (g_client) g_client->sendAction(sw_get((lv_obj_t*)lv_event_get_target(e)) ? "startEco" : "stopEco"); } static void eco_cb(lv_event_t* e) { if (g_client) g_client->sendAction(sw_get((lv_obj_t*)lv_event_get_target(e)) ? "startEco" : "stopEco"); }
static void maint_cb(lv_event_t* e) { if (g_client) g_client->sendAction("toggleMaintenance", sw_get((lv_obj_t*)lv_event_get_target(e)) ? "true" : "false"); } static void maint_cb(lv_event_t* e) { if (g_client) g_client->sendAction("toggleMaintenance", sw_get((lv_obj_t*)lv_event_get_target(e)) ? "true" : "false"); }
// Sperrgrund-Code der S3 (cxBlock) in Klartext. Die S3 sendet bewusst nur den Code, damit
// die State-Zeile kurz bleibt - die Texte gehoeren ins Display (analog statusKey).
static String coldex_block_text(uint8_t code, float maxTemp, float tempW) {
switch (code) {
case 1: return "nicht freigeschaltet";
case 2: return "Standby aktiv";
case 3: return "Wartungsmodus aktiv";
case 4: return "Reinigungsassistent aktiv";
case 5: return "PID-Tuning läuft";
case 6: return "Wasser-Sensor liefert keinen gültigen Wert";
case 7: {
char b[72];
snprintf(b, sizeof(b), "Wasserkessel zu warm (%.1f °C, benötigt < %.1f °C)",
(double)tempW, (double)maxTemp);
return String(b);
}
default: return "";
}
}
// Cold Extraction: gezielt start/stop senden (nicht toggle), damit ein abgewiesener Befehl
// den Schalter nicht in einen Zustand bringt, den die S3 nie bestaetigt.
static void coldex_cb(lv_event_t* e) {
bool want = sw_get((lv_obj_t*)lv_event_get_target(e));
if (g_client) g_client->sendAction(want ? "startColdExtraction" : "stopColdExtraction");
}
// Stepper-Zeile: Beschriftung | [-] Wert [+] // Stepper-Zeile: Beschriftung | [-] Wert [+]
static lv_obj_t* build_stepper(lv_obj_t* parent, const char* label, lv_event_cb_t minus, lv_event_cb_t plus, int dec) { static lv_obj_t* build_stepper(lv_obj_t* parent, const char* label, lv_event_cb_t minus, lv_event_cb_t plus, int dec) {
@@ -1283,6 +1315,16 @@ static void build_temp(lv_obj_t* p) {
lv_obj_add_event_cb(swEco, eco_cb, LV_EVENT_VALUE_CHANGED, nullptr); 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");
lv_obj_add_event_cb(swColdEx, coldex_cb, LV_EVENT_VALUE_CHANGED, nullptr);
lblColdExHint = lv_label_create(p);
lv_label_set_long_mode(lblColdExHint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblColdExHint, LV_PCT(100));
lv_obj_set_style_text_color(lblColdExHint, COL_TEXT_DIM, 0);
lv_label_set_text(lblColdExHint, "");
// Beide Zeilen bleiben versteckt, solange die S3 die Funktion nicht freigeschaltet meldet
lv_obj_add_flag(lv_obj_get_parent(swColdEx), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
} }
// ===================================================================================== // =====================================================================================
@@ -1607,7 +1649,10 @@ static void chart_sample_cb(lv_timer_t*) {
// Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird. // Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird.
{ {
long rs = g_state.heatUpRemainSec; long rs = g_state.heatUpRemainSec;
if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen // Bei aktiver Cold Extraction heizt der Wasserkreis nicht - ein Aufheiz-Countdown
// waere schlicht falsch (er liefe ab, ohne dass die Maschine warm wird).
if (g_state.cxActive) g_heatUpLatched = false;
else if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen
else if (g_state.tempW < 40.0f) g_heatUpLatched = true; // Kaltstart erkannt -> ganzen Countdown zeigen else if (g_state.tempW < 40.0f) g_heatUpLatched = true; // Kaltstart erkannt -> ganzen Countdown zeigen
if (g_heatUpLatched) { if (g_heatUpLatched) {
char b[56]; char b[56];
@@ -1761,8 +1806,11 @@ static void brew_live_tick() {
if (phase <= 2) { if (phase <= 2) {
uint32_t endMs = (phase == 1) ? g_piDurMs : (g_piDurMs + g_piPauseMs); uint32_t endMs = (phase == 1) ? g_piDurMs : (g_piDurMs + g_piPauseMs);
uint32_t remainMs = (g_shotFrozenMs < endMs) ? (endMs - g_shotFrozenMs) : 0; uint32_t remainMs = (g_shotFrozenMs < endMs) ? (endMs - g_shotFrozenMs) : 0;
snprintf(b, sizeof(b), "%s - noch %.1f s", const char* pname = (phase == 1) ? (g_piColdMode ? "Vorbenetzung" : "Pre-Infusion")
(phase == 1) ? "Pre-Infusion" : "Pause", remainMs / 1000.0f); : "Pause";
snprintf(b, sizeof(b), "%s - noch %.1f s", pname, remainMs / 1000.0f);
} else if (g_piColdMode && g_state.cxResting) {
snprintf(b, sizeof(b), "Extraktion - Pumpenpause");
} else { } else {
snprintf(b, sizeof(b), "Extraktion"); snprintf(b, sizeof(b), "Extraktion");
} }
@@ -1963,6 +2011,15 @@ static void build_brew(lv_obj_t* p) {
swSBT = add_switch(p, "Steam-by-Time aktiv"); swSBT = add_switch(p, "Steam-by-Time aktiv");
taSbtSecs = add_num(p, "Dampf-Zielzeit (s)"); taSbtSecs = add_num(p, "Dampf-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);
lv_label_set_long_mode(lblBrewColdEx, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblBrewColdEx, LV_PCT(100));
lv_obj_set_style_text_color(lblBrewColdEx, COL_TEXT_DIM, 0);
lv_label_set_text(lblBrewColdEx, "");
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
} }
// ===================================================================================== // =====================================================================================
@@ -2761,7 +2818,9 @@ static void pi_bar_update() {
} }
// Texte: erledigte Schritte mit Haken, aktive zeitgesteuerte Phase mit Restzeit // Texte: erledigte Schritte mit Haken, aktive zeitgesteuerte Phase mit Restzeit
static const char* piNames[3] = { "Pre-Infusion", "Pause", "Extraktion" }; static const char* piNamesHot[3] = { "Pre-Infusion", "Pause", "Extraktion" };
static const char* piNamesCold[3] = { "Vorbenetzung", "Pause", "Extraktion" };
const char** piNames = g_piColdMode ? piNamesCold : piNamesHot;
char pbuf[40]; char pbuf[40];
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
if (!g_piChipLbl[i]) continue; if (!g_piChipLbl[i]) continue;
@@ -3385,6 +3444,32 @@ void ui_update(const MachineState& st) {
status_set(ST_SAFETY, false); status_set(ST_SAFETY, false);
} }
// Cold-Extraction-Hinweis: erklaert dauerhaft, warum der Wasserkreis nicht heizt.
// Transiente Meldung (abgewiesener Start / Abbruch) hat Vorrang vor dem Dauerzustand.
if (st.cxNotice == 2) {
status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction beendet: kein Zulauf",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 3) {
status_set(ST_COLDEX, true, "Cold Extraction: erst nach dem Bezug umschaltbar",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 1) {
String t = "Cold Extraction nicht möglich - ";
t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive && st.cxResting) {
status_set(ST_COLDEX, true, "Cold Extraction: Pumpenpause (Pumpenschutz)",
COL_ACCENT, lv_color_hex(0x000000));
} else if (st.cxActive && !st.cxAllowed) {
String t = "Cold Extraction aktiv, Bezug gesperrt - ";
t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive) {
status_set(ST_COLDEX, true, "Cold Extraction aktiv - Wasserkreis heizt nicht",
lv_color_hex(0x1976d2), COL_TEXT);
} else {
status_set(ST_COLDEX, false);
}
// Dampf-Hinweis (Heizen aus hat Vorrang vor Startverzoegerung) // Dampf-Hinweis (Heizen aus hat Vorrang vor Startverzoegerung)
if (st.steamHeatDisabled) { if (st.steamHeatDisabled) {
status_set(ST_DAMPF, true, "Dampf: Heizen deaktiviert", COL_DANGER, COL_TEXT); status_set(ST_DAMPF, true, "Dampf: Heizen deaktiviert", COL_DANGER, COL_TEXT);
@@ -3523,6 +3608,14 @@ void ui_update(const MachineState& st) {
temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER); temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER);
} else { } else {
lv_label_set_text(lblTempW, tempStr(st.tempW, 1).c_str()); lv_label_set_text(lblTempW, tempStr(st.tempW, 1).c_str());
if (st.cxActive) {
// Cold Extraction: der Sollwert ist bedeutungslos, es wird nicht geheizt.
// Statt eines Zielbands, das nie erreicht wird, den Grund anzeigen.
lv_obj_set_style_text_color(lblTempW, lv_color_hex(0x1976d2), 0);
label_set_if_changed(lblSetW, "Cold Extraction - Heizen aus");
lv_obj_set_style_text_color(lblSetW, lv_color_hex(0x1976d2), 0);
temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, lv_color_hex(0x1976d2));
} else {
// Ist-Wert faerbt sich weich Richtung Soll (Blau -> Gold -> Gruen im Zielband) // Ist-Wert faerbt sich weich Richtung Soll (Blau -> Gold -> Gruen im Zielband)
lv_color_t colW = temp_state_color(st.tempW, st.setW); lv_color_t colW = temp_state_color(st.tempW, st.setW);
lv_obj_set_style_text_color(lblTempW, colW, 0); lv_obj_set_style_text_color(lblTempW, colW, 0);
@@ -3532,6 +3625,7 @@ void ui_update(const MachineState& st) {
int pctW = (st.setW > 0.0f) ? (int)(st.tempW / st.setW * 100.0f + 0.5f) : 0; int pctW = (st.setW > 0.0f) ? (int)(st.tempW / st.setW * 100.0f + 0.5f) : 0;
temp_progress_set(barDutyW, arcTempW, bulbTempW, pctW, colW); temp_progress_set(barDutyW, arcTempW, bulbTempW, pctW, colW);
} }
}
if (st.dampfSafetyShutdown || st.dampfSensorError) { if (st.dampfSafetyShutdown || st.dampfSensorError) {
lv_label_set_text(lblTempD, LV_SYMBOL_WARNING); lv_label_set_text(lblTempD, LV_SYMBOL_WARNING);
lv_obj_set_style_text_color(lblTempD, COL_DANGER, 0); lv_obj_set_style_text_color(lblTempD, COL_DANGER, 0);
@@ -3596,10 +3690,19 @@ void ui_update(const MachineState& st) {
g_shotBbtMode = st.bbtEnabled && st.bbtSecs > 0.0f; // Bezug mit Brew-by-Time? g_shotBbtMode = st.bbtEnabled && st.bbtSecs > 0.0f; // Bezug mit Brew-by-Time?
if (g_shotBbtMode) g_bbtTarget = st.bbtSecs; if (g_shotBbtMode) g_bbtTarget = st.bbtSecs;
// Pre-Infusion-Phasenleiste: Modus, S3-Phase und Phasendauern latchen (pi_bar_update) // Pre-Infusion-Phasenleiste: Modus, S3-Phase und Phasendauern latchen (pi_bar_update)
// Ein kalter Bezug hat seine eigene Phasenfolge (Vorbenetzung -> Extraktion, keine
// Pause) und ignoriert die Pre-Infusion-Einstellungen komplett.
g_piColdMode = st.cxShot;
if (g_piColdMode) {
g_piShotMode = (st.cxPreInfSec > 0.0f);
g_piDurMs = (uint32_t)(st.cxPreInfSec * 1000.0f);
g_piPauseMs = 0;
} else {
g_piShotMode = st.piEnabled; g_piShotMode = st.piEnabled;
g_piStateS3 = st.piState;
g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f); g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f);
g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f); g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f);
}
g_piStateS3 = st.piState;
// Live-Bezugsschirm einblenden (nur echte Bezuege; nicht im Reinigungsassistenten, // Live-Bezugsschirm einblenden (nur echte Bezuege; nicht im Reinigungsassistenten,
// nicht im Standby und nicht, wenn der Nutzer ihn fuer diesen Bezug weggetippt hat) // nicht im Standby und nicht, wenn der Nutzer ihn fuer diesen Bezug weggetippt hat)
if (g_brewLiveEnabled && !g_brewLiveClosed && !g_brewLiveShown && if (g_brewLiveEnabled && !g_brewLiveClosed && !g_brewLiveShown &&
@@ -3623,6 +3726,7 @@ void ui_update(const MachineState& st) {
} }
g_shotActive = false; g_shotActive = false;
g_piShotMode = false; // Phasenleiste ausblenden (pi_bar_update) g_piShotMode = false; // Phasenleiste ausblenden (pi_bar_update)
g_piColdMode = false;
g_piStateS3 = 0; g_piStateS3 = 0;
brew_live_hide(); // Bezug vorbei -> Overlay weich ausblenden brew_live_hide(); // Bezug vorbei -> Overlay weich ausblenden
g_brewLiveClosed = false; // Wegtipp-Sperre gilt nur fuer den laufenden Bezug g_brewLiveClosed = false; // Wegtipp-Sperre gilt nur fuer den laufenden Bezug
@@ -3725,6 +3829,60 @@ void ui_update(const MachineState& st) {
if (swEco) sw_set(swEco, st.ecoActive); if (swEco) sw_set(swEco, st.ecoActive);
if (swMaint) sw_set(swMaint, st.maintenanceActive); if (swMaint) sw_set(swMaint, st.maintenanceActive);
if (swMaint2) sw_set(swMaint2, st.maintenanceActive); if (swMaint2) sw_set(swMaint2, st.maintenanceActive);
if (swColdEx) {
// Zeile nur zeigen, wenn die S3 die Funktion freigeschaltet meldet (und sie kennt)
lv_obj_t* rowCx = lv_obj_get_parent(swColdEx);
if (st.cxEnabled) {
lv_obj_remove_flag(rowCx, LV_OBJ_FLAG_HIDDEN);
if (lblColdExHint) lv_obj_remove_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(rowCx, LV_OBJ_FLAG_HIDDEN);
if (lblColdExHint) lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
}
sw_set(swColdEx, st.cxActive);
// Einschalten nur bei Freigabe; Ausschalten immer - ausser waehrend eines kalten Bezugs
bool usable = st.cxEnabled && (st.cxActive ? !st.cxShot : st.cxAllowed);
if (usable) lv_obj_remove_state(swColdEx, LV_STATE_DISABLED);
else lv_obj_add_state(swColdEx, LV_STATE_DISABLED);
if (lblColdExHint && st.cxEnabled) {
String hint;
if (st.cxActive) {
hint = "Wasserkreis heizt nicht (bleibt über Standby und Neustart aktiv). ";
if (st.cxAllowed) {
char b[96];
snprintf(b, sizeof(b), "Bezug regulär starten - Ziel %.0f g, max %.0f s.",
(double)st.cxTarget, (double)st.cxMaxSecs);
hint += b;
} else {
hint += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
}
} else if (!st.cxAllowed) {
hint = coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
} else {
char b[112];
snprintf(b, sizeof(b), "Kalte Extraktion möglich (Wasser %.1f °C < %.1f °C). "
"Solange aktiv, heizt der Wasserkreis nicht.",
(double)st.tempW, (double)st.cxMaxTemp);
hint = b;
}
label_set_if_changed(lblColdExHint, hint.c_str());
}
}
if (lblBrewColdEx) {
if (st.cxEnabled) {
char b[192];
snprintf(b, sizeof(b),
"Cold Extraction (nur Anzeige, Einstellung in der Web-UI):\n"
"Freigabe < %.1f °C | Vorbenetzung %.0f s | Ziel %.0f g | max. %.0f s\n"
"Geschaltet wird der Modus auf der Seite \"Temperaturen\" unter \"Modi\".",
(double)st.cxMaxTemp, (double)st.cxPreInfSec,
(double)st.cxTarget, (double)st.cxMaxSecs);
label_set_if_changed(lblBrewColdEx, b);
lv_obj_remove_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
}
}
// Licht-Button spiegeln (gold = an) // Licht-Button spiegeln (gold = an)
if (btnLight) { if (btnLight) {
@@ -3857,14 +4015,19 @@ void ui_set_usage_stats(const String& json) {
String l; String l;
int n = 0; int n = 0;
float durSum = 0.0f; float durSum = 0.0f;
int nDur = 0; // Anzahl der Bezuege, die in den Mittelwert eingehen (ohne kalte)
for (JsonVariant v : doc["shots"].as<JsonArray>()) { for (JsonVariant v : doc["shots"].as<JsonArray>()) {
const char* dt = v["dt"] | ""; const char* dt = v["dt"] | "";
float d = v["d"] | 0.0f; float d = v["d"] | 0.0f;
float w = v["w"] | -1.0f; float w = v["w"] | -1.0f;
bool cold = v["cx"] | false; // kalte Extraktion (fehlt bei S3-FW < 5.3.0)
if (n > 0) l += "\n"; if (n > 0) l += "\n";
l += String(dt) + " " + String(d, 1) + " s"; l += String(dt) + " " + String(d, 1) + " s";
if (w >= 0.0f) l += " " + String(w, 1) + " g"; if (w >= 0.0f) l += " " + String(w, 1) + " g";
durSum += d; if (cold) l += " (kalt)";
// Minutenlange kalte Bezuege wuerden den Mittelwert unbrauchbar machen -> raus
// (gleiche Regel wie in der S3-Statistik)
if (!cold) { durSum += d; nDur++; }
n++; n++;
} }
@@ -3873,7 +4036,10 @@ void ui_set_usage_stats(const String& json) {
s += "Diese Woche: " + String(week) + "\n"; s += "Diese Woche: " + String(week) + "\n";
// total ist exakt, solange die ganze Datei ins Fenster passte (sonst Fenster-Untergrenze) // total ist exakt, solange die ganze Datei ins Fenster passte (sonst Fenster-Untergrenze)
s += "Gesamt: " + (partial ? String("min. ") : String("")) + String(total) + " Bezüge"; s += "Gesamt: " + (partial ? String("min. ") : String("")) + String(total) + " Bezüge";
if (n > 0) s += "\nMittl. Dauer (letzte " + String(n) + "): " + String(durSum / n, 1) + " s"; if (nDur > 0) {
s += "\nMittl. Dauer (letzte " + String(nDur) + "): " + String(durSum / nDur, 1) + " s";
if (nDur < n) s += " (ohne kalte)";
}
label_set_if_changed(g_statSummary, s.c_str()); label_set_if_changed(g_statSummary, s.c_str());
if (g_statList) { if (g_statList) {