diff --git a/Changelog.txt b/Changelog.txt index aa22c0f..55c6c99 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,3 +1,34 @@ +Version 5.3.0: +- 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 + freigegeben (Standard 30 °C, einstellbar 10–40 °C) und ist praktisch aus dem kalten Zustand heraus nutzbar. +- Solange der Modus aktiv ist, heizt der Wasserkreis nicht (SSR gesperrt, Stellgröße und damit auch die + dutyW-Telemetrie auf 0, I-Anteil der Wasser-PID eingefroren, Feed-Forward-Boost deaktiviert). Der + Dampfkreis bleibt unberührt und darf weiter heizen. +- Die Freigabe wird vor jedem Bezug neu geprüft, nicht nur beim Aktivieren: Da der Dampfkessel weiter heizt, + kann der Wasserkessel über die Zeit über die Schwelle steigen. In diesem Fall wird der nächste Bezug + abgewiesen (Meldung auf OLED, Touch-Display und im Web-Dashboard) - ein laufender Bezug wird nie abgebrochen. + Hysterese von 5 °C, damit die Anzeige an der Schwelle nicht flattert; Sensorfehler sperrt die Funktion. +- Der Fluss wird über Pumpen-Pulsung reduziert (Pulsperiode 200–5000 ms, Pumpenleistung 5–100 %), davor läuft + eine einstellbare Vorbenetzung mit voller Pumpenleistung. Bezugsende über Zielgewicht oder Sicherheits-Timeout; + Brew-by-Time/Brew-by-Weight und FlowGuard sind während einer kalten Extraktion bewusst außer Kraft (FlowGuard + und Cold Extraction würden sonst um denselben Pumpen-Ausgang konkurrieren). +- Pumpenschutz: Die Steuerung zählt die kumulierte Pumpen-Einschaltzeit und legt nach der eingestellten + Laufzeit eine Zwangspause ein (Standard 60 s Laufzeit / 20 s Pause). Zusätzlich Stillstands-Erkennung - + läuft die Pumpe bei aktiver Waage 15 s ohne Zulauf, wird der Bezug beendet (dichter Puck oder ausgelöster + Pumpen-Thermoschutz). +- Bedienung: Parameter auf der Seite /Brew-Control, Ein-/Ausschalten über das Web-Dashboard oder das + UART-Touch-Display (neue Aktionen "startColdExtraction"/"stopColdExtraction"/"toggleColdExtraction"). + Cold Extraction ist bewusst kein Profilfeld (maschinen- und situationsabhängig, wie der Feed-Forward); + der Modus selbst wird nicht persistiert und ist nach einem Neustart immer aus. Standby beendet ihn. +- Statistik: Kalte Bezüge werden im Log mit einem vierten CSV-Feld "C" gekennzeichnet, zählen in allen + Stückzahlen mit, gehen aber NICHT in die durchschnittliche Bezugsdauer ein (Minuten statt Sekunden würden + den Wert verzerren). Die Info-Seite weist die Anzahl separat aus, der Verlaufs-Download hat eine neue + Spalte "Art". Ältere Logzeilen bleiben unverändert lesbar. +- Neue additive State-Felder im UART-JSON (Protokoll bleibt v2): cxEnabled, cxActive, cxShot, cxAllowed, + cxResting, cxMaxTemp, cxTarget, cxMaxSecs, cxMessage sowie "cx" pro Eintrag in usageStats; neuer + statusKey "coldextraction". Ältere Display-Firmware ignoriert die Felder. + Version 5.2.0: - Feed-Forward bei Bezug (Störgrößenaufschaltung) für den Wasserkessel: Während eines Bezugs wird der Heizung sofort ein einstellbarer Leistungs-Boost (0–100 % der Fenstergröße, 0 % = aus) aufgeschaltet, diff --git a/Doku/JC-Display_UART-Protokoll.md b/Doku/JC-Display_UART-Protokoll.md index 18f953c..235cc2b 100644 --- a/Doku/JC-Display_UART-Protokoll.md +++ b/Doku/JC-Display_UART-Protokoll.md @@ -315,6 +315,23 @@ Alle gültigen `action`-Strings aus `executeDashboardAction()` (Stand v4.8.1, Ze | `disableSteamHeat` | — | Dampf-Heizung abschalten | | `enableSteamHeat` | — | Dampf-Heizung wieder aktivieren | +**Cold Extraction (kalte Extraktion, ab S3 5.3.0)** + +| `action` | `value` | Wirkung | +|---|---|---| +| `startColdExtraction` | — | Modus aktivieren (Wasserkreis heizt nicht, Dampf bleibt möglich) | +| `stopColdExtraction` | — | Modus beenden | +| `toggleColdExtraction` | — | Modus umschalten | + +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 +**nicht** persistiert und ist nach jedem Neustart aus; Standby beendet ihn. 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. + **Persistente Toggles** | `action` | `value` | Wirkung | @@ -354,7 +371,28 @@ Aus `getTouchUartStatus()`. Für sprach-unabhängige UI-Logik/Icons auf dem P4 v (statt `statusText` zu parsen): `ready`, `error`, `standby`, `maintenance`, `tuning`, `brewing`, `eco`, `fastheatup`, -`heating`, `ready_eco_pending`. +`heating`, `ready_eco_pending`, `coldextraction` (ab S3 5.3.0). + +### Cold-Extraction-Felder im `state` (ab S3 5.3.0, additiv — Protokoll bleibt v2) + +| Feld | Typ | Bedeutung | +|---|---|---| +| `cxEnabled` | bool | Funktion in der Web-UI freigeschaltet (sonst Bedienelement ausblenden) | +| `cxActive` | bool | Modus scharf: Wasserkreis heizt nicht | +| `cxShot` | bool | Der laufende Bezug ist eine kalte Extraktion | +| `cxAllowed` | bool | Freigabe **jetzt** (Wasserkessel kalt genug, kein Standby/Wartung/Tuning) | +| `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 | +| `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s | +| `cxMessage` | string | Sperr-/Abbruchmeldung, leer = keine (läuft nach ~6 s selbst ab) | + +Während `cxShot` folgt die Phase weiterhin `piState`: `1` = Vorbenetzung, `3` = Hauptbezug +(gepulst). Eine Pause-Phase (`2`) gibt es bei der kalten Extraktion nicht. + +In der Antwort auf `getUsageStats` trägt jeder Eintrag in `shots[]` zusätzlich `cx` (bool) — +kalte Bezüge zählen in `total`/`today`/`week` mit, sind aber in der durchschnittlichen +Bezugsdauer der S3-Statistik ausgenommen. --- diff --git a/Dual-PID.ino b/Dual-PID.ino index 92264af..81e3f32 100644 --- a/Dual-PID.ino +++ b/Dual-PID.ino @@ -175,7 +175,7 @@ unsigned long lastDashboardWsPushMs = 0; #define TOUCH_UART_BAUDRATE 230400 #define TOUCH_UART_MAX_LINE_LEN 1024 #define TOUCH_UART_PROTOCOL_VERSION 2 // v2: + Netzwerk-/PID-/Duty-/AutoTune-Telemetrie, hello-Handshake -#define TOUCH_UART_JSON_BUFFER_SIZE 3000 // v2: groesserer Headroom fuer die zusaetzlichen Felder +#define TOUCH_UART_JSON_BUFFER_SIZE 3400 // v2: groesserer Headroom fuer die zusaetzlichen Felder (inkl. Cold Extraction) #define DEFAULT_FULLY_KIOSK_ENABLED 0 #define DEFAULT_FULLY_KIOSK_HOST "" @@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire); * Firmware-Informationen ************************************************************************************/ -String version = "5.2.0"; +String version = "5.3.0"; String versionHersteller = "Thomas Müller"; String versionHerstellerMail = "thomas@mueller.black"; @@ -696,6 +696,10 @@ struct ShotStats { unsigned long shotsLastWeek = 0; // Kalenderwoche unsigned long shotsLastMonth = 0; // Kalendermonat double avgShotsPerDay = 0.0; // Durchschnitt seit erstem Log + // Kalte Extraktionen zaehlen in allen Stueckzahlen mit, gehen aber bewusst NICHT in + // die durchschnittliche Bezugsdauer ein (Minuten statt Sekunden verzerren den Wert). + unsigned long coldExtractionShots = 0; + unsigned long durationCountedShots = 0; }; /************************************************************************************ @@ -767,6 +771,72 @@ float flowGuardLastNetWeight = NAN; unsigned long flowGuardLastSampleMs = 0; bool flowGuardPumpPulseActive = false; +/************************************************************************************ + * Cold Extraction (kalte Extraktion) - Einstellungen & Laufzeitzustand + ************************************************************************************ + * Die Maschine hat keinen Bypass um den Wasserkessel: Kaltes Wasser kommt nur dann am + * Puck an, wenn der Wasserkessel selbst kalt ist. Deshalb wird die Funktion ueber eine + * Temperaturschwelle freigegeben (Default 30 C) und der Wasserkreis heizt waehrend des + * Modus nicht (der Dampfkreis bleibt unangetastet und darf weiter heizen). + * Der Fluss wird ueber Pumpen-Pulsung reduziert, der Bezug laeuft ueber Minuten. + ************************************************************************************/ +bool coldExtractionEnabled = false; // Funktion in der Web-UI freigeschaltet +float coldExtractionMaxTempC = 30.0f; // Freigabeschwelle Wasserkessel +float coldExtractionPreInfusionSeconds = 30.0f; // Vorbenetzung (Pumpe laeuft ungepulst) +uint16_t coldExtractionPulsePeriodMs = 2000; // Pulsperiode der Pumpe im Hauptbezug +float coldExtractionDutyPercent = 30.0f; // Pumpen-Duty im Hauptbezug +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 + +const bool defaultColdExtractionEnabled = false; +const float defaultColdExtractionMaxTempC = 30.0f; +const float defaultColdExtractionPreInfusionSeconds = 30.0f; +const uint16_t defaultColdExtractionPulsePeriodMs = 2000; +const float defaultColdExtractionDutyPercent = 30.0f; +const float defaultColdExtractionTargetGrams = 60.0f; +const float defaultColdExtractionMaxSeconds = 300.0f; +const float defaultColdExtractionPumpMaxRunSec = 60.0f; +const float defaultColdExtractionPumpRestSec = 20.0f; + +const float COLDEX_MAX_TEMP_MIN = 10.0f; +const float COLDEX_MAX_TEMP_MAX = 40.0f; +const float COLDEX_TEMP_RELEASE_MARGIN_C = 5.0f; // Hysterese: Sperre erst ueber Schwelle + Marge zurueck +const float COLDEX_PREINF_SEC_MIN = 0.0f; +const float COLDEX_PREINF_SEC_MAX = 120.0f; +const uint16_t COLDEX_PULSE_PERIOD_MIN_MS = 200; +const uint16_t COLDEX_PULSE_PERIOD_MAX_MS = 5000; +const float COLDEX_DUTY_MIN = 5.0f; +const float COLDEX_DUTY_MAX = 100.0f; +const float COLDEX_TARGET_GRAMS_MIN = 10.0f; +const float COLDEX_TARGET_GRAMS_MAX = 300.0f; +const float COLDEX_MAX_SECONDS_MIN = 30.0f; +const float COLDEX_MAX_SECONDS_MAX = 900.0f; +const float COLDEX_PUMP_MAX_RUN_SEC_MIN = 10.0f; +const float COLDEX_PUMP_MAX_RUN_SEC_MAX = 300.0f; +const float COLDEX_PUMP_REST_SEC_MIN = 0.0f; +const float COLDEX_PUMP_REST_SEC_MAX = 120.0f; +// Stillstands-Erkennung: Pumpe laeuft, aber die Waage sieht keinen Zulauf -> Bezug beenden +// (faengt einen ausgeloesten Pumpen-Thermoschutz und einen verstopften Puck ab). +const unsigned long COLDEX_STALL_TIMEOUT_MS = 15000UL; +const float COLDEX_STALL_MIN_WEIGHT_DELTA_G = 0.3f; + +bool coldExtractionModeActive = false; // Modus scharf: Wasserkreis heizt nicht +bool coldExtractionShotActive = false; // der laufende Bezug ist eine kalte Extraktion +bool coldExtractionPumpResting = false; // Zwangspause der Pumpe laeuft +bool coldExtractionPumpOutputOn = false; // letzter an die Pumpe geschriebener Zustand +unsigned long coldExtractionPumpRunMs = 0; // kumulierte Pumpen-Einschaltzeit seit letzter Pause +unsigned long coldExtractionRestUntilMs = 0; +unsigned long coldExtractionPumpSampleMs = 0; +float coldExtractionStallLastWeight = NAN; +unsigned long coldExtractionStallSinceMs = 0; +bool lastShotWasColdExtraction = false; // fuer Anzeige/Log des letzten Bezugs +// Sperrmeldung fuer OLED und Touch-Display, wenn ein kalter Bezug abgewiesen wurde +const unsigned long COLDEX_BLOCK_MESSAGE_MS = 6000UL; +String coldExtractionBlockMessage = ""; +unsigned long coldExtractionBlockMessageUntilMs = 0; + /************************************************************************************ * Standardwerte und Default-Einstellungen ************************************************************************************/ @@ -957,7 +1027,16 @@ const int EEPROM_ADDR_ACTIVE_PROFILE_NAME = 912; // 31 Bytes (char[31], nul const int EEPROM_ADDR_ACTIVE_PROFILE_DIRTY = 943; // 1 Byte (bool) -> Ende 943 const int EEPROM_ADDR_FF_BOOST_PERCENT_WASSER = 944; // 1 Byte (uint8_t) -> Ende 944 const int EEPROM_ADDR_FF_IFREEZE_WASSER = 945; // 1 Byte (bool) -> Ende 945 -// Naechste freie Adresse: 946 (Innerhalb EEPROM_SIZE=1024) +const int EEPROM_ADDR_COLDEX_ENABLED = 946; // 1 Byte (bool) -> Ende 946 +const int EEPROM_ADDR_COLDEX_MAX_TEMP = 947; // 4 Bytes (float) -> Ende 950 +const int EEPROM_ADDR_COLDEX_PREINF_SEC = 951; // 4 Bytes (float) -> Ende 954 +const int EEPROM_ADDR_COLDEX_PULSE_PERIOD_MS = 955; // 2 Bytes (uint16_t) -> Ende 956 +const int EEPROM_ADDR_COLDEX_DUTY = 957; // 4 Bytes (float) -> Ende 960 +const int EEPROM_ADDR_COLDEX_TARGET_GRAMS = 961; // 4 Bytes (float) -> Ende 964 +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_REST = 973; // 4 Bytes (float) -> Ende 976 +// Naechste freie Adresse: 977 (Innerhalb EEPROM_SIZE=1024) /************************************************************************************ * Eco-Mode Variablen @@ -1272,6 +1351,7 @@ unsigned long savedShotDuration = 0; // --- Zusätzliche Variablen für das Speichern des Gewichts --- bool savedShotWasByWeight = false; // War der zu speichernde Shot ein "By-Weight"-Shot? float savedShotFinalWeight = 0.0f; // Das finale Gewicht des zu speichernden Shots +bool savedShotWasColdExtraction = false; // War der zu speichernde Shot eine kalte Extraktion? /************************************************************************************ * Wartungserinnerung Variablen @@ -2938,6 +3018,15 @@ void resetToDefaults() { EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, defaultFlowGuardMinDutyPercent); EEPROM.put(EEPROM_ADDR_STEAMBYTIME_ENABLED, defaultSteamByTimeEnabled); EEPROM.put(EEPROM_ADDR_STEAMBYTIME_SECONDS, defaultSteamByTimeTargetSeconds); + EEPROM.put(EEPROM_ADDR_COLDEX_ENABLED, defaultColdExtractionEnabled); + EEPROM.put(EEPROM_ADDR_COLDEX_MAX_TEMP, defaultColdExtractionMaxTempC); + EEPROM.put(EEPROM_ADDR_COLDEX_PREINF_SEC, defaultColdExtractionPreInfusionSeconds); + EEPROM.put(EEPROM_ADDR_COLDEX_PULSE_PERIOD_MS, defaultColdExtractionPulsePeriodMs); + EEPROM.put(EEPROM_ADDR_COLDEX_DUTY, defaultColdExtractionDutyPercent); + EEPROM.put(EEPROM_ADDR_COLDEX_TARGET_GRAMS, defaultColdExtractionTargetGrams); + EEPROM.put(EEPROM_ADDR_COLDEX_MAX_SECONDS, defaultColdExtractionMaxSeconds); + EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_MAX_RUN, defaultColdExtractionPumpMaxRunSec); + EEPROM.put(EEPROM_ADDR_COLDEX_PUMP_REST, defaultColdExtractionPumpRestSec); // Piezo auf Default im EEPROM schreiben EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled); @@ -3016,6 +3105,17 @@ void resetToDefaults() { flowGuardLastNetWeight = NAN; flowGuardLastSampleMs = 0; flowGuardPumpPulseActive = false; + coldExtractionEnabled = defaultColdExtractionEnabled; + coldExtractionMaxTempC = defaultColdExtractionMaxTempC; + coldExtractionPreInfusionSeconds = defaultColdExtractionPreInfusionSeconds; + coldExtractionPulsePeriodMs = defaultColdExtractionPulsePeriodMs; + coldExtractionDutyPercent = defaultColdExtractionDutyPercent; + coldExtractionTargetGrams = defaultColdExtractionTargetGrams; + coldExtractionMaxSeconds = defaultColdExtractionMaxSeconds; + coldExtractionPumpMaxRunSec = defaultColdExtractionPumpMaxRunSec; + coldExtractionPumpRestSec = defaultColdExtractionPumpRestSec; + coldExtractionModeActive = false; + coldExtractionShotActive = false; steamByTimeEnabled = defaultSteamByTimeEnabled; steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds; // Globale Piezo Variable auf Default setzen @@ -3786,6 +3886,14 @@ void updateFlowGuardFlowEstimate(float netWeight, unsigned long nowMs) { } void applyFlowGuardPumpControl(unsigned long currentShotTimeMillis, float netWeight, float targetWeightToStopAt) { + // Cold Extraction steuert die Pumpe exklusiv ueber die eigene Pulslogik. FlowGuard + // muss hier vor jedem digitalWrite(PUMP_PIN, ...) aussteigen, sonst kaempfen beide + // Regler um denselben Ausgang. + if (coldExtractionShotActive) { + flowGuardPumpPulseActive = false; + return; + } + if (currentPreInfusionState != PI_MAIN_BREW) { flowGuardPumpPulseActive = false; return; @@ -3876,6 +3984,196 @@ void applyFlowGuardPumpControl(unsigned long currentShotTimeMillis, float netWei digitalWrite(PUMP_PIN, pumpOn ? HIGH : LOW); } +/************************************************************************************ + * Cold Extraction (kalte Extraktion) + ************************************************************************************/ + +void resetColdExtractionRuntime() { + coldExtractionShotActive = false; + coldExtractionPumpResting = false; + coldExtractionPumpOutputOn = false; + coldExtractionPumpRunMs = 0; + coldExtractionRestUntilMs = 0; + coldExtractionPumpSampleMs = 0; + coldExtractionStallLastWeight = NAN; + coldExtractionStallSinceMs = 0; +} + +// Ist der Wasserkessel kalt genug fuer eine kalte Extraktion? +// Mit Hysterese: einmal freigegeben, bleibt die Freigabe bis Schwelle + Marge bestehen, +// damit die Anzeige an der Schwelle nicht flattert. Sensorfehler = keine Freigabe. +bool coldExtractionTemperatureOk() { + if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) { + return false; + } + float limit = coldExtractionMaxTempC; + if (coldExtractionModeActive) { + limit += COLDEX_TEMP_RELEASE_MARGIN_C; + } + return (InputWasser < (double)limit); +} + +// Allokationsfreie Freigabepruefung - wird in den JSON-Pushes (2x/s) verwendet. +bool coldExtractionConditionsOk() { + return coldExtractionEnabled && + !standbyModeActive && + !wartungsModusAktiv && + !cleaningAssistantActive && + !autoTuneWasserActive && + !autoTuneDampfActive && + coldExtractionTemperatureOk(); +} + +// Klartext-Grund, warum gerade keine kalte Extraktion moeglich ist ("" = alles frei). +String coldExtractionBlockReason() { + if (!coldExtractionEnabled) { + return "Cold Extraction ist nicht freigeschaltet."; + } + if (standbyModeActive) { + return "Cold Extraction nicht moeglich: Standby aktiv."; + } + if (wartungsModusAktiv) { + return "Cold Extraction nicht moeglich: Wartungsmodus aktiv."; + } + if (cleaningAssistantActive) { + return "Cold Extraction nicht moeglich: Reinigungsassistent aktiv."; + } + if (autoTuneWasserActive || autoTuneDampfActive) { + return "Cold Extraction nicht moeglich: PID-Tuning laeuft."; + } + if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) { + return "Cold Extraction nicht moeglich: Wasser-Sensor liefert keinen gueltigen Wert."; + } + if (!coldExtractionTemperatureOk()) { + char buf[96]; + snprintf(buf, sizeof(buf), "Wasserkessel zu warm (%.1f C, benoetigt < %.1f C).", + (double)InputWasser, (double)coldExtractionMaxTempC); + return String(buf); + } + return ""; +} + +// Modus scharf schalten bzw. beenden. Ein laufender Bezug wird nie abgebrochen. +bool setColdExtractionMode(bool enable, String& message) { + if (enable == coldExtractionModeActive) { + message = coldExtractionModeActive ? "Cold Extraction bereits aktiv." : "Cold Extraction bereits aus."; + return true; + } + + if (enable) { + String reason = coldExtractionBlockReason(); + if (reason.length() > 0) { + message = reason; + return false; + } + if (shotActive || shotSoftwareActive || flushActive) { + message = "Cold Extraction nicht moeglich: Bezug oder Spuelen laeuft."; + return false; + } + coldExtractionModeActive = true; + resetColdExtractionRuntime(); + message = "Cold Extraction aktiviert - Wasserkreis heizt nicht."; + return true; + } + + if (shotActive || shotSoftwareActive) { + message = "Cold Extraction kann erst nach dem laufenden Bezug beendet werden."; + return false; + } + coldExtractionModeActive = false; + resetColdExtractionRuntime(); + message = "Cold Extraction beendet - Wasserkreis heizt wieder."; + return true; +} + +// Darf jetzt ein kalter Bezug starten? (Prueft pro Shot neu, nicht nur beim Aktivieren.) +bool coldExtractionShotAllowed() { + return coldExtractionModeActive && coldExtractionConditionsOk(); +} + +// Pumpensteuerung des kalten Bezugs: Vorbenetzung ungepulst, danach Puls-Duty. +// Zusaetzlich Pumpenschutz ueber die kumulierte Einschaltzeit (Zwangspause) - die Olab +// hat zwar einen Thermoschutz, ein Auslösen mitten im Bezug wuerde aber den Shot +// abbrechen und den Puck unter geschlossenem Ventil stehen lassen. +void applyColdExtractionPumpControl(unsigned long currentShotTimeMillis) { + unsigned long nowMs = millis(); + + // Kumulierte Pumpenlaufzeit fortschreiben (nur solange die Pumpe wirklich lief). + if (coldExtractionPumpSampleMs != 0 && coldExtractionPumpOutputOn) { + coldExtractionPumpRunMs += (nowMs - coldExtractionPumpSampleMs); + } + coldExtractionPumpSampleMs = nowMs; + + // Zwangspause verwalten + if (coldExtractionPumpResting) { + if ((long)(nowMs - coldExtractionRestUntilMs) >= 0) { + coldExtractionPumpResting = false; + coldExtractionPumpRunMs = 0; + } + } else if (coldExtractionPumpMaxRunSec > 0.0f && + coldExtractionPumpRunMs >= (unsigned long)(coldExtractionPumpMaxRunSec * 1000.0f)) { + if (coldExtractionPumpRestSec > 0.0f) { + coldExtractionPumpResting = true; + coldExtractionRestUntilMs = nowMs + (unsigned long)(coldExtractionPumpRestSec * 1000.0f); + } else { + coldExtractionPumpRunMs = 0; // Pause deaktiviert -> nur Zaehler zuruecksetzen + } + } + + bool pumpOn; + if (coldExtractionPumpResting) { + pumpOn = false; + } else if (currentPreInfusionState == PI_PRE_BREW) { + pumpOn = true; // Vorbenetzung: volle Pumpenleistung + } else { + float duty = coldExtractionDutyPercent / 100.0f; + duty = constrain(duty, COLDEX_DUTY_MIN / 100.0f, 1.0f); + uint16_t periodMs = coldExtractionPulsePeriodMs; + if (periodMs < COLDEX_PULSE_PERIOD_MIN_MS || periodMs > COLDEX_PULSE_PERIOD_MAX_MS) { + periodMs = defaultColdExtractionPulsePeriodMs; + } + if (duty >= 0.999f) { + pumpOn = true; + } else { + unsigned long onTimeMs = (unsigned long)((float)periodMs * duty); + if (onTimeMs < 1UL) { onTimeMs = 1UL; } + pumpOn = ((currentShotTimeMillis % periodMs) < onTimeMs); + } + } + + coldExtractionPumpOutputOn = pumpOn; + digitalWrite(VALVE_PIN, HIGH); + digitalWrite(PUMP_PIN, pumpOn ? HIGH : LOW); +} + +// Stillstands-Erkennung: laeuft die Pumpe (ohne Zwangspause) ohne Zulauf auf der Waage, +// ist entweder der Puck dicht oder der Pumpen-Thermoschutz hat ausgeloest. +bool coldExtractionStallDetected(float netWeight) { + if (!scaleEnabled || !scaleConnected || !isfinite(netWeight)) { + coldExtractionStallLastWeight = NAN; + coldExtractionStallSinceMs = 0; + return false; + } + if (coldExtractionPumpResting) { + coldExtractionStallSinceMs = 0; // Pause ist kein Stillstand + coldExtractionStallLastWeight = netWeight; + return false; + } + + unsigned long nowMs = millis(); + if (!isfinite(coldExtractionStallLastWeight) || coldExtractionStallSinceMs == 0) { + coldExtractionStallLastWeight = netWeight; + coldExtractionStallSinceMs = nowMs; + return false; + } + if ((netWeight - coldExtractionStallLastWeight) >= COLDEX_STALL_MIN_WEIGHT_DELTA_G) { + coldExtractionStallLastWeight = netWeight; + coldExtractionStallSinceMs = nowMs; + return false; + } + return ((nowMs - coldExtractionStallSinceMs) >= COLDEX_STALL_TIMEOUT_MS); +} + /************************************************************************************ * Feed-Forward bei Bezug (Wasserkessel) ************************************************************************************/ @@ -3900,7 +4198,7 @@ void setPidWasserIntegralFrozen(bool frozen) { // nicht im Wartungsmodus und nicht während AutoTune. bool ffBoostWasserActiveNow() { return ffBoostPercentWasser > 0 && (shotActive || shotSoftwareActive) && - !wartungsModusAktiv && !autoTuneWasserActive; + !wartungsModusAktiv && !autoTuneWasserActive && !coldExtractionModeActive; } // Effektive Einschaltzeit im aktuellen Fenster: PID-Output + Feed-Forward-Boost, @@ -3937,11 +4235,14 @@ void stopBrewSequence(unsigned long durationMillis, bool manualStop = false, boo } // Statusvariablen zurücksetzen + bool wasColdExtraction = coldExtractionShotActive; + lastShotWasColdExtraction = wasColdExtraction; shotActive = false; shotSoftwareActive = false; currentPreInfusionState = PI_INACTIVE; lastShotTime = millis(); // Wichtig für Eco-Modus resetFlowGuardRuntime(); + resetColdExtractionRuntime(); // beendet nur den Bezug, coldExtractionModeActive bleibt // Hardware stoppen digitalWrite(PUMP_PIN, LOW); @@ -3958,6 +4259,7 @@ void stopBrewSequence(unsigned long durationMillis, bool manualStop = false, boo shotNeedsToBeSaved = true; savedShotDuration = durationMillis; savedShotWasByWeight = stoppedByWeight; // Kennzeichnen, ob der Bezug gewichtsbasierend war + savedShotWasColdExtraction = wasColdExtraction; // Kennzeichnen fuer Log/Statistik if(stoppedByWeight) { savedShotFinalWeight = lastShotFinalNetWeight + brewByWeightOffsetGrams; ; // Gewicht (inkl. Offset) für den Log merken } else { @@ -4274,6 +4576,50 @@ void setup() { steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds; } + // --- Cold Extraction --- + // Bei einem Update aus einer aelteren Version steht in den neuen Adressen 0xFF. Ein + // direktes EEPROM.get in ein bool wuerde daraus "true" machen -> als uint8_t pruefen. + uint8_t storedColdExtractionEnabled = 0; + EEPROM.get(EEPROM_ADDR_COLDEX_ENABLED, storedColdExtractionEnabled); + if (storedColdExtractionEnabled > 1) { storedColdExtractionEnabled = (uint8_t)(defaultColdExtractionEnabled ? 1 : 0); } + coldExtractionEnabled = (storedColdExtractionEnabled == 1); + EEPROM.get(EEPROM_ADDR_COLDEX_MAX_TEMP, coldExtractionMaxTempC); + EEPROM.get(EEPROM_ADDR_COLDEX_PREINF_SEC, coldExtractionPreInfusionSeconds); + EEPROM.get(EEPROM_ADDR_COLDEX_PULSE_PERIOD_MS, coldExtractionPulsePeriodMs); + EEPROM.get(EEPROM_ADDR_COLDEX_DUTY, coldExtractionDutyPercent); + EEPROM.get(EEPROM_ADDR_COLDEX_TARGET_GRAMS, coldExtractionTargetGrams); + EEPROM.get(EEPROM_ADDR_COLDEX_MAX_SECONDS, coldExtractionMaxSeconds); + EEPROM.get(EEPROM_ADDR_COLDEX_PUMP_MAX_RUN, coldExtractionPumpMaxRunSec); + EEPROM.get(EEPROM_ADDR_COLDEX_PUMP_REST, coldExtractionPumpRestSec); + if (!isfinite(coldExtractionMaxTempC) || coldExtractionMaxTempC < COLDEX_MAX_TEMP_MIN || coldExtractionMaxTempC > COLDEX_MAX_TEMP_MAX) { + coldExtractionMaxTempC = defaultColdExtractionMaxTempC; + } + if (!isfinite(coldExtractionPreInfusionSeconds) || coldExtractionPreInfusionSeconds < COLDEX_PREINF_SEC_MIN || coldExtractionPreInfusionSeconds > COLDEX_PREINF_SEC_MAX) { + coldExtractionPreInfusionSeconds = defaultColdExtractionPreInfusionSeconds; + } + if (coldExtractionPulsePeriodMs < COLDEX_PULSE_PERIOD_MIN_MS || coldExtractionPulsePeriodMs > COLDEX_PULSE_PERIOD_MAX_MS) { + coldExtractionPulsePeriodMs = defaultColdExtractionPulsePeriodMs; + } + if (!isfinite(coldExtractionDutyPercent) || coldExtractionDutyPercent < COLDEX_DUTY_MIN || coldExtractionDutyPercent > COLDEX_DUTY_MAX) { + coldExtractionDutyPercent = defaultColdExtractionDutyPercent; + } + if (!isfinite(coldExtractionTargetGrams) || coldExtractionTargetGrams < COLDEX_TARGET_GRAMS_MIN || coldExtractionTargetGrams > COLDEX_TARGET_GRAMS_MAX) { + coldExtractionTargetGrams = defaultColdExtractionTargetGrams; + } + if (!isfinite(coldExtractionMaxSeconds) || coldExtractionMaxSeconds < COLDEX_MAX_SECONDS_MIN || coldExtractionMaxSeconds > COLDEX_MAX_SECONDS_MAX) { + coldExtractionMaxSeconds = defaultColdExtractionMaxSeconds; + } + if (!isfinite(coldExtractionPumpMaxRunSec) || coldExtractionPumpMaxRunSec < COLDEX_PUMP_MAX_RUN_SEC_MIN || coldExtractionPumpMaxRunSec > COLDEX_PUMP_MAX_RUN_SEC_MAX) { + coldExtractionPumpMaxRunSec = defaultColdExtractionPumpMaxRunSec; + } + if (!isfinite(coldExtractionPumpRestSec) || coldExtractionPumpRestSec < COLDEX_PUMP_REST_SEC_MIN || coldExtractionPumpRestSec > COLDEX_PUMP_REST_SEC_MAX) { + coldExtractionPumpRestSec = defaultColdExtractionPumpRestSec; + } + // Der Modus selbst wird bewusst NICHT persistiert: nach einem Neustart startet die + // Maschine immer im normalen Heizbetrieb. + coldExtractionModeActive = false; + coldExtractionShotActive = false; + // Piezo-Status laden EEPROM.get(EEPROM_ADDR_PIEZO_ENABLED, piezoEnabled); @@ -4888,6 +5234,12 @@ void loop() { if (wartungsModusAktiv) { wartungsModusAktiv = false; } + // Cold Extraction beim Wechsel in den Standby beenden (analog Eco/Wartung) + if (coldExtractionModeActive) { + coldExtractionModeActive = false; + resetColdExtractionRuntime(); + } + coldExtractionBlockMessage = ""; } else if (lastStandbyModeActive) { startupTime = currentMillis; ecoForcedActive = false; @@ -4931,7 +5283,10 @@ void loop() { // Feed-Forward: I-Anteil der Wasser-PID während des Bezugs einfrieren/freigeben. // Zentral hier verwaltet (statt an Start-/Stopp-Stellen), damit der Zustand auch // nach Software-Shots, Einstellungsänderungen mid-shot etc. immer konsistent ist. - setPidWasserIntegralFrozen(ffIFreezeWasser && (shotActive || shotSoftwareActive) && !wartungsModusAktiv); + // 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 || + (ffIFreezeWasser && (shotActive || shotSoftwareActive) && !wartungsModusAktiv)); // --- Angepasste Sicherheitsueberpruefung Wasser --- if (InputWasser < 0.0 || isnan(InputWasser)) { @@ -4951,8 +5306,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 && (InputWasser < (SetpointWasser * 0.95)); + bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && !coldExtractionModeActive && (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; } } yield(); @@ -5029,7 +5388,7 @@ void loop() { recordResetCheckpoint(RESET_CP_LOOP_SSR); unsigned long now = millis(); // Wasser SSR - if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive) { digitalWrite(SSR_WASSER_PIN, LOW); } else { + if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive || coldExtractionModeActive) { 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)); @@ -5102,10 +5461,33 @@ void loop() { bool stopForTime = brewByTimeEnabled && (currentShotTimeMillis >= (unsigned long)(brewByTimeTargetSeconds * 1000.0f)); bool stopForWeight = brewByWeightEnabled && scaleConnected && (netWeight >= targetWeightToStopAt); + // Cold Extraction hat eigene Abbruchbedingungen und ignoriert Brew-by-Time/Weight: + // Zielgewicht, Sicherheits-Timeout und Stillstands-Erkennung. + if (coldExtractionShotActive) { + targetWeightToStopAt = coldExtractionTargetGrams; + stopForTime = (currentShotTimeMillis >= (unsigned long)(coldExtractionMaxSeconds * 1000.0f)); + stopForWeight = scaleEnabled && scaleConnected && (netWeight >= targetWeightToStopAt); + if (!stopForTime && !stopForWeight && coldExtractionStallDetected(netWeight)) { + stopForTime = true; + coldExtractionBlockMessage = "Cold Extraction beendet: kein Zulauf (Puck dicht oder Pumpenschutz?)."; + coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; + } + } + if (stopForTime || stopForWeight) { stopBrewSequence(currentShotTimeMillis, false, stopForWeight); // Uebergibt die aktuelle Dauer und den Grund des Stopps } + // --- Cold-Extraction-Phasen (Vorbenetzung -> Hauptbezug, keine Pause) --- + else if (coldExtractionShotActive) { + unsigned long currentPhaseTimeMillis = millis() - preInfusionPhaseStartTime; + if (currentPreInfusionState == PI_PRE_BREW && + (currentPhaseTimeMillis >= (unsigned long)(coldExtractionPreInfusionSeconds * 1000.0f))) { + currentPreInfusionState = PI_MAIN_BREW; + preInfusionPhaseStartTime = millis(); + } + } + // --- Pre-Infusion Zustandsmaschine (Nur ausfuehren, wenn noch nicht gestoppt wurde) --- else if (preInfusionEnabled) { unsigned long currentPhaseTimeMillis = millis() - preInfusionPhaseStartTime; @@ -5125,7 +5507,11 @@ void loop() { } if (shotActive) { - applyFlowGuardPumpControl(currentShotTimeMillis, netWeight, targetWeightToStopAt); + if (coldExtractionShotActive) { + applyColdExtractionPumpControl(currentShotTimeMillis); + } else { + applyFlowGuardPumpControl(currentShotTimeMillis, netWeight, targetWeightToStopAt); + } } // Kein else hier -> Entweder Stopp, oder PreInfusion-Check, oder normaler Bezug ohne PreInfusion laeuft weiter } @@ -5582,6 +5968,36 @@ void updateDisplay() { return; } + // Cold-Extraction-Sperrmeldung (abgewiesener kalter Bezug bzw. Abbruch wegen Stillstand) + if (coldExtractionBlockMessage.length() > 0) { + if ((long)(millis() - coldExtractionBlockMessageUntilMs) >= 0) { + coldExtractionBlockMessage = ""; + } else if (!standbyModeActive) { + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SH110X_WHITE); + display.setCursor(0, 0); + display.println(F("Cold Extraction")); + display.println(F("")); + // Meldung auf 21 Zeichen pro Zeile umbrechen (128 px / 6 px Zeichenbreite) + const size_t lineLen = 21; + String rest = coldExtractionBlockMessage; + while (rest.length() > 0) { + if (rest.length() <= lineLen) { + display.println(rest); + break; + } + int cut = rest.lastIndexOf(' ', lineLen); + if (cut <= 0) { cut = lineLen; } + display.println(rest.substring(0, cut)); + rest = rest.substring(cut); + rest.trim(); + } + display.display(); + return; + } + } + static bool lastStandbyDisplayActive = false; static int lastStandbyDisplayMinute = -1; if (standbyModeActive) { @@ -6038,9 +6454,15 @@ if (scaleModeActive) { display.setCursor(0, 30); display.println(weightBuffer); display.println(""); - if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));} - if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));} - if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));} + if (coldExtractionShotActive) { + if (coldExtractionPumpResting) { display.println(F("Cold: Pumpenpause")); } + else if (currentPreInfusionState == PI_PRE_BREW) { display.println(F("Cold: Vorbenetzung")); } + else { display.println(F("Cold Extraction ...")); } + } else { + if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));} + if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));} + if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));} + } } else { unsigned long elapsed = millis() - shotStartTime; display.println(F("Shot-Timer:")); @@ -6051,9 +6473,15 @@ if (scaleModeActive) { display.println(buffer); display.setTextSize(1); display.println(F("Sekunden")); - if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));} - if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));} - if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));} + if (coldExtractionShotActive) { + if (coldExtractionPumpResting) { display.println(F("Cold: Pumpenpause")); } + else if (currentPreInfusionState == PI_PRE_BREW) { display.println(F("Cold: Vorbenetzung")); } + else { display.println(F("Cold Extraction ...")); } + } else { + if(preInfusionEnabled && currentPreInfusionState == PI_PRE_BREW) {display.println(F("Pre-Infusion ..."));} + if(preInfusionEnabled && currentPreInfusionState == PI_PAUSE) {display.println(F("Pause ..."));} + if(preInfusionEnabled && currentPreInfusionState == PI_MAIN_BREW) {display.println(F("Pre-Infusion beendet."));} + } } fastHeatUpHeating = false; } @@ -6120,6 +6548,13 @@ if (scaleModeActive) { display.println(F("Wasser: ")); if (wasserSafetyShutdown) { display.print(F("Sicherheitsabsch.")); } else if (wasserSensorError) { display.print(F("Sensorfehler!")); } + else if (coldExtractionModeActive) { + // Cold Extraction: kein Sollwert-Vergleich sinnvoll, der Wasserkreis heizt nicht + display.print((int)displayedWaterTemp); + display.print(F(" ")); + display.print((char)247); + display.print(F("C (COLD)")); + } else { display.print((int)displayedWaterTemp); display.print(F(" / ")); @@ -6284,8 +6719,24 @@ void updateShotTimer() { } } + // Cold Extraction hat eine eigene Phasenlogik (lange Vorbenetzung, danach + // gepulster Hauptbezug) und laeuft unabhaengig von Pre-Infusion/FlowGuard. + if (coldExtractionModeActive) { + coldExtractionShotActive = true; + coldExtractionPumpResting = false; + coldExtractionPumpRunMs = 0; + coldExtractionRestUntilMs = 0; + coldExtractionPumpSampleMs = millis(); + coldExtractionStallLastWeight = NAN; + coldExtractionStallSinceMs = 0; + coldExtractionPumpOutputOn = true; + currentPreInfusionState = (coldExtractionPreInfusionSeconds > 0.0f) ? PI_PRE_BREW : PI_MAIN_BREW; + preInfusionPhaseStartTime = millis(); + digitalWrite(PUMP_PIN, HIGH); + digitalWrite(VALVE_PIN, HIGH); + } // Pre-Infusion oder direkten Start behandeln - if (preInfusionEnabled) { + else if (preInfusionEnabled) { currentPreInfusionState = PI_PRE_BREW; preInfusionPhaseStartTime = millis(); digitalWrite(PUMP_PIN, HIGH); @@ -6578,7 +7029,8 @@ Steuert das automatische Beenden des Brühvorgangs basierend auf Zeit oder G Wenn beide aktiviert sind, stoppt der Bezug, sobald die erste Bedingung (Zeit oder Gewicht) erreicht ist. Der Bezug kann aber weiterhin manuell über den Bezugs-Schalter beendet werden.
Ebenso kann eine Pre-Infusion aktiviert und eingestellt werden.
-FlowGuard verlängert bei Bedarf die Bezugszeit bis zur eingestellten Mindest-Brühzeit, indem die Pumpe in der Hauptbezugsphase dynamisch gepulst wird.

+FlowGuard verlängert bei Bedarf die Bezugszeit bis zur eingestellten Mindest-Brühzeit, indem die Pumpe in der Hauptbezugsphase dynamisch gepulst wird.
+Cold Extraction erlaubt eine kalte Extraktion bei kaltem Wasserkessel; die Parameter dazu stehen weiter unten.

)rawliteral"; // --- Brew-By-Time (BBT) Chunks --- @@ -6631,6 +7083,37 @@ static const char brewControlPI_Fields[] PROGMEM = R"rawliteral( (Pumpe AN -> Pumpe AUS -> Pumpe AN)
)rawliteral"; +// --- Cold Extraction (CX) Chunks --- +static const char brewControlCX_H3[] PROGMEM = R"rawliteral(

Cold Extraction (kalte Extraktion)

)rawliteral"; +static const char brewControlCX_ToggleContainerStart[] PROGMEM = R"rawliteral(
Freischalten:
)rawliteral"; +static const char brewControlCX_Fields[] PROGMEM = R"rawliteral( + + + + + + + + + + + + + + + + + (Die Maschine hat keinen Bypass um den Wasserkessel: Kaltes Wasser kommt nur bei kaltem + Kessel am Puck an. Deshalb ist die kalte Extraktion nur unterhalb der Freigabetemperatur möglich, praktisch also + aus dem kalten Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht - der Dampfkreis bleibt + unberührt und darf weiter heizen. Die Pumpenlaufzeit wird kumuliert gezählt; nach der eingestellten + Laufzeit legt die Steuerung eine Zwangspause ein. Der Modus selbst wird auf dem Dashboard bzw. am Touch-Display + ein- und ausgeschaltet.) + {CX_STATUS_MSG} +
+)rawliteral"; + // --- Steam-By-Time (SBT) Chunks --- static const char brewControlSBT_H3[] PROGMEM = R"rawliteral(

Dampf-Timer

)rawliteral"; static const char brewControlSBT_ToggleContainerStart[] PROGMEM = R"rawliteral(
Aktivieren:
+ +

Waage

Waage anzeigen: @@ -14218,6 +14912,27 @@ static const char dashboardJavaScript[] PROGMEM = R"rawliteral( if (scaleModeToggle) { scaleModeToggle.disabled = (data.scaleConnected === undefined || data.scaleConnected === false); } + // Cold Extraction: Zeile nur bei freigeschalteter Funktion, Toggle nur bei Temperatur-Freigabe + const coldexRow = document.getElementById('coldex_row'); + const coldexToggleEl = document.getElementById('coldex_toggle'); + const coldexHint = document.getElementById('coldex_hint'); + if (coldexRow) { coldexRow.style.display = (data.cxEnabled === true) ? '' : 'none'; } + if (coldexToggleEl) { + updateToggleState(coldexToggleEl, data.cxActive); + coldexToggleEl.disabled = (data.cxEnabled !== true) || (data.cxActive !== true && data.cxAllowed !== true); + } + if (coldexHint) { + let hintText = ''; + if (data.cxEnabled === true && data.cxAllowed !== true && data.cxActive !== true) { + if (typeof data.tempW === 'number' && typeof data.cxMaxTemp === 'number' && data.tempW >= data.cxMaxTemp) { + hintText = 'Wasserkessel zu warm (' + data.tempW.toFixed(1) + ' °C, benötigt < ' + data.cxMaxTemp.toFixed(1) + ' °C).'; + } else { + hintText = 'Cold Extraction derzeit nicht möglich.'; + } + } + coldexHint.textContent = hintText; + coldexHint.style.display = hintText ? '' : 'none'; + } // --- Ende Toggle Update --- @@ -14289,8 +15004,11 @@ static const char dashboardJavaScript[] PROGMEM = R"rawliteral( if (piToggle) { piToggle.addEventListener('change', (event) => { sendDashboardAction('togglePI', event.target.checked); }); } const bbtToggle = document.getElementById('bbt_toggle'); if (bbtToggle) { bbtToggle.addEventListener('change', (event) => { sendDashboardAction('toggleBBT', event.target.checked); }); } - const bbwToggle = document.getElementById('bbw_toggle'); - if (bbwToggle) { bbwToggle.addEventListener('change', (event) => { sendDashboardAction('toggleBBW', event.target.checked); }); } document.getElementById('profile_load_button').addEventListener('click', () => { const select = document.getElementById('profile_select'); + const bbwToggle = document.getElementById('bbw_toggle'); + if (bbwToggle) { bbwToggle.addEventListener('change', (event) => { sendDashboardAction('toggleBBW', event.target.checked); }); } + const coldexToggle = document.getElementById('coldex_toggle'); + if (coldexToggle) { coldexToggle.addEventListener('change', () => { sendDashboardAction('toggleColdExtraction'); }); } + document.getElementById('profile_load_button').addEventListener('click', () => { const select = document.getElementById('profile_select'); const profileName = select.value; if (profileName) { sendDashboardAction('loadProfile', profileName); } else { showDashboardMessage('Bitte zuerst ein Profil auswählen.', 'error'); } }); const steamBtn = document.getElementById('steam_now_button'); @@ -14745,6 +15463,20 @@ bool executeDashboardAction(const String& action, const String& value, message = "Dampf-Heizung aktiviert."; success = true; } + } else if (action == "startColdExtraction" || action == "stopColdExtraction" || action == "toggleColdExtraction") { + bool target; + if (action == "startColdExtraction") { + target = true; + } else if (action == "stopColdExtraction") { + target = false; + } else { + target = !coldExtractionModeActive; + } + success = setColdExtractionMode(target, message); + if (!success) { + coldExtractionBlockMessage = message; + coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; + } } else if (action == "startShot") { if (standbyModeActive) { message = "Standby aktiv - Bezug nicht moeglich."; @@ -14755,6 +15487,15 @@ bool executeDashboardAction(const String& action, const String& value, } else if (flushActive) { message = "Sp\xC3\xBClen laeuft - Bezug nicht moeglich."; success = false; + } else if (coldExtractionModeActive && !coldExtractionShotAllowed()) { + // 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."; } + coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS; + coldExtractionBlockMessage = message; + beepShort(); + success = false; } else { shotSoftwareActive = true; lastShotTime = millis(); @@ -15213,13 +15954,26 @@ void buildDashboardJson(char *buffer, size_t bufferSize) { } statusKey = "maintenance"; } else if (shotActive) { - switch (currentPreInfusionState) { - case PI_PRE_BREW: statusText = "Pre-Infusion..."; break; - case PI_PAUSE: statusText = "Pre-Infusion Pause..."; break; - case PI_MAIN_BREW:statusText = "Bezug aktiv..."; break; - default: statusText = "Bezug aktiv..."; break; + if (coldExtractionShotActive) { + if (coldExtractionPumpResting) { + statusText = "Cold Extraction: Pumpenpause..."; + } else { + statusText = (currentPreInfusionState == PI_PRE_BREW) + ? "Cold Extraction: Vorbenetzung..." + : "Cold Extraction laeuft..."; + } + } else { + switch (currentPreInfusionState) { + case PI_PRE_BREW: statusText = "Pre-Infusion..."; break; + case PI_PAUSE: statusText = "Pre-Infusion Pause..."; break; + case PI_MAIN_BREW:statusText = "Bezug aktiv..."; break; + default: statusText = "Bezug aktiv..."; break; + } } statusKey = "brewing"; + } else if (coldExtractionModeActive) { + statusText = "Cold Extraction bereit (Wasserkreis heizt nicht)"; + statusKey = "coldextraction"; } else if (ecoModeAktiv && ecoSwitchActive) { statusText = dynamicEcoActive ? "Eco+ Modus aktiv (Schalter)" : "Eco Modus aktiv (Schalter)"; statusKey = "eco"; @@ -15330,6 +16084,12 @@ void buildDashboardJson(char *buffer, size_t bufferSize) { offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtEnabled\":%s,", brewByTimeEnabled ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwEnabled\":%s,", brewByWeightEnabled ? "true" : "false"); offset += snprintf(buffer + offset, bufferSize - offset, "\"sbtEnabled\":%s,", steamByTimeEnabled ? "true" : "false"); + // Cold Extraction: kompakte Flags, den Hinweistext baut die Oberflaeche selbst + // (haelt das Dashboard-JSON klein - DASHBOARD_JSON_BUFFER_SIZE ist knapp bemessen). + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxEnabled\":%s,", coldExtractionEnabled ? "true" : "false"); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxActive\":%s,", coldExtractionModeActive ? "true" : "false"); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxAllowed\":%s,", coldExtractionConditionsOk() ? "true" : "false"); + dtostrf(coldExtractionMaxTempC, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxTemp\":%s,", floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleModeActive\":%s,", scaleModeActive ? "true" : "false"); dtostrf(brewByTimeTargetSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbtSecs\":%s,", floatBuf); dtostrf(preInfusionDurationSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"piDurSecs\":%s,", floatBuf); @@ -15446,8 +16206,21 @@ static void getTouchUartStatus(String& statusText, String& statusKey) { } statusKey = "maintenance"; } else if (shotActive) { - statusText = "Bezug aktiv..."; + if (coldExtractionShotActive) { + if (coldExtractionPumpResting) { + statusText = "Cold Extraction: Pumpenpause..."; + } else { + statusText = (currentPreInfusionState == PI_PRE_BREW) + ? "Cold Extraction: Vorbenetzung..." + : "Cold Extraction laeuft..."; + } + } else { + statusText = "Bezug aktiv..."; + } statusKey = "brewing"; + } else if (coldExtractionModeActive) { + statusText = "Cold Extraction bereit"; + statusKey = "coldextraction"; } else if (ecoModeAktiv) { statusText = dynamicEcoActive ? "Eco+ Modus aktiv" : "Eco Modus aktiv"; statusKey = "eco"; @@ -15634,6 +16407,26 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) { // Pre-Infusion-Phase des laufenden Bezugs (0=inaktiv,1=Pre-Infusion,2=Pause,3=Hauptbezug) // - additives Feld (Protokoll bleibt v2), fuer die Phasenleiste auf dem Touch-Display offset += snprintf(buffer + offset, bufferSize - offset, "\"piState\":%u,", (unsigned int)currentPreInfusionState); + // --- Cold Extraction (additive Felder, Protokoll bleibt v2) --- + // cxEnabled = freigeschaltet, cxActive = Modus scharf, cxShot = laufender Bezug ist kalt, + // cxAllowed = Temperatur-Freigabe jetzt, cxResting = Pumpen-Zwangspause laeuft. + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxEnabled\":%s,", coldExtractionEnabled ? "true" : "false"); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxActive\":%s,", coldExtractionModeActive ? "true" : "false"); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxShot\":%s,", coldExtractionShotActive ? "true" : "false"); + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxAllowed\":%s,", coldExtractionConditionsOk() ? "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(coldExtractionTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxTarget\":%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. + // Die Texte stammen ausschliesslich aus dieser Firmware und enthalten keine JSON-Sonderzeichen. + { + const char* cxMsg = ""; + if (coldExtractionBlockMessage.length() > 0 && (long)(millis() - coldExtractionBlockMessageUntilMs) < 0) { + cxMsg = coldExtractionBlockMessage.c_str(); + } + offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMessage\":\"%s\",", cxMsg); + } 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); @@ -15956,6 +16749,10 @@ static void touchUartSendError(uint32_t id, const String& message) { static void touchUartSendState() { static char stateBuf[TOUCH_UART_JSON_BUFFER_SIZE]; // static: loopTask-Stack schonen (nur loopTask-Aufrufer) buildTouchUartStateJson(stateBuf, sizeof(stateBuf)); + // Abgeschnittenes JSON waere am Display nicht parsbar -> beim Erweitern der Felder sofort sichtbar machen + if (strlen(stateBuf) >= sizeof(stateBuf) - 1) { + Serial.println(F("WARNUNG: State-JSON fuer das Touch-Display abgeschnitten - TOUCH_UART_JSON_BUFFER_SIZE erhoehen!")); + } touchUartSendLine(stateBuf); } @@ -16022,6 +16819,7 @@ static int buildUsageStatsJson(char* buf, size_t bufSize, uint32_t id) { time_t tsArr[MAXS]; uint32_t durArr[MAXS]; float wArr[MAXS]; + bool cxArr[MAXS]; int count = 0; char* p = tail; @@ -16041,7 +16839,20 @@ static int buildUsageStatsJson(char* buf, size_t bufSize, uint32_t id) { char* c2 = strchr(rest, ','); unsigned long durMs; float w = -1.0f; - if (c2) { *c2 = '\0'; durMs = strtoul(rest, nullptr, 10); w = atof(c2 + 1); } + bool cold = false; + if (c2) { + *c2 = '\0'; + durMs = strtoul(rest, nullptr, 10); + char* wStart = c2 + 1; + // Optionales viertes Feld "C" = kalte Extraktion (siehe logShot) + char* c3 = strchr(wStart, ','); + if (c3) { + *c3 = '\0'; + cold = (*(c3 + 1) == 'C'); + } + w = atof(wStart); + if (cold && w <= 0.0f) { w = -1.0f; } // kalter Bezug ohne Gewichtsstopp + } else { durMs = strtoul(rest, nullptr, 10); } if (ts > 0 && durMs > 0) { if (timeValid) { @@ -16049,7 +16860,7 @@ static int buildUsageStatsJson(char* buf, size_t bufSize, uint32_t id) { if (isSameWeek(ts, now_t)) week++; } int idx = count % MAXS; - tsArr[idx] = ts; durArr[idx] = durMs; wArr[idx] = w; + tsArr[idx] = ts; durArr[idx] = durMs; wArr[idx] = w; cxArr[idx] = cold; count++; } } @@ -16071,11 +16882,13 @@ static int buildUsageStatsJson(char* buf, size_t bufSize, uint32_t id) { char dt[16]; strftime(dt, sizeof(dt), "%d.%m. %H:%M", &tmv); char db[12]; dtostrf(durArr[idx] / 1000.0, 3, 1, db); if (k > 0) o += snprintf(buf + o, bufSize - o, ","); + // "cx": kalte Extraktion (additiv, aeltere Display-Firmware ignoriert das Feld) + const char* cxFlag = cxArr[idx] ? "true" : "false"; if (wArr[idx] >= 0.0f) { char wb[12]; dtostrf(wArr[idx], 3, 1, wb); - o += snprintf(buf + o, bufSize - o, "{\"dt\":\"%s\",\"d\":%s,\"w\":%s}", dt, db, wb); + o += snprintf(buf + o, bufSize - o, "{\"dt\":\"%s\",\"d\":%s,\"w\":%s,\"cx\":%s}", dt, db, wb, cxFlag); } else { - o += snprintf(buf + o, bufSize - o, "{\"dt\":\"%s\",\"d\":%s,\"w\":-1}", dt, db); + o += snprintf(buf + o, bufSize - o, "{\"dt\":\"%s\",\"d\":%s,\"w\":-1,\"cx\":%s}", dt, db, cxFlag); } } o += snprintf(buf + o, bufSize - o, "]");