Compare commits

..
Author SHA1 Message Date
raw-designsandClaude Opus 5 49bdc686c4 fix(S3): Auswahl beim Aufwecken auch nach Kaltstart + Diagnose der Bedingungen (5.6.1)
Die Abfrage hängt an fünf Bedingungen gleichzeitig (freigeschaltet, Abfrage
eingeschaltet, Display verbunden, Modus gerade aus, kein Wartungsmodus/Reinigung/
Tuning). Fehlt eine, passiert schlicht nichts, und von außen war nicht erkennbar
welche. Die Seite /Cold-Extraction zeigt sie jetzt live mit Haken bzw. Kreuz an,
dazu ob das Auswahlfenster gerade offen ist.

Zusätzlich eine echte Lücke geschlossen: Die Abfrage kam nur beim Standby-Übergang.
Wer die Maschine am Netzschalter einschaltet, durchläuft nie einen solchen Übergang
— dieser Weg war nicht abgedeckt. Jetzt öffnet das Fenster einmal pro Laufzeit auch
nach einem Kaltstart, sobald sich das Display gemeldet hat. Startet die Maschine in
den Standby hinein, bleibt es beim Standby-Übergang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:33:35 +02:00
thomas 8dcebaa22f Merge pull request 'feat: Espresso/Cold-Auswahl bei jedem Aufwecken, auch per Schalter (S3 5.6.0 / P4 1.4.0)' (#22) from feat/cold-extraction-auswahl-nach-aufwecken into main 2026-08-12 01:47:09 +02:00
raw-designsandClaude Opus 5 7c1e485722 feat: Espresso/Cold-Auswahl bei jedem Aufwecken, auch per Schalter (S3 5.6.0 / P4 1.4.0)
Die Abfrage war eine reine Vorab-Abfrage des Displays und erschien nur beim
Antippen des Standby-Bildschirms. Beim Aufwecken über den Schalter ist der Standby
aber schon beendet, bevor das Display überhaupt fragen könnte — dieser Weg war nie
abgedeckt.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:51:48 +02:00
thomas e8b1832fc1 Merge pull request 'feat: eigene Cold-Extraction-Seite in Web-UI und Display (S3 5.4.0 / P4 1.2.0)' (#19) from feat/cold-extraction-eigene-seite into main 2026-08-12 00:09:49 +02:00
raw-designsandClaude Opus 5 2d8f70ae37 feat: eigene Cold-Extraction-Seite in Web-UI und Display (S3 5.4.0 / P4 1.2.0)
Cold Extraction bekommt in beiden Oberflächen einen eigenen Menüpunkt statt eines
angehängten Blocks auf der Brew-Control-Seite. Die Parameter sind damit erstmals
auch am Touch-Display änderbar.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:09:23 +02:00
thomas 537f1d4c20 Merge pull request 'fix(S3): Cold Extraction benetzt sanft statt mit voller Leistung (5.3.6)' (#18) from fix/cold-extraction-sanfte-vorbenetzung into main 2026-08-11 23:41:43 +02:00
raw-designsandClaude Opus 5 2cf375a2b4 fix(S3): Cold Extraction benetzt jetzt sanft statt mit voller Leistung (5.3.6)
Messung an der Maschine: Nach 7 s Vorbenetzung kommen heiß 1-2 sirupartige Tropfen,
kalt bei voller Pumpenleistung rund 10 g dünne Brühe ohne Crema — etwa zehnfacher
Durchfluss trotz dreifacher Viskosität.

Die Ursache ist nicht die Viskosität, sondern das Kaffeebett: Kaltes Mehl quillt
nicht, ist schlecht benetzbar, es wandert kein Feinanteil und es lösen sich kaum
Feststoffe, die das Bett verdichten. Volle Leistung auf den trockenen kalten Puck
legt deshalb sofort Kanäle an, die sich — anders als bei heißem Bezug — nie wieder
schließen; der restliche Bezug läuft daran vorbei statt zu extrahieren.

Vorbenetzung und Hauptbezug pulsen jetzt beide, nur mit unterschiedlicher Leistung.
Neue Einstellung "Pumpenleistung Vorbenetzung" (Standard 10 %), Standard im
Hauptbezug von 30 % auf 20 % gesenkt. Bestehende Installationen behalten ihre Werte;
das neue Feld greift beim Update automatisch auf den Default zurück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:41:23 +02:00
thomas e33de56edc Merge pull request 'feat: Cold Extraction im Standby scharf schaltbar (S3 5.3.5 / P4 1.1.3)' (#17) from feat/cold-extraction-im-standby into main 2026-08-11 03:01:10 +02:00
raw-designsandClaude Opus 5 9b91543435 feat: Cold Extraction im Standby scharf schaltbar (S3 5.3.5 / P4 1.1.3)
"Standby aktiv" und "Kessel zu warm" galten bisher auch fürs Aktivieren. Das war
falsch herum gedacht: Der Modus schaltet die Heizung AB, man kam aber erst an den
Schalter, nachdem die Maschine aufgeweckt war und schon zu heizen begonnen hatte —
genau der Ablauf, den die Funktion vermeiden soll.

Scharfschalten und Beziehen sind jetzt getrennt:
- Scharfschalten (neues State-Feld cxArmable): blockiert nur durch fehlende
  Freischaltung, Wartungsmodus, Reinigungsassistent, PID-Tuning oder einen
  laufenden Bezug/Spülvorgang.
- Bezug (unverändert cxAllowed): zusätzlich kein Standby, Kessel unter der
  Freigabeschwelle, gültiger Sensorwert.

Ist der Modus scharf, der Bezug aber gesperrt, sagen Web-UI und Display das jetzt
ausdrücklich. Bei Aktivierung mit warmem Kessel geht die Heizung sofort aus, die
Rückmeldung nennt die Sperre bis zum Unterschreiten der Schwelle.

P4: Der Aufweck-Dialog bekommt "Aufwecken mit Cold Extraction" — sendet
startColdExtraction vor deactivateStandby, damit der Wasserkreis gar nicht erst
anheizt. Der Schalter hängt jetzt an cxArmable; bei älterer S3-Firmware fällt das
Feld auf cxEnabled zurück und bleibt bedienbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:59:22 +02:00
thomas 6dd3f1eaf2 Merge pull request 'feat: Cold Extraction warnt bei fehlender Waage (S3 5.3.4 / P4 1.1.2)' (#16) from feat/cold-extraction-waagen-hinweis into main 2026-08-11 02:07:35 +02:00
8 changed files with 956 additions and 189 deletions
+95
View File
@@ -1,3 +1,98 @@
Version 5.6.1:
- Diagnose: Die Seite /Cold-Extraction zeigt jetzt live, welche Voraussetzungen der Abfrage beim
Aufwecken erfüllt sind (freigeschaltet, Abfrage eingeschaltet, Touch-Display verbunden, Modus
gerade aus, kein Wartungsmodus/Reinigung/Tuning) und ob das Auswahlfenster gerade offen ist.
Hintergrund: Die Abfrage hängt an fünf Bedingungen gleichzeitig - fehlt eine, passiert einfach
nichts, und es war von außen nicht erkennbar welche.
- Die Abfrage kommt jetzt auch nach einem Kaltstart, nicht nur nach dem Standby. Wer die Maschine
am Netzschalter einschaltet, durchläuft nie einen Standby-Übergang; dieser Weg war deshalb bisher
nicht abgedeckt. Startet die Maschine in den Standby hinein, bleibt es beim Standby-Übergang.
Version 5.6.0:
- Die Abfrage „Espresso oder Cold Extraction" kommt jetzt bei JEDEM Weg aus dem Standby, also auch beim
Aufwecken über den Schalter. Bisher war sie eine reine Vorab-Abfrage des Touch-Displays und erschien nur
beim Antippen des Standby-Bildschirms - beim Schalter ist der Standby aber schon beendet, bevor das
Display überhaupt fragen könnte. Dieser Weg war damit nie abgedeckt.
- Dafür hält die Steuerung nach dem Aufwecken ein Auswahlfenster offen (20 s): Der Wasserkreis heizt in
dieser Zeit nicht, das Display zeigt die Auswahl mit Countdown. Ohne Entscheidung wird anschließend
normal geheizt - es geht also nichts verloren, wenn niemand hinsieht.
- Das Fenster öffnet nur mit angeschlossenem Touch-Display (ohne Display könnte niemand wählen), bei
freigeschalteter Funktion und aktivierter Einstellung „Abfrage beim Aufwecken", und nur wenn der
Cold-Extraction-Modus nicht ohnehin schon aktiv ist. Es schließt sofort bei einer Entscheidung, bei
Bezug/Spülen oder erneutem Standby.
- Neue Aktion „wakeEspresso": beendet den Standby (falls noch aktiv) und schließt das Auswahlfenster.
Das Display sendet sie statt „deactivateStandby", sobald die Auswahl relevant ist - sonst würde nach
dem Aufwecken über den Dialog gleich nochmal gefragt.
- Neue State-Felder „cxWakeChoice" (Fenster offen) und „cxWakeChoiceSec" (Restsekunden).
Version 5.5.0:
- Neu: Beim Aufwecken aus dem Standby fragt das Touch-Display, ob normal geheizt (Espresso) oder kalt
extrahiert werden soll. Damit lässt sich das bei jedem Aufwecken neu entscheiden, ohne vorher die
Web-UI zu bedienen. Bei „Cold Extraction" wird der Modus scharf geschaltet, BEVOR der Standby endet -
der Wasserkreis heizt also gar nicht erst an.
- Abschaltbar über die neue Einstellung „Abfrage beim Aufwecken" auf /Cold-Extraction bzw. auf der
Cold-Extraction-Seite des Touch-Displays. Ohne angeschlossenes Display oder bei nicht freigeschalteter
Funktion hat sie keine Wirkung; ist sie aus, bleibt der bisherige Dialog („Aufwecken" / „Abbrechen").
- Neues UART-Feld „cxAskOnWake" im State und als Feld in saveBrew.
- Die Meldungstexte der kalten Extraktion sind jetzt mit echten Umlauten geschrieben („nicht möglich"
statt „nicht moeglich"). Für das OLED werden sie beim Rendern nach CP437 übersetzt (neue Hilfsfunktion
oledUmlauts) - dort hätte UTF-8 sonst zwei Fehlzeichen je Umlaut ergeben. Web-UI und Touch-Display
stellen UTF-8 ohnehin direkt dar.
Version 5.4.1:
- Bug-Fix: Während einer kalten Extraktion stand als Zielgewicht überall noch der Brew-by-Weight-Wert
des normalen Espresso-Bezugs. Betroffen waren OLED, Web-Dashboard und Touch-Display, weil alle drei
direkt brewByWeightTargetGrams anzeigten - die kalte Extraktion ersetzt Brew-by-Weight aber und stoppt
nach coldExtractionTargetGrams. Neue Hilfsfunktion activeTargetWeightGrams() liefert jetzt das Ziel
des aktuellen bzw. nächsten Bezugs; das UART-Feld "targetWeight" führt diesen Wert (bbwTarget bleibt
unverändert die Roh-Einstellung).
- Auf dem OLED wird während eines kalten Bezugs jetzt überhaupt Gewicht angezeigt: Die Anzeige hing an
„Brew-by-Weight aktiv", das bei kalter Extraktion typischerweise aus ist - trotz vorhandenem Ziel.
Version 5.4.0:
- Cold Extraction hat eine eigene Seite: /Cold-Extraction, eigener Eintrag im Menü. Auf /Brew-Control
steht nur noch ein Verweis darauf - die Seite war mit dem angehängten Block unübersichtlich geworden.
- Die neue Seite ist in Abschnitte gegliedert (Grundeinstellung, Vorbenetzung, Hauptbezug, Bezugsende,
Pumpenschutz), jeder mit einer kurzen Erklärung, warum der Wert so eingestellt gehört.
- Wichtig für bestehende Installationen: Ein Speichern auf /Brew-Control fasst die Cold-Extraction-
Einstellungen nicht mehr an. Vorher wertete dieser Handler die cx-Felder mit aus; nach dem Verschieben
hätte ein Speichern dort die Freischaltung abgeschaltet, weil die Checkbox im Formular fehlt.
- Das UART-Kommando "saveBrew" akzeptiert jetzt zusätzlich die Cold-Extraction-Felder (cxEnabled,
cxMaxTemp, cxPreInf, cxPreInfDuty, cxPulseMs, cxDuty, cxTarget, cxMaxSecs, cxPumpMaxRun, cxPumpRest) -
damit lässt sich die Funktion auch am Touch-Display vollständig einstellen.
- Das State-JSON liefert die restlichen Parameter mit (cxPreInfDuty, cxDuty, cxPulseMs, cxPumpMaxRun,
cxPumpRest), damit das Display seine Eingabefelder vorbelegen kann.
Version 5.3.6:
- Cold Extraction: Die Vorbenetzung läuft nicht mehr mit voller Pumpenleistung, sondern gepulst mit
eigener, niedriger Leistung (neue Einstellung „Pumpenleistung Vorbenetzung", Standard 10 %).
Messung an der Maschine: Nach 7 s Vorbenetzung kommen heiß 1-2 Tropfen, kalt bei voller Leistung
rund 10 g dünne Brühe ohne Crema - etwa zehnfacher Durchfluss trotz dreifacher Viskosität.
Ursache ist nicht die Viskosität, sondern das Kaffeebett: Kaltes Mehl quillt nicht, ist schlecht
benetzbar, es wandert kein Feinanteil und es lösen sich kaum Feststoffe, die das Bett verdichten.
Volle Leistung auf den trockenen kalten Puck legt deshalb sofort Kanäle an, die sich - anders als
bei heißem Bezug - nie wieder schließen; der restliche Bezug läuft daran vorbei statt zu extrahieren.
- Standard der Pumpenleistung im Hauptbezug von 30 % auf 20 % gesenkt (gleicher Grund: das kalte Bett
ist erheblich durchlässiger als ein heißes). Bestehende Installationen behalten ihren Wert.
- Der Erklärtext auf /Brew-Control benennt den Zusammenhang jetzt und weist darauf hin, dass die
Vorbenetzung deutlich schwächer eingestellt sein sollte als der Hauptbezug.
Version 5.3.5:
- Cold Extraction lässt sich jetzt auch im Standby und bei noch warmem Kessel scharf schalten.
Bisher galt „Standby aktiv" und „Kessel zu warm" auch fürs Aktivieren - das war falsch herum
gedacht: Der Modus schaltet die Heizung AB, man kam aber erst an den Schalter, nachdem die
Maschine aufgeweckt war und bereits zu heizen begonnen hatte. Genau der Ablauf, den die
Funktion vermeiden soll.
- Scharfschalten und Beziehen sind jetzt zwei getrennte Bedingungen:
- Scharfschalten (neues State-Feld „cxArmable"): blockiert nur durch fehlende Freischaltung,
Wartungsmodus, Reinigungsassistent, PID-Tuning oder einen laufenden Bezug/Spülvorgang.
- Bezug (unverändert „cxAllowed"): zusätzlich kein Standby, Kessel unter der Freigabeschwelle,
gültiger Sensorwert.
Ist der Modus scharf, der Bezug aber noch gesperrt, sagen Web-UI und Display das jetzt
ausdrücklich („Modus aktiv, Bezug noch gesperrt: …").
- Wird bei warmem Kessel aktiviert, geht die Wasserheizung sofort aus und der Kessel kühlt ab;
die Rückmeldung weist darauf hin, dass Bezüge bis zum Unterschreiten der Schwelle gesperrt sind.
Version 5.3.4:
- Cold Extraction weist jetzt deutlich darauf hin, wenn keine Waage aktiv/verbunden ist. Ohne Waage
greifen weder das Zielgewicht noch die Stillstands-Erkennung - der Bezug endet dann erst nach der
+29 -7
View File
@@ -322,18 +322,31 @@ Alle gültigen `action`-Strings aus `executeDashboardAction()` (Stand v4.8.1, Ze
| `startColdExtraction` | — | Modus aktivieren (Wasserkreis heizt nicht, Dampf bleibt möglich) |
| `stopColdExtraction` | — | Modus beenden |
| `toggleColdExtraction` | — | Modus umschalten |
| `wakeEspresso` | — | Standby beenden (falls aktiv) und das Auswahlfenster schließen, also „normal heizen" wählen (ab S3 5.6.0) |
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 ist
Für das Bedienelement zählt `cxArmable`, **nicht** `cxAllowed`: Der Modus schaltet die
Wasserheizung ab und muss deshalb auch im Standby und bei noch warmem Kessel scharf zu
stellen sein — sonst heizt die Maschine nach dem Aufwecken an, bevor man an den Schalter
kommt. `cxAllowed` gilt nur für den einzelnen Bezug; ist der Modus scharf und `cxAllowed`
false, sollte die UI „Modus aktiv, Bezug noch gesperrt" zeigen.
Ein Aufwecken direkt in die kalte Extraktion sendet `startColdExtraction` **vor**
`deactivateStandby` — in dieser Reihenfolge, damit der Wasserkreis gar nicht erst anheizt.
Die Aktionen werden abgewiesen (`ok:false` + `message`), solange die Bedingungen nicht
erfüllt sind. Der Modus ist
**persistent** (EEPROM) und überlebt Standby und Neustart; er endet nur durch explizites
Ausschalten, durch Entzug der Freischaltung auf `/Brew-Control` oder durch einen Werksreset.
Das P4-UI sollte `cxActive` deshalb dauerhaft sichtbar spiegeln — sonst wundert sich der
Benutzer, warum die Maschine nicht heizt. Ein laufender Bezug wird nie abgebrochen,
`stopColdExtraction` schlägt dann fehl.
Die Parameter der kalten Extraktion (Schwelle, Vorbenetzung, Pulsperiode, Pumpenleistung,
Zielgewicht, Timeout, Pumpenschutz) sind bewusst **nicht** über `saveBrew` erreichbar, sondern
nur über die Web-Seite `/Brew-Control`. Das Display schaltet den Modus, konfiguriert ihn nicht.
Die Parameter der kalten Extraktion sind ab S3 5.4.0 über `saveBrew` einstellbar (zusätzlich
zur Web-Seite `/Cold-Extraction`). Zulässige Felder: `cxEnabled` (bool), `cxMaxTemp`,
`cxPreInf`, `cxPreInfDuty`, `cxPulseMs`, `cxDuty`, `cxTarget`, `cxMaxSecs`, `cxPumpMaxRun`,
`cxPumpRest`, `cxAskOnWake` (bool). Es werden nur die tatsächlich gesendeten Felder übernommen; Grenzwerte prüft
die S3. Die aktuellen Werte stehen alle im `state`, damit das Display seine Eingabefelder
vorbelegen kann.
**Persistente Toggles**
@@ -383,11 +396,20 @@ Aus `getTouchUartStatus()`. Für sprach-unabhängige UI-Logik/Icons auf dem P4 v
| `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) |
| `cxAllowed` | bool | **Bezug** jetzt möglich (Wasserkessel kalt genug, kein Standby/Wartung/Tuning) |
| `cxArmable` | bool | **Modus umschaltbar** — auch im Standby und bei warmem Kessel (ab S3 5.3.5) |
| `cxResting` | bool | Zwangspause des Pumpenschutzes läuft (Bezug läuft weiter) |
| `cxMaxTemp` | float | Freigabeschwelle in °C (für eigene Hinweistexte am Display) |
| `cxTarget` | float | Zielgewicht in g |
| `cxPreInf` | float | Vorbenetzung in s (für die Phasenleiste des kalten Bezugs) |
| `cxPreInf` | float | Vorbenetzung in s (auch für die Phasenleiste des kalten Bezugs) |
| `cxPreInfDuty` | float | Pumpenleistung Vorbenetzung in % (ab S3 5.4.0) |
| `cxDuty` | float | Pumpenleistung Hauptbezug in % (ab S3 5.4.0) |
| `cxPulseMs` | uint16 | Pulsperiode in ms (ab S3 5.4.0) |
| `cxPumpMaxRun` | float | Pumpenschutz: max. kumulierte Laufzeit in s (ab S3 5.4.0) |
| `cxPumpRest` | float | Pumpenschutz: Zwangspause in s (ab S3 5.4.0) |
| `cxAskOnWake` | bool | Beim Aufwecken aus dem Standby nach Espresso/Cold Extraction fragen (ab S3 5.5.0) |
| `cxWakeChoice` | bool | Auswahlfenster nach dem Aufwecken offen — Wasserkreis heizt solange nicht (ab S3 5.6.0) |
| `cxWakeChoiceSec` | long | Restsekunden der Bedenkzeit |
| `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s |
| `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei |
| `cxNotice` | uint8 | transiente Meldung, `0` = keine (läuft nach ~6 s selbst ab) |
+498 -114
View File
@@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
* Firmware-Informationen
************************************************************************************/
String version = "5.3.4";
String version = "5.6.1";
String versionHersteller = "Thomas M&uuml;ller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -782,27 +782,46 @@ bool flowGuardPumpPulseActive = false;
************************************************************************************/
bool coldExtractionEnabled = false; // Funktion in der Web-UI freigeschaltet
float coldExtractionMaxTempC = 30.0f; // Freigabeschwelle Wasserkessel
float coldExtractionPreInfusionSeconds = 10.0f; // Vorbenetzung (Pumpe laeuft ungepulst)
float coldExtractionPreInfusionSeconds = 10.0f; // Vorbenetzung (gepulst, eigene Leistung)
float coldExtractionPreInfDutyPercent = 10.0f; // Pumpenleistung waehrend der Vorbenetzung
uint16_t coldExtractionPulsePeriodMs = 2000; // Pulsperiode der Pumpe im Hauptbezug
float coldExtractionDutyPercent = 30.0f; // Pumpen-Duty im Hauptbezug
float coldExtractionDutyPercent = 20.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
// Beim Aufwecken aus dem Standby am Touch-Display fragen, ob normal geheizt oder kalt
// extrahiert werden soll. Nur wirksam bei angeschlossenem UART-Display und freigeschalteter
// Funktion; abschaltbar, wenn man den Zwischenschritt nicht mehr will.
bool coldExtractionAskOnWake = true;
// Auswahlfenster nach dem Aufwecken: Der Dialog am Display ist eine Vorab-Abfrage und kann
// den Schalter-Weg nicht abdecken - dort ist der Standby schon beendet, bevor das Display
// fragen koennte. Deshalb haelt die Steuerung den Wasserkreis nach dem Aufwecken kurz aus
// und wartet auf die Entscheidung; laeuft das Fenster ab, wird normal geheizt.
bool coldExtractionWakeChoicePending = false;
unsigned long coldExtractionWakeChoiceUntilMs = 0;
unsigned long coldExtractionWakeDecisionMs = 0; // Zeitpunkt der letzten expliziten Wahl
const unsigned long COLDEX_WAKE_CHOICE_MS = 20000UL; // Bedenkzeit
const unsigned long COLDEX_WAKE_DECISION_GRACE_MS = 5000UL; // frisch entschieden -> kein Fenster
const bool defaultColdExtractionEnabled = false;
const float defaultColdExtractionMaxTempC = 30.0f;
// 10 s statt der urspruenglichen 30 s: Die Pumpe laeuft in dieser Phase ungepulst auf
// voller Leistung. Heiss kommt der erste Tropfen nach ~7 s, kalt (rund dreifache
// Viskositaet) entsprechend spaeter - 30 s waeren laengst voller Bezug bei vollem Druck
// gewesen, und die Haelfte des Pumpen-Laufzeitbudgets waere vor dem Hauptbezug verbraucht.
const float defaultColdExtractionPreInfusionSeconds = 10.0f;
// Die Vorbenetzung laeuft bewusst SEHR sanft. Messung an der Maschine: heiss kommen nach
// 7 s Vorbenetzung 1-2 Tropfen, kalt bei voller Pumpenleistung rund 10 g duenne Bruehe -
// etwa zehnfacher Durchfluss trotz dreifacher Viskositaet. Kaltes Mehl quillt nicht und ist
// schlecht benetzbar; volle Leistung auf den trockenen Puck legt sofort Kanaele an, die sich
// - anders als heiss - nie wieder zudruecken. Der restliche Bezug laeuft dann daran vorbei.
const float defaultColdExtractionPreInfDutyPercent = 10.0f;
const uint16_t defaultColdExtractionPulsePeriodMs = 2000;
const float defaultColdExtractionDutyPercent = 30.0f;
// 20 % statt 30 %: Das kalte Kaffeebett ist deutlich durchlaessiger als ein heisses
// (kein Quellen, keine Feinanteil-Wanderung, keine geloesten Feststoffe, die es verdichten).
const float defaultColdExtractionDutyPercent = 20.0f;
const float defaultColdExtractionTargetGrams = 60.0f;
const float defaultColdExtractionMaxSeconds = 300.0f;
const float defaultColdExtractionPumpMaxRunSec = 60.0f;
const float defaultColdExtractionPumpRestSec = 20.0f;
const bool defaultColdExtractionAskOnWake = true;
const float COLDEX_MAX_TEMP_MIN = 10.0f;
const float COLDEX_MAX_TEMP_MAX = 40.0f;
@@ -1067,7 +1086,9 @@ const int EEPROM_ADDR_COLDEX_MAX_SECONDS = 965; // 4 Bytes (float) -> Ende
const int EEPROM_ADDR_COLDEX_PUMP_MAX_RUN = 969; // 4 Bytes (float) -> Ende 972
const int EEPROM_ADDR_COLDEX_PUMP_REST = 973; // 4 Bytes (float) -> Ende 976
const int EEPROM_ADDR_COLDEX_MODE_ACTIVE = 977; // 1 Byte (uint8_t) -> Ende 977
// Naechste freie Adresse: 978 (Innerhalb EEPROM_SIZE=1024)
const int EEPROM_ADDR_COLDEX_PREINF_DUTY = 978; // 4 Bytes (float) -> Ende 981
const int EEPROM_ADDR_COLDEX_ASK_ON_WAKE = 982; // 1 Byte (uint8_t) -> Ende 982
// Naechste freie Adresse: 983 (Innerhalb EEPROM_SIZE=1024)
/************************************************************************************
* Eco-Mode Variablen
@@ -2023,6 +2044,7 @@ static const char commonNav[] PROGMEM = R"rawliteral(
<a href="/Profile">Profile</a>
<a href="/Timer">Timer</a>
<a href="/Brew-Control">Brew Control</a>
<a href="/Cold-Extraction">Cold Extraction</a>
<a href="/Chart">Chart</a>
<a href="/Sensoren">Sensoren</a>
<a href="/Fast-Heat-Up">Fast-Heat-Up</a>
@@ -3054,11 +3076,13 @@ void resetToDefaults() {
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_PREINF_DUTY, defaultColdExtractionPreInfDutyPercent);
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);
EEPROM.put(EEPROM_ADDR_COLDEX_MODE_ACTIVE, (uint8_t)0); // Werksreset: Modus immer aus
EEPROM.put(EEPROM_ADDR_COLDEX_ASK_ON_WAKE, (uint8_t)(defaultColdExtractionAskOnWake ? 1 : 0));
// Piezo auf Default im EEPROM schreiben
EEPROM.put(EEPROM_ADDR_PIEZO_ENABLED, defaultPiezoEnabled);
@@ -3142,10 +3166,12 @@ void resetToDefaults() {
coldExtractionPreInfusionSeconds = defaultColdExtractionPreInfusionSeconds;
coldExtractionPulsePeriodMs = defaultColdExtractionPulsePeriodMs;
coldExtractionDutyPercent = defaultColdExtractionDutyPercent;
coldExtractionPreInfDutyPercent = defaultColdExtractionPreInfDutyPercent;
coldExtractionTargetGrams = defaultColdExtractionTargetGrams;
coldExtractionMaxSeconds = defaultColdExtractionMaxSeconds;
coldExtractionPumpMaxRunSec = defaultColdExtractionPumpMaxRunSec;
coldExtractionPumpRestSec = defaultColdExtractionPumpRestSec;
coldExtractionAskOnWake = defaultColdExtractionAskOnWake;
coldExtractionModeActive = false;
coldExtractionShotActive = false;
steamByTimeEnabled = defaultSteamByTimeEnabled; steamByTimeTargetSeconds = defaultSteamByTimeTargetSeconds;
@@ -4051,6 +4077,42 @@ bool coldExtractionTemperatureOk() {
return (InputWasser < (double)limit);
}
// UTF-8-Umlaute in die Codepage der OLED-Schrift (CP437) uebersetzen. Meldungstexte werden
// mit echten Umlauten gehalten (Web-UI und Touch-Display koennen UTF-8); das OLED wuerde
// daraus zwei Fehlzeichen machen - vgl. "Startverz\x94gerung" an anderer Stelle.
String oledUmlauts(const String& in) {
String out;
out.reserve(in.length());
for (size_t i = 0; i < in.length(); i++) {
uint8_t c = (uint8_t)in[i];
if (c == 0xC3 && (i + 1) < in.length()) {
uint8_t d = (uint8_t)in[i + 1];
char mapped = 0;
switch (d) {
case 0xA4: mapped = (char)0x84; break; // ae
case 0xB6: mapped = (char)0x94; break; // oe
case 0xBC: mapped = (char)0x81; break; // ue
case 0x84: mapped = (char)0x8E; break; // Ae
case 0x96: mapped = (char)0x99; break; // Oe
case 0x9C: mapped = (char)0x9A; break; // Ue
case 0x9F: mapped = (char)0xE1; break; // ss
default: break;
}
if (mapped) { out += mapped; i++; continue; }
}
out += (char)c;
}
return out;
}
// Zielgewicht des aktuellen bzw. naechsten Bezugs. Bei aktiver Cold Extraction ist das
// coldExtractionTargetGrams - brewByWeightTargetGrams gilt dann nicht, weil die kalte
// Extraktion Brew-by-Weight ersetzt. Alle Anzeigen (OLED, Web, Display) muessen diesen
// Wert nehmen, sonst steht dort das Espresso-Ziel waehrend eines kalten Bezugs.
float activeTargetWeightGrams() {
return coldExtractionModeActive ? coldExtractionTargetGrams : brewByWeightTargetGrams;
}
// Steht eine nutzbare Waage bereit? Ohne sie laeuft die kalte Extraktion rein zeitgesteuert:
// Zielgewicht und Stillstands-Erkennung greifen nicht, es bleibt nur die maximale Bezugsdauer.
// Bewusst kein Sperrgrund - ein kurz abgemeldeter HX711 soll die Funktion nicht blockieren.
@@ -4088,23 +4150,23 @@ String coldExtractionBlockReason() {
return "Cold Extraction ist nicht freigeschaltet.";
}
if (standbyModeActive) {
return "Cold Extraction nicht moeglich: Standby aktiv.";
return "Cold Extraction nicht möglich: Standby aktiv.";
}
if (wartungsModusAktiv) {
return "Cold Extraction nicht moeglich: Wartungsmodus aktiv.";
return "Cold Extraction nicht möglich: Wartungsmodus aktiv.";
}
if (cleaningAssistantActive) {
return "Cold Extraction nicht moeglich: Reinigungsassistent aktiv.";
return "Cold Extraction nicht möglich: Reinigungsassistent aktiv.";
}
if (autoTuneWasserActive || autoTuneDampfActive) {
return "Cold Extraction nicht moeglich: PID-Tuning laeuft.";
return "Cold Extraction nicht möglich: PID-Tuning läuft.";
}
if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) {
return "Cold Extraction nicht moeglich: Wasser-Sensor liefert keinen gueltigen Wert.";
return "Cold Extraction nicht möglich: Wasser-Sensor liefert keinen gültigen Wert.";
}
if (!coldExtractionTemperatureOk()) {
char buf[96];
snprintf(buf, sizeof(buf), "Wasserkessel zu warm (%.1f C, benoetigt < %.1f C).",
snprintf(buf, sizeof(buf), "Wasserkessel zu warm (%.1f °C, benötigt < %.1f °C).",
(double)InputWasser, (double)coldExtractionMaxTempC);
return String(buf);
}
@@ -4119,19 +4181,26 @@ bool setColdExtractionMode(bool enable, String& message) {
}
if (enable) {
String reason = coldExtractionBlockReason();
String reason = coldExtractionArmBlockReason();
if (reason.length() > 0) {
message = reason;
return false;
}
if (shotActive || shotSoftwareActive || flushActive) {
message = "Cold Extraction nicht moeglich: Bezug oder Spuelen laeuft.";
message = "Cold Extraction nicht möglich: Bezug oder Spülen läuft.";
return false;
}
coldExtractionModeActive = true;
resetColdExtractionRuntime();
persistColdExtractionMode();
message = "Cold Extraction aktiviert - Wasserkreis heizt nicht (bleibt auch nach Standby/Neustart aktiv).";
if (standbyModeActive) {
message += " Nach dem Aufwecken bleibt der Wasserkreis aus.";
} else if (!coldExtractionTemperatureOk()) {
// Scharfschalten bei warmem Kessel ist erlaubt (Heizung geht sofort aus), ein
// Bezug aber erst, wenn der Kessel unter die Schwelle gefallen ist.
message += " Der Kessel ist noch zu warm - er kühlt jetzt ab, Bezüge sind bis dahin gesperrt.";
}
if (!coldExtractionScaleUsable()) {
// Ohne Waage faellt die Dosierung auf die maximale Bezugsdauer zurueck - das
// muss der Benutzer wissen, sonst wundert er sich ueber das Ergebnis.
@@ -4160,6 +4229,37 @@ bool coldExtractionShotAllowed() {
return coldExtractionModeActive && coldExtractionConditionsOk();
}
/************************************************************************************
* Scharfschalten vs. Beziehen - bewusst zwei verschiedene Bedingungen
************************************************************************************
* Der Modus schaltet die Wasserheizung AB. Das an Standby oder an die Kesseltemperatur
* zu koppeln waere falsch herum: Im Standby kaeme man gar nicht an den Schalter, und
* nach dem Aufwecken heizt die Maschine sofort los - man muesste erst aufheizen lassen,
* um danach auf das Abkuehlen zu warten. Genau der Fall, den die Funktion vermeiden soll.
* Deshalb: Scharfschalten ist im Standby und bei warmem Kessel erlaubt; ueber den
* einzelnen Bezug entscheidet weiterhin coldExtractionShotAllowed().
************************************************************************************/
bool coldExtractionArmable() {
return coldExtractionEnabled && !wartungsModusAktiv && !cleaningAssistantActive &&
!autoTuneWasserActive && !autoTuneDampfActive;
}
String coldExtractionArmBlockReason() {
if (!coldExtractionEnabled) {
return "Cold Extraction ist nicht freigeschaltet.";
}
if (wartungsModusAktiv) {
return "Cold Extraction nicht möglich: Wartungsmodus aktiv.";
}
if (cleaningAssistantActive) {
return "Cold Extraction nicht möglich: Reinigungsassistent aktiv.";
}
if (autoTuneWasserActive || autoTuneDampfActive) {
return "Cold Extraction nicht möglich: PID-Tuning läuft.";
}
return "";
}
// 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
@@ -4192,10 +4292,12 @@ void applyColdExtractionPumpControl(unsigned long currentShotTimeMillis) {
bool pumpOn;
if (coldExtractionPumpResting) {
pumpOn = false;
} else if (currentPreInfusionState == PI_PRE_BREW) {
pumpOn = true; // Vorbenetzung: volle Pumpenleistung
} else {
float duty = coldExtractionDutyPercent / 100.0f;
// Vorbenetzung und Hauptbezug pulsen beide - nur mit unterschiedlicher Leistung.
// Volle Leistung auf den trockenen kalten Puck legt Kanaele an, die sich mangels
// Quellung nie wieder schliessen (siehe Kommentar bei den Defaults).
float duty = ((currentPreInfusionState == PI_PRE_BREW) ? coldExtractionPreInfDutyPercent
: 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) {
@@ -4660,6 +4762,7 @@ void setup() {
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_PREINF_DUTY, coldExtractionPreInfDutyPercent);
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);
@@ -4676,6 +4779,10 @@ void setup() {
if (!isfinite(coldExtractionDutyPercent) || coldExtractionDutyPercent < COLDEX_DUTY_MIN || coldExtractionDutyPercent > COLDEX_DUTY_MAX) {
coldExtractionDutyPercent = defaultColdExtractionDutyPercent;
}
// Beim Update aus <= 5.3.5 steht hier 0xFFFFFFFF (NaN) -> Default greift automatisch
if (!isfinite(coldExtractionPreInfDutyPercent) || coldExtractionPreInfDutyPercent < COLDEX_DUTY_MIN || coldExtractionPreInfDutyPercent > COLDEX_DUTY_MAX) {
coldExtractionPreInfDutyPercent = defaultColdExtractionPreInfDutyPercent;
}
if (!isfinite(coldExtractionTargetGrams) || coldExtractionTargetGrams < COLDEX_TARGET_GRAMS_MIN || coldExtractionTargetGrams > COLDEX_TARGET_GRAMS_MAX) {
coldExtractionTargetGrams = defaultColdExtractionTargetGrams;
}
@@ -4691,6 +4798,11 @@ void setup() {
// Der Modus ist bewusst persistent: Wer nur kalte Bezuege machen will, soll die
// Maschine nicht nach jedem Standby/Neustart erst aufheizen lassen, um sie danach
// wieder abkuehlen zu muessen. Nur bei entzogener Freischaltung faellt er weg.
uint8_t storedColdExtractionAsk = 0;
EEPROM.get(EEPROM_ADDR_COLDEX_ASK_ON_WAKE, storedColdExtractionAsk);
if (storedColdExtractionAsk > 1) { storedColdExtractionAsk = (uint8_t)(defaultColdExtractionAskOnWake ? 1 : 0); }
coldExtractionAskOnWake = (storedColdExtractionAsk == 1);
uint8_t storedColdExtractionMode = 0;
EEPROM.get(EEPROM_ADDR_COLDEX_MODE_ACTIVE, storedColdExtractionMode);
if (storedColdExtractionMode > 1) { storedColdExtractionMode = 0; }
@@ -5122,6 +5234,8 @@ void setup() {
// Routen für Brew Control
asyncServer.on("/Brew-Control", HTTP_GET, handleBrewControl);
asyncServer.on("/saveBrewControl", HTTP_POST, handleSaveBrewControl);
asyncServer.on("/Cold-Extraction", HTTP_GET, handleColdExtraction);
asyncServer.on("/saveColdExtraction", HTTP_POST, handleSaveColdExtraction);
dashboardWs.onEvent(onDashboardWsEvent);
asyncServer.addHandler(&dashboardWs);
@@ -5331,6 +5445,39 @@ void loop() {
lastShotTime = millis();
resetOffsetCompensationReferences();
applySteamHeatDisabledOnStartupWakeIfConfigured();
// Auswahlfenster oeffnen: gilt fuer JEDEN Weg aus dem Standby (Schalter, Touch, Web,
// Timer). Nur sinnvoll mit angeschlossenem Display - ohne das koennte niemand waehlen,
// und die Maschine wuerde nur unnoetig spaeter heizen.
if (coldExtractionEnabled && coldExtractionAskOnWake && touchUartClientActive &&
!coldExtractionModeActive && coldExtractionArmable() &&
(coldExtractionWakeDecisionMs == 0 ||
(currentMillis - coldExtractionWakeDecisionMs) > COLDEX_WAKE_DECISION_GRACE_MS)) {
coldExtractionWakeChoicePending = true;
coldExtractionWakeChoiceUntilMs = currentMillis + COLDEX_WAKE_CHOICE_MS;
}
}
// Gleiche Auswahl nach einem Kaltstart: Wer die Maschine am Netzschalter einschaltet,
// kommt nie durch den Standby-Uebergang - ohne diesen Zweig gaebe es dort keine Abfrage.
// Einmal pro Laufzeit, sobald sich das Display gemeldet hat.
static bool coldExtractionBootChoiceDone = false;
if (!coldExtractionBootChoiceDone && !standbyModeActive && touchUartClientActive &&
coldExtractionEnabled && coldExtractionAskOnWake && !coldExtractionModeActive &&
coldExtractionArmable()) {
coldExtractionBootChoiceDone = true;
coldExtractionWakeChoicePending = true;
coldExtractionWakeChoiceUntilMs = currentMillis + COLDEX_WAKE_CHOICE_MS;
} else if (!coldExtractionBootChoiceDone && touchUartClientActive && standbyModeActive) {
// Start in den Standby hinein: Der Standby-Uebergang uebernimmt die Abfrage.
coldExtractionBootChoiceDone = true;
}
// Auswahlfenster beenden: Zeit abgelaufen, Standby, oder es wird ohnehin schon bezogen
if (coldExtractionWakeChoicePending) {
if (standbyModeActive || coldExtractionModeActive || shotActive || shotSoftwareActive ||
flushActive || !coldExtractionEnabled ||
(long)(currentMillis - coldExtractionWakeChoiceUntilMs) >= 0) {
coldExtractionWakeChoicePending = false;
}
}
if (standbyModeActive != lastStandbyModeActive) {
syncFullyKioskWithStandby(standbyModeActive);
@@ -5362,7 +5509,7 @@ void loop() {
// nach Software-Shots, Einstellungsänderungen mid-shot etc. immer konsistent ist.
// Bei Cold Extraction zusaetzlich einfrieren: der Wasserkreis heizt minutenlang nicht,
// ohne Freeze wuerde der I-Anteil aufladen und nach dem Modus stark ueberschwingen.
setPidWasserIntegralFrozen(coldExtractionModeActive ||
setPidWasserIntegralFrozen(coldExtractionModeActive || coldExtractionWakeChoicePending ||
(ffIFreezeWasser && (shotActive || shotSoftwareActive) && !wartungsModusAktiv));
// --- Angepasste Sicherheitsueberpruefung Wasser ---
@@ -5383,12 +5530,12 @@ void loop() {
} else {
if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK."); wasserSensorError = false;
if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich."); wasserSafetyShutdown = false;
bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && !coldExtractionModeActive && (InputWasser < (SetpointWasser * 0.95));
bool activateBoost = boostWasserActive && !wartungsModusAktiv && !ecoModeAktiv && !autoTuneWasserActive && !coldExtractionModeActive && !coldExtractionWakeChoicePending && (InputWasser < (SetpointWasser * 0.95));
if (activateBoost) { OutputWasser = windowSizeWasser; } else if (!autoTuneWasserActive) { pidWasser.Compute(); }
// Cold Extraction: Stellgroesse hart auf 0 - der Wasserkreis heizt nicht. Die PID
// laeuft weiter (D-Anteil/lastInput bleiben aktuell), nur der Output wird verworfen,
// damit auch die dutyW-Telemetrie fuer das Display die Wahrheit zeigt.
if (coldExtractionModeActive) { OutputWasser = 0; }
if (coldExtractionModeActive || coldExtractionWakeChoicePending) { OutputWasser = 0; }
}
yield();
@@ -5465,7 +5612,7 @@ void loop() {
recordResetCheckpoint(RESET_CP_LOOP_SSR);
unsigned long now = millis();
// Wasser SSR
if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive || coldExtractionModeActive) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
if (wasserSensorError || wasserSafetyShutdown || standbyModeActive || displayOtaActive || coldExtractionModeActive || coldExtractionWakeChoicePending) { digitalWrite(SSR_WASSER_PIN, LOW); } else {
if (now - windowStartTimeWasser > windowSizeWasser) { windowStartTimeWasser = now; }
unsigned long effOutWasser = effectiveOutputWasserMs(); // PID-Output + Feed-Forward-Boost bei Bezug
bool pidWantsHeat = (effOutWasser > 0) && (now < (windowStartTimeWasser + effOutWasser));
@@ -6060,7 +6207,9 @@ void updateDisplay() {
display.println(F(""));
// Meldung auf 21 Zeichen pro Zeile umbrechen (128 px / 6 px Zeichenbreite)
const size_t lineLen = 21;
String rest = coldExtractionBlockMessage;
// Umlaute in die CP437-Codepage der OLED-Schrift wandeln; danach ist jeder Umlaut
// wieder ein Byte, der Zeilenumbruch unten zaehlt also richtig.
String rest = oledUmlauts(coldExtractionBlockMessage);
while (rest.length() > 0) {
if (rest.length() <= lineLen) {
display.println(rest);
@@ -6510,7 +6659,9 @@ if (scaleModeActive) {
// Wenn die Zustandsmaschine nicht aktiv ist, zeige den laufenden Shot oder die Temperaturen
else if (shotActive) {
// Priorität: Shot läuft *aktuell*?
if (brewByWeightEnabled && scaleConnected) {
// Auch ein kalter Bezug hat ein Gewichtsziel - unabhaengig von Brew-by-Weight,
// das die Cold Extraction ersetzt.
if ((brewByWeightEnabled || coldExtractionShotActive) && scaleConnected) {
display.setTextSize(1);
display.setCursor(0, 0);
display.println(F("Bezug aktiv:"));
@@ -6527,7 +6678,7 @@ if (scaleModeActive) {
netWeight = 0.0;
}
char weightBuffer[15];
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f/%.1fg", netWeight, brewByWeightTargetGrams);
snprintf(weightBuffer, sizeof(weightBuffer), "%.1f/%.1fg", netWeight, activeTargetWeightGrams());
// --- ENDE ÄNDERUNG ---
display.setCursor(0, 30);
@@ -7163,36 +7314,79 @@ static const char brewControlPI_Fields[] PROGMEM = R"rawliteral(
)rawliteral";
// --- Cold Extraction (CX) Chunks ---
static const char brewControlCX_H3[] PROGMEM = R"rawliteral(<h3>Cold Extraction (kalte Extraktion)</h3>)rawliteral";
// Cold Extraction hat eine eigene Seite (/Cold-Extraction); auf /Brew-Control steht nur der Verweis.
static const char brewControlCX_Link[] PROGMEM = R"rawliteral(
<h3>Cold Extraction (kalte Extraktion)</h3>
Die Einstellungen der kalten Extraktion stehen auf einer <a href="/Cold-Extraction">eigenen Seite</a>.<br><br>
)rawliteral";
static const char coldExHead[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><title>Cold Extraction</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
)rawliteral";
static const char coldExHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
static const char coldExBodyStart[] PROGMEM = R"rawliteral(
<body><h1>Cold Extraction</h1>
<form action='/saveColdExtraction' method='POST'>
Kalte Extraktion: Bezug mit kaltem Wasser, gepulster Pumpe und Bezugszeiten im Minutenbereich.<br>
Die Maschine hat keinen Bypass um den Wasserkessel - kaltes Wasser kommt nur bei kaltem Kessel am Puck an.
Deshalb ist die Funktion nur unterhalb der Freigabetemperatur beziehbar, praktisch also aus dem kalten
Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht; der Dampfkreis bleibt unber&uuml;hrt.<br>
Ein- und ausgeschaltet wird der Modus auf dem Dashboard oder am Touch-Display - hier stehen nur die Parameter.<br><br>
)rawliteral";
static const char coldExCX_H3[] PROGMEM = R"rawliteral(<h3>Grundeinstellung</h3>)rawliteral";
static const char brewControlCX_ToggleContainerStart[] PROGMEM = R"rawliteral(<div class="toggle-switch-container"><span class="toggle-switch-label-text">Freischalten:</span><label class="toggle-switch"><input type="checkbox" id="cxEnabled" name="cxEnabled" value="1")rawliteral";
static const char brewControlCX_ToggleContainerEnd[] PROGMEM = R"rawliteral(><span class="toggle-slider"></span></label></div>)rawliteral";
static const char coldExFormEnd[] PROGMEM = R"rawliteral(
<input type='submit' value='Einstellungen speichern'>
</form>
</body></html>
)rawliteral";
static const char brewControlCX_Fields[] PROGMEM = R"rawliteral(
<label for='cxMaxTemp'>Freigabe bis Wassertemperatur (&deg;C):</label>
<input type='number' min='10.0' max='40.0' step='0.5' id='cxMaxTemp' name='cxMaxTemp' value='{CX_MAX_TEMP}' required>
<label for='cxPreInf'>Vorbenetzung (Sekunden, Pumpe l&auml;uft dabei durchgehend):</label>
<small class="toggle-description-input">(Oberhalb dieser Temperatur bleibt der Modus zwar schaltbar, Bez&uuml;ge sind aber gesperrt.)</small>
{CX_STATUS_MSG}
<h3>Abfrage beim Aufwecken</h3>
<div class="toggle-switch-container"><span class="toggle-switch-label-text">Am Touch-Display fragen:</span>
<label class="toggle-switch"><input type="checkbox" id="cxAskOnWake" name="cxAskOnWake" value="1"{CX_ASK_CHECKED}>
<span class="toggle-slider"></span></label></div>
<small class="toggle-description-input">(Beim Aufwecken aus dem Standby fragt das Touch-Display, ob normal geheizt oder kalt
extrahiert werden soll - so entscheidet man das bei jedem Aufwecken neu, ohne vorher die Web-UI zu bedienen.
Ohne angeschlossenes Touch-Display oder bei nicht freigeschalteter Funktion hat die Einstellung keine Wirkung.)</small>
{CX_ASK_DIAG}
<h3>Vorbenetzung</h3>
<label for='cxPreInf'>Dauer (Sekunden):</label>
<input type='number' min='0.0' max='120.0' step='1.0' id='cxPreInf' name='cxPreInf' value='{CX_PREINF}' required>
<label for='cxPreInfDuty'>Pumpenleistung (%):</label>
<input type='number' min='5.0' max='100.0' step='1.0' id='cxPreInfDuty' name='cxPreInfDuty' value='{CX_PREINF_DUTY}' required>
<small class="toggle-description-input">(Deutlich schw&auml;cher einstellen als den Hauptbezug: Kaltes Kaffeemehl quillt nicht und
ist schlecht benetzbar. Volle Leistung auf den trockenen Puck legt sofort Kan&auml;le an, die sich - anders als bei
hei&szlig;em Bezug - nie wieder schlie&szlig;en; der restliche Bezug l&auml;uft dann daran vorbei statt zu extrahieren.)</small>
<h3>Hauptbezug</h3>
<label for='cxPulseMs'>Pulsperiode (ms):</label>
<input type='number' min='200' max='5000' step='50' id='cxPulseMs' name='cxPulseMs' value='{CX_PULSE_MS}' required>
<label for='cxDuty'>Pumpenleistung (%):</label>
<input type='number' min='5.0' max='100.0' step='1.0' id='cxDuty' name='cxDuty' value='{CX_DUTY}' required>
<small class="toggle-description-input">(Das kalte Kaffeebett ist erheblich durchl&auml;ssiger als ein hei&szlig;es - niedrige Werte
sind normal. L&auml;uft der Bezug zu schnell durch: Leistung senken und feiner mahlen.)</small>
<h3>Bezugsende</h3>
<label for='cxTarget'>Zielgewicht (Gramm, nur mit Waage wirksam):</label>
<input type='number' min='10.0' max='300.0' step='1.0' id='cxTarget' name='cxTarget' value='{CX_TARGET}' required>
<label for='cxMaxSecs'>Maximale Bezugsdauer (Sekunden):</label>
<input type='number' min='30.0' max='900.0' step='5.0' id='cxMaxSecs' name='cxMaxSecs' value='{CX_MAX_SECS}' required>
<label for='cxPumpMaxRun'>Pumpenschutz: max. Laufzeit (Sekunden):</label>
<small class="toggle-description-input">(Ohne Waage ist das Zielgewicht wirkungslos und die maximale Bezugsdauer wird zur Dosierung.)</small>
<h3>Pumpenschutz</h3>
<label for='cxPumpMaxRun'>Max. Laufzeit (Sekunden):</label>
<input type='number' min='10.0' max='300.0' step='5.0' id='cxPumpMaxRun' name='cxPumpMaxRun' value='{CX_PUMP_MAX_RUN}' required>
<label for='cxPumpRest'>Pumpenschutz: Zwangspause (Sekunden):</label>
<label for='cxPumpRest'>Zwangspause (Sekunden):</label>
<input type='number' min='0.0' max='120.0' step='1.0' id='cxPumpRest' name='cxPumpRest' value='{CX_PUMP_REST}' required>
<small class="toggle-description-input">(Die Maschine hat keinen Bypass um den Wasserkessel: Kaltes Wasser kommt nur bei kaltem
Kessel am Puck an. Deshalb ist die kalte Extraktion nur unterhalb der Freigabetemperatur m&ouml;glich, praktisch also
aus dem kalten Zustand heraus. Solange der Modus aktiv ist, heizt der Wasserkreis nicht - der Dampfkreis bleibt
unber&uuml;hrt und darf weiter heizen. W&auml;hrend der Vorbenetzung l&auml;uft die Pumpe durchgehend auf voller
Leistung (kein Pulsen) - sinnvoll ist eine Dauer knapp unterhalb des ersten Tropfens; gepulst wird erst im
Hauptbezug. Die Pumpenlaufzeit wird kumuliert gez&auml;hlt; nach der eingestellten
Laufzeit legt die Steuerung eine Zwangspause ein. Der Modus selbst wird auf dem Dashboard bzw. am Touch-Display
ein- und ausgeschaltet und bleibt &uuml;ber Standby und Neustart hinweg aktiv, bis er dort wieder ausgeschaltet
wird - solange heizt der Wasserkreis nicht.)</small>
{CX_STATUS_MSG}
<small class="toggle-description-input">(Gez&auml;hlt wird die kumulierte Einschaltzeit der Pumpe, nicht die Bezugsdauer. Nach Ablauf
legt die Steuerung eine Zwangspause ein; der Bezug l&auml;uft dabei weiter.)</small>
<br>
)rawliteral";
@@ -7307,51 +7501,8 @@ void handleBrewControl(AsyncWebServerRequest *request) {
response->print(piFieldsHtml);
yield();
// --- Cold Extraction Sektion ---
response->print(FPSTR(brewControlCX_H3));
response->print(FPSTR(brewControlCX_ToggleContainerStart));
if (coldExtractionEnabled) {
response->print(F(" checked"));
}
response->print(FPSTR(brewControlCX_ToggleContainerEnd));
String cxFieldsHtml = FPSTR(brewControlCX_Fields);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxTempC);
cxFieldsHtml.replace("{CX_MAX_TEMP}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfusionSeconds);
cxFieldsHtml.replace("{CX_PREINF}", buffer);
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)coldExtractionPulsePeriodMs);
cxFieldsHtml.replace("{CX_PULSE_MS}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionDutyPercent);
cxFieldsHtml.replace("{CX_DUTY}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionTargetGrams);
cxFieldsHtml.replace("{CX_TARGET}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxSeconds);
cxFieldsHtml.replace("{CX_MAX_SECS}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpMaxRunSec);
cxFieldsHtml.replace("{CX_PUMP_MAX_RUN}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpRestSec);
cxFieldsHtml.replace("{CX_PUMP_REST}", buffer);
if (coldExtractionEnabled) {
String cxStatus;
// Waagen-Hinweis zuerst: er gilt unabhaengig von der Temperatur-Freigabe und
// erklaert, warum das Zielgewicht oben wirkungslos ist.
if (!coldExtractionScaleUsable()) {
cxStatus += "<br><b><small style='color: #FFCC00;'>Keine Waage aktiv/verbunden: Zielgewicht und "
"Stillstands-Erkennung greifen nicht. Der Bezug endet erst nach der maximalen "
"Bezugsdauer - diesen Wert dann als Dosierung verstehen.</small></b>";
}
String reason = coldExtractionBlockReason();
if (reason.length() > 0) {
cxStatus += "<br><b><small style='color: #FFCC00;'>" + reason + "</small></b>";
} else {
cxStatus += "<br><b><small style='color: #66CC66;'>Kalte Extraktion derzeit m&ouml;glich.</small></b>";
}
cxFieldsHtml.replace("{CX_STATUS_MSG}", cxStatus);
} else {
cxFieldsHtml.replace("{CX_STATUS_MSG}", "");
}
response->print(cxFieldsHtml);
// Cold Extraction hat eine eigene Seite (/Cold-Extraction) - hier nur der Verweis
response->print(FPSTR(brewControlCX_Link));
yield();
// --- Dampf-Timer Sektion ---
@@ -7422,6 +7573,8 @@ struct BrewControlUpdate {
uint16_t cxPulseMs = 0;
bool hasCXDuty = false;
float cxDuty = 0.0f;
bool hasCXPreInfDuty = false;
float cxPreInfDuty = 0.0f;
bool hasCXTarget = false;
float cxTarget = 0.0f;
bool hasCXMaxSecs = false;
@@ -7430,6 +7583,8 @@ struct BrewControlUpdate {
float cxPumpMaxRun = 0.0f;
bool hasCXPumpRest = false;
float cxPumpRest = 0.0f;
bool hasCXAskOnWake = false;
bool cxAskOnWake = false;
};
bool applyBrewControlUpdate(const BrewControlUpdate& update) {
@@ -7587,6 +7742,14 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
coldExtractionConfigChanged = true;
}
}
if (update.hasCXPreInfDuty) {
float v = update.cxPreInfDuty;
if (isfinite(v) && v >= COLDEX_DUTY_MIN && v <= COLDEX_DUTY_MAX && fabs(v - coldExtractionPreInfDutyPercent) > 0.01f) {
coldExtractionPreInfDutyPercent = v;
EEPROM.put(EEPROM_ADDR_COLDEX_PREINF_DUTY, coldExtractionPreInfDutyPercent);
coldExtractionConfigChanged = true;
}
}
if (update.hasCXTarget) {
float v = update.cxTarget;
if (isfinite(v) && v >= COLDEX_TARGET_GRAMS_MIN && v <= COLDEX_TARGET_GRAMS_MAX && fabs(v - coldExtractionTargetGrams) > 0.01f) {
@@ -7619,6 +7782,11 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
coldExtractionConfigChanged = true;
}
}
if (update.hasCXAskOnWake && update.cxAskOnWake != coldExtractionAskOnWake) {
coldExtractionAskOnWake = update.cxAskOnWake;
EEPROM.put(EEPROM_ADDR_COLDEX_ASK_ON_WAKE, (uint8_t)(coldExtractionAskOnWake ? 1 : 0));
coldExtractionConfigChanged = true;
}
// "changed" umfasst hier nur noch profil-relevante Felder (BBT/BBW/PI/SBT);
// FlowGuard und Cold Extraction sind kein Bestandteil von TemperatureProfile und
@@ -7632,6 +7800,130 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
return changed || flowGuardConfigChanged || coldExtractionConfigChanged;
}
/************************************************************************************
* Cold Extraction: eigene Seite (GET /Cold-Extraction, POST /saveColdExtraction)
* Bewusst getrennt von /Brew-Control - die Parameter gehoeren logisch zusammen und die
* Brew-Control-Seite war mit dem Block darunter unuebersichtlich geworden.
************************************************************************************/
void handleColdExtraction(AsyncWebServerRequest *request) {
char buffer[16];
AsyncResponseStream *response = request->beginResponseStream("text/html; charset=utf-8");
response->print(FPSTR(coldExHead));
response->print(FPSTR(commonStyle));
response->print(FPSTR(coldExHeadEnd));
response->print(FPSTR(commonNav));
response->print(FPSTR(coldExBodyStart));
yield();
response->print(FPSTR(coldExCX_H3));
response->print(FPSTR(brewControlCX_ToggleContainerStart));
if (coldExtractionEnabled) {
response->print(F(" checked"));
}
response->print(FPSTR(brewControlCX_ToggleContainerEnd));
String cxFieldsHtml = FPSTR(brewControlCX_Fields);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxTempC);
cxFieldsHtml.replace("{CX_MAX_TEMP}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfusionSeconds);
cxFieldsHtml.replace("{CX_PREINF}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPreInfDutyPercent);
cxFieldsHtml.replace("{CX_PREINF_DUTY}", buffer);
snprintf(buffer, sizeof(buffer), "%u", (unsigned int)coldExtractionPulsePeriodMs);
cxFieldsHtml.replace("{CX_PULSE_MS}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionDutyPercent);
cxFieldsHtml.replace("{CX_DUTY}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionTargetGrams);
cxFieldsHtml.replace("{CX_TARGET}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionMaxSeconds);
cxFieldsHtml.replace("{CX_MAX_SECS}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpMaxRunSec);
cxFieldsHtml.replace("{CX_PUMP_MAX_RUN}", buffer);
snprintf(buffer, sizeof(buffer), "%.1f", coldExtractionPumpRestSec);
cxFieldsHtml.replace("{CX_PUMP_REST}", buffer);
cxFieldsHtml.replace("{CX_ASK_CHECKED}", coldExtractionAskOnWake ? " checked" : "");
// Live-Status der Bedingungen: Die Abfrage haengt an mehreren Voraussetzungen gleichzeitig.
// Fehlt eine, passiert schlicht nichts - ohne diese Anzeige ist nicht erkennbar, welche.
{
String diag = "<br><small><b>Voraussetzungen der Abfrage:</b><br>";
auto flag = [](bool ok, const String& text) {
return String("<span style='color:") + (ok ? "#66CC66" : "#FFCC00") + ";'>" +
(ok ? "&#10004; " : "&#10008; ") + text + "</span><br>";
};
diag += flag(coldExtractionEnabled, "Funktion freigeschaltet");
diag += flag(coldExtractionAskOnWake, "Abfrage beim Aufwecken eingeschaltet");
diag += flag(touchUartClientActive, "Touch-Display verbunden");
diag += flag(!coldExtractionModeActive, "Cold-Extraction-Modus derzeit aus (sonst gibt es nichts zu fragen)");
diag += flag(coldExtractionArmable(), "Kein Wartungsmodus / Reinigung / PID-Tuning");
if (coldExtractionWakeChoicePending) {
long remainMs = (long)(coldExtractionWakeChoiceUntilMs - millis());
long remainSec = (remainMs > 0) ? ((remainMs + 999) / 1000) : 0;
diag += "<b style='color:#66CC66;'>Auswahlfenster ist gerade offen (noch " + String(remainSec) + " s).</b><br>";
}
diag += "</small>";
cxFieldsHtml.replace("{CX_ASK_DIAG}", diag);
}
if (coldExtractionEnabled) {
String cxStatus;
// Waagen-Hinweis zuerst: er gilt unabhaengig von der Temperatur-Freigabe und
// erklaert, warum das Zielgewicht weiter unten wirkungslos ist.
if (!coldExtractionScaleUsable()) {
cxStatus += "<br><b><small style='color: #FFCC00;'>Keine Waage aktiv/verbunden: Zielgewicht und "
"Stillstands-Erkennung greifen nicht. Der Bezug endet erst nach der maximalen "
"Bezugsdauer - diesen Wert dann als Dosierung verstehen.</small></b>";
}
String reason = coldExtractionBlockReason();
if (reason.length() > 0) {
cxStatus += "<br><b><small style='color: #FFCC00;'>" + reason + "</small></b>";
} else {
cxStatus += "<br><b><small style='color: #66CC66;'>Kalte Extraktion derzeit m&ouml;glich.</small></b>";
}
cxFieldsHtml.replace("{CX_STATUS_MSG}", cxStatus);
} else {
cxFieldsHtml.replace("{CX_STATUS_MSG}", "");
}
response->print(cxFieldsHtml);
yield();
response->print(FPSTR(coldExFormEnd));
request->send(response);
}
void handleSaveColdExtraction(AsyncWebServerRequest *request) {
BrewControlUpdate update; // nur die cx-Felder setzen, alles andere bleibt unangetastet
update.hasCXEnabled = true;
update.cxEnabled = request->hasArg("cxEnabled");
update.hasCXMaxTemp = request->hasArg("cxMaxTemp");
if (update.hasCXMaxTemp) { update.cxMaxTemp = request->arg("cxMaxTemp").toFloat(); }
update.hasCXPreInf = request->hasArg("cxPreInf");
if (update.hasCXPreInf) { update.cxPreInf = request->arg("cxPreInf").toFloat(); }
update.hasCXPreInfDuty = request->hasArg("cxPreInfDuty");
if (update.hasCXPreInfDuty) { update.cxPreInfDuty = request->arg("cxPreInfDuty").toFloat(); }
update.hasCXPulseMs = request->hasArg("cxPulseMs");
if (update.hasCXPulseMs) { update.cxPulseMs = (uint16_t)request->arg("cxPulseMs").toInt(); }
update.hasCXDuty = request->hasArg("cxDuty");
if (update.hasCXDuty) { update.cxDuty = request->arg("cxDuty").toFloat(); }
update.hasCXTarget = request->hasArg("cxTarget");
if (update.hasCXTarget) { update.cxTarget = request->arg("cxTarget").toFloat(); }
update.hasCXMaxSecs = request->hasArg("cxMaxSecs");
if (update.hasCXMaxSecs) { update.cxMaxSecs = request->arg("cxMaxSecs").toFloat(); }
update.hasCXPumpMaxRun = request->hasArg("cxPumpMaxRun");
if (update.hasCXPumpMaxRun) { update.cxPumpMaxRun = request->arg("cxPumpMaxRun").toFloat(); }
update.hasCXPumpRest = request->hasArg("cxPumpRest");
if (update.hasCXPumpRest) { update.cxPumpRest = request->arg("cxPumpRest").toFloat(); }
update.hasCXAskOnWake = true; // Checkbox: fehlt im POST = "aus"
update.cxAskOnWake = request->hasArg("cxAskOnWake");
applyBrewControlUpdate(update);
AsyncWebServerResponse *response = request->beginResponse(303);
response->addHeader("Location", "/Cold-Extraction");
request->send(response);
}
void handleSaveBrewControl(AsyncWebServerRequest *request) {
BrewControlUpdate update;
@@ -7667,25 +7959,9 @@ void handleSaveBrewControl(AsyncWebServerRequest *request) {
update.hasSBTSecs = request->hasArg("sbtSecs");
if (update.hasSBTSecs) { update.sbtSecs = request->arg("sbtSecs").toFloat(); }
update.hasCXEnabled = true;
update.cxEnabled = request->hasArg("cxEnabled");
update.hasCXMaxTemp = request->hasArg("cxMaxTemp");
if (update.hasCXMaxTemp) { update.cxMaxTemp = request->arg("cxMaxTemp").toFloat(); }
update.hasCXPreInf = request->hasArg("cxPreInf");
if (update.hasCXPreInf) { update.cxPreInf = request->arg("cxPreInf").toFloat(); }
update.hasCXPulseMs = request->hasArg("cxPulseMs");
if (update.hasCXPulseMs) { update.cxPulseMs = (uint16_t)request->arg("cxPulseMs").toInt(); }
update.hasCXDuty = request->hasArg("cxDuty");
if (update.hasCXDuty) { update.cxDuty = request->arg("cxDuty").toFloat(); }
update.hasCXTarget = request->hasArg("cxTarget");
if (update.hasCXTarget) { update.cxTarget = request->arg("cxTarget").toFloat(); }
update.hasCXMaxSecs = request->hasArg("cxMaxSecs");
if (update.hasCXMaxSecs) { update.cxMaxSecs = request->arg("cxMaxSecs").toFloat(); }
update.hasCXPumpMaxRun = request->hasArg("cxPumpMaxRun");
if (update.hasCXPumpMaxRun) { update.cxPumpMaxRun = request->arg("cxPumpMaxRun").toFloat(); }
update.hasCXPumpRest = request->hasArg("cxPumpRest");
if (update.hasCXPumpRest) { update.cxPumpRest = request->arg("cxPumpRest").toFloat(); }
// Cold Extraction wird hier bewusst NICHT angefasst - die Felder stehen auf
// /Cold-Extraction. Wuerde man sie hier weiter auswerten, wuerde ein Speichern auf
// dieser Seite die Freischaltung abschalten (Checkbox fehlt im Formular = "aus").
applyBrewControlUpdate(update);
AsyncWebServerResponse *response = request->beginResponse(303);
@@ -15011,15 +15287,21 @@ static const char dashboardJavaScript[] PROGMEM = R"rawliteral(
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);
// Umschalten haengt an cxArmable, NICHT an cxAllowed: Der Modus muss auch im
// Standby und bei noch warmem Kessel scharf zu stellen sein.
coldexToggleEl.disabled = (data.cxEnabled !== true) || (data.cxArmable !== 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).';
if (data.cxEnabled === true && data.cxAllowed !== true) {
// Gilt auch bei aktivem Modus: dann ist er scharf, aber der Bezug noch gesperrt
const prefix = (data.cxActive === true) ? 'Modus aktiv, Bezug noch gesperrt: ' : '';
if (data.standbyActive === true) {
hintText = prefix + 'Standby aktiv - Bezug erst nach dem Aufwecken.';
} else if (typeof data.tempW === 'number' && typeof data.cxMaxTemp === 'number' && data.tempW >= data.cxMaxTemp) {
hintText = prefix + 'Wasserkessel zu warm (' + data.tempW.toFixed(1) + ' °C, benötigt < ' + data.cxMaxTemp.toFixed(1) + ' °C).';
} else {
hintText = 'Cold Extraction derzeit nicht möglich.';
hintText = prefix + 'Bezug derzeit nicht möglich.';
}
}
// Waagen-Hinweis gilt zusätzlich und unabhängig von der Temperatur-Freigabe
@@ -15440,6 +15722,29 @@ bool executeDashboardAction(const String& action, const String& value,
success = true;
}
}
// Gegenstueck zu startColdExtraction beim Aufwecken: "normal heizen" bewusst waehlen.
// Beendet den Standby, falls noch aktiv, und schliesst das Auswahlfenster - sonst
// wuerde nach dem Aufwecken noch einmal gefragt.
else if (action == "wakeEspresso") {
coldExtractionWakeDecisionMs = millis();
coldExtractionWakeChoicePending = false;
if (standbyModeActive) {
standbyModeActive = false;
startupTime = millis();
ecoForcedActive = false;
steamDelayOverridden = false;
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
ecoModeAktiv = false;
ecoModeActivatedTime = 0;
lastShotTime = millis();
applyLightOutput();
message = "Espresso: Standby aufgehoben, Kessel heizt auf.";
} else {
message = "Espresso: Kessel heizt auf.";
}
success = true;
}
else if (action == "activateStandby") {
if (standbyModeActive) {
message = "Standby ist bereits aktiv.";
@@ -15569,6 +15874,9 @@ bool executeDashboardAction(const String& action, const String& value,
} else {
target = !coldExtractionModeActive;
}
// Jede bewusste Wahl schliesst das Auswahlfenster nach dem Aufwecken
coldExtractionWakeDecisionMs = millis();
coldExtractionWakeChoicePending = false;
success = setColdExtractionMode(target, message);
if (!success) {
coldExtractionBlockMessage = message;
@@ -15590,7 +15898,7 @@ bool executeDashboardAction(const String& action, const String& value,
// Freigabe wird pro Bezug neu geprueft (der Dampfkreis darf weiter heizen und
// kann den Wasserkessel ueber die Zeit ueber die Schwelle bringen).
message = coldExtractionBlockReason();
if (message.length() == 0) { message = "Cold Extraction derzeit nicht moeglich."; }
if (message.length() == 0) { message = "Cold Extraction derzeit nicht möglich."; }
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionBlockMessage = message;
coldExtractionNotice = COLDEX_NOTICE_START_REJECTED;
@@ -16071,6 +16379,9 @@ void buildDashboardJson(char *buffer, size_t bufferSize) {
}
}
statusKey = "brewing";
} else if (coldExtractionWakeChoicePending) {
statusText = "Auswahl: Espresso oder Cold Extraction?";
statusKey = "coldextraction";
} else if (coldExtractionModeActive) {
statusText = coldExtractionScaleUsable()
? "Cold Extraction bereit (Wasserkreis heizt nicht)"
@@ -16178,7 +16489,7 @@ void buildDashboardJson(char *buffer, size_t bufferSize) {
// Plattformdaten
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
dtostrf(activeTargetWeightGrams(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf); // bei Cold Extraction das kalte Ziel
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleEnabled\":%s,", scaleEnabled ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleType\":%u,", (unsigned int)scaleType);
offset += snprintf(buffer + offset, bufferSize - offset, "\"scaleConnected\":%s,", scaleConnected ? "true" : "false");
@@ -16191,6 +16502,8 @@ void buildDashboardJson(char *buffer, size_t bufferSize) {
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");
// cxArmable = Modus umschaltbar (auch im Standby und bei warmem Kessel); cxAllowed = Bezug moeglich
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxArmable\":%s,", coldExtractionArmable() ? "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);
@@ -16320,6 +16633,9 @@ static void getTouchUartStatus(String& statusText, String& statusKey) {
statusText = "Bezug aktiv...";
}
statusKey = "brewing";
} else if (coldExtractionWakeChoicePending) {
statusText = "Auswahl: Espresso oder Cold Extraction?";
statusKey = "coldextraction";
} else if (coldExtractionModeActive) {
statusText = coldExtractionScaleUsable() ? "Cold Extraction bereit"
: "Cold Extraction bereit (Zeitsteuerung)";
@@ -16461,7 +16777,7 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
}
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
dtostrf(activeTargetWeightGrams(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf); // bei Cold Extraction das kalte Ziel
// Endgewicht des letzten Bezugs inkl. Offset (identisch zur OLED: Netto beim Stopp + Offset).
// Nur bei gewichtsbasiertem Bezug aussagekraeftig -> lastShotByWeight signalisiert das dem P4.
dtostrf(lastShotFinalNetWeight + brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"lastShotWeight\":%s,", floatBuf);
@@ -16517,10 +16833,29 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
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");
// cxArmable = Modus umschaltbar (auch im Standby und bei warmem Kessel); cxAllowed = Bezug moeglich
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxArmable\":%s,", coldExtractionArmable() ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxResting\":%s,", coldExtractionPumpResting ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxAskOnWake\":%s,", coldExtractionAskOnWake ? "true" : "false");
// Offenes Auswahlfenster nach dem Aufwecken (+ Restsekunden fuer den Countdown am Display)
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxWakeChoice\":%s,", coldExtractionWakeChoicePending ? "true" : "false");
{
long cxWakeSec = 0;
if (coldExtractionWakeChoicePending) {
long remainMs = (long)(coldExtractionWakeChoiceUntilMs - millis());
cxWakeSec = (remainMs > 0) ? (long)((remainMs + 999) / 1000) : 0;
}
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxWakeChoiceSec\":%ld,", cxWakeSec);
}
dtostrf(coldExtractionMaxTempC, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxTemp\":%s,", floatBuf);
dtostrf(coldExtractionTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxTarget\":%s,", floatBuf);
dtostrf(coldExtractionPreInfusionSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPreInf\":%s,", floatBuf);
// Restliche Parameter fuer die Einstellseite am Display (seit S3 5.4.0)
dtostrf(coldExtractionPreInfDutyPercent, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPreInfDuty\":%s,", floatBuf);
dtostrf(coldExtractionDutyPercent, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxDuty\":%s,", floatBuf);
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPulseMs\":%u,", (unsigned int)coldExtractionPulsePeriodMs);
dtostrf(coldExtractionPumpMaxRunSec, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPumpMaxRun\":%s,", floatBuf);
dtostrf(coldExtractionPumpRestSec, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPumpRest\":%s,", floatBuf);
dtostrf(coldExtractionMaxSeconds, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxSecs\":%s,", floatBuf);
// Sperrgrund + transiente Meldung als Codes (kein Freitext - die State-Zeile muss kurz
// bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst).
@@ -17202,9 +17537,58 @@ static bool touchUartHandleCommandLine(const String& line) {
update.sbtSecs = floatVal;
}
// --- Cold Extraction (seit S3 5.4.0 auch ueber saveBrew einstellbar) ---
if (jsonGetBoolValue(line, "cxEnabled", boolVal)) {
update.hasCXEnabled = true;
update.cxEnabled = boolVal;
}
if (jsonGetFloatValue(line, "cxMaxTemp", floatVal)) {
update.hasCXMaxTemp = true;
update.cxMaxTemp = floatVal;
}
if (jsonGetFloatValue(line, "cxPreInf", floatVal)) {
update.hasCXPreInf = true;
update.cxPreInf = floatVal;
}
if (jsonGetFloatValue(line, "cxPreInfDuty", floatVal)) {
update.hasCXPreInfDuty = true;
update.cxPreInfDuty = floatVal;
}
if (jsonGetFloatValue(line, "cxPulseMs", floatVal)) {
update.hasCXPulseMs = true;
update.cxPulseMs = (uint16_t)floatVal;
}
if (jsonGetFloatValue(line, "cxDuty", floatVal)) {
update.hasCXDuty = true;
update.cxDuty = floatVal;
}
if (jsonGetFloatValue(line, "cxTarget", floatVal)) {
update.hasCXTarget = true;
update.cxTarget = floatVal;
}
if (jsonGetFloatValue(line, "cxMaxSecs", floatVal)) {
update.hasCXMaxSecs = true;
update.cxMaxSecs = floatVal;
}
if (jsonGetFloatValue(line, "cxPumpMaxRun", floatVal)) {
update.hasCXPumpMaxRun = true;
update.cxPumpMaxRun = floatVal;
}
if (jsonGetFloatValue(line, "cxPumpRest", floatVal)) {
update.hasCXPumpRest = true;
update.cxPumpRest = floatVal;
}
if (jsonGetBoolValue(line, "cxAskOnWake", boolVal)) {
update.hasCXAskOnWake = true;
update.cxAskOnWake = boolVal;
}
if (!update.hasBBTEnabled && !update.hasBBTSecs && !update.hasBBWEnabled && !update.hasBBWTarget &&
!update.hasBBWOffset && !update.hasPIEnabled && !update.hasPIDurSecs && !update.hasPIPauseSecs &&
!update.hasSBTEnabled && !update.hasSBTSecs) {
!update.hasSBTEnabled && !update.hasSBTSecs &&
!update.hasCXEnabled && !update.hasCXMaxTemp && !update.hasCXPreInf && !update.hasCXPreInfDuty &&
!update.hasCXPulseMs && !update.hasCXDuty && !update.hasCXTarget && !update.hasCXMaxSecs &&
!update.hasCXPumpMaxRun && !update.hasCXPumpRest && !update.hasCXAskOnWake) {
touchUartSendError(id, "Keine Brew-Felder");
return false;
}
+53
View File
@@ -1,3 +1,56 @@
Version 1.4.0:
- Die Auswahl „Espresso oder Cold Extraction" erscheint jetzt auch beim Aufwecken über den Schalter
(braucht S3-Firmware ab 5.6.0). Meldet die S3 ein offenes Auswahlfenster (cxWakeChoice), blendet das
Display den Dialog von sich aus ein - ohne Antippen - und zeigt die verbleibende Bedenkzeit als
Countdown. „Abbrechen" entfällt in diesem Fall, weil es nichts abzubrechen gibt: Läuft die Zeit ab,
heizt die Maschine normal auf.
- Der Espresso-Knopf sendet „wakeEspresso" statt „deactivateStandby", sobald die Auswahl relevant ist.
Damit wird die Entscheidung auf der S3 vermerkt und nicht direkt danach erneut gefragt.
- Der Cold-Extraction-Knopf beendet den Standby nur noch, wenn er überhaupt aktiv ist - im Auswahlfenster
nach dem Aufwecken schaltet er nur den Modus.
Version 1.3.0:
- Der Aufweck-Dialog wird zur Auswahl: „Womit aufwecken?" mit den Knöpfen „Espresso" (heizt normal auf)
und „Cold Extraction (Wasser bleibt kalt)". Braucht S3-Firmware ab 5.5.0.
Bei „Cold Extraction" wird erst der Modus scharf geschaltet und dann der Standby beendet, damit der
Wasserkreis nicht zwischendurch anheizt.
- Steuerbar über die neue Einstellung „Beim Aufwecken fragen" (Cold-Extraction-Seite, Abschnitt
Grundeinstellung; identisch zur Web-UI). Ist sie aus, erscheint wieder der bisherige Dialog mit
„Aufwecken" und „Abbrechen" - der Zwischenschritt entfällt dann komplett.
Version 1.2.1:
- Bug-Fix: Während einer kalten Extraktion zeigten Waage-Kachel und Live-Bezugsschirm das Zielgewicht
des normalen Espresso-Bezugs (bbwTarget) statt des Ziels der kalten Extraktion (cxTarget). Außerdem
erschienen Gewichtsanzeige und Fortschrittsbalken nur, wenn Brew-by-Weight aktiv war - bei kalter
Extraktion ist das typischerweise aus, obwohl ein Ziel existiert. Beide Anzeigen nutzen jetzt
cxTarget, sobald cxShot gesetzt ist.
Version 1.2.0:
- Neue Seite „Cold Extraction" mit eigenem Menüeintrag (braucht S3-Firmware ab 5.4.0). Dort stehen jetzt
alle Parameter der kalten Extraktion und lassen sich direkt am Display ändern: Freigabetemperatur,
Vorbenetzung (Dauer + Pumpenleistung), Hauptbezug (Pulsperiode + Pumpenleistung), Zielgewicht,
maximale Bezugsdauer sowie der Pumpenschutz. Gespeichert wird über das erweiterte saveBrew-Kommando.
- Der Modus-Schalter ist von der Temperatur-Seite auf die neue Seite umgezogen (eine Stelle statt zwei).
Der Knopf „Aufwecken mit Cold Extraction" im Standby-Dialog bleibt unverändert.
- Brew-Control-Seite neu gegliedert: Statt einer durchlaufenden Liste unter Zwischenüberschriften liegt
jede Funktion (Pre-Infusion, Brew-by-Time, Brew-by-Weight, Dampf-Timer) jetzt in einer eigenen Karte
mit Titel und einer Zeile, die erklärt, was sie tut. Die neue Cold-Extraction-Seite nutzt dieselbe
Gliederung.
- Eingabefelder werden nur beim Öffnen der Seite aus dem Zustand gefüllt, damit laufende State-Pushes
eine begonnene Eingabe nicht überschreiben (gleiches Verhalten wie auf der Brew-Control-Seite).
Version 1.1.3:
- Aufweck-Dialog hat einen dritten Knopf: „Aufwecken mit Cold Extraction" (braucht S3 ab 5.3.5).
Er stellt den Modus scharf und beendet erst danach den Standby - in dieser Reihenfolge, damit
der Wasserkreis gar nicht erst anheizt. Ohne diesen Weg musste man normal aufwecken (Maschine
heizt an) und den Modus danach umschalten. Der Knopf erscheint nur, wenn die Funktion
freigeschaltet und umschaltbar ist und der Modus nicht ohnehin schon läuft.
- Der Schalter „Cold Extraction" auf der Temperatur-Seite hängt jetzt am neuen State-Feld
cxArmable statt an cxAllowed: umschaltbar auch im Standby und bei warmem Kessel, gesperrt nur
während eines laufenden kalten Bezugs. Bei älterer S3-Firmware fällt das Feld auf cxEnabled
zurück, der Schalter bleibt also bedienbar.
- Erklärzeile im Standby: „Nach dem Aufwecken bleibt der Wasserkreis aus."
Version 1.1.2:
- Cold Extraction: Fehlt eine nutzbare Waage, weist das Display jetzt dauerhaft darauf hin.
Ohne Waage greifen weder Zielgewicht noch Stillstands-Erkennung, der Bezug endet erst nach der
+1 -1
View File
@@ -65,7 +65,7 @@
// -------------------------------------------------------------------------------------
// Firmware
// -------------------------------------------------------------------------------------
#define DISPLAY_FW_VERSION "1.1.2" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
#define DISPLAY_FW_VERSION "1.4.0" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
// -------------------------------------------------------------------------------------
// Protokoll
+11 -1
View File
@@ -72,11 +72,21 @@ struct MachineState {
bool cxEnabled = false; // Funktion auf der S3 freigeschaltet (sonst Bedienelement ausblenden)
bool cxActive = false; // Modus scharf: Wasserkreis heizt nicht (persistent, seit S3 5.3.1)
bool cxShot = false; // der laufende Bezug ist eine kalte Extraktion
bool cxAllowed = false; // Freigabe jetzt (Wasserkessel kalt genug, kein Standby/Wartung/Tuning)
bool cxAllowed = false; // Bezug jetzt moeglich (Wasserkessel kalt genug, kein Standby/Wartung/Tuning)
bool cxArmable = false; // Modus umschaltbar - auch im Standby und bei warmem Kessel (seit S3 5.3.5)
bool cxResting = false; // Zwangspause des Pumpenschutzes laeuft (Bezug laeuft weiter)
float cxMaxTemp = 0; // Freigabeschwelle in C
float cxTarget = 0; // Zielgewicht in g
float cxPreInfSec = 0; // Vorbenetzung in s (fuer die Phasenleiste des kalten Bezugs)
// Restliche Parameter fuer die Einstellseite am Display (seit S3 5.4.0)
float cxPreInfDuty = 0; // Pumpenleistung Vorbenetzung in %
float cxDuty = 0; // Pumpenleistung Hauptbezug in %
uint16_t cxPulseMs = 0; // Pulsperiode in ms
float cxPumpMaxRun = 0; // Pumpenschutz: max. kumulierte Laufzeit in s
float cxPumpRest = 0; // Pumpenschutz: Zwangspause in s
bool cxAskOnWake = false; // Beim Aufwecken aus dem Standby fragen (seit S3 5.5.0)
bool cxWakeChoice = false; // Auswahlfenster nach dem Aufwecken offen (seit S3 5.6.0)
long cxWakeChoiceSec = 0; // Restsekunden der Bedenkzeit
float cxMaxSecs = 0; // Sicherheits-Timeout des Bezugs in s
// Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent,
// 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm
+10
View File
@@ -225,10 +225,20 @@ void ProtocolClient::applyState(const String& json) {
s.cxActive = doc["cxActive"] | false;
s.cxShot = doc["cxShot"] | false;
s.cxAllowed = doc["cxAllowed"] | false;
// Fehlt bei S3-FW < 5.3.5 -> auf cxEnabled zurueckfallen, sonst waere der Schalter tot
s.cxArmable = doc["cxArmable"] | (doc["cxEnabled"] | false);
s.cxResting = doc["cxResting"] | false;
s.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp;
s.cxTarget = doc["cxTarget"] | s.cxTarget;
s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec;
s.cxPreInfDuty = doc["cxPreInfDuty"] | s.cxPreInfDuty;
s.cxDuty = doc["cxDuty"] | s.cxDuty;
s.cxPulseMs = doc["cxPulseMs"] | s.cxPulseMs;
s.cxPumpMaxRun = doc["cxPumpMaxRun"] | s.cxPumpMaxRun;
s.cxPumpRest = doc["cxPumpRest"] | s.cxPumpRest;
s.cxAskOnWake = doc["cxAskOnWake"] | false;
s.cxWakeChoice = doc["cxWakeChoice"] | false;
s.cxWakeChoiceSec = doc["cxWakeChoiceSec"] | (long)0;
s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
s.cxBlock = doc["cxBlock"] | (uint8_t)0;
s.cxNotice = doc["cxNotice"] | (uint8_t)0;
+259 -66
View File
@@ -38,6 +38,10 @@ static lv_timer_t* g_toastTimer = nullptr;
static lv_obj_t* g_standbyScreen = nullptr; // Vollbild-Overlay im Standby (Uhr)
static lv_obj_t* g_standbyClock = nullptr;
static lv_obj_t* g_wakeConfirm = nullptr; // Bestaetigungsdialog "Aus dem Standby aufwecken?"
static lv_obj_t* g_wakeColdBtn = nullptr; // "Cold Extraction" im Aufweck-Dialog
static lv_obj_t* g_wakeMsg = nullptr; // Text des Aufweck-Dialogs (wechselt bei Abfrage-Modus)
static lv_obj_t* g_wakeYesLbl = nullptr; // Beschriftung des Bestaetigen-Knopfs
static lv_obj_t* g_wakeNoBtn = nullptr; // "Abbrechen" (entfaellt beim Auswahlfenster nach dem Aufwecken)
static bool g_wakeDialogOpen = false; // Dialog sichtbar -> Display hell schalten (lesbar)
static bool g_standbyShown = false; // Standby-Uhr-Overlay zuletzt als sichtbar geschaltet (fuer Ein-/Ausblend-Uebergang)
@@ -49,8 +53,8 @@ static lv_obj_t* g_otaStatus = nullptr;
static lv_obj_t* g_otaBytes = nullptr;
// Navigation
enum { PG_DASH = 0, PG_CHART, PG_STATS, PG_TEMP, PG_BREW, PG_SERVICE, PG_PROFILE, PG_LIGHT, PG_WIFI, PG_INFO, PG_COUNT };
static const char* PAGE_NAMES[PG_COUNT] = { "Dashboard", "Verlauf", "Statistik", "Temperatur", "Brew-Control", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Info" };
enum { PG_DASH = 0, PG_CHART, PG_STATS, PG_TEMP, PG_BREW, PG_COLDEX, PG_SERVICE, PG_PROFILE, PG_LIGHT, PG_WIFI, PG_INFO, PG_COUNT };
static const char* PAGE_NAMES[PG_COUNT] = { "Dashboard", "Verlauf", "Statistik", "Temperatur", "Brew-Control", "Cold Extraction", "Reinigung/Wartung", "Profile", "Licht", "WiFi", "Info" };
static lv_obj_t* g_content = nullptr;
static lv_obj_t* g_pages[PG_COUNT] = { nullptr };
static lv_obj_t* g_drawer = nullptr;
@@ -250,7 +254,16 @@ static lv_obj_t* swPI; static lv_obj_t* taPiDur; static lv_obj_t* taPiPause;
static lv_obj_t* swBBT; static lv_obj_t* taBbtSecs;
static lv_obj_t* swBBW; static lv_obj_t* taBbwTarget; static lv_obj_t* taBbwOffset;
static lv_obj_t* swSBT; static lv_obj_t* taSbtSecs;
static lv_obj_t* lblBrewColdEx = nullptr; // Cold-Extraction-Parameter (nur Anzeige)
// Cold-Extraction-Seite (eigener Menuepunkt)
static lv_obj_t* taCxMaxTemp = nullptr; static lv_obj_t* taCxPreInf = nullptr;
static lv_obj_t* taCxPreInfDuty = nullptr; static lv_obj_t* taCxPulseMs = nullptr;
static lv_obj_t* taCxDuty = nullptr; static lv_obj_t* taCxTarget = nullptr;
static lv_obj_t* taCxMaxSecs = nullptr; static lv_obj_t* taCxPumpMaxRun = nullptr;
static lv_obj_t* taCxPumpRest = nullptr;
static lv_obj_t* swCxEnabled = nullptr; // Freischaltung (entspricht dem Web-Toggle)
static lv_obj_t* swCxAskOnWake = nullptr; // Abfrage beim Aufwecken aus dem Standby
static lv_obj_t* lblCxPageHint = nullptr;
static uint32_t g_cxEditMs = 0; // waehrend der Eingabe die Felder nicht ueberschreiben
// WiFi
static lv_obj_t* wifiStatusLbl; static lv_obj_t* wifiHintLbl; static lv_obj_t* wifiList;
@@ -399,6 +412,29 @@ static lv_obj_t* add_num(lv_obj_t* parent, const char* label) {
attach_kb(ta, false);
return ta;
}
// Gruppen-Karte: abgesetzter Block mit Titel, in den Zeilen (row/add_switch/add_num)
// gelegt werden. Deutlich uebersichtlicher als eine lange Liste gleich aussehender
// Zeilen unter Zwischenueberschriften.
static lv_obj_t* group_card(lv_obj_t* parent, const char* title, const char* subtitle = nullptr) {
lv_obj_t* c = th_card(parent);
lv_obj_set_width(c, LV_PCT(100));
lv_obj_set_height(c, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(c, 2, 0);
lv_obj_clear_flag(c, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(c);
lv_label_set_text(t, title);
lv_obj_set_style_text_color(t, COL_ACCENT, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
if (subtitle) {
lv_obj_t* s = lv_label_create(c);
lv_label_set_long_mode(s, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s, LV_PCT(100));
lv_label_set_text(s, subtitle);
lv_obj_set_style_text_color(s, COL_TEXT_MUT, 0);
}
return c;
}
static lv_obj_t* section_title(lv_obj_t* parent, const char* txt) {
lv_obj_t* l = lv_label_create(parent);
lv_label_set_text(l, txt);
@@ -429,6 +465,7 @@ static lv_obj_t* make_page(lv_obj_t* parent) {
// =====================================================================================
// Brew-Control-Felder einmalig aus dem Zustand fuellen (nur beim Oeffnen der Seite,
// damit laufende State-Pushes die Eingaben nicht vor dem Speichern ueberschreiben).
static void populate_coldex(const MachineState& st); // Definition auf der Cold-Extraction-Seite
static void populate_brew(const MachineState& st) {
if (!swPI) return;
sw_set(swPI, st.piEnabled); ta_setf(taPiDur, st.piDurSecs, 1); ta_setf(taPiPause, st.piPauseSecs, 1);
@@ -462,6 +499,7 @@ static void show_page(int idx) {
if (idx == PG_PROFILE && g_client) g_client->sendListProfiles();
if (idx == PG_STATS) stats_request(); // Nutzungsstatistik beim Oeffnen frisch anfordern
if (idx == PG_BREW) populate_brew(g_state); // Felder beim Oeffnen frisch laden
if (idx == PG_COLDEX) populate_coldex(g_state);
if (idx == PG_SERVICE) populate_service(g_state);
}
static void drawer_open() { if (g_drawer) { lv_obj_remove_flag(g_drawer, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(g_drawer); } }
@@ -570,7 +608,23 @@ static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 bee
wake_confirm_hide();
// g_wakeDialogOpen bleibt true, bis der Standby wirklich endet (ui_update) - sonst wuerde
// das Display in der Uebergangsphase kurz auf Standby-Helligkeit abdunkeln.
if (g_client) { g_client->sendAction("deactivateStandby"); showToast("Maschine wird aufgeweckt...", false); }
if (!g_client) return;
// "wakeEspresso" beendet den Standby UND schliesst das Auswahlfenster der S3. Ohne das
// wuerde nach dem Aufwecken noch einmal gefragt bzw. der Wasserkreis bliebe kurz aus.
const bool choiceRelevant = g_state.cxWakeChoice || (g_state.cxEnabled && g_state.cxAskOnWake);
g_client->sendAction(choiceRelevant ? "wakeEspresso" : "deactivateStandby");
showToast(g_state.standbyActive ? "Maschine wird aufgeweckt..." : "Espresso: Kessel heizt auf.", false);
}
// Aufwecken direkt in die kalte Extraktion. Reihenfolge ist wichtig: erst den Modus scharf
// stellen, dann den Standby beenden - sonst heizt der Wasserkreis in der Zwischenzeit an,
// und genau das soll die Funktion ja verhindern.
static void wake_cold_cb(lv_event_t*) {
wake_confirm_hide();
if (g_client) {
g_client->sendAction("startColdExtraction");
if (g_state.standbyActive) g_client->sendAction("deactivateStandby");
showToast("Cold Extraction aktiviert.", false);
}
}
bool ui_wake_dialog_open() { return g_wakeDialogOpen; } // fuer die Helligkeitssteuerung in onState
@@ -1315,16 +1369,7 @@ static void build_temp(lv_obj_t* p) {
lv_obj_add_event_cb(swEco, eco_cb, LV_EVENT_VALUE_CHANGED, nullptr);
swMaint = add_switch(p, "Wartungsmodus");
lv_obj_add_event_cb(swMaint, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr);
swColdEx = add_switch(p, "Cold Extraction");
lv_obj_add_event_cb(swColdEx, coldex_cb, LV_EVENT_VALUE_CHANGED, nullptr);
lblColdExHint = lv_label_create(p);
lv_label_set_long_mode(lblColdExHint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblColdExHint, LV_PCT(100));
lv_obj_set_style_text_color(lblColdExHint, COL_TEXT_DIM, 0);
lv_label_set_text(lblColdExHint, "");
// Beide Zeilen bleiben versteckt, solange die S3 die Funktion nicht freigeschaltet meldet
lv_obj_add_flag(lv_obj_get_parent(swColdEx), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
// Cold Extraction hat eine eigene Seite (PG_COLDEX) - Schalter und Parameter stehen dort.
}
// =====================================================================================
@@ -1822,12 +1867,16 @@ static void brew_live_tick() {
}
// Zielfortschritt: Brew-by-Weight (Gewicht) vor Brew-by-Time (Zeit), sonst versteckt
if (g_bzBarRow && g_bzBar && g_bzBarLbl) {
bool bbw = scale && g_state.bbwEnabled && g_state.bbwTarget > 0.0f;
// Ein kalter Bezug hat sein eigenes Ziel (cxTarget) und ersetzt Brew-by-Weight -
// sonst stuende hier das Espresso-Ziel.
const bool cxShotNow = g_state.cxShot && g_state.cxTarget > 0.0f;
const float wTarget = cxShotNow ? g_state.cxTarget : g_state.bbwTarget;
bool bbw = scale && (cxShotNow || (g_state.bbwEnabled && g_state.bbwTarget > 0.0f));
if (bbw || (g_shotBbtMode && g_bbtTarget > 0.0f)) {
int pct;
if (bbw) {
pct = (int)(w / g_state.bbwTarget * 100.0f + 0.5f);
snprintf(b, sizeof(b), "%.1f g / %.1f g", w, g_state.bbwTarget);
pct = (int)(w / wTarget * 100.0f + 0.5f);
snprintf(b, sizeof(b), "%.1f g / %.1f g", w, wTarget);
} else {
pct = (int)(el / g_bbtTarget * 100.0f + 0.5f);
snprintf(b, sizeof(b), "%.1f s / %.1f s", el, g_bbtTarget);
@@ -1998,28 +2047,112 @@ static void send_save_brew(lv_event_t*) {
showToast("Speichere Brühen...", false);
}
static void build_brew(lv_obj_t* p) {
section_title(p, "Pre-Infusion");
swPI = add_switch(p, "Aktiv");
taPiDur = add_num(p, "Dauer (s)");
taPiPause = add_num(p, "Pause (s)");
section_title(p, "Brew-by-Time / Weight / Steam-by-Time");
swBBT = add_switch(p, "Brew-by-Time aktiv");
taBbtSecs = add_num(p, "Zielzeit (s)");
swBBW = add_switch(p, "Brew-by-Weight aktiv");
taBbwTarget= add_num(p, "Zielgewicht (g)");
taBbwOffset= add_num(p, "Offset (g)");
swSBT = add_switch(p, "Steam-by-Time aktiv");
taSbtSecs = add_num(p, "Dampf-Zielzeit (s)");
// Jede Funktion in einer eigenen Karte - vorher standen alle Zeilen unter blossen
// Zwischenueberschriften untereinander und waren kaum auseinanderzuhalten.
lv_obj_t* cPI = group_card(p, "Pre-Infusion", "Pumpe an - aus - an, vor dem eigentlichen Bezug");
swPI = add_switch(cPI, "Aktiv");
taPiDur = add_num(cPI, "Dauer (s)");
taPiPause = add_num(cPI, "Pause (s)");
lv_obj_t* cBBT = group_card(p, "Brew-by-Time", "Beendet den Bezug nach der Zielzeit");
swBBT = add_switch(cBBT, "Aktiv");
taBbtSecs = add_num(cBBT, "Zielzeit (s)");
lv_obj_t* cBBW = group_card(p, "Brew-by-Weight", "Beendet den Bezug beim Zielgewicht (braucht Waage)");
swBBW = add_switch(cBBW, "Aktiv");
taBbwTarget= add_num(cBBW, "Zielgewicht (g)");
taBbwOffset= add_num(cBBW, "Offset (g)");
lv_obj_t* cSBT = group_card(p, "Dampf-Timer", "Beendet den Dampfbezug automatisch");
swSBT = add_switch(cSBT, "Aktiv");
taSbtSecs = add_num(cSBT, "Zielzeit (s)");
add_save_button(p, "Brühen speichern", send_save_brew);
// Cold Extraction: nur Anzeige. Die Parameter liegen bewusst nur in der Web-UI der S3
// (maschinenabhaengig, nicht ueber saveBrew erreichbar); geschaltet wird der Modus
// auf der Temperatur-Seite unter "Modi".
lblBrewColdEx = lv_label_create(p);
lv_label_set_long_mode(lblBrewColdEx, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblBrewColdEx, LV_PCT(100));
lv_obj_set_style_text_color(lblBrewColdEx, COL_TEXT_DIM, 0);
lv_label_set_text(lblBrewColdEx, "");
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
}
// =====================================================================================
// Cold Extraction (eigene Seite)
// =====================================================================================
static void send_save_coldex(lv_event_t*) {
if (!g_client) return;
String f;
f += "\"cxEnabled\":"; f += boolStr(sw_get(swCxEnabled)); f += ",";
f += "\"cxMaxTemp\":"; f += String(ta_f(taCxMaxTemp), 1); f += ",";
f += "\"cxPreInf\":"; f += String(ta_f(taCxPreInf), 1); f += ",";
f += "\"cxPreInfDuty\":"; f += String(ta_f(taCxPreInfDuty), 1); f += ",";
f += "\"cxPulseMs\":"; f += String((int)ta_f(taCxPulseMs)); f += ",";
f += "\"cxDuty\":"; f += String(ta_f(taCxDuty), 1); f += ",";
f += "\"cxTarget\":"; f += String(ta_f(taCxTarget), 1); f += ",";
f += "\"cxMaxSecs\":"; f += String(ta_f(taCxMaxSecs), 1); f += ",";
f += "\"cxPumpMaxRun\":"; f += String(ta_f(taCxPumpMaxRun), 1); f += ",";
f += "\"cxPumpRest\":"; f += String(ta_f(taCxPumpRest), 1); f += ",";
f += "\"cxAskOnWake\":"; f += boolStr(sw_get(swCxAskOnWake));
g_client->sendCommand("saveBrew", f);
g_cxEditMs = millis();
showToast("Speichere Cold Extraction...", false);
}
static void build_coldex(lv_obj_t* p) {
lv_obj_t* cMode = group_card(p, "Modus",
"Schaltet die Wasserheizung ab. Auch im Standby und bei warmem Kessel schaltbar; "
"bezogen werden kann erst unterhalb der Freigabetemperatur.");
swColdEx = add_switch(cMode, "Cold Extraction aktiv");
lv_obj_add_event_cb(swColdEx, coldex_cb, LV_EVENT_VALUE_CHANGED, nullptr);
lblColdExHint = lv_label_create(cMode);
lv_label_set_long_mode(lblColdExHint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblColdExHint, LV_PCT(100));
lv_obj_set_style_text_color(lblColdExHint, COL_TEXT_DIM, 0);
lv_label_set_text(lblColdExHint, "");
lv_obj_t* cBase = group_card(p, "Grundeinstellung");
swCxEnabled = add_switch(cBase, "Funktion freigeschaltet");
taCxMaxTemp = add_num(cBase, "Freigabe bis (°C)");
swCxAskOnWake = add_switch(cBase, "Beim Aufwecken fragen");
lv_obj_t* cPre = group_card(p, "Vorbenetzung",
"Deutlich schwächer als der Hauptbezug: Kaltes Mehl quillt nicht, volle Leistung legt "
"Kanäle an, die sich nie wieder schließen.");
taCxPreInf = add_num(cPre, "Dauer (s)");
taCxPreInfDuty = add_num(cPre, "Pumpenleistung (%)");
lv_obj_t* cMain = group_card(p, "Hauptbezug",
"Das kalte Kaffeebett ist viel durchlässiger als ein heißes - niedrige Werte sind normal.");
taCxPulseMs = add_num(cMain, "Pulsperiode (ms)");
taCxDuty = add_num(cMain, "Pumpenleistung (%)");
lv_obj_t* cEnd = group_card(p, "Bezugsende",
"Ohne Waage ist das Zielgewicht wirkungslos, dann zählt die maximale Dauer.");
taCxTarget = add_num(cEnd, "Zielgewicht (g)");
taCxMaxSecs = add_num(cEnd, "Max. Dauer (s)");
lv_obj_t* cPump = group_card(p, "Pumpenschutz",
"Gezählt wird die kumulierte Einschaltzeit der Pumpe, nicht die Bezugsdauer.");
taCxPumpMaxRun = add_num(cPump, "Max. Laufzeit (s)");
taCxPumpRest = add_num(cPump, "Zwangspause (s)");
lblCxPageHint = lv_label_create(p);
lv_label_set_long_mode(lblCxPageHint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblCxPageHint, LV_PCT(100));
lv_obj_set_style_text_color(lblCxPageHint, COL_WARN, 0);
lv_label_set_text(lblCxPageHint, "");
lv_obj_add_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN);
add_save_button(p, "Cold Extraction speichern", send_save_coldex);
}
// Felder nur beim Oeffnen der Seite fuellen - laufende State-Pushes duerfen eine
// begonnene Eingabe nicht ueberschreiben (gleiches Muster wie populate_brew).
static void populate_coldex(const MachineState& st) {
if (!taCxMaxTemp) return;
sw_set(swCxEnabled, st.cxEnabled);
sw_set(swCxAskOnWake, st.cxAskOnWake);
ta_setf(taCxMaxTemp, st.cxMaxTemp, 1);
ta_setf(taCxPreInf, st.cxPreInfSec, 0);
ta_setf(taCxPreInfDuty, st.cxPreInfDuty, 0);
ta_setf(taCxPulseMs, (float)st.cxPulseMs, 0);
ta_setf(taCxDuty, st.cxDuty, 0);
ta_setf(taCxTarget, st.cxTarget, 0);
ta_setf(taCxMaxSecs, st.cxMaxSecs, 0);
ta_setf(taCxPumpMaxRun, st.cxPumpMaxRun, 0);
ta_setf(taCxPumpRest, st.cxPumpRest, 0);
}
// =====================================================================================
@@ -3100,6 +3233,7 @@ void ui_init(ProtocolClient* client) {
g_pages[PG_TEMP] = make_page(g_content); build_temp(g_pages[PG_TEMP]);
g_pages[PG_LIGHT] = make_page(g_content); build_light(g_pages[PG_LIGHT]);
g_pages[PG_BREW] = make_page(g_content); build_brew(g_pages[PG_BREW]);
g_pages[PG_COLDEX] = make_page(g_content); build_coldex(g_pages[PG_COLDEX]);
g_pages[PG_SERVICE] = make_page(g_content); build_service(g_pages[PG_SERVICE]);
g_pages[PG_WIFI] = make_page(g_content); build_wifi(g_pages[PG_WIFI]);
g_pages[PG_PROFILE] = make_page(g_content); build_profiles(g_pages[PG_PROFILE]);
@@ -3202,12 +3336,12 @@ void ui_init(ProtocolClient* client) {
lv_obj_set_flex_align(wakeCard, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(wakeCard, 18, 0);
lv_obj_clear_flag(wakeCard, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* wakeMsg = lv_label_create(wakeCard);
lv_obj_set_width(wakeMsg, LV_PCT(100));
lv_obj_set_style_text_align(wakeMsg, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(wakeMsg, COL_TEXT, 0);
lv_obj_set_style_text_font(wakeMsg, &lv_font_maven_pro_18, 0);
lv_label_set_text(wakeMsg, "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?");
g_wakeMsg = lv_label_create(wakeCard);
lv_obj_set_width(g_wakeMsg, LV_PCT(100));
lv_obj_set_style_text_align(g_wakeMsg, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_wakeMsg, COL_TEXT, 0);
lv_obj_set_style_text_font(g_wakeMsg, &lv_font_maven_pro_18, 0);
lv_label_set_text(g_wakeMsg, "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?");
lv_obj_t* wakeBtns = lv_obj_create(wakeCard);
lv_obj_set_width(wakeBtns, LV_PCT(100));
lv_obj_set_height(wakeBtns, LV_SIZE_CONTENT);
@@ -3221,9 +3355,18 @@ void ui_init(ProtocolClient* client) {
lv_obj_t* wakeNo = th_button(wakeBtns, "Abbrechen", COL_CARD2, COL_TEXT);
lv_obj_set_width(wakeNo, 150);
lv_obj_add_event_cb(wakeNo, wake_no_cb, LV_EVENT_CLICKED, nullptr);
g_wakeNoBtn = wakeNo;
lv_obj_t* wakeYes = th_accent_button(wakeBtns, "Aufwecken");
lv_obj_set_width(wakeYes, 150);
lv_obj_add_event_cb(wakeYes, wake_yes_cb, LV_EVENT_CLICKED, nullptr);
g_wakeYesLbl = lv_obj_get_child(wakeYes, 0); // Label des Knopfs (fuer "Espresso" im Abfrage-Modus)
// Zweite Zeile: direkt in die kalte Extraktion aufwecken. Ohne diesen Weg muesste man
// erst normal aufwecken (Maschine heizt an) und den Modus danach umschalten.
// Nur sichtbar, wenn die S3 die Funktion freigeschaltet meldet (siehe ui_update).
g_wakeColdBtn = th_button(wakeCard, "Cold Extraction (Wasser bleibt kalt)", COL_CARD2, COL_ACCENT);
lv_obj_set_width(g_wakeColdBtn, LV_PCT(100));
lv_obj_add_event_cb(g_wakeColdBtn, wake_cold_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN);
// OTA-Overlay (Vollbild, waehrend Firmware-Update)
@@ -3419,7 +3562,9 @@ void ui_update(const MachineState& st) {
}
// Aufweck-Ablauf: nach beendetem Standby Dialog schliessen und Helligkeits-Flag loeschen
// (auch wenn der Dialog schon ausgeblendet ist, weil "Aufwecken" das Flag bewusst haelt).
if (!st.standbyActive) {
// Bei offenem Auswahlfenster (cxWakeChoice) bleibt der Dialog stehen, obwohl der Standby
// schon beendet ist - genau dafuer ist er da.
if (!st.standbyActive && !st.cxWakeChoice) {
g_wakeDialogOpen = false;
if (g_wakeConfirm && !lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN))
lv_obj_add_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN);
@@ -3449,6 +3594,52 @@ void ui_update(const MachineState& st) {
// Ohne nutzbare Waage laeuft die kalte Extraktion rein zeitgesteuert (Zielgewicht und
// Stillstands-Erkennung greifen nicht) - das wird dauerhaft mit angezeigt.
const bool cxScaleOk = st.scaleEnabled && st.scaleConnected;
// "Aufwecken mit Cold Extraction" nur anbieten, wenn die Funktion freigeschaltet und
// umschaltbar ist und der Modus nicht ohnehin schon laeuft.
// Abfrage-Modus: Der Aufweck-Dialog wird zur Auswahl "Espresso oder Cold Extraction".
// Abschaltbar ueber die Einstellung cxAskOnWake, damit man sich den Zwischenschritt
// sparen kann, wenn gerade keine kalten Bezuege anstehen.
{
// Zwei Faelle: Vorab-Abfrage beim Antippen im Standby (askMode) und das von der S3
// geoeffnete Auswahlfenster NACH dem Aufwecken (choiceOpen) - letzteres deckt auch
// das Aufwecken per Schalter ab, wo es keine Vorab-Abfrage geben kann.
const bool choiceOpen = st.cxWakeChoice;
const bool askMode = choiceOpen ||
(st.cxEnabled && st.cxArmable && !st.cxActive && st.cxAskOnWake);
if (g_wakeColdBtn) {
if (askMode) lv_obj_remove_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
}
// Im Auswahlfenster gibt es nichts abzubrechen - laeuft die Zeit ab, wird geheizt.
if (g_wakeNoBtn) {
if (choiceOpen) lv_obj_add_flag(g_wakeNoBtn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_remove_flag(g_wakeNoBtn, LV_OBJ_FLAG_HIDDEN);
}
if (g_wakeMsg) {
if (choiceOpen) {
char b[128];
snprintf(b, sizeof(b),
"Womit weiter?\nEspresso heizt auf, Cold Extraction bleibt kalt.\n"
"Ohne Auswahl wird in %ld s geheizt.", st.cxWakeChoiceSec);
label_set_if_changed(g_wakeMsg, b);
} else {
label_set_if_changed(g_wakeMsg, askMode
? "Die Maschine befindet sich im Standby.\nWomit aufwecken?"
: "Die Maschine befindet sich im Standby.\nAus dem Standby aufwecken?");
}
}
if (g_wakeYesLbl) {
label_set_if_changed(g_wakeYesLbl, askMode ? "Espresso" : "Aufwecken");
}
// Auswahlfenster von der S3 -> Dialog von sich aus zeigen (kein Antippen noetig)
if (choiceOpen && g_wakeConfirm && lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) {
wake_confirm_show();
} else if (!choiceOpen && !st.standbyActive && g_wakeConfirm &&
!lv_obj_has_flag(g_wakeConfirm, LV_OBJ_FLAG_HIDDEN)) {
wake_confirm_hide(); // Fenster abgelaufen oder entschieden
g_wakeDialogOpen = false;
}
}
if (st.cxNotice == 2) {
status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction beendet: kein Zulauf",
COL_WARN, lv_color_hex(0x000000));
@@ -3784,7 +3975,10 @@ void ui_update(const MachineState& st) {
else lv_obj_add_flag(cellBezug, LV_OBJ_FLAG_HIDDEN);
}
if (lblBezug) {
bool bbwShot = st.shotActive && st.bbwEnabled && st.bbwTarget > 0.0f;
// Bei kaltem Bezug zaehlt cxTarget, nicht das Brew-by-Weight-Ziel
const bool cxShotNow = st.cxShot && st.cxTarget > 0.0f;
const float shotTarget = cxShotNow ? st.cxTarget : st.bbwTarget;
bool bbwShot = st.shotActive && (cxShotNow || (st.bbwEnabled && st.bbwTarget > 0.0f));
// Winzige negative Messwerte (z.B. -0.04 g) oder negatives Float-Null wuerden von
// "%.1f" als "-0.0" dargestellt -> auf 0.0 normalisieren (kein fuehrendes Minus).
float wDisp = st.weight;
@@ -3793,14 +3987,14 @@ void ui_update(const MachineState& st) {
lv_label_set_text(lblBezug, "--");
} else if (bbwShot) {
// Waehrend des Bezugs mit Brew-by-Weight: Ist oben, Ziel darunter (zweizeilig)
lv_label_set_text_fmt(lblBezug, "%.1f g\n/ %.1f g", wDisp, st.bbwTarget);
lv_label_set_text_fmt(lblBezug, "%.1f g\n/ %.1f g", wDisp, shotTarget);
} else {
lv_label_set_text_fmt(lblBezug, "%.1f g", wDisp);
}
// Fortschrittsbalken Gewicht/Ziel (nur waehrend BBW-Bezug)
if (g_bbwBar) {
if (bbwShot) {
int pct = (int)(st.weight / st.bbwTarget * 100.0f + 0.5f);
int pct = (int)(st.weight / shotTarget * 100.0f + 0.5f);
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
lv_bar_set_value(g_bbwBar, pct, LV_ANIM_OFF);
lv_obj_remove_flag(g_bbwBar, LV_OBJ_FLAG_HIDDEN);
@@ -3851,8 +4045,9 @@ void ui_update(const MachineState& st) {
if (lblColdExHint) lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
}
sw_set(swColdEx, st.cxActive);
// Einschalten nur bei Freigabe; Ausschalten immer - ausser waehrend eines kalten Bezugs
bool usable = st.cxEnabled && (st.cxActive ? !st.cxShot : st.cxAllowed);
// Umschalten haengt an cxArmable, NICHT an cxAllowed: Der Modus muss auch im Standby
// und bei warmem Kessel scharf zu stellen sein. Waehrend eines kalten Bezugs gesperrt.
bool usable = st.cxEnabled && st.cxArmable && !(st.cxActive && st.cxShot);
if (usable) lv_obj_remove_state(swColdEx, LV_STATE_DISABLED);
else lv_obj_add_state(swColdEx, LV_STATE_DISABLED);
if (lblColdExHint && st.cxEnabled) {
@@ -3860,7 +4055,9 @@ void ui_update(const MachineState& st) {
String hint;
if (st.cxActive) {
hint = "Wasserkreis heizt nicht (bleibt über Standby und Neustart aktiv). ";
if (st.cxAllowed) {
if (st.standbyActive) {
hint += "Nach dem Aufwecken bleibt der Wasserkreis aus.";
} else if (st.cxAllowed) {
char b[96];
if (scaleOk) {
snprintf(b, sizeof(b), "Bezug regulär starten - Ziel %.0f g, max %.0f s.",
@@ -3889,22 +4086,18 @@ void ui_update(const MachineState& st) {
label_set_if_changed(lblColdExHint, hint.c_str());
}
}
if (lblBrewColdEx) {
if (st.cxEnabled) {
char b[320]; // Grundtext ~205 Zeichen (das ° zaehlt als 2 Bytes) + Waagen-Hinweis
snprintf(b, sizeof(b),
"Cold Extraction (nur Anzeige, Einstellung in der Web-UI):\n"
"Freigabe < %.1f °C | Vorbenetzung %.0f s | Ziel %.0f g | max. %.0f s\n"
"Geschaltet wird der Modus auf der Seite \"Temperaturen\" unter \"Modi\".%s",
(double)st.cxMaxTemp, (double)st.cxPreInfSec,
(double)st.cxTarget, (double)st.cxMaxSecs,
(st.scaleEnabled && st.scaleConnected)
? ""
: "\nOhne Waage ist das Zielgewicht wirkungslos - der Bezug endet nach der max. Dauer.");
label_set_if_changed(lblBrewColdEx, b);
lv_obj_remove_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
if (lblCxPageHint) {
// Warnhinweis auf der Cold-Extraction-Seite (Waage fehlt bzw. Funktion gesperrt)
String h;
if (st.cxEnabled && !cxScaleOk) {
h = "Keine Waage aktiv/verbunden: Zielgewicht und Stillstands-Erkennung greifen nicht, "
"der Bezug endet erst nach der maximalen Dauer.";
}
if (h.length() > 0) {
label_set_if_changed(lblCxPageHint, h.c_str());
lv_obj_remove_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(lblCxPageHint, LV_OBJ_FLAG_HIDDEN);
}
}