Compare commits

...
Author SHA1 Message Date
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
raw-designsandClaude Opus 5 b6155acafe feat: Cold Extraction warnt bei fehlender Waage (S3 5.3.4 / P4 1.1.2)
Ohne aktive und verbundene Waage greifen weder das Zielgewicht noch die
Stillstands-Erkennung — der Bezug endet dann erst nach der maximalen Bezugsdauer,
die damit faktisch zur Dosierung wird. Bisher passierte diese Degradierung
stillschweigend.

Die Funktion bleibt ohne Waage bewusst nutzbar (Zeitsteuerung als Rückfallebene),
damit ein kurzzeitig abgemeldeter HX711 die kalte Extraktion nicht komplett
blockiert. Angezeigt wird der Zustand jetzt an vier Stellen: dauerhafter
Warnhinweis auf /Brew-Control (Zielgewicht-Feld zusätzlich als "nur mit Waage
wirksam" beschriftet), Hinweiszeile unter dem Dashboard-Schalter, Statustext
"Cold Extraction bereit (ohne Waage: Zeitsteuerung)" und eine einmalige Meldung
beim Aktivieren.

P4: Statuszeile zeigt "ohne Waage, Ende nach Zeit" in Warnfarbe statt des blauen
Normalhinweises; Erklärzeile am Schalter und Parameter-Anzeige auf der Brew-Seite
ergänzen den Hinweis und nennen bei aktivem Modus ohne Waage die Zeit statt des
Zielgewichts. Neuer cxNotice-Code 4 (additiv, Protokoll bleibt v2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:05:50 +02:00
thomas 4cc9ceea7c Merge pull request 'fix(S3): Cold Extraction brach waehrend der Vorbenetzung ab (5.3.3)' (#15) from fix/cold-extraction-vorbenetzung into main 2026-08-11 01:55:31 +02:00
raw-designsandClaude Opus 5 881acec3df fix(S3): Cold Extraction brach während der Vorbenetzung ab (5.3.3)
Die Stillstands-Erkennung lief bereits während der Vorbenetzung mit. In dieser
Phase sättigt der Puck aber erst, in der Tasse kommt naturgemäß nichts an — bei
einer Vorbenetzung länger als die 15 s Stillstands-Zeit wurde deshalb jeder kalte
Bezug mit "kein Zulauf" abgebrochen. Mit dem bisherigen Standard von 30 s wäre das
ausnahmslos passiert; die Funktion war mit aktiver Waage unbenutzbar. Die
Erkennung startet jetzt erst mit dem Hauptbezug.

Dazu der Standard der Vorbenetzung von 30 s auf 10 s: Die Pumpe läuft in dieser
Phase bewusst ungepulst auf voller Leistung. Heiß kommt der erste Tropfen nach
etwa 7 s, kalt (rund dreifache Viskosität) entsprechend später — 30 s wären längst
voller Bezug bei vollem Druck gewesen, und die Hälfte des Pumpen-Laufzeitbudgets
wäre vor dem eigentlichen Bezug verbraucht. Bestehende Installationen behalten
ihren gespeicherten Wert.

Die Beschriftung auf /Brew-Control sagt jetzt ausdrücklich, dass die Pumpe während
der Vorbenetzung durchläuft und erst im Hauptbezug gepulst wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:53:48 +02:00
thomas bc78a10505 Merge pull request 'fix(P4): Compiler-Warnungen des Display-Builds beseitigt (1.1.1)' (#14) from fix/p4-compiler-warnungen into main 2026-08-11 01:23:02 +02:00
raw-designsandClaude Opus 5 1f8cce48aa fix(P4): Compiler-Warnungen des Display-Builds beseitigt (1.1.1)
Von 68 Warnungen (arduino-cli --warnings all) auf eine einzige, die im
ESP32-Core-SDK steckt und nicht im Projekt behebbar ist.

- LV_FS_DEFAULT_DRIVE_LETTER heißt ab LVGL 9.3 LV_FS_DEFAULT_DRIVER_LETTER. Der
  alte Name funktionierte weiter, löste aber in lv_api_map_v9_1.h ein #warning in
  JEDER Übersetzungseinheit aus, die lvgl.h einbindet — allein rund 60 Warnungen
  pro Build, die alles andere zugedeckt haben. In lv_conf.h umbenannt.
- LV_PART_* und LV_STATE_* sind in LVGL 9 verschiedene Enum-Typen; die direkte
  |-Verknüpfung ist in C++20 abgekündigt. Neues Makro TH_SEL(part, state) in
  theme.h castet beide auf lv_style_selector_t (ohnehin uint32_t), 5 Stellen.
- Bug-Fix: Der Puffer der Cold-Extraction-Infozeile auf der Brew-Seite war mit 192
  Bytes zu klein für den ~205 Zeichen langen Text (das "°" zählt doppelt), die
  letzte Zeile wurde abgeschnitten. Jetzt 256 Bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:20:49 +02:00
thomas 1933a41e64 Merge pull request 'feat(P4): Cold Extraction am Touch-Display + RX-Zeilenlimit-Fix (1.1.0 / S3 5.3.2)' (#13) from feat/cold-extraction-p4 into main 2026-08-10 23:58:06 +02:00
raw-designsandClaude Opus 5 e8edc8eb30 feat(P4): Cold Extraction am Touch-Display + RX-Zeilenlimit-Fix (1.1.0 / S3 5.3.2)
Display-Seite der kalten Extraktion: Schalter unter "Modi" auf der Temperatur-Seite
mit Erklärzeile, eigener Slot in der Statuszeile direkt hinter der
Sicherheitsmeldung, "Cold Extraction - Heizen aus" in der Wasser-Kachel statt eines
unerreichbaren Sollwerts, unterdrückter Aufheiz-Countdown, "Vorbenetzung" als erste
Phase des kalten Bezugs, Parameter-Anzeige auf der Brew-Seite und "(kalt)"-Markierung
in der Statistik (kalte Bezüge zählen nicht in die mittlere Dauer).

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:31:59 +02:00
thomas d7a1a8dee2 Merge pull request 'feat(S3): Cold Extraction (kalte Extraktion) mit Temperatur-Freigabe (5.3.0)' (#11) from feat/cold-extraction into main 2026-08-10 21:04:17 +02:00
raw-designsandClaude Opus 5 db1b080dc4 feat(S3): Cold Extraction (kalte Extraktion) mit Temperatur-Freigabe (5.3.0)
Kalte Extraktion ohne Kessel-Bypass: Da kaltes Wasser nur bei kaltem Wasserkessel
am Puck ankommt, wird die Funktion über eine Temperaturschwelle freigegeben
(Standard 30 °C, 10–40 °C einstellbar). Solange der Modus aktiv ist, heizt der
Wasserkreis nicht (SSR gesperrt, Stellgröße und dutyW-Telemetrie auf 0, I-Anteil
eingefroren, Feed-Forward-Boost aus); der Dampfkreis bleibt unberührt.

Die Freigabe wird vor jedem Bezug neu geprüft, weil der weiter heizende
Dampfkessel den Wasserkessel über die Zeit über die Schwelle bringen kann. Ein
abgewiesener Start meldet den Grund auf OLED, Touch-Display und Web-Dashboard;
ein laufender Bezug wird nie abgebrochen. 5 °C Hysterese, Sensorfehler sperrt.

Fluss über Pumpen-Pulsung nach einer Vorbenetzung mit voller Leistung; Ende über
Zielgewicht oder Timeout. Brew-by-Time/Weight und FlowGuard sind während einer
kalten Extraktion außer Kraft, da FlowGuard sonst um denselben Pumpen-Ausgang
konkurrieren würde. Pumpenschutz über kumulierte Einschaltzeit mit Zwangspause
plus Stillstands-Erkennung bei aktiver Waage.

Kalte Bezüge werden im CSV-Log mit einem vierten Feld "C" gekennzeichnet, zählen
in allen Stückzahlen mit, bleiben aber aus der durchschnittlichen Bezugsdauer
heraus. Neue additive State-Felder cx* im UART-JSON (Protokoll bleibt v2) und
neuer statusKey "coldextraction".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:02:11 +02:00
thomas 5a1e8d4b50 Merge pull request 'feat(S3): Feed-Forward-Boost bei Bezug für den Wasserkessel (5.2.0)' (#10) from feat/feed-forward-bei-bezug into main 2026-07-18 04:11:40 +02:00
raw-designsandClaude Fable 5 4f840173a9 feat(S3): Feed-Forward-Boost bei Bezug für den Wasserkessel (5.2.0)
Während eines Bezugs strömt kaltes Wasser in den Kessel; die PID reagiert
erst auf den verzögert gemessenen Temperaturabfall. Der neue Feed-Forward
schaltet der Heizung sofort einen einstellbaren Leistungs-Boost auf
(0-100 % der Fenstergröße, 0 % = aus), die PID regelt nur den Restfehler.

- Zustandslose Aufschaltung in der SSR-Fensterlogik (effectiveOutputWasserMs),
  gedeckelt auf die Fenstergröße; Duty-Telemetrie (dutyW) zeigt den Boost mit
- Optional: I-Anteil der Wasser-PID während des Bezugs einfrieren
  (zentral in der PID-Schleife verwaltet, applyPidWasserTunings-Helper)
- Nur bei echten Bezügen (Hardware/Software-Shot), nicht bei Spülen,
  Wartungsmodus oder AutoTune; Sicherheitslogik unverändert
- Web-UI: neue Sektion „Feed-Forward bei Bezug (Wasser)“ auf /PID;
  EEPROM 944/945, bewusst kein Profilfeld (maschinenabhängig)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +02:00
thomas 6371b22683 Merge pull request 'fix(S3): „Aktives Profil"-Anzeige auf der Profil-Seite ins Karten-Layout eingefügt' (#9) from fix/profilseite-aktiv-anzeige-layout into main 2026-07-14 00:15:30 +02:00
raw-designsandClaude Fable 5 de2d0635b2 fix(S3): „Aktives Profil"-Anzeige auf der Profil-Seite ins Karten-Layout eingefügt
Die Zeile stand linksbündig ohne max-width außerhalb der zentrierten
Seitenstruktur. Jetzt im status-message-Stil der Seite (zentriert,
max. 600 px, dezenter neutraler Rahmen), Profilname fett. Version 5.1.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:14:53 +02:00
thomas 801a659b0c Merge pull request 'feat(P4): Profil-Schnellwahl im Header + Anzeige des aktiven Profils' (#8) from feat/profil-schnellwahl-p4 into main 2026-07-13 20:55:47 +02:00
raw-designsandClaude Fable 5 d97e4c34d5 feat(P4): Profil-Schnellwahl im Header + Anzeige des aktiven Profils
Neuer Profil-Chip im Header (alle Seiten): zeigt das zuletzt geladene
bzw. gespeicherte Profil aus den neuen S3-State-Feldern profile/profDirty
(S3 ab 5.1.1), „*" = seitdem geänderte Einstellungen. Antippen öffnet
ein Schnellwahl-Overlay mit allen Profilen (aktives mit Häkchen und
Akzentfarbe); ein Tap lädt direkt per loadProfile. Profile-Seite
markiert das aktive Profil in der Liste. Bei älterer S3-Firmware bleibt
der Chip unsichtbar. Version 1.0.13, Protokoll-Doku ergänzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:55:10 +02:00
thomas 00a4c80b13 Merge pull request 'feat(S3): Aktives Profil führen und anzeigen — Grundlage für Profil-Schnellwahl am Display' (#7) from feat/aktives-profil-anzeige-s3 into main 2026-07-13 20:48:40 +02:00
raw-designsandClaude Fable 5 a1431a9d42 feat(S3): Aktives Profil führen und anzeigen — Grundlage für Profil-Schnellwahl am Display
Die Steuerung merkt sich das zuletzt geladene bzw. gespeicherte Profil
dauerhaft im EEPROM (Adressen 912-943). Profil-relevante Änderungen
(Setpoints, PID inkl. AutoTune, Boost, Eco, Brew-Control, Piezo,
Fast-Heat-Up) markieren das Profil als „geändert"; reine Anzeige-/
Systemeinstellungen bewusst nicht. Löschen des aktiven Profils und
Werksreset setzen die Anzeige zurück.

Sichtbar in der Web-UI (Dashboard + Profil-Seite) und als neue additive
State-Felder "profile"/"profDirty" im UART-JSON für das Touch-Display.
Version 5.1.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:47:59 +02:00
thomas d9bc31b555 Merge pull request 'feat(S3): Display-Auswahl über die Web-UI statt Compile-Schalter — Default „Ohne Display"' (#6) from feat/display-auswahl-über-web-ui into main 2026-07-13 01:56:25 +02:00
raw-designsandClaude Fable 5 5691a4c04f feat(S3): Display-Auswahl in die Web-UI verlegt — Default „Ohne Display" für sicheren Start
Compile-Schalter ENABLE_DISPLAY entfällt: Der Display-Typ (Ohne / OLED SH1106 /
UART-Touch) wird jetzt zur Laufzeit unter /Sensoren gewählt, im EEPROM (Adresse 911)
gespeichert und ohne Neustart übernommen. Vor der OLED-Initialisierung prüft eine
I2C-Probe (0x3C), ob das Display angeschlossen ist — ohne Antwort bleibt die Anzeige
deaktiviert und die Steuerung bootet sicher weiter. Die Touch-UART (Serial1) wird nur
noch bei gewähltem UART-Touch-Display initialisiert und bedient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 01:54:37 +02:00
thomas f8df6f85cd Merge pull request 'fix(P4): Heizleistungs-Kurven flackerten in großen Zeitfenstern — Bucket-Mittelung statt Einzelsample' (#5) from fix/verlauf-duty-dezimierung-mitteln into main 2026-07-13 01:27:13 +02:00
raw-designsandClaude Fable 5 f43375e991 fix(P4): Heizleistungs-Kurven flackerten in großen Zeitfenstern — Bucket-Mittelung statt Einzelsample
Die Dezimierung im Verlauf-Chart pickte pro Punkt ein Einzelsample; das
Abtast-Raster hing an g_histPos und wanderte mit jedem Sekundentick um
eine Position. Die schnell schaltende Heizleistung (PWM) sprang dadurch
sichtbar zwischen zwei Kurvenbildern hin und her. Jetzt wird über den
gesamten stride-Bucket gemittelt — Bild steht ruhig, Leistungs-Kurve
zeigt die echte mittlere Heizleistung. Version 1.0.12.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 01:26:26 +02:00
thomas 92ef4fbfb6 Merge pull request 'fix(P4): Verlaufs-Historie ins PSRAM verlagern — interner RAM lief über' (#4) from fix/verlaufs-historie-ins-psram into main 2026-07-12 21:17:13 +02:00
10 changed files with 2598 additions and 185 deletions
+162
View File
@@ -1,3 +1,165 @@
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
maximalen Bezugsdauer, die damit faktisch zur Dosierung wird. Bisher passierte das stillschweigend.
- Die Funktion bleibt ohne Waage bewusst nutzbar (Zeitsteuerung als Rückfallebene): Ein kurzzeitig
abgemeldeter HX711 soll die kalte Extraktion nicht komplett blockieren.
- Angezeigt wird das an vier Stellen: dauerhafter Warnhinweis auf /Brew-Control (dort ist das
Zielgewicht-Feld zusätzlich als „nur mit Waage wirksam" beschriftet), Hinweiszeile unter dem
Dashboard-Schalter, Statustext „Cold Extraction bereit (ohne Waage: Zeitsteuerung)" sowie eine
einmalige Meldung beim Aktivieren.
- Neuer Code im UART-Feld cxNotice: 4 = ohne Waage aktiviert (additiv, Protokoll bleibt v2).
Version 5.3.3:
- Bug-Fix (Cold Extraction, hätte die Funktion mit Waage komplett unbrauchbar gemacht): Die
Stillstands-Erkennung lief bereits während der Vorbenetzung mit. In dieser Phase sättigt der Puck
aber erst, es kommt naturgemäß nichts in der Tasse an - bei einer Vorbenetzung länger als die
15 s Stillstands-Zeit wurde deshalb JEDER kalte Bezug mit „kein Zulauf" abgebrochen. Mit dem
bisherigen Standard von 30 s Vorbenetzung wäre das immer passiert. Die Erkennung startet jetzt
erst mit dem Hauptbezug.
- Standard der Vorbenetzung von 30 s auf 10 s gesenkt. In dieser Phase läuft die Pumpe bewusst
ungepulst auf voller Leistung; bei heißem Bezug kommt der erste Tropfen nach etwa 7 s, kalt
(rund dreifache Viskosität) entsprechend später. 30 s wären also längst voller Bezug bei vollem
Druck gewesen, und die Hälfte des Pumpen-Laufzeitbudgets wäre vor dem eigentlichen Bezug
verbraucht. Bestehende Installationen behalten ihren gespeicherten Wert - dort ggf. auf
/Brew-Control nachziehen.
- Die Beschriftung auf /Brew-Control sagt jetzt ausdrücklich, dass die Pumpe während der
Vorbenetzung durchläuft und erst im Hauptbezug gepulst wird.
Version 5.3.2:
- Cold Extraction sendet den Sperrgrund jetzt als Code („cxBlock") statt als Klartext („cxMessage"),
dazu eine transiente Meldung als Code („cxNotice") und die Vorbenetzungsdauer („cxPreInf").
Hintergrund: Die State-Zeile ans Touch-Display liegt real bereits bei rund 2,1 KB, und der P4
verwirft eingehende Zeilen ab einer Obergrenze komplett - ein bis zu 110 Byte langer Klartext
hätte die Zeile über diese Grenze gedrückt und damit die ganze Telemetrie unbrauchbar gemacht.
Die Texte rendert jetzt das Display, analog zur bestehenden Trennung statusKey/statusText.
- Der Klartext bleibt unverändert auf dem OLED und in den Web-/UART-Antworten (ack-message).
Version 5.3.1:
- Cold Extraction ist jetzt persistent: Der Modus wird im EEPROM gespeichert und überlebt sowohl den
Standby als auch einen Neustart. Zuvor wurde er beim Standby beendet und war nach dem Aufwachen aus -
die Maschine heizte dann erst auf, obwohl der nächste kalte Bezug einen kalten Kessel braucht, und man
musste auf das Abkühlen warten. Wer nur kalte Bezüge machen will, schaltet den Modus damit einmal ein
und wieder aus, wenn er fertig ist.
- Der Modus fällt nur weg, wenn auf /Brew-Control die Freischaltung entzogen wird oder ein Werksreset
läuft. Solange er aktiv ist, heizt der Wasserkreis nicht - Dashboard, OLED („(COLD)" in der Wasserzeile)
und Touch-Display zeigen das durchgehend an.
Version 5.3.0:
- Neu: Cold Extraction (kalte Extraktion). Die Maschine hat keinen Bypass um den Wasserkessel, kaltes Wasser
kommt also nur bei kaltem Kessel am Puck an. Die Funktion wird deshalb über eine Temperaturschwelle
freigegeben (Standard 30 °C, einstellbar 1040 °C) und ist praktisch aus dem kalten Zustand heraus nutzbar.
- Solange der Modus aktiv ist, heizt der Wasserkreis nicht (SSR gesperrt, Stellgröße und damit auch die
dutyW-Telemetrie auf 0, I-Anteil der Wasser-PID eingefroren, Feed-Forward-Boost deaktiviert). Der
Dampfkreis bleibt unberührt und darf weiter heizen.
- Die Freigabe wird vor jedem Bezug neu geprüft, nicht nur beim Aktivieren: Da der Dampfkessel weiter heizt,
kann der Wasserkessel über die Zeit über die Schwelle steigen. In diesem Fall wird der nächste Bezug
abgewiesen (Meldung auf OLED, Touch-Display und im Web-Dashboard) - ein laufender Bezug wird nie abgebrochen.
Hysterese von 5 °C, damit die Anzeige an der Schwelle nicht flattert; Sensorfehler sperrt die Funktion.
- Der Fluss wird über Pumpen-Pulsung reduziert (Pulsperiode 2005000 ms, Pumpenleistung 5100 %), davor läuft
eine einstellbare Vorbenetzung mit voller Pumpenleistung. Bezugsende über Zielgewicht oder Sicherheits-Timeout;
Brew-by-Time/Brew-by-Weight und FlowGuard sind während einer kalten Extraktion bewusst außer Kraft (FlowGuard
und Cold Extraction würden sonst um denselben Pumpen-Ausgang konkurrieren).
- Pumpenschutz: Die Steuerung zählt die kumulierte Pumpen-Einschaltzeit und legt nach der eingestellten
Laufzeit eine Zwangspause ein (Standard 60 s Laufzeit / 20 s Pause). Zusätzlich Stillstands-Erkennung -
läuft die Pumpe bei aktiver Waage 15 s ohne Zulauf, wird der Bezug beendet (dichter Puck oder ausgelöster
Pumpen-Thermoschutz).
- Bedienung: Parameter auf der Seite /Brew-Control, Ein-/Ausschalten über das Web-Dashboard oder das
UART-Touch-Display (neue Aktionen "startColdExtraction"/"stopColdExtraction"/"toggleColdExtraction").
Cold Extraction ist bewusst kein Profilfeld (maschinen- und situationsabhängig, wie der Feed-Forward);
der Modus selbst wird nicht persistiert und ist nach einem Neustart immer aus. Standby beendet ihn.
- Statistik: Kalte Bezüge werden im Log mit einem vierten CSV-Feld "C" gekennzeichnet, zählen in allen
Stückzahlen mit, gehen aber NICHT in die durchschnittliche Bezugsdauer ein (Minuten statt Sekunden würden
den Wert verzerren). Die Info-Seite weist die Anzahl separat aus, der Verlaufs-Download hat eine neue
Spalte "Art". Ältere Logzeilen bleiben unverändert lesbar.
- Neue additive State-Felder im UART-JSON (Protokoll bleibt v2): cxEnabled, cxActive, cxShot, cxAllowed,
cxResting, cxMaxTemp, cxTarget, cxMaxSecs, cxMessage sowie "cx" pro Eintrag in usageStats; neuer
statusKey "coldextraction". Ältere Display-Firmware ignoriert die Felder.
Version 5.2.0:
- Feed-Forward bei Bezug (Störgrößenaufschaltung) für den Wasserkessel: Während eines Bezugs wird der
Heizung sofort ein einstellbarer Leistungs-Boost (0100 % der Fenstergröße, 0 % = aus) aufgeschaltet,
statt auf den vom Sensor gemeldeten Temperaturabfall zu warten. Die PID regelt nur noch den Restfehler;
der Temperatur-Dip während des Shots wird deutlich kleiner. Einstellung auf der /PID-Seite
(„Feed-Forward bei Bezug"), gespeichert im EEPROM (bewusst kein Profilfeld, da maschinenabhängig).
- Optional: I-Anteil der Wasser-PID während des Bezugs einfrieren. Verhindert das Aufladen des
Integral-Anteils während des Shots und damit das Überschwingen nach Bezugsende.
- Der Boost wirkt nur bei echten Bezügen (Hardware-Schalter oder Software-Start), nicht bei
Spülen/Wartungsmodus/AutoTune. Sicherheitslogik unverändert: Sensorfehler, Übertemperatur-Abschaltung
und „Heizen oberhalb Setpoint verhindern" haben weiterhin Vorrang; Deckelung auf die Fenstergröße.
- Die Heizleistungs-Telemetrie (dutyW, u. a. für das Verlaufs-Chart des Touch-Displays) zeigt den
Boost mit an - damit ist die Wirkung direkt im Chart kalibrierbar.
Version 5.1.2:
- Profil-Seite (Web-UI): Die neue „Aktives Profil"-Anzeige stand linksbündig außerhalb des
Karten-Layouts. Sie nutzt jetzt den status-message-Stil der Seite (zentriert, max. 600 px,
dezenter Rahmen) und fügt sich damit in das bestehende Layout ein; Profilname zusätzlich fett.
Version 5.1.1:
- Aktives Profil wird jetzt geführt und angezeigt: Die Steuerung merkt sich das zuletzt geladene bzw.
gespeicherte Profil dauerhaft im EEPROM (überlebt Neustarts). Web-UI: Anzeige auf dem Dashboard über der
Profil-Auswahl sowie auf der Profil-Seite (aktives Profil zusätzlich mit grünem Punkt in der Liste).
- „Geändert"-Erkennung: Werden nach dem Laden profil-relevante Einstellungen geändert (Solltemperaturen,
PID-Werte inkl. AutoTune-Ergebnis und AutoTune-Parameter, Boost, Eco, Dampfverzögerung, Fast-Heat-Up,
Brew-Control, Piezo), erscheint hinter dem Profilnamen „✱ (geändert)". Reine Anzeige-/Systemeinstellungen
(Anzeigemodus, Aufheizzeit, Licht, FlowGuard, Waage/Sensoren) lösen das bewusst nicht aus.
- Profil löschen setzt die Anzeige zurück, wenn das gelöschte Profil das aktive war; Werksreset ebenso.
- Neue additive State-Felder "profile" und "profDirty" im UART-JSON für das Touch-Display (Protokoll bleibt
kompatibel, ältere Display-Firmware ignoriert die Felder) - Grundlage für die Profil-Schnellwahl am Display.
Version 5.1.0:
- Display-Auswahl in die Web-UI (/Sensoren) verlegt: Ohne Display / OLED (SH1106, I2C) / UART-Touch-Display.
Der Compile-Schalter ENABLE_DISPLAY entfällt, die Einstellung wird im EEPROM gespeichert und ohne Neustart
übernommen. Default ist "Ohne Display", damit die Steuerung ohne angeschlossene Anzeige immer sicher startet.
- Boot-Schutz: Vor der OLED-Initialisierung wird per I2C-Probe (0x3C) geprüft, ob das Display wirklich
angeschlossen ist. Ohne Antwort bleibt die Anzeige deaktiviert und die Steuerung bootet normal weiter -
eine falsch gesetzte Display-Einstellung kann die Maschine damit nicht mehr blockieren.
- UART-Touch-Anbindung (Serial1) wird nur noch initialisiert und bedient, wenn "UART-Touch-Display" gewählt ist.
Version 5.0.12:
- Neues additives Feld "piState" im State-JSON für das UART-Touch-Display (0=inaktiv, 1=Pre-Infusion,
2=Pause, 3=Hauptbezug): Die Pre-Infusion-Phasenleiste auf dem Display folgt damit quelltreu der echten
+80 -1
View File
@@ -157,6 +157,8 @@ Erzeugt von `buildTouchUartStateJson()`. Alle Felder in einem flachen JSON-Objek
| `steamHeatDisabled` | bool | Dampfheizung per Nutzer deaktiviert |
| `lightOn` | bool | Beleuchtung an |
| `piezoEnabled` | bool | Piezo-Summer aktiv |
| `profile` | string | zuletzt geladenes/gespeichertes Profil, leer = keines (additiv seit S3 5.1.1) |
| `profDirty` | bool | profil-relevante Einstellungen seit dem Laden geändert (additiv seit S3 5.1.1) |
| `weight` | float | aktuelles Waagengewicht (UI) |
| `targetWeight` | float | Ziel-Gewicht (Brew-by-Weight) |
| `scaleEnabled` | bool | Waage aktiviert |
@@ -313,6 +315,38 @@ Alle gültigen `action`-Strings aus `executeDashboardAction()` (Stand v4.8.1, Ze
| `disableSteamHeat` | — | Dampf-Heizung abschalten |
| `enableSteamHeat` | — | Dampf-Heizung wieder aktivieren |
**Cold Extraction (kalte Extraktion, ab S3 5.3.0)**
| `action` | `value` | Wirkung |
|---|---|---|
| `startColdExtraction` | — | Modus aktivieren (Wasserkreis heizt nicht, Dampf bleibt möglich) |
| `stopColdExtraction` | — | Modus beenden |
| `toggleColdExtraction` | — | Modus umschalten |
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 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`. 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**
| `action` | `value` | Wirkung |
@@ -352,7 +386,52 @@ Aus `getTouchUartStatus()`. Für sprach-unabhängige UI-Logik/Icons auf dem P4 v
(statt `statusText` zu parsen):
`ready`, `error`, `standby`, `maintenance`, `tuning`, `brewing`, `eco`, `fastheatup`,
`heating`, `ready_eco_pending`.
`heating`, `ready_eco_pending`, `coldextraction` (ab S3 5.3.0).
### Cold-Extraction-Felder im `state` (ab S3 5.3.0, additiv — Protokoll bleibt v2)
| Feld | Typ | Bedeutung |
|---|---|---|
| `cxEnabled` | bool | Funktion in der Web-UI freigeschaltet (sonst Bedienelement ausblenden) |
| `cxActive` | bool | Modus scharf: Wasserkreis heizt nicht |
| `cxShot` | bool | Der laufende Bezug ist eine kalte Extraktion |
| `cxAllowed` | bool | **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 (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) |
| `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) |
`cxBlock`: `0` frei, `1` nicht freigeschaltet, `2` Standby, `3` Wartungsmodus,
`4` Reinigungsassistent, `5` PID-Tuning, `6` Sensorfehler, `7` Wasserkessel zu warm.
`cxNotice`: `0` keine, `1` Start abgewiesen (Grund steht in `cxBlock`), `2` Bezug wegen
fehlenden Zulaufs beendet, `3` Umschalten während eines Bezugs abgelehnt, `4` ohne Waage
aktiviert (ab S3 5.3.4).
**Ohne Waage** (`scaleEnabled`/`scaleConnected` false) läuft die kalte Extraktion rein
zeitgesteuert: `cxTarget` ist wirkungslos und die Stillstands-Erkennung ist inaktiv, es
bleibt `cxMaxSecs` als Ende. Das ist Absicht (ein kurz abgemeldeter HX711 soll die Funktion
nicht blockieren) und **kein** Sperrgrund — das Display sollte es aber dauerhaft anzeigen.
Bewusst **Codes statt Klartext**: Die State-Zeile muss unter dem Zeilenlimit des P4 bleiben
(`PROTO_RX_LINE_MAX`; sie liegt real bereits bei ~2,1 KB), und die Texte gehören ins Display —
dieselbe Trennung wie bei `statusKey`/`statusText`.
Während `cxShot` folgt die Phase weiterhin `piState`: `1` = Vorbenetzung, `3` = Hauptbezug
(gepulst). Eine Pause-Phase (`2`) gibt es bei der kalten Extraktion nicht.
In der Antwort auf `getUsageStats` trägt jeder Eintrag in `shots[]` zusätzlich `cx` (bool) —
kalte Bezüge zählen in `total`/`today`/`week` mit, sind aber in der durchschnittlichen
Bezugsdauer der S3-Statistik ausgenommen.
---
+1657 -138
View File
File diff suppressed because it is too large Load Diff
+101
View File
@@ -1,3 +1,104 @@
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
maximalen Bezugsdauer (braucht S3-Firmware ab 5.3.4).
- Statuszeile: „Cold Extraction aktiv - ohne Waage, Ende nach Zeit" (gelb) statt des blauen
Normalhinweises; beim Aktivieren zusätzlich kurz die Meldung zum neuen cxNotice-Code 4.
- Erklärzeile unter dem Schalter und die Parameter-Anzeige auf der Brew-Seite ergänzen den
Hinweis; bei aktivem Modus ohne Waage nennt die Zeile die Zeit statt des Zielgewichts.
Version 1.1.1:
- Compiler-Warnungen des P4-Builds aufgeräumt (rein technisch, keine Funktionsänderung):
- „LV_FS_DEFAULT_DRIVE_LETTER is deprecated": Die Option heißt ab LVGL 9.3
LV_FS_DEFAULT_DRIVER_LETTER. Der alte Name funktionierte zwar noch, löste aber in
lv_api_map_v9_1.h ein #warning aus - und zwar in JEDER Übersetzungseinheit, die lvgl.h
einbindet. Das allein waren rund 60 Warnungen pro Build, die alles andere zugedeckt haben.
In lv_conf.h umbenannt.
- „bitwise operation between different enumeration types is deprecated": LV_PART_* und
LV_STATE_* sind in LVGL 9 zwei verschiedene Enum-Typen; sie direkt mit | zu verknüpfen ist
in C++20 abgekündigt. Neues Makro TH_SEL(part, state) in theme.h castet beide auf
lv_style_selector_t (ohnehin uint32_t). Betraf 5 Stellen in ui.cpp und theme.h.
- Bug-Fix: Der Puffer für die Cold-Extraction-Infozeile auf der Brew-Seite war mit 192 Bytes
zu klein - der Text ist rund 205 Zeichen lang (das „°" zählt als zwei Bytes), die letzte
Zeile wurde also abgeschnitten. Jetzt 256 Bytes.
Version 1.1.0:
- Cold Extraction am Display bedienbar (braucht S3-Firmware ab 5.3.2):
- Neuer Schalter „Cold Extraction" auf der Seite „Temperaturen" unter „Modi", darunter eine
Erklärzeile mit dem aktuellen Zustand bzw. dem Sperrgrund. Schalter und Zeile bleiben
unsichtbar, solange die S3 die Funktion nicht freigeschaltet meldet (auch bei älterer
S3-Firmware, die die Felder gar nicht sendet).
- Einschalten ist nur bei erteilter Freigabe möglich (Wasserkessel kalt genug, kein Standby /
Wartung / Tuning); ausschalten immer, außer während eines laufenden kalten Bezugs. Gesendet
wird gezielt startColdExtraction/stopColdExtraction statt eines Toggles, damit ein
abgewiesener Befehl den Schalter nicht in einen nie bestätigten Zustand bringt.
- Statuszeile: eigener Slot direkt hinter der Sicherheitsmeldung. Zeigt dauerhaft
„Cold Extraction aktiv - Wasserkreis heizt nicht", bei fehlender Freigabe den Grund,
während der Pumpen-Zwangspause den Pumpenschutz und transient einen abgewiesenen Start
bzw. einen Abbruch wegen fehlenden Zulaufs.
- Wasser-Kachel im Dashboard zeigt bei aktivem Modus „Cold Extraction - Heizen aus" statt
eines Sollwerts, der nicht erreicht wird; der Aufheiz-Countdown ist dann unterdrückt.
- Phasenleiste und Live-Bezugsschirm benennen die erste Phase eines kalten Bezugs
„Vorbenetzung" (keine Pause-Phase) und zeigen die Pumpenpause an.
- Brew-Seite: Cold-Extraction-Parameter als Anzeige (Freigabeschwelle, Vorbenetzung,
Zielgewicht, Timeout) mit Hinweis, dass sie in der Web-UI eingestellt werden.
- Statistik-Seite: kalte Bezüge sind mit „(kalt)" markiert und gehen nicht in die mittlere
Bezugsdauer ein - gleiche Regel wie in der S3-Statistik.
- Bug-Fix: Das Limit für eingehende UART-Zeilen (PROTO_RX_LINE_MAX) lag bei 2048 Bytes, während
die State-Zeile der S3 bereits rund 2,1 KB erreicht - mit langem Status-, Profil- oder
SSID-Text konnte sie darüber liegen. In dem Fall wurde die GANZE Zeile verworfen und das
Display fror auf dem letzten Stand ein, ohne dass die Verbindung als tot erkannt wurde.
Das Limit ist jetzt 4096 Bytes; der RX-Puffer selbst bleibt bei 2 KB reserviert, weil der
interne RAM knapp ist und eine Arduino-String nicht ins PSRAM alloziert werden kann.
Version 1.0.13:
- Profil-Schnellwahl + Anzeige des aktiven Profils (braucht S3-Firmware ab 5.1.1):
- Neuer Profil-Chip im Header (auf allen Seiten sichtbar): zeigt das zuletzt geladene bzw.
gespeicherte Profil; ein „*" hinter dem Namen bedeutet, dass seitdem profil-relevante
Einstellungen geändert wurden. Lange Namen werden gekürzt; ohne bekanntes Profil
(oder mit älterer S3-Firmware) bleibt der Chip unsichtbar.
- Antippen des Chips öffnet die Profil-Schnellwahl: ein Overlay mit allen gespeicherten
Profilen, das aktive mit Häkchen und Akzentfarbe hervorgehoben. Ein Tap lädt das Profil
direkt (Toast bestätigt, Chip aktualisiert sich mit dem nächsten State) — zwei Berührungen
vom Dashboard zum Profilwechsel statt Umweg über die Profile-Seite.
- Profile-Seite: das aktive Profil wird in der Liste mit Häkchen-Symbol und Akzentfarbe
markiert.
Version 1.0.12:
- Verlauf-Chart: Flackernde Heizleistungs-Kurven in den großen Zeitfenstern behoben. Die
Dezimierung pickte pro Chart-Punkt ein Einzelsample, dessen Abtast-Raster mit jedem
Sekundentick um eine Position wanderte — die schnell schaltende Heizleistung (PWM)
sprang dadurch sichtbar zwischen zwei Kurvenbildern hin und her. Jetzt wird über den
gesamten Zeitabschnitt eines Chart-Punkts gemittelt: das Bild steht ruhig, und die
Leistungs-Kurve zeigt die tatsächliche mittlere Heizleistung statt zufälliger
Momentwerte. Im 2-min-Fenster (1 Sample je Punkt) ändert sich nichts.
Version 1.0.11:
- Verlauf-Seite ausgebaut (nur P4, keine S3-/Protokolländerung nötig):
- Umschaltbares Zeitfenster: Buttons „2 min / 10 min / 30 min / 60 min" in der Kopfzeile.
+5 -2
View File
@@ -65,13 +65,16 @@
// -------------------------------------------------------------------------------------
// Firmware
// -------------------------------------------------------------------------------------
#define DISPLAY_FW_VERSION "1.0.11" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
#define DISPLAY_FW_VERSION "1.2.0" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
// -------------------------------------------------------------------------------------
// Protokoll
// -------------------------------------------------------------------------------------
#define PROTO_EXPECTED_VERSION 2 // muss zur S3-Firmware passen (TOUCH_UART_PROTOCOL_VERSION)
#define PROTO_RX_LINE_MAX 2048 // max. eingehende Zeilenlaenge (Bytes)
// Die State-Zeile der S3 kann mit langem Status-/Profil-/SSID-Text ueber 2 KB gehen. Wird
// das Limit erreicht, verwirft der Client die GANZE Zeile und das Display friert auf dem
// letzten Stand ein -> Reserve deutlich groesser als der reale Bedarf (~2,3 KB).
#define PROTO_RX_LINE_MAX 4096 // max. eingehende Zeilenlaenge (Bytes)
#define PROTO_HEARTBEAT_MS 5000 // Intervall fuer 'ping' (S3-Timeout = 15 s)
#define PROTO_HELLO_RETRY_MS 2000 // erneuter 'hello'-Versuch, solange nicht verbunden
#define PROTO_LINK_TIMEOUT_MS 8000 // ohne empfangene Nachricht -> Verbindung gilt als tot
+4 -1
View File
@@ -727,7 +727,10 @@
/*File system interfaces for common APIs */
/*Setting a default driver letter allows skipping the driver prefix in filepaths*/
#define LV_FS_DEFAULT_DRIVE_LETTER '\0'
/* Ab LVGL 9.3 heisst die Option LV_FS_DEFAULT_DRIVER_LETTER. Der alte Name wird zwar noch
uebersetzt, loest aber in lv_api_map_v9_1.h ein #warning aus - und zwar in JEDER
Uebersetzungseinheit, die lvgl.h einbindet (~60 Warnungen pro Build). */
#define LV_FS_DEFAULT_DRIVER_LETTER '\0'
/*API for fopen, fread, etc*/
#define LV_USE_FS_STDIO 0
+26
View File
@@ -50,6 +50,8 @@ struct MachineState {
long heatUpRemainSec = -1; // verbleibende Sekunden bis "durchgewaermt" (-1 = aus)
bool lightOn = false;
bool piezoEnabled = false;
String activeProfile; // zuletzt geladenes/gespeichertes Profil (leer = keines; seit S3 5.1.1)
bool profileDirty = false; // Einstellungen weichen seit dem Laden vom Profil ab
// --- Waage ---
float weight = 0, targetWeight = 0;
@@ -66,6 +68,30 @@ struct MachineState {
float bbwTarget = 0, bbwOffset = 0;
uint8_t piState = 0; // Pre-Infusion-Phase (0=inaktiv,1=Pre-Infusion,2=Pause,3=Hauptbezug; seit S3 5.0.12)
// --- Cold Extraction (kalte Extraktion; seit S3 5.3.0) ---
bool cxEnabled = false; // Funktion auf der S3 freigeschaltet (sonst Bedienelement ausblenden)
bool cxActive = false; // Modus scharf: Wasserkreis heizt nicht (persistent, seit S3 5.3.1)
bool cxShot = false; // der laufende Bezug ist eine kalte Extraktion
bool cxAllowed = false; // 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
float cxMaxSecs = 0; // Sicherheits-Timeout des Bezugs in s
// Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent,
// 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm
uint8_t cxBlock = 0;
// Transiente Meldung: 0=keine, 1=Start abgewiesen, 2=Bezug ohne Zulauf beendet,
// 3=Umschalten waehrend eines Bezugs abgelehnt
uint8_t cxNotice = 0;
// --- Gehaeusesensor ---
bool caseSensorEnabled = false;
bool caseTempDashboard = false;
+26
View File
@@ -16,6 +16,10 @@ void ProtocolClient::begin() {
DISPLAY_UART_PORT.setRxBufferSize(2048);
DISPLAY_UART_PORT.begin(DISPLAY_UART_BAUDRATE, SERIAL_8N1,
DISPLAY_UART_RX_PIN, DISPLAY_UART_TX_PIN);
// Bewusst NICHT auf PROTO_RX_LINE_MAX (4 KB) reserviert: Der interne RAM ist knapp
// (statisch ~98 %), und eine Arduino-String kann nicht ins PSRAM alloziert werden.
// 2 KB deckt die reale State-Zeile (~2,1 KB) fast vollstaendig ab; der Rest waechst
// in kleinen Schritten nach. Das Limit dient nur als Obergrenze gegen Muell/Overflow.
_rxBuf.reserve(2048);
_lastHelloMs = 0;
_lastHeartbeatMs = 0;
@@ -192,6 +196,8 @@ void ProtocolClient::applyState(const String& json) {
s.heatUpRemainSec = doc["heatUpRemainSec"] | (long)-1;
s.lightOn = doc["lightOn"] | false;
s.piezoEnabled = doc["piezoEnabled"] | false;
s.activeProfile = String((const char*)(doc["profile"] | "")); // fehlt bei aelterer S3-FW -> leer
s.profileDirty = doc["profDirty"] | false;
s.weight = doc["weight"] | s.weight;
s.targetWeight = doc["targetWeight"] | s.targetWeight;
@@ -214,6 +220,26 @@ void ProtocolClient::applyState(const String& json) {
s.bbwTarget = doc["bbwTarget"] | s.bbwTarget;
s.bbwOffset = doc["bbwOffset"] | s.bbwOffset;
// Cold Extraction (fehlt bei S3-FW < 5.3.0 -> cxEnabled bleibt false, UI blendet alles aus)
s.cxEnabled = doc["cxEnabled"] | false;
s.cxActive = doc["cxActive"] | false;
s.cxShot = doc["cxShot"] | false;
s.cxAllowed = doc["cxAllowed"] | false;
// 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.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
s.cxBlock = doc["cxBlock"] | (uint8_t)0;
s.cxNotice = doc["cxNotice"] | (uint8_t)0;
s.caseSensorEnabled = doc["caseSensorEnabled"] | false;
s.caseTempDashboard = doc["caseTempDashboard"] | false;
s.caseSensorType = doc["caseSensorType"] | 0;
+6 -1
View File
@@ -25,6 +25,11 @@
#define COL_BORDER lv_color_hex(0x555555)
#define COL_INPUT_BG lv_color_hex(0x101010)
// Style-Selektor aus Part + State. LV_PART_* und LV_STATE_* sind in LVGL 9 zwei
// verschiedene Enum-Typen; sie direkt mit | zu verknuepfen ist in C++20 deprecated
// (-Wdeprecated-enum-enum-conversion). Der Zieltyp ist ohnehin uint32_t.
#define TH_SEL(part, state) ((lv_style_selector_t)(part) | (lv_style_selector_t)(state))
// ---- Karte (dunkel, abgerundet) ----
static inline lv_obj_t* th_card(lv_obj_t* parent) {
lv_obj_t* c = lv_obj_create(parent);
@@ -61,7 +66,7 @@ static inline lv_obj_t* th_textarea(lv_obj_t* parent, bool oneLine) {
lv_textarea_set_one_line(ta, oneLine);
lv_obj_set_style_bg_color(ta, COL_INPUT_BG, 0);
lv_obj_set_style_border_color(ta, COL_BORDER, 0);
lv_obj_set_style_border_color(ta, COL_ACCENT, LV_PART_MAIN | LV_STATE_FOCUSED);
lv_obj_set_style_border_color(ta, COL_ACCENT, TH_SEL(LV_PART_MAIN, LV_STATE_FOCUSED));
lv_obj_set_style_border_width(ta, 1, 0);
lv_obj_set_style_radius(ta, 8, 0);
lv_obj_set_style_text_color(ta, COL_TEXT, 0);
+531 -42
View File
@@ -38,6 +38,7 @@ static lv_timer_t* g_toastTimer = nullptr;
static lv_obj_t* g_standbyScreen = nullptr; // Vollbild-Overlay im Standby (Uhr)
static lv_obj_t* g_standbyClock = nullptr;
static lv_obj_t* g_wakeConfirm = nullptr; // Bestaetigungsdialog "Aus dem Standby aufwecken?"
static lv_obj_t* g_wakeColdBtn = nullptr; // "Aufwecken mit Cold Extraction" (nur wenn freigeschaltet)
static 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 +50,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;
@@ -65,7 +66,9 @@ static lv_obj_t* g_errIcon = nullptr; // Fehlersymbol im Header (rot, auf
// Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner.
// Jeder Hinweis belegt einen Slot (Reihenfolge = Prioritaet, Rot vor Gelb vor Blau);
// mehrere aktive Meldungen rotieren alle 4 s weich durch (siehe status_line_tick).
enum StatusSlot { ST_SAFETY = 0, ST_DAMPF, ST_CLEAN, ST_TUNE, ST_MAINT, ST_HEAT, ST_COUNT };
// ST_COLDEX steht direkt hinter der Sicherheitsmeldung: Solange der Modus laeuft, heizt der
// Wasserkreis nicht - das muss der Benutzer sehen, sonst wartet er auf Temperatur, die nicht kommt.
enum StatusSlot { ST_SAFETY = 0, ST_COLDEX, ST_DAMPF, ST_CLEAN, ST_TUNE, ST_MAINT, ST_HEAT, ST_COUNT };
struct StatusMsg {
bool active = false;
char text[120] = "";
@@ -104,6 +107,12 @@ static lv_obj_t* bulbTempW = nullptr; static lv_obj_t* bulbTempD = nullptr;
static lv_obj_t* g_designBtn[TD_DESIGN_COUNT] = {}; // Umschalter auf der Info-Seite
static lv_obj_t* g_readyPill = nullptr; // "BEREIT"-Pille im Header (Kessel im Zielband)
static bool g_readyState = false; // mit Hysterese, gegen Flackern an der Bandgrenze
// Profil-Chip im Header (zuletzt geladenes Profil; "*" = danach geaenderte Einstellungen).
// Antippen oeffnet die Profil-Schnellwahl (Overlay mit allen Profilen zum Direkt-Laden).
static lv_obj_t* g_profChip = nullptr;
static lv_obj_t* g_profPicker = nullptr; // Schnellwahl-Overlay (Vollbild-Abdunkelung)
static lv_obj_t* g_profPickerList = nullptr; // Button-Liste im Overlay
static lv_obj_t* g_profPickerInfo = nullptr; // "Lade Profile..."-Hinweis im Overlay
static lv_obj_t* cellShot; static lv_obj_t* lblShotTimer; static lv_obj_t* g_bbtBar = nullptr;
static lv_obj_t* cellSteam; static lv_obj_t* lblSteamTimer; static lv_obj_t* g_sbtBar = nullptr;
static lv_obj_t* cellBezug; static lv_obj_t* lblBezug; static lv_obj_t* g_bbwBar = nullptr;
@@ -199,6 +208,7 @@ static lv_obj_t* g_piRow = nullptr; // Containe
static lv_obj_t* g_piChip[3] = { nullptr, nullptr, nullptr }; // Chip je Schritt
static lv_obj_t* g_piChipLbl[3] = { nullptr, nullptr, nullptr };
static bool g_piShotMode = false; // laufender Bezug nutzt Pre-Infusion
static bool g_piColdMode = false; // laufender Bezug ist eine kalte Extraktion (andere Phasennamen)
static uint8_t g_piStateS3 = 0; // Phasenstatus der S3 (0=inaktiv,1=PI,2=Pause,3=Extraktion)
static uint32_t g_piDurMs = 0, g_piPauseMs = 0; // beim Bezug gelatchte Phasendauern
static int g_piLastPhase = -1; // zuletzt gestylte Phase (Styles nur bei Wechsel anfassen)
@@ -213,6 +223,8 @@ static float g_sbtTarget = 0.0f; // Dampf-Zielzeit (s)
// Temperatur
static lv_obj_t* lblTargW; static lv_obj_t* lblTargD; static lv_obj_t* swEco; static lv_obj_t* swMaint;
static lv_obj_t* swColdEx = nullptr; // Cold Extraction (nur bei S3-FW >= 5.3.0 sichtbar)
static lv_obj_t* lblColdExHint = nullptr; // Erklaerung/Sperrgrund unter dem Schalter
static float g_targW = 0, g_targD = 0; static unsigned long g_targEditMs = 0;
static lv_obj_t* lblHeatUp = nullptr; static int g_heatUp = 0; static unsigned long g_heatUpEditMs = 0;
static bool g_heatUpDirty = false; // ungesendete Aufheizzeit-Aenderung (entprellt senden)
@@ -239,6 +251,15 @@ 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;
// 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* 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;
@@ -377,7 +398,7 @@ static lv_obj_t* row(lv_obj_t* parent, const char* label) {
static lv_obj_t* add_switch(lv_obj_t* parent, const char* label) {
lv_obj_t* r = row(parent, label);
lv_obj_t* sw = lv_switch_create(r);
lv_obj_set_style_bg_color(sw, COL_ACCENT, LV_PART_INDICATOR | LV_STATE_CHECKED);
lv_obj_set_style_bg_color(sw, COL_ACCENT, TH_SEL(LV_PART_INDICATOR, LV_STATE_CHECKED));
return sw;
}
static lv_obj_t* add_num(lv_obj_t* parent, const char* label) {
@@ -387,6 +408,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);
@@ -417,6 +461,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);
@@ -450,6 +495,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); } }
@@ -560,6 +606,17 @@ static void wake_yes_cb(lv_event_t*) { // Aufwecken -> hell lassen, S3 bee
// das Display in der Uebergangsphase kurz auf Standby-Helligkeit abdunkeln.
if (g_client) { g_client->sendAction("deactivateStandby"); showToast("Maschine wird aufgeweckt...", 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");
g_client->sendAction("deactivateStandby");
showToast("Aufwecken mit Cold Extraction...", false);
}
}
bool ui_wake_dialog_open() { return g_wakeDialogOpen; } // fuer die Helligkeitssteuerung in onState
// =====================================================================================
@@ -1189,6 +1246,32 @@ static lv_obj_t* build_temp_arc(lv_obj_t* parent, const char* title, lv_color_t
static void eco_cb(lv_event_t* e) { if (g_client) g_client->sendAction(sw_get((lv_obj_t*)lv_event_get_target(e)) ? "startEco" : "stopEco"); }
static void maint_cb(lv_event_t* e) { if (g_client) g_client->sendAction("toggleMaintenance", sw_get((lv_obj_t*)lv_event_get_target(e)) ? "true" : "false"); }
// Sperrgrund-Code der S3 (cxBlock) in Klartext. Die S3 sendet bewusst nur den Code, damit
// die State-Zeile kurz bleibt - die Texte gehoeren ins Display (analog statusKey).
static String coldex_block_text(uint8_t code, float maxTemp, float tempW) {
switch (code) {
case 1: return "nicht freigeschaltet";
case 2: return "Standby aktiv";
case 3: return "Wartungsmodus aktiv";
case 4: return "Reinigungsassistent aktiv";
case 5: return "PID-Tuning läuft";
case 6: return "Wasser-Sensor liefert keinen gültigen Wert";
case 7: {
char b[72];
snprintf(b, sizeof(b), "Wasserkessel zu warm (%.1f °C, benötigt < %.1f °C)",
(double)tempW, (double)maxTemp);
return String(b);
}
default: return "";
}
}
// Cold Extraction: gezielt start/stop senden (nicht toggle), damit ein abgewiesener Befehl
// den Schalter nicht in einen Zustand bringt, den die S3 nie bestaetigt.
static void coldex_cb(lv_event_t* e) {
bool want = sw_get((lv_obj_t*)lv_event_get_target(e));
if (g_client) g_client->sendAction(want ? "startColdExtraction" : "stopColdExtraction");
}
// Stepper-Zeile: Beschriftung | [-] Wert [+]
static lv_obj_t* build_stepper(lv_obj_t* parent, const char* label, lv_event_cb_t minus, lv_event_cb_t plus, int dec) {
@@ -1277,6 +1360,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);
// Cold Extraction hat eine eigene Seite (PG_COLDEX) - Schalter und Parameter stehen dort.
}
// =====================================================================================
@@ -1483,20 +1567,39 @@ static void chart_redraw() {
int32_t lo = INT32_MAX, hi = INT32_MIN; // Zehntelgrad
bool anyW = false, anyD = false;
for (int i = 0; i < 120; i++) {
int age = (119 - i) * stride; // Alter der Probe (Sekunden)
int ageStart = (119 - i) * stride; // juengstes Alter im Bucket (Sekunden)
int32_t vW = LV_CHART_POINT_NONE, vD = LV_CHART_POINT_NONE, vC = LV_CHART_POINT_NONE;
int32_t dW = LV_CHART_POINT_NONE, dD = LV_CHART_POINT_NONE;
if (age < (int)g_histFill) {
int idx = (int)g_histPos - 1 - age;
if (idx < 0) idx += CHART_HIST_MAX;
vW = g_hist->w[idx];
vD = g_hist->d[idx];
if (g_hist->c[idx] != CHART_HIST_NONE) vC = g_hist->c[idx];
dW = g_hist->dutyW[idx];
dD = g_hist->dutyD[idx];
if (vW > 300) { anyW = true; if (vW < lo) lo = vW; if (vW > hi) hi = vW; }
if (vD > 300) { anyD = true; if (vD < lo) lo = vD; if (vD > hi) hi = vD; }
if (vC != LV_CHART_POINT_NONE && vC > 300) { if (vC < lo) lo = vC; if (vC > hi) hi = vC; }
if (ageStart < (int)g_histFill) {
// Ueber den ganzen Bucket mitteln statt ein Einzelsample zu picken: das
// Abtast-Raster haengt an g_histPos und wandert sonst mit jedem Sekundentick,
// wodurch schnell schaltende Werte (Duty-PWM) sichtbar zwischen zwei
// Kurvenbildern hin- und herspringen. Der Mittelwert ist zudem bei der
// Heizleistung die ehrlichere Aussage als ein zufaelliger Momentwert.
int32_t sW = 0, sD = 0, sC = 0, sDW = 0, sDD = 0;
int nT = 0, nC = 0;
for (int s = 0; s < stride; s++) {
int age = ageStart + s;
if (age >= (int)g_histFill) break;
int idx = (int)g_histPos - 1 - age;
if (idx < 0) idx += CHART_HIST_MAX;
sW += g_hist->w[idx];
sD += g_hist->d[idx];
sDW += g_hist->dutyW[idx];
sDD += g_hist->dutyD[idx];
nT++;
if (g_hist->c[idx] != CHART_HIST_NONE) { sC += g_hist->c[idx]; nC++; }
}
if (nT > 0) {
vW = (sW + nT / 2) / nT;
vD = (sD + nT / 2) / nT;
dW = (sDW + nT / 2) / nT;
dD = (sDD + nT / 2) / nT;
if (nC > 0) vC = (sC + nC / 2) / nC;
if (vW > 300) { anyW = true; if (vW < lo) lo = vW; if (vW > hi) hi = vW; }
if (vD > 300) { anyD = true; if (vD < lo) lo = vD; if (vD > hi) hi = vD; }
if (vC != LV_CHART_POINT_NONE && vC > 300) { if (vC < lo) lo = vC; if (vC > hi) hi = vC; }
}
}
lv_chart_set_value_by_id(g_chart, g_serW, i, vW);
lv_chart_set_value_by_id(g_chart, g_serD, i, vD);
@@ -1582,7 +1685,10 @@ static void chart_sample_cb(lv_timer_t*) {
// Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird.
{
long rs = g_state.heatUpRemainSec;
if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen
// Bei aktiver Cold Extraction heizt der Wasserkreis nicht - ein Aufheiz-Countdown
// waere schlicht falsch (er liefe ab, ohne dass die Maschine warm wird).
if (g_state.cxActive) g_heatUpLatched = false;
else if (g_state.standbyActive || rs <= 0) g_heatUpLatched = false; // Ende/Standby -> zuruecksetzen
else if (g_state.tempW < 40.0f) g_heatUpLatched = true; // Kaltstart erkannt -> ganzen Countdown zeigen
if (g_heatUpLatched) {
char b[56];
@@ -1736,8 +1842,11 @@ static void brew_live_tick() {
if (phase <= 2) {
uint32_t endMs = (phase == 1) ? g_piDurMs : (g_piDurMs + g_piPauseMs);
uint32_t remainMs = (g_shotFrozenMs < endMs) ? (endMs - g_shotFrozenMs) : 0;
snprintf(b, sizeof(b), "%s - noch %.1f s",
(phase == 1) ? "Pre-Infusion" : "Pause", remainMs / 1000.0f);
const char* pname = (phase == 1) ? (g_piColdMode ? "Vorbenetzung" : "Pre-Infusion")
: "Pause";
snprintf(b, sizeof(b), "%s - noch %.1f s", pname, remainMs / 1000.0f);
} else if (g_piColdMode && g_state.cxResting) {
snprintf(b, sizeof(b), "Extraktion - Pumpenpause");
} else {
snprintf(b, sizeof(b), "Extraktion");
}
@@ -1925,21 +2034,111 @@ 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 (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);
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)");
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);
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);
}
// =====================================================================================
// Reinigungsassistent (Sub-Screen, geoeffnet ueber die Reinigung/Wartung-Seite)
// =====================================================================================
@@ -2266,7 +2465,7 @@ static void build_wifi(lv_obj_t* p) {
// Aktionszeile: Scan + gewaehltes Netz
lv_obj_t* ar = row(p, nullptr);
g_btnScan = th_accent_button(ar, LV_SYMBOL_REFRESH " Netzwerke suchen");
lv_obj_set_style_bg_opa(g_btnScan, LV_OPA_40, LV_PART_MAIN | LV_STATE_DISABLED);
lv_obj_set_style_bg_opa(g_btnScan, LV_OPA_40, TH_SEL(LV_PART_MAIN, LV_STATE_DISABLED));
lv_obj_add_event_cb(g_btnScan, wifi_scan_cb, LV_EVENT_CLICKED, nullptr);
lblWifiSel = lv_label_create(ar);
lv_label_set_text(lblWifiSel, "Netz: -");
@@ -2356,6 +2555,112 @@ static void prof_save_cb(lv_event_t*) {
}
static void prof_refresh_cb(lv_event_t*) { if (g_client) g_client->sendListProfiles(); }
// --- Profil-Schnellwahl: Overlay mit allen Profilen zum Direkt-Laden ------------------
// Geoeffnet ueber den Profil-Chip im Header; ein Tap laedt das Profil sofort (die
// Bestaetigung uebernimmt der Toast + der aktualisierte Chip aus dem naechsten State).
static void prof_picker_close() {
if (!g_profPicker) return;
lv_obj_delete(g_profPicker);
g_profPicker = nullptr;
g_profPickerList = nullptr;
g_profPickerInfo = nullptr;
}
static void prof_picker_bg_cb(lv_event_t* e) {
// nur ein Tap auf die Abdunkelung selbst schliesst (Karte bubbelt nicht)
if (lv_event_get_target(e) == lv_event_get_current_target(e)) prof_picker_close();
}
static void prof_picker_close_cb(lv_event_t*) { prof_picker_close(); }
static void prof_picker_load_cb(lv_event_t* e) {
lv_obj_t* btn = (lv_obj_t*)lv_event_get_current_target(e);
lv_obj_t* lbl = lv_obj_get_child(btn, 0); // Kind 0 = Name (pur), Kind 1 = optionales Haekchen
if (!lbl || !g_client) return;
String name = lv_label_get_text(lbl);
prof_picker_close();
if (name == g_state.activeProfile && !g_state.profileDirty) {
showToast(("Profil ist bereits aktiv: " + name).c_str(), false);
return;
}
g_client->sendLoadProfile(name);
showToast(("Lade Profil: " + name).c_str(), false);
}
static void prof_picker_fill(const String profiles[], int count) {
if (!g_profPickerList) return;
lv_obj_clean(g_profPickerList);
if (g_profPickerInfo) {
if (count == 0) {
lv_label_set_text(g_profPickerInfo, "Keine Profile gespeichert.");
lv_obj_remove_flag(g_profPickerInfo, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(g_profPickerInfo, LV_OBJ_FLAG_HIDDEN);
}
}
for (int i = 0; i < count; i++) {
bool active = (profiles[i] == g_state.activeProfile);
lv_obj_t* b = lv_button_create(g_profPickerList);
lv_obj_set_width(b, LV_PCT(100));
lv_obj_set_style_bg_color(b, active ? COL_ACCENT : COL_CARD2, 0);
lv_obj_set_style_radius(b, 10, 0);
lv_obj_set_style_pad_ver(b, 12, 0);
lv_obj_set_style_pad_hor(b, 14, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_t* l = lv_label_create(b); // Kind 0: purer Profilname (prof_picker_load_cb liest ihn)
lv_label_set_text(l, profiles[i].c_str());
lv_obj_set_style_text_color(l, active ? lv_color_hex(0x000000) : COL_TEXT, 0);
lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0);
if (active) {
lv_obj_t* chk = lv_label_create(b);
lv_label_set_text(chk, LV_SYMBOL_OK);
lv_obj_set_style_text_color(chk, lv_color_hex(0x000000), 0);
lv_obj_align(chk, LV_ALIGN_RIGHT_MID, 0, 0);
}
lv_obj_add_event_cb(b, prof_picker_load_cb, LV_EVENT_CLICKED, nullptr);
}
}
static void prof_picker_open() {
if (g_profPicker) return;
g_profPicker = lv_obj_create(lv_screen_active());
lv_obj_set_size(g_profPicker, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(g_profPicker, 0, 0);
lv_obj_set_style_bg_color(g_profPicker, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(g_profPicker, LV_OPA_60, 0);
lv_obj_set_style_border_width(g_profPicker, 0, 0);
lv_obj_set_style_radius(g_profPicker, 0, 0);
lv_obj_set_style_pad_all(g_profPicker, 0, 0);
lv_obj_clear_flag(g_profPicker, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(g_profPicker, prof_picker_bg_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* card = th_card(g_profPicker);
lv_obj_set_size(card, 460, 400);
lv_obj_center(card);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(card, 8, 0);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* hdr = row(card, nullptr);
lv_obj_t* t = lv_label_create(hdr);
lv_label_set_text(t, "Profil laden");
lv_obj_set_style_text_color(t, COL_ACCENT, 0);
lv_obj_set_style_text_font(t, &lv_font_maven_pro_18, 0);
lv_obj_t* x = th_button(hdr, LV_SYMBOL_CLOSE, COL_CARD2, COL_TEXT);
lv_obj_add_event_cb(x, prof_picker_close_cb, LV_EVENT_CLICKED, nullptr);
g_profPickerInfo = lv_label_create(card);
lv_label_set_text(g_profPickerInfo, "Lade Profile...");
lv_obj_set_style_text_color(g_profPickerInfo, COL_TEXT_DIM, 0);
g_profPickerList = lv_obj_create(card);
lv_obj_set_width(g_profPickerList, LV_PCT(100));
lv_obj_set_flex_grow(g_profPickerList, 1);
lv_obj_set_style_bg_opa(g_profPickerList, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(g_profPickerList, 0, 0);
lv_obj_set_style_pad_all(g_profPickerList, 0, 0);
lv_obj_set_style_pad_row(g_profPickerList, 6, 0);
lv_obj_set_flex_flow(g_profPickerList, LV_FLEX_FLOW_COLUMN);
if (g_client) g_client->sendListProfiles(); // Antwort fuellt via ui_set_profiles/prof_picker_fill
}
static void prof_chip_cb(lv_event_t*) { prof_picker_open(); }
static void build_profiles(lv_obj_t* p) {
lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE);
@@ -2397,13 +2702,13 @@ static void build_profiles(lv_obj_t* p) {
lv_obj_set_style_text_color(profDetailLbl, COL_TEXT_DIM, 0);
profLoadBtn = th_accent_button(dcard, "Profil laden");
lv_obj_set_width(profLoadBtn, LV_PCT(100));
lv_obj_set_style_bg_opa(profLoadBtn, LV_OPA_40, LV_PART_MAIN | LV_STATE_DISABLED);
lv_obj_set_style_bg_opa(profLoadBtn, LV_OPA_40, TH_SEL(LV_PART_MAIN, LV_STATE_DISABLED));
lv_obj_add_state(profLoadBtn, LV_STATE_DISABLED);
lv_obj_add_event_cb(profLoadBtn, prof_do_load_cb, LV_EVENT_CLICKED, nullptr);
profDelBtn = th_button(dcard, "Profil löschen", COL_DANGER, COL_TEXT);
lv_obj_set_width(profDelBtn, LV_PCT(100));
lv_obj_set_style_bg_opa(profDelBtn, LV_OPA_40, LV_PART_MAIN | LV_STATE_DISABLED);
lv_obj_set_style_bg_opa(profDelBtn, LV_OPA_40, TH_SEL(LV_PART_MAIN, LV_STATE_DISABLED));
lv_obj_add_state(profDelBtn, LV_STATE_DISABLED);
lv_obj_add_event_cb(profDelBtn, prof_delete_cb, LV_EVENT_CLICKED, nullptr);
}
@@ -2630,7 +2935,9 @@ static void pi_bar_update() {
}
// Texte: erledigte Schritte mit Haken, aktive zeitgesteuerte Phase mit Restzeit
static const char* piNames[3] = { "Pre-Infusion", "Pause", "Extraktion" };
static const char* piNamesHot[3] = { "Pre-Infusion", "Pause", "Extraktion" };
static const char* piNamesCold[3] = { "Vorbenetzung", "Pause", "Extraktion" };
const char** piNames = g_piColdMode ? piNamesCold : piNamesHot;
char pbuf[40];
for (int i = 0; i < 3; i++) {
if (!g_piChipLbl[i]) continue;
@@ -2840,6 +3147,22 @@ void ui_init(ProtocolClient* client) {
lv_obj_set_flex_grow(g_hdrTitle, 1);
lv_obj_set_style_text_font(g_hdrTitle, &lv_font_maven_pro_18, 0);
// Profil-Chip: zuletzt geladenes Profil ("*" = seitdem geaenderte Einstellungen).
// Antippen oeffnet die Profil-Schnellwahl. Unsichtbar, solange die S3 kein Profil meldet.
g_profChip = lv_label_create(header);
lv_label_set_text(g_profChip, "");
lv_obj_set_style_bg_color(g_profChip, COL_CARD2, 0);
lv_obj_set_style_bg_opa(g_profChip, LV_OPA_COVER, 0);
lv_obj_set_style_text_color(g_profChip, COL_TEXT, 0);
lv_obj_set_style_radius(g_profChip, 12, 0);
lv_obj_set_style_pad_hor(g_profChip, 10, 0);
lv_obj_set_style_pad_ver(g_profChip, 3, 0);
lv_obj_set_style_margin_right(g_profChip, 10, 0);
lv_obj_add_flag(g_profChip, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_ext_click_area(g_profChip, 8);
lv_obj_add_event_cb(g_profChip, prof_chip_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_add_flag(g_profChip, LV_OBJ_FLAG_HIDDEN);
g_readyPill = lv_label_create(header); // "BEREIT": Kessel im Zielband (auf allen Seiten sichtbar)
lv_label_set_text(g_readyPill, "BEREIT");
lv_obj_set_style_bg_color(g_readyPill, COL_OK, 0);
@@ -2894,6 +3217,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]);
@@ -3018,6 +3342,13 @@ void ui_init(ProtocolClient* client) {
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);
// Zweite Zeile: direkt in die kalte Extraktion aufwecken. Ohne diesen Weg muesste man
// erst normal aufwecken (Maschine heizt an) und den Modus danach umschalten.
// Nur sichtbar, wenn die S3 die Funktion freigeschaltet meldet (siehe ui_update).
g_wakeColdBtn = th_button(wakeCard, "Aufwecken mit Cold Extraction", COL_CARD2, COL_ACCENT);
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)
@@ -3238,6 +3569,49 @@ void ui_update(const MachineState& st) {
status_set(ST_SAFETY, false);
}
// Cold-Extraction-Hinweis: erklaert dauerhaft, warum der Wasserkreis nicht heizt.
// Transiente Meldung (abgewiesener Start / Abbruch) hat Vorrang vor dem Dauerzustand.
// 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.
if (g_wakeColdBtn) {
if (st.cxEnabled && st.cxArmable && !st.cxActive) lv_obj_remove_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(g_wakeColdBtn, LV_OBJ_FLAG_HIDDEN);
}
if (st.cxNotice == 2) {
status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction beendet: kein Zulauf",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 3) {
status_set(ST_COLDEX, true, "Cold Extraction: erst nach dem Bezug umschaltbar",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 1) {
String t = "Cold Extraction nicht möglich - ";
t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 4) {
status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction ohne Waage: Bezug endet nach Zeit",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive && st.cxResting) {
status_set(ST_COLDEX, true, "Cold Extraction: Pumpenpause (Pumpenschutz)",
COL_ACCENT, lv_color_hex(0x000000));
} else if (st.cxActive && !st.cxAllowed) {
String t = "Cold Extraction aktiv, Bezug gesperrt - ";
t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive && !cxScaleOk) {
// Dauerhinweis, solange ohne Waage gefahren wird: das Zielgewicht greift nicht,
// der Bezug endet erst nach der maximalen Bezugsdauer.
status_set(ST_COLDEX, true, "Cold Extraction aktiv - ohne Waage, Ende nach Zeit",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive) {
status_set(ST_COLDEX, true, "Cold Extraction aktiv - Wasserkreis heizt nicht",
lv_color_hex(0x1976d2), COL_TEXT);
} else {
status_set(ST_COLDEX, false);
}
// Dampf-Hinweis (Heizen aus hat Vorrang vor Startverzoegerung)
if (st.steamHeatDisabled) {
status_set(ST_DAMPF, true, "Dampf: Heizen deaktiviert", COL_DANGER, COL_TEXT);
@@ -3376,6 +3750,14 @@ void ui_update(const MachineState& st) {
temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER);
} else {
lv_label_set_text(lblTempW, tempStr(st.tempW, 1).c_str());
if (st.cxActive) {
// Cold Extraction: der Sollwert ist bedeutungslos, es wird nicht geheizt.
// Statt eines Zielbands, das nie erreicht wird, den Grund anzeigen.
lv_obj_set_style_text_color(lblTempW, lv_color_hex(0x1976d2), 0);
label_set_if_changed(lblSetW, "Cold Extraction - Heizen aus");
lv_obj_set_style_text_color(lblSetW, lv_color_hex(0x1976d2), 0);
temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, lv_color_hex(0x1976d2));
} else {
// Ist-Wert faerbt sich weich Richtung Soll (Blau -> Gold -> Gruen im Zielband)
lv_color_t colW = temp_state_color(st.tempW, st.setW);
lv_obj_set_style_text_color(lblTempW, colW, 0);
@@ -3384,6 +3766,7 @@ void ui_update(const MachineState& st) {
// Fortschritt = Ist/Soll in %, gedeckelt auf 100 (Ist >= Soll -> voll)
int pctW = (st.setW > 0.0f) ? (int)(st.tempW / st.setW * 100.0f + 0.5f) : 0;
temp_progress_set(barDutyW, arcTempW, bulbTempW, pctW, colW);
}
}
if (st.dampfSafetyShutdown || st.dampfSensorError) {
lv_label_set_text(lblTempD, LV_SYMBOL_WARNING);
@@ -3418,6 +3801,24 @@ void ui_update(const MachineState& st) {
else ui_fade_out_hide(g_readyPill, UI_FADE_MS);
}
}
// Profil-Chip im Header: Name des zuletzt geladenen Profils, "*" wenn seitdem
// profil-relevante Einstellungen geaendert wurden. Lange Namen werden gekuerzt,
// damit der Header nicht ueberlaeuft. Leer (alte S3-FW / kein Profil) -> versteckt.
if (g_profChip) {
if (st.activeProfile.length() > 0) {
String t = st.activeProfile;
if (t.length() > 18) {
int cut = 17; // nicht mitten in einem UTF-8-Mehrbyte-Zeichen (Umlaut) schneiden
while (cut > 0 && ((uint8_t)t[cut] & 0xC0) == 0x80) cut--;
t = t.substring(0, cut) + "...";
}
if (st.profileDirty) t += " *";
label_set_if_changed(g_profChip, t.c_str());
lv_obj_remove_flag(g_profChip, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(g_profChip, LV_OBJ_FLAG_HIDDEN);
}
}
// Timer-Synchronisation (die laufende Anzeige macht der 100-ms-Timer dash_timers_cb)
// Tarier-Phase vor Brew-by-Weight-Bezug -> dash_timers_cb zeigt "Tariere..." im Shot-Timer
g_scaleTaring = st.scaleTaring;
@@ -3431,10 +3832,19 @@ void ui_update(const MachineState& st) {
g_shotBbtMode = st.bbtEnabled && st.bbtSecs > 0.0f; // Bezug mit Brew-by-Time?
if (g_shotBbtMode) g_bbtTarget = st.bbtSecs;
// Pre-Infusion-Phasenleiste: Modus, S3-Phase und Phasendauern latchen (pi_bar_update)
g_piShotMode = st.piEnabled;
// Ein kalter Bezug hat seine eigene Phasenfolge (Vorbenetzung -> Extraktion, keine
// Pause) und ignoriert die Pre-Infusion-Einstellungen komplett.
g_piColdMode = st.cxShot;
if (g_piColdMode) {
g_piShotMode = (st.cxPreInfSec > 0.0f);
g_piDurMs = (uint32_t)(st.cxPreInfSec * 1000.0f);
g_piPauseMs = 0;
} else {
g_piShotMode = st.piEnabled;
g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f);
g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f);
}
g_piStateS3 = st.piState;
g_piDurMs = (uint32_t)(st.piDurSecs * 1000.0f);
g_piPauseMs = (uint32_t)(st.piPauseSecs * 1000.0f);
// Live-Bezugsschirm einblenden (nur echte Bezuege; nicht im Reinigungsassistenten,
// nicht im Standby und nicht, wenn der Nutzer ihn fuer diesen Bezug weggetippt hat)
if (g_brewLiveEnabled && !g_brewLiveClosed && !g_brewLiveShown &&
@@ -3458,6 +3868,7 @@ void ui_update(const MachineState& st) {
}
g_shotActive = false;
g_piShotMode = false; // Phasenleiste ausblenden (pi_bar_update)
g_piColdMode = false;
g_piStateS3 = 0;
brew_live_hide(); // Bezug vorbei -> Overlay weich ausblenden
g_brewLiveClosed = false; // Wegtipp-Sperre gilt nur fuer den laufenden Bezug
@@ -3560,6 +3971,72 @@ void ui_update(const MachineState& st) {
if (swEco) sw_set(swEco, st.ecoActive);
if (swMaint) sw_set(swMaint, st.maintenanceActive);
if (swMaint2) sw_set(swMaint2, st.maintenanceActive);
if (swColdEx) {
// Zeile nur zeigen, wenn die S3 die Funktion freigeschaltet meldet (und sie kennt)
lv_obj_t* rowCx = lv_obj_get_parent(swColdEx);
if (st.cxEnabled) {
lv_obj_remove_flag(rowCx, LV_OBJ_FLAG_HIDDEN);
if (lblColdExHint) lv_obj_remove_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(rowCx, LV_OBJ_FLAG_HIDDEN);
if (lblColdExHint) lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
}
sw_set(swColdEx, st.cxActive);
// 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) {
const bool scaleOk = st.scaleEnabled && st.scaleConnected;
String hint;
if (st.cxActive) {
hint = "Wasserkreis heizt nicht (bleibt über Standby und Neustart aktiv). ";
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.",
(double)st.cxTarget, (double)st.cxMaxSecs);
} else {
snprintf(b, sizeof(b), "Bezug regulär starten - Ende nach %.0f s.",
(double)st.cxMaxSecs);
}
hint += b;
} else {
hint += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
}
} else if (!st.cxAllowed) {
hint = coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
} else {
char b[112];
snprintf(b, sizeof(b), "Kalte Extraktion möglich (Wasser %.1f °C < %.1f °C). "
"Solange aktiv, heizt der Wasserkreis nicht.",
(double)st.tempW, (double)st.cxMaxTemp);
hint = b;
}
if (!scaleOk) {
hint += " Ohne Waage: Zielgewicht und Stillstands-Erkennung greifen nicht, "
"der Bezug endet erst nach der maximalen Bezugsdauer.";
}
label_set_if_changed(lblColdExHint, hint.c_str());
}
}
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(lblCxPageHint, LV_OBJ_FLAG_HIDDEN);
}
}
// Licht-Button spiegeln (gold = an)
if (btnLight) {
@@ -3610,12 +4087,16 @@ void ui_update(const MachineState& st) {
void ui_toast(const char* txt, bool error) { showToast(txt, error); }
void ui_set_profiles(const String profiles[], int count) {
// Schnellwahl-Overlay (falls offen) mit derselben Antwort befuellen
prof_picker_fill(profiles, count);
if (!profList) return;
lv_obj_clean(profList);
for (int i = 0; i < count; i++) {
lv_obj_t* b = lv_list_add_button(profList, LV_SYMBOL_FILE, profiles[i].c_str());
// aktives Profil markieren (Haekchen + Akzentfarbe statt Datei-Symbol)
bool active = (profiles[i] == g_state.activeProfile);
lv_obj_t* b = lv_list_add_button(profList, active ? LV_SYMBOL_OK : LV_SYMBOL_FILE, profiles[i].c_str());
lv_obj_set_style_bg_color(b, COL_CARD2, 0);
lv_obj_set_style_text_color(b, COL_TEXT, 0);
lv_obj_set_style_text_color(b, active ? COL_ACCENT : COL_TEXT, 0);
lv_obj_add_event_cb(b, prof_select_cb, LV_EVENT_CLICKED, nullptr);
}
}
@@ -3688,14 +4169,19 @@ void ui_set_usage_stats(const String& json) {
String l;
int n = 0;
float durSum = 0.0f;
int nDur = 0; // Anzahl der Bezuege, die in den Mittelwert eingehen (ohne kalte)
for (JsonVariant v : doc["shots"].as<JsonArray>()) {
const char* dt = v["dt"] | "";
float d = v["d"] | 0.0f;
float w = v["w"] | -1.0f;
bool cold = v["cx"] | false; // kalte Extraktion (fehlt bei S3-FW < 5.3.0)
if (n > 0) l += "\n";
l += String(dt) + " " + String(d, 1) + " s";
if (w >= 0.0f) l += " " + String(w, 1) + " g";
durSum += d;
if (cold) l += " (kalt)";
// Minutenlange kalte Bezuege wuerden den Mittelwert unbrauchbar machen -> raus
// (gleiche Regel wie in der S3-Statistik)
if (!cold) { durSum += d; nDur++; }
n++;
}
@@ -3704,7 +4190,10 @@ void ui_set_usage_stats(const String& json) {
s += "Diese Woche: " + String(week) + "\n";
// total ist exakt, solange die ganze Datei ins Fenster passte (sonst Fenster-Untergrenze)
s += "Gesamt: " + (partial ? String("min. ") : String("")) + String(total) + " Bezüge";
if (n > 0) s += "\nMittl. Dauer (letzte " + String(n) + "): " + String(durSum / n, 1) + " s";
if (nDur > 0) {
s += "\nMittl. Dauer (letzte " + String(nDur) + "): " + String(durSum / nDur, 1) + " s";
if (nDur < n) s += " (ohne kalte)";
}
label_set_if_changed(g_statSummary, s.c_str());
if (g_statList) {