From db1b080dc41a827a8db66cc68780b6c0d6a0a140 Mon Sep 17 00:00:00 2001 From: raw-designs Date: Mon, 10 Aug 2026 21:02:11 +0200 Subject: [PATCH] feat(S3): Cold Extraction (kalte Extraktion) mit Temperatur-Freigabe (5.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kalte Extraktion ohne Kessel-Bypass: Da kaltes Wasser nur bei kaltem Wasserkessel am Puck ankommt, wird die Funktion über eine Temperaturschwelle freigegeben (Standard 30 °C, 10–40 °C einstellbar). Solange der Modus aktiv ist, heizt der Wasserkreis nicht (SSR gesperrt, Stellgröße und dutyW-Telemetrie auf 0, I-Anteil eingefroren, Feed-Forward-Boost aus); der Dampfkreis bleibt unberührt. Die Freigabe wird vor jedem Bezug neu geprüft, weil der weiter heizende Dampfkessel den Wasserkessel über die Zeit über die Schwelle bringen kann. Ein abgewiesener Start meldet den Grund auf OLED, Touch-Display und Web-Dashboard; ein laufender Bezug wird nie abgebrochen. 5 °C Hysterese, Sensorfehler sperrt. Fluss über Pumpen-Pulsung nach einer Vorbenetzung mit voller Leistung; Ende über Zielgewicht oder Timeout. Brew-by-Time/Weight und FlowGuard sind während einer kalten Extraktion außer Kraft, da FlowGuard sonst um denselben Pumpen-Ausgang konkurrieren würde. Pumpenschutz über kumulierte Einschaltzeit mit Zwangspause plus Stillstands-Erkennung bei aktiver Waage. Kalte Bezüge werden im CSV-Log mit einem vierten Feld "C" gekennzeichnet, zählen in allen Stückzahlen mit, bleiben aber aus der durchschnittlichen Bezugsdauer heraus. Neue additive State-Felder cx* im UART-JSON (Protokoll bleibt v2) und neuer statusKey "coldextraction". Co-Authored-By: Claude Opus 5 --- Changelog.txt | 31 + Doku/JC-Display_UART-Protokoll.md | 40 +- Dual-PID.ino | 909 ++++++++++++++++++++++++++++-- 3 files changed, 931 insertions(+), 49 deletions(-) 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, "]");