Compare commits

...
Author SHA1 Message Date
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
10 changed files with 353 additions and 27 deletions
+9
View File
@@ -1,3 +1,12 @@
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 -
+13 -1
View File
@@ -387,8 +387,20 @@ Aus `getTouchUartStatus()`. Für sprach-unabhängige UI-Logik/Icons auf dem P4 v
| `cxResting` | bool | Zwangspause des Pumpenschutzes läuft (Bezug läuft weiter) |
| `cxMaxTemp` | float | Freigabeschwelle in °C (für eigene Hinweistexte am Display) |
| `cxTarget` | float | Zielgewicht in g |
| `cxPreInf` | float | Vorbenetzung in s (für die Phasenleiste des kalten Bezugs) |
| `cxMaxSecs` | float | Sicherheits-Timeout des Bezugs in s |
| `cxMessage` | string | Sperr-/Abbruchmeldung, leer = keine (läuft nach ~6 s selbst ab) |
| `cxBlock` | uint8 | Sperrgrund (siehe unten), `0` = frei |
| `cxNotice` | uint8 | transiente Meldung, `0` = keine (läuft nach ~6 s selbst ab) |
`cxBlock`: `0` frei, `1` nicht freigeschaltet, `2` Standby, `3` Wartungsmodus,
`4` Reinigungsassistent, `5` PID-Tuning, `6` Sensorfehler, `7` Wasserkessel zu warm.
`cxNotice`: `0` keine, `1` Start abgewiesen (Grund steht in `cxBlock`), `2` Bezug wegen
fehlenden Zulaufs beendet, `3` Umschalten während eines Bezugs abgelehnt.
Bewusst **Codes statt Klartext**: Die State-Zeile muss unter dem Zeilenlimit des P4 bleiben
(`PROTO_RX_LINE_MAX`; sie liegt real bereits bei ~2,1 KB), und die Texte gehören ins Display —
dieselbe Trennung wie bei `statusKey`/`statusText`.
Während `cxShot` folgt die Phase weiterhin `piState`: `1` = Vorbenetzung, `3` = Hauptbezug
(gepulst). Eine Pause-Phase (`2`) gibt es bei der kalten Extraktion nicht.
+58 -8
View File
@@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
* Firmware-Informationen
************************************************************************************/
String version = "5.3.1";
String version = "5.3.2";
String versionHersteller = "Thomas M&uuml;ller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -832,11 +832,36 @@ unsigned long coldExtractionPumpSampleMs = 0;
float coldExtractionStallLastWeight = NAN;
unsigned long coldExtractionStallSinceMs = 0;
bool lastShotWasColdExtraction = false; // fuer Anzeige/Log des letzten Bezugs
// Sperrmeldung fuer OLED und Touch-Display, wenn ein kalter Bezug abgewiesen wurde
// Sperrmeldung fuer das OLED, wenn ein kalter Bezug abgewiesen bzw. abgebrochen wurde.
// Der Klartext bleibt S3-intern; ans Touch-Display gehen nur die Codes unten, damit die
// State-Zeile kurz bleibt (der P4 hat ein Zeilenlimit) und das Display seine eigenen
// Texte rendern kann - dieselbe Trennung wie bei statusKey/statusText.
const unsigned long COLDEX_BLOCK_MESSAGE_MS = 6000UL;
String coldExtractionBlockMessage = "";
unsigned long coldExtractionBlockMessageUntilMs = 0;
// Sperrgrund (cxBlock): 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung,
// 4=Reinigungsassistent, 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm
enum ColdExtractionBlock : uint8_t {
COLDEX_BLOCK_NONE = 0,
COLDEX_BLOCK_NOT_ENABLED = 1,
COLDEX_BLOCK_STANDBY = 2,
COLDEX_BLOCK_MAINTENANCE = 3,
COLDEX_BLOCK_CLEANING = 4,
COLDEX_BLOCK_TUNING = 5,
COLDEX_BLOCK_SENSOR = 6,
COLDEX_BLOCK_TOO_WARM = 7
};
// Transiente Meldung (cxNotice): 0=keine, 1=Start abgewiesen, 2=Bezug wegen fehlenden
// Zulaufs beendet, 3=Umschalten waehrend eines Bezugs abgelehnt
enum ColdExtractionNotice : uint8_t {
COLDEX_NOTICE_NONE = 0,
COLDEX_NOTICE_START_REJECTED = 1,
COLDEX_NOTICE_NO_FLOW = 2,
COLDEX_NOTICE_BUSY = 3
};
uint8_t coldExtractionNotice = COLDEX_NOTICE_NONE;
/************************************************************************************
* Standardwerte und Default-Einstellungen
************************************************************************************/
@@ -4032,6 +4057,19 @@ bool coldExtractionConditionsOk() {
coldExtractionTemperatureOk();
}
// Sperrgrund als Code fuer das Touch-Display (0 = frei). Gleiche Reihenfolge wie
// coldExtractionBlockReason(), damit Text und Code immer dieselbe Ursache nennen.
uint8_t coldExtractionBlockCode() {
if (!coldExtractionEnabled) return COLDEX_BLOCK_NOT_ENABLED;
if (standbyModeActive) return COLDEX_BLOCK_STANDBY;
if (wartungsModusAktiv) return COLDEX_BLOCK_MAINTENANCE;
if (cleaningAssistantActive) return COLDEX_BLOCK_CLEANING;
if (autoTuneWasserActive || autoTuneDampfActive) return COLDEX_BLOCK_TUNING;
if (wasserSensorError || wasserSafetyShutdown || !isfinite(InputWasser)) return COLDEX_BLOCK_SENSOR;
if (!coldExtractionTemperatureOk()) return COLDEX_BLOCK_TOO_WARM;
return COLDEX_BLOCK_NONE;
}
// Klartext-Grund, warum gerade keine kalte Extraktion moeglich ist ("" = alles frei).
String coldExtractionBlockReason() {
if (!coldExtractionEnabled) {
@@ -5253,6 +5291,7 @@ void loop() {
// kalten Bezug wieder abkuehlen zu muessen. Nur die Laufzeitdaten werden verworfen.
resetColdExtractionRuntime();
coldExtractionBlockMessage = "";
coldExtractionNotice = COLDEX_NOTICE_NONE;
} else if (lastStandbyModeActive) {
startupTime = currentMillis;
ecoForcedActive = false;
@@ -5484,6 +5523,7 @@ void loop() {
stopForTime = true;
coldExtractionBlockMessage = "Cold Extraction beendet: kein Zulauf (Puck dicht oder Pumpenschutz?).";
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionNotice = COLDEX_NOTICE_NO_FLOW;
}
}
@@ -5985,6 +6025,7 @@ void updateDisplay() {
if (coldExtractionBlockMessage.length() > 0) {
if ((long)(millis() - coldExtractionBlockMessageUntilMs) >= 0) {
coldExtractionBlockMessage = "";
coldExtractionNotice = COLDEX_NOTICE_NONE;
} else if (!standbyModeActive) {
display.clearDisplay();
display.setTextSize(1);
@@ -15491,6 +15532,8 @@ bool executeDashboardAction(const String& action, const String& value,
if (!success) {
coldExtractionBlockMessage = message;
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionNotice = (shotActive || shotSoftwareActive) ? COLDEX_NOTICE_BUSY
: COLDEX_NOTICE_START_REJECTED;
}
} else if (action == "startShot") {
if (standbyModeActive) {
@@ -15509,6 +15552,7 @@ bool executeDashboardAction(const String& action, const String& value,
if (message.length() == 0) { message = "Cold Extraction derzeit nicht moeglich."; }
coldExtractionBlockMessageUntilMs = millis() + COLDEX_BLOCK_MESSAGE_MS;
coldExtractionBlockMessage = message;
coldExtractionNotice = COLDEX_NOTICE_START_REJECTED;
beepShort();
success = false;
} else {
@@ -16432,15 +16476,21 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxResting\":%s,", coldExtractionPumpResting ? "true" : "false");
dtostrf(coldExtractionMaxTempC, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxTemp\":%s,", floatBuf);
dtostrf(coldExtractionTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxTarget\":%s,", floatBuf);
dtostrf(coldExtractionPreInfusionSeconds, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxPreInf\":%s,", floatBuf);
dtostrf(coldExtractionMaxSeconds, 5, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMaxSecs\":%s,", floatBuf);
// Sperrmeldung nach abgewiesenem Start (leer = keine); laeuft nach COLDEX_BLOCK_MESSAGE_MS ab.
// Die Texte stammen ausschliesslich aus dieser Firmware und enthalten keine JSON-Sonderzeichen.
// Sperrgrund + transiente Meldung als Codes (kein Freitext - die State-Zeile muss kurz
// bleiben, der P4 begrenzt eingehende Zeilen; Texte rendert das Display selbst).
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxBlock\":%u,", (unsigned int)coldExtractionBlockCode());
{
const char* cxMsg = "";
if (coldExtractionBlockMessage.length() > 0 && (long)(millis() - coldExtractionBlockMessageUntilMs) < 0) {
cxMsg = coldExtractionBlockMessage.c_str();
uint8_t notice = COLDEX_NOTICE_NONE;
if (coldExtractionNotice != COLDEX_NOTICE_NONE) {
if ((long)(millis() - coldExtractionBlockMessageUntilMs) < 0) {
notice = coldExtractionNotice;
} else {
coldExtractionNotice = COLDEX_NOTICE_NONE;
}
}
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxMessage\":\"%s\",", cxMsg);
offset += snprintf(buffer + offset, bufferSize - offset, "\"cxNotice\":%u,", (unsigned int)notice);
}
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwTarget\":%s,", floatBuf);
dtostrf(brewByWeightOffsetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"bbwOffset\":%s,", floatBuf);
+44
View File
@@ -1,3 +1,47 @@
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.
+5 -2
View File
@@ -65,13 +65,16 @@
// -------------------------------------------------------------------------------------
// Firmware
// -------------------------------------------------------------------------------------
#define DISPLAY_FW_VERSION "1.0.13" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
#define DISPLAY_FW_VERSION "1.1.1" // 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
+17
View File
@@ -68,6 +68,23 @@ 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; // Freigabe jetzt (Wasserkessel kalt genug, kein Standby/Wartung/Tuning)
bool cxResting = false; // Zwangspause des Pumpenschutzes laeuft (Bezug laeuft weiter)
float cxMaxTemp = 0; // Freigabeschwelle in C
float cxTarget = 0; // Zielgewicht in g
float cxPreInfSec = 0; // Vorbenetzung in s (fuer die Phasenleiste des kalten Bezugs)
float cxMaxSecs = 0; // Sicherheits-Timeout des Bezugs in s
// Sperrgrund: 0=frei, 1=nicht freigeschaltet, 2=Standby, 3=Wartung, 4=Reinigungsassistent,
// 5=PID-Tuning, 6=Sensorfehler, 7=Wasserkessel zu warm
uint8_t cxBlock = 0;
// Transiente Meldung: 0=keine, 1=Start abgewiesen, 2=Bezug ohne Zulauf beendet,
// 3=Umschalten waehrend eines Bezugs abgelehnt
uint8_t cxNotice = 0;
// --- Gehaeusesensor ---
bool caseSensorEnabled = false;
bool caseTempDashboard = false;
+17
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;
@@ -216,6 +220,19 @@ 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;
s.cxResting = doc["cxResting"] | false;
s.cxMaxTemp = doc["cxMaxTemp"] | s.cxMaxTemp;
s.cxTarget = doc["cxTarget"] | s.cxTarget;
s.cxPreInfSec= doc["cxPreInf"] | s.cxPreInfSec;
s.cxMaxSecs = doc["cxMaxSecs"] | s.cxMaxSecs;
s.cxBlock = doc["cxBlock"] | (uint8_t)0;
s.cxNotice = doc["cxNotice"] | (uint8_t)0;
s.caseSensorEnabled = doc["caseSensorEnabled"] | false;
s.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);
+180 -14
View File
@@ -65,7 +65,9 @@ static lv_obj_t* g_errIcon = nullptr; // Fehlersymbol im Header (rot, auf
// Konsolidierte Statuszeile: EINE feste einzeilige Meldung statt gestapelter Einzelbanner.
// 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] = "";
@@ -205,6 +207,7 @@ static lv_obj_t* g_piRow = nullptr; // Containe
static lv_obj_t* g_piChip[3] = { nullptr, nullptr, nullptr }; // Chip je Schritt
static lv_obj_t* g_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)
@@ -219,6 +222,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)
@@ -245,6 +250,7 @@ static lv_obj_t* swPI; static lv_obj_t* taPiDur; static lv_obj_t* taPiPause;
static lv_obj_t* swBBT; static lv_obj_t* taBbtSecs;
static lv_obj_t* swBBW; static lv_obj_t* taBbwTarget; static lv_obj_t* taBbwOffset;
static lv_obj_t* swSBT; static lv_obj_t* taSbtSecs;
static lv_obj_t* lblBrewColdEx = nullptr; // Cold-Extraction-Parameter (nur Anzeige)
// WiFi
static lv_obj_t* wifiStatusLbl; static lv_obj_t* wifiHintLbl; static lv_obj_t* wifiList;
@@ -383,7 +389,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) {
@@ -1195,6 +1201,32 @@ static lv_obj_t* build_temp_arc(lv_obj_t* parent, const char* title, lv_color_t
static void eco_cb(lv_event_t* e) { if (g_client) g_client->sendAction(sw_get((lv_obj_t*)lv_event_get_target(e)) ? "startEco" : "stopEco"); }
static void 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) {
@@ -1283,6 +1315,16 @@ static void build_temp(lv_obj_t* p) {
lv_obj_add_event_cb(swEco, eco_cb, LV_EVENT_VALUE_CHANGED, nullptr);
swMaint = add_switch(p, "Wartungsmodus");
lv_obj_add_event_cb(swMaint, maint_cb, LV_EVENT_VALUE_CHANGED, nullptr);
swColdEx = add_switch(p, "Cold Extraction");
lv_obj_add_event_cb(swColdEx, coldex_cb, LV_EVENT_VALUE_CHANGED, nullptr);
lblColdExHint = lv_label_create(p);
lv_label_set_long_mode(lblColdExHint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblColdExHint, LV_PCT(100));
lv_obj_set_style_text_color(lblColdExHint, COL_TEXT_DIM, 0);
lv_label_set_text(lblColdExHint, "");
// Beide Zeilen bleiben versteckt, solange die S3 die Funktion nicht freigeschaltet meldet
lv_obj_add_flag(lv_obj_get_parent(swColdEx), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(lblColdExHint, LV_OBJ_FLAG_HIDDEN);
}
// =====================================================================================
@@ -1607,7 +1649,10 @@ static void chart_sample_cb(lv_timer_t*) {
// Countdown voll bis zum eingestellten Wert durch - auch wenn das Wasser dabei >40 Grad wird.
{
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];
@@ -1761,8 +1806,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");
}
@@ -1963,6 +2011,15 @@ static void build_brew(lv_obj_t* p) {
swSBT = add_switch(p, "Steam-by-Time aktiv");
taSbtSecs = add_num(p, "Dampf-Zielzeit (s)");
add_save_button(p, "Brühen speichern", send_save_brew);
// Cold Extraction: nur Anzeige. Die Parameter liegen bewusst nur in der Web-UI der S3
// (maschinenabhaengig, nicht ueber saveBrew erreichbar); geschaltet wird der Modus
// auf der Temperatur-Seite unter "Modi".
lblBrewColdEx = lv_label_create(p);
lv_label_set_long_mode(lblBrewColdEx, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblBrewColdEx, LV_PCT(100));
lv_obj_set_style_text_color(lblBrewColdEx, COL_TEXT_DIM, 0);
lv_label_set_text(lblBrewColdEx, "");
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
}
// =====================================================================================
@@ -2291,7 +2348,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: -");
@@ -2528,13 +2585,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);
}
@@ -2761,7 +2818,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;
@@ -3385,6 +3444,32 @@ 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.
if (st.cxNotice == 2) {
status_set(ST_COLDEX, true, LV_SYMBOL_WARNING " Cold Extraction beendet: kein Zulauf",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 3) {
status_set(ST_COLDEX, true, "Cold Extraction: erst nach dem Bezug umschaltbar",
COL_WARN, lv_color_hex(0x000000));
} else if (st.cxNotice == 1) {
String t = "Cold Extraction nicht möglich - ";
t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive && st.cxResting) {
status_set(ST_COLDEX, true, "Cold Extraction: Pumpenpause (Pumpenschutz)",
COL_ACCENT, lv_color_hex(0x000000));
} else if (st.cxActive && !st.cxAllowed) {
String t = "Cold Extraction aktiv, Bezug gesperrt - ";
t += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
status_set(ST_COLDEX, true, t.c_str(), COL_WARN, lv_color_hex(0x000000));
} else if (st.cxActive) {
status_set(ST_COLDEX, true, "Cold Extraction aktiv - Wasserkreis heizt nicht",
lv_color_hex(0x1976d2), COL_TEXT);
} else {
status_set(ST_COLDEX, false);
}
// Dampf-Hinweis (Heizen aus hat Vorrang vor Startverzoegerung)
if (st.steamHeatDisabled) {
status_set(ST_DAMPF, true, "Dampf: Heizen deaktiviert", COL_DANGER, COL_TEXT);
@@ -3523,6 +3608,14 @@ void ui_update(const MachineState& st) {
temp_progress_set(barDutyW, arcTempW, bulbTempW, 0, COL_DANGER);
} 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);
@@ -3531,6 +3624,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);
@@ -3596,10 +3690,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 &&
@@ -3623,6 +3726,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
@@ -3725,6 +3829,60 @@ 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);
// Einschalten nur bei Freigabe; Ausschalten immer - ausser waehrend eines kalten Bezugs
bool usable = st.cxEnabled && (st.cxActive ? !st.cxShot : st.cxAllowed);
if (usable) lv_obj_remove_state(swColdEx, LV_STATE_DISABLED);
else lv_obj_add_state(swColdEx, LV_STATE_DISABLED);
if (lblColdExHint && st.cxEnabled) {
String hint;
if (st.cxActive) {
hint = "Wasserkreis heizt nicht (bleibt über Standby und Neustart aktiv). ";
if (st.cxAllowed) {
char b[96];
snprintf(b, sizeof(b), "Bezug regulär starten - Ziel %.0f g, max %.0f s.",
(double)st.cxTarget, (double)st.cxMaxSecs);
hint += b;
} else {
hint += coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
}
} else if (!st.cxAllowed) {
hint = coldex_block_text(st.cxBlock, st.cxMaxTemp, st.tempW);
} else {
char b[112];
snprintf(b, sizeof(b), "Kalte Extraktion möglich (Wasser %.1f °C < %.1f °C). "
"Solange aktiv, heizt der Wasserkreis nicht.",
(double)st.tempW, (double)st.cxMaxTemp);
hint = b;
}
label_set_if_changed(lblColdExHint, hint.c_str());
}
}
if (lblBrewColdEx) {
if (st.cxEnabled) {
char b[256]; // der Text ist ~205 Zeichen lang (das ° zaehlt als 2 Bytes)
snprintf(b, sizeof(b),
"Cold Extraction (nur Anzeige, Einstellung in der Web-UI):\n"
"Freigabe < %.1f °C | Vorbenetzung %.0f s | Ziel %.0f g | max. %.0f s\n"
"Geschaltet wird der Modus auf der Seite \"Temperaturen\" unter \"Modi\".",
(double)st.cxMaxTemp, (double)st.cxPreInfSec,
(double)st.cxTarget, (double)st.cxMaxSecs);
label_set_if_changed(lblBrewColdEx, b);
lv_obj_remove_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(lblBrewColdEx, LV_OBJ_FLAG_HIDDEN);
}
}
// Licht-Button spiegeln (gold = an)
if (btnLight) {
@@ -3857,14 +4015,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++;
}
@@ -3873,7 +4036,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) {