Compare commits

...
Author SHA1 Message Date
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
raw-designsandClaude Fable 5 fd6ec5371b fix(P4): Verlaufs-Historie ins PSRAM verlagern — interner RAM lief über
Die 60-min-Ringpuffer der Verlauf-Seite (~29 KB) lagen als statische Arrays
im internen RAM und sprengten das Board-Limit (Linker: 107 % dynamischer
Speicher, Build brach ab). Historie jetzt als ein Block per heap_caps_malloc
im PSRAM (Fallback MALLOC_CAP_8BIT), wie die LVGL-Framebuffer; alle Zugriffe
gegen Allokationsfehler abgesichert (Chart bliebe dann leer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:16:37 +02:00
thomas ea64e7de04 Merge pull request 'fix(P4): Shot-Zusammenfassung — größere Abstände zwischen Dauer/Gewicht/Flow' (#3) from fix/shot-zusammenfassung-abstände into main 2026-07-12 21:12:32 +02:00
raw-designsandClaude Fable 5 7d240cd13e fix(P4): Shot-Zusammenfassung — größere Abstände zwischen Dauer/Gewicht/Flow
Karte von 460 auf 560 px verbreitert und Spaltenabstand von 10 auf 28 px
erhöht; die drei großen Werte standen zu nahe beisammen und waren schlecht
lesbar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:12:01 +02:00
thomas 27dabd884a Merge pull request 'feat(P4): Verlauf mit Zeitfenster, Soll-Linien und Heizleistung; PID-Tuning-Live-Karte' (#2) from feature/verlauf-zeitfenster-und-tuning-karte into main 2026-07-12 18:47:52 +02:00
raw-designsandClaude Fable 5 dc6cbbb191 feat(P4): Verlauf mit Zeitfenster, Soll-Linien und Heizleistung; PID-Tuning-Live-Karte
- Verlauf-Seite: umschaltbares Zeitfenster 2/10/30/60 min über 1-s-Ringpuffer
  (60 min Historie, 120 Chart-Punkte dezimiert), Fensterwahl in NVS
- Gestrichelte Soll-Linien für Wasser/Dampf; Y-Skala bezieht Soll-Werte nur
  sichtbarer Kreise ein
- Heizleistung (Duty) Wasser/Dampf als zuschaltbare Zusatzkurven auf
  Sekundärachse 0–100 % inkl. Live-Legende, Einstellung in NVS
- Temperaturkurven in Zehntelgrad statt ganzzahlig (Schwingungen ablesbar)
- PID-AutoTune-Live-Karte auf der Verlauf-Seite: Laufzeit, heizt/kühlt ab,
  Schwingungshub je Kreis; nach Ende ~30 s Ergebnis farbcodiert
- PID-Tuning-Statusmeldung antippbar -> springt zur Verlauf-Seite
- Display-Firmware 1.0.11 + Changelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:43:52 +02:00
thomas 940dc0fcd0 Merge pull request 'docs: Merge-Regel auf dauerhaftes Auto-Merge ändern und Umlaut-Regel ergänzen' (#1) from docs/auto-merge-regel into main 2026-07-12 14:31:00 +02:00
8 changed files with 1096 additions and 150 deletions
+40
View File
@@ -1,3 +1,43 @@
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
+2
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 |
+492 -97
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -1,3 +1,53 @@
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.
Intern läuft eine 1-s-Historie als Ringpuffer über volle 60 Minuten; das Chart zeigt
weiterhin 120 Punkte und dezimiert je Fenster. Dadurch ist beim Fensterwechsel der
komplette Verlauf sofort da (kein leeres Chart, kein Neuaufbau). Die Wahl wird P4-lokal
im NVS gespeichert und überlebt Neustarts.
- Gestrichelte Soll-Linien für Wasser und Dampf im Chart (Farbe der jeweiligen Kurve,
gedimmt). Die Y-Skala bezieht die Soll-Werte mit ein, damit die Linien im Bild liegen —
aber nur für Kreise, deren Ist-Kurve im Fenster sichtbar ist (ein kalter Dampfkessel
zieht die Skala nicht auseinander).
- Heizleistung zuschaltbar: Button „Leistung" blendet Duty-Cycle Wasser/Dampf als gedimmte
Zusatzkurven auf einer zweiten Achse (0100 %) ein, inkl. Live-Legende. Einstellung
ebenfalls P4-lokal im NVS.
- Feinere Kurven: Die Temperatur-Historie wird jetzt in Zehntelgrad geführt und gezeichnet
(vorher ganzzahlig gerundet) — PID-Schwingungen um ±1 °C sind damit sauber ablesbar.
- Die ~29 KB Historie liegen im PSRAM (heap_caps_malloc, wie die LVGL-Framebuffer) —
als statische Arrays sprengten sie den internen RAM (Linker: 107 % belegt).
- Shot-Zusammenfassung: Karte auf 560 px verbreitert und Spaltenabstand zwischen
Dauer/Gewicht/Flow deutlich vergrößert (28 px statt 10 px) — die drei Werte standen
zu nahe beisammen und waren schlecht lesbar.
- PID-AutoTune sichtbar gemacht: Während eines Tuning-Laufs erscheint auf der Verlauf-Seite
eine Live-Karte (mit Spinner) — je Kreis Laufzeit, aktuelle Phase (heizt/kühlt ab) und
Schwingungshub samt Mittelpunkt (Min/Max der Ist-Temperatur seit Start). Nach dem Ende
zeigt die Karte ~30 s das Ergebnis (Erfolg/Abbruch samt Grund, farbcodiert). Die
PID-Tuning-Meldung in der Statuszeile ist jetzt antippbar und springt zur Verlauf-Seite,
wo die Schwingung auch als Kurve zu sehen ist.
Version 1.0.10:
- Live-Bezugsschirm (abschaltbar): Beim Start eines echten Bezugs blendet sich ein Vollbild-Overlay
weich ein — große Bezugszeit, Live-Gewicht und Flow samt Flow-Verlaufskurve der letzten 30 Sekunden
+1 -1
View File
@@ -65,7 +65,7 @@
// -------------------------------------------------------------------------------------
// Firmware
// -------------------------------------------------------------------------------------
#define DISPLAY_FW_VERSION "1.0.10" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
#define DISPLAY_FW_VERSION "1.0.13" // Firmware-Stand der P4-Display-Steuerung (Info-Seite)
// -------------------------------------------------------------------------------------
// Protokoll
+2
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;
+2
View File
@@ -192,6 +192,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;
+507 -52
View File
@@ -17,6 +17,7 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <Preferences.h> // Dashboard-Design P4-lokal merken (NVS)
#include <esp_heap_caps.h> // Verlaufs-Historie im PSRAM allozieren (interner RAM ist knapp)
#include "ui.h"
#include "theme.h"
#include "config.h"
@@ -103,6 +104,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;
@@ -118,6 +125,46 @@ static lv_chart_series_t* g_serC = nullptr; // Zusatzsensor (Gehaeuse/Tassenab
static lv_obj_t* g_legW = nullptr; static lv_obj_t* g_legD = nullptr; static lv_obj_t* g_legC = nullptr;
static lv_obj_t* g_axHi = nullptr; static lv_obj_t* g_axMid = nullptr; static lv_obj_t* g_axLo = nullptr; // Y-Skala
#define COL_CASE lv_color_hex(0xba68c8) // Farbe Zusatzsensor im Chart (Violett; klar distinkt von Wasser-Gold/Dampf-Teal)
// Umschaltbares Zeitfenster: das Chart zeigt immer 120 Punkte; die Werte kommen aus einer
// 1-s-Historie (Ringpuffer ueber 60 min) und werden je Fenster dezimiert. So bleibt beim
// Fensterwechsel der komplette Verlauf sofort sichtbar (kein leeres Chart).
static const uint16_t CHART_WIN_MIN[4] = { 2, 10, 30, 60 }; // waehlbare Fenster (Minuten)
#define CHART_HIST_MAX 3600 // Historie: 60 min à 1 s
#define CHART_HIST_NONE INT16_MIN // Markierung "keine Probe"
// Die ~29 KB Historie liegen im PSRAM (heap_caps_malloc in ui_init, wie die LVGL-
// Framebuffer) - als statische Arrays wuerden sie den knappen internen RAM sprengen
// (Board-Limit war vor diesem Feature bereits zu ~98 % belegt).
struct ChartHist {
int16_t w[CHART_HIST_MAX]; // Wasser (Zehntelgrad)
int16_t d[CHART_HIST_MAX]; // Dampf (Zehntelgrad)
int16_t c[CHART_HIST_MAX]; // Zusatzsensor (Zehntelgrad; CHART_HIST_NONE = aus)
uint8_t dutyW[CHART_HIST_MAX]; // Heizleistung Wasser (%)
uint8_t dutyD[CHART_HIST_MAX]; // Heizleistung Dampf (%)
};
static ChartHist* g_hist = nullptr;
static uint16_t g_histPos = 0, g_histFill = 0;
static uint8_t g_chartWin = 0; // Index in CHART_WIN_MIN (P4-lokal in NVS, "chartwin")
static bool g_chartDuty = false; // Heizleistung als Zusatzkurven (NVS "chartduty")
static lv_obj_t* g_chartTitle = nullptr;
static lv_obj_t* g_chartWinBtn[4] = {};
static lv_obj_t* g_chartDutyBtn = nullptr;
static lv_chart_series_t* g_serDutyW = nullptr; // Heizleistung auf Sekundaerachse 0..100 %
static lv_chart_series_t* g_serDutyD = nullptr;
static lv_obj_t* g_legDuty = nullptr;
static lv_obj_t* g_lineSetW = nullptr; // gestrichelte Soll-Linien im Chart
static lv_obj_t* g_lineSetD = nullptr;
static lv_point_precise_t g_lineSetPts[2][2];
#define COL_DUTY_W lv_color_hex(0x8a6206) // gedimmtes Gold (Heizleistung Wasser)
#define COL_DUTY_D lv_color_hex(0x4d7a76) // gedimmtes Teal (Heizleistung Dampf)
// PID-Tuning-Live-Karte (Verlauf-Seite): Laufzeit, Heiz-/Abkuehlphase und Schwingungshub
static lv_obj_t* g_tuneCard = nullptr;
static lv_obj_t* g_tuneCardTitle = nullptr;
static lv_obj_t* g_tuneCardBody = nullptr;
static lv_obj_t* g_tuneCardSpin = nullptr;
static bool g_tuneCardShown = false;
static uint32_t g_tuneStartW = 0, g_tuneStartD = 0; // millis() beim Tuning-Start
static float g_tuneMinW = 0, g_tuneMaxW = 0; // Schwingungs-Min/Max seit Start
static float g_tuneMinD = 0, g_tuneMaxD = 0;
// Shot-Zusammenfassung (Karte nach Bezug; blendet weich ein/aus)
static lv_obj_t* g_shotSum = nullptr;
@@ -901,10 +948,12 @@ static lv_obj_t* infoCell(lv_obj_t* parent, const char* title, lv_obj_t** val, l
lv_obj_set_style_text_align(*val, LV_TEXT_ALIGN_CENTER, 0); // fuer mehrzeilige Werte
return card;
}
// Tipp auf die Statuszeile: Wartungsmeldung -> Serviceseite, Reinigungsmeldung -> Sub-Screen
// Tipp auf die Statuszeile: Wartung -> Serviceseite, Reinigung -> Sub-Screen,
// PID-Tuning -> Verlauf-Seite (dort liegen Live-Karte und Schwingungskurve)
static void status_line_cb(lv_event_t*) {
if (g_statusShown == ST_MAINT) show_page(PG_SERVICE);
else if (g_statusShown == ST_CLEAN) cln_open(nullptr);
else if (g_statusShown == ST_TUNE) show_page(PG_CHART);
}
static void wifi_icon_cb(lv_event_t*) { show_page(PG_WIFI); } // Tipp auf WLAN-Symbol -> WiFi-Seite
static void build_dashboard(lv_obj_t* p) {
@@ -1260,9 +1309,107 @@ static void build_light(lv_obj_t* p) {
// Verlauf (Chart): Wasser/Dampf + Zusatzsensor (Gehaeuse/Tassenablage), ~2 min,
// mit automatisch skalierter Temperatur-Achse und Y-Skala-Beschriftung links
// =====================================================================================
// Zeitfenster-/Leistungs-Buttons gemaess Auswahl einfaerben, Titel aktualisieren
static void chart_win_btns_refresh() {
for (int i = 0; i < 4; i++) {
if (!g_chartWinBtn[i]) continue;
bool sel = (g_chartWin == i);
lv_obj_set_style_bg_color(g_chartWinBtn[i], sel ? COL_ACCENT : COL_CARD2, 0);
lv_obj_set_style_text_color(lv_obj_get_child(g_chartWinBtn[i], 0),
sel ? lv_color_hex(0x000000) : COL_TEXT, 0);
}
if (g_chartDutyBtn) {
lv_obj_set_style_bg_color(g_chartDutyBtn, g_chartDuty ? COL_ACCENT : COL_CARD2, 0);
lv_obj_set_style_text_color(lv_obj_get_child(g_chartDutyBtn, 0),
g_chartDuty ? lv_color_hex(0x000000) : COL_TEXT, 0);
}
if (g_chartTitle)
lv_label_set_text_fmt(g_chartTitle, "Temperatur-Verlauf (%u min)", (unsigned)CHART_WIN_MIN[g_chartWin]);
}
static void chart_redraw(); // Definition weiter unten
static void chart_win_btn_cb(lv_event_t* e) {
int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (idx == (int)g_chartWin) return;
g_chartWin = (uint8_t)idx;
{ Preferences prefs; prefs.begin("ui", false); prefs.putUChar("chartwin", g_chartWin); prefs.end(); }
chart_win_btns_refresh();
chart_redraw(); // neues Fenster sofort aus der Historie zeichnen
}
static void chart_duty_btn_cb(lv_event_t*) {
g_chartDuty = !g_chartDuty;
{ Preferences prefs; prefs.begin("ui", false); prefs.putBool("chartduty", g_chartDuty); prefs.end(); }
chart_win_btns_refresh();
if (g_chart && g_serDutyW) {
lv_chart_hide_series(g_chart, g_serDutyW, !g_chartDuty);
lv_chart_hide_series(g_chart, g_serDutyD, !g_chartDuty);
}
if (g_legDuty) {
if (g_chartDuty) lv_obj_remove_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN);
}
chart_redraw();
}
static void build_chart(lv_obj_t* p) {
lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE);
section_title(p, "Temperatur-Verlauf (2 min)");
// Kopfzeile: Titel links, Zeitfenster-Umschalter + Heizleistung-Toggle rechts
lv_obj_t* hdr = row(p, nullptr);
g_chartTitle = section_title(hdr, "Temperatur-Verlauf (2 min)");
lv_obj_t* btns = lv_obj_create(hdr);
lv_obj_set_size(btns, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(btns, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(btns, 0, 0);
lv_obj_set_style_pad_all(btns, 0, 0);
lv_obj_set_style_pad_column(btns, 6, 0);
lv_obj_set_flex_flow(btns, LV_FLEX_FLOW_ROW);
lv_obj_clear_flag(btns, LV_OBJ_FLAG_SCROLLABLE);
static const char* winNames[4] = { "2 min", "10 min", "30 min", "60 min" };
for (int i = 0; i < 4; i++) {
g_chartWinBtn[i] = th_button(btns, winNames[i], COL_CARD2, COL_TEXT);
lv_obj_set_style_pad_hor(g_chartWinBtn[i], 10, 0);
lv_obj_set_style_pad_ver(g_chartWinBtn[i], 5, 0);
lv_obj_set_style_text_font(lv_obj_get_child(g_chartWinBtn[i], 0), &lv_font_maven_pro_14, 0);
lv_obj_add_event_cb(g_chartWinBtn[i], chart_win_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
}
g_chartDutyBtn = th_button(btns, "Leistung", COL_CARD2, COL_TEXT);
lv_obj_set_style_pad_hor(g_chartDutyBtn, 10, 0);
lv_obj_set_style_pad_ver(g_chartDutyBtn, 5, 0);
lv_obj_set_style_text_font(lv_obj_get_child(g_chartDutyBtn, 0), &lv_font_maven_pro_14, 0);
lv_obj_add_event_cb(g_chartDutyBtn, chart_duty_btn_cb, LV_EVENT_CLICKED, nullptr);
chart_win_btns_refresh();
// PID-Tuning-Live-Karte: nur waehrend/kurz nach einem AutoTune-Lauf sichtbar
// (Inhalt kommt aus ui_update; die Statuszeilen-Meldung ist antippbar und fuehrt hierher)
g_tuneCard = th_card(p);
lv_obj_set_width(g_tuneCard, LV_PCT(100));
lv_obj_set_height(g_tuneCard, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(g_tuneCard, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(g_tuneCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(g_tuneCard, 12, 0);
lv_obj_clear_flag(g_tuneCard, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(g_tuneCard, LV_OBJ_FLAG_HIDDEN);
g_tuneCardSpin = lv_spinner_create(g_tuneCard);
lv_spinner_set_anim_params(g_tuneCardSpin, 1000, 60);
lv_obj_set_size(g_tuneCardSpin, 28, 28);
lv_obj_set_style_arc_width(g_tuneCardSpin, 4, LV_PART_MAIN);
lv_obj_set_style_arc_width(g_tuneCardSpin, 4, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(g_tuneCardSpin, COL_CARD2, LV_PART_MAIN);
lv_obj_set_style_arc_color(g_tuneCardSpin, COL_WARN, LV_PART_INDICATOR);
lv_obj_t* tcol = lv_obj_create(g_tuneCard);
lv_obj_set_size(tcol, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(tcol, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(tcol, 0, 0);
lv_obj_set_style_pad_all(tcol, 0, 0);
lv_obj_set_style_pad_row(tcol, 2, 0);
lv_obj_set_flex_flow(tcol, LV_FLEX_FLOW_COLUMN);
lv_obj_clear_flag(tcol, LV_OBJ_FLAG_SCROLLABLE);
g_tuneCardTitle = lv_label_create(tcol);
lv_label_set_text(g_tuneCardTitle, "PID-Tuning läuft...");
lv_obj_set_style_text_color(g_tuneCardTitle, COL_WARN, 0);
lv_obj_set_style_text_font(g_tuneCardTitle, &lv_font_maven_pro_18, 0);
g_tuneCardBody = lv_label_create(tcol);
lv_label_set_text(g_tuneCardBody, "--");
lv_obj_set_style_text_color(g_tuneCardBody, COL_TEXT, 0);
g_chart = lv_chart_create(p);
lv_obj_set_width(g_chart, LV_PCT(100));
@@ -1276,17 +1423,38 @@ static void build_chart(lv_obj_t* p) {
lv_obj_set_style_width(g_chart, 0, LV_PART_INDICATOR); // keine Punkt-Marker
lv_obj_set_style_height(g_chart, 0, LV_PART_INDICATOR);
lv_chart_set_type(g_chart, LV_CHART_TYPE_LINE);
lv_chart_set_point_count(g_chart, 120); // 120 * 1 s = 2 min
lv_chart_set_update_mode(g_chart, LV_CHART_UPDATE_MODE_SHIFT);
lv_chart_set_point_count(g_chart, 120); // fest; Zeit je Punkt haengt am Fenster
lv_chart_set_div_line_count(g_chart, 5, 0);
lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, 80, 160); // Temperaturen (Grad C)
lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, 800, 1600); // Zehntelgrad (feinere Kurven)
lv_chart_set_range(g_chart, LV_CHART_AXIS_SECONDARY_Y, 0, 100); // Heizleistung (%)
g_serW = lv_chart_add_series(g_chart, COL_ACCENT, LV_CHART_AXIS_PRIMARY_Y);
g_serD = lv_chart_add_series(g_chart, lv_color_hex(0x80cbc4), LV_CHART_AXIS_PRIMARY_Y);
g_serC = lv_chart_add_series(g_chart, COL_CASE, LV_CHART_AXIS_PRIMARY_Y);
g_serDutyW = lv_chart_add_series(g_chart, COL_DUTY_W, LV_CHART_AXIS_SECONDARY_Y);
g_serDutyD = lv_chart_add_series(g_chart, COL_DUTY_D, LV_CHART_AXIS_SECONDARY_Y);
lv_chart_set_all_value(g_chart, g_serW, LV_CHART_POINT_NONE);
lv_chart_set_all_value(g_chart, g_serD, LV_CHART_POINT_NONE);
lv_chart_set_all_value(g_chart, g_serC, LV_CHART_POINT_NONE);
lv_chart_set_all_value(g_chart, g_serDutyW, LV_CHART_POINT_NONE);
lv_chart_set_all_value(g_chart, g_serDutyD, LV_CHART_POINT_NONE);
lv_chart_hide_series(g_chart, g_serDutyW, !g_chartDuty);
lv_chart_hide_series(g_chart, g_serDutyD, !g_chartDuty);
// Gestrichelte Soll-Linien (Wasser/Dampf); Position rechnet chart_redraw aus der Skala
lv_color_t setCols[2] = { COL_ACCENT, lv_color_hex(0x80cbc4) };
lv_obj_t** setLines[2] = { &g_lineSetW, &g_lineSetD };
for (int i = 0; i < 2; i++) {
lv_obj_t* ln = lv_line_create(g_chart);
lv_obj_set_style_line_width(ln, 1, 0);
lv_obj_set_style_line_color(ln, setCols[i], 0);
lv_obj_set_style_line_opa(ln, LV_OPA_60, 0);
lv_obj_set_style_line_dash_width(ln, 6, 0);
lv_obj_set_style_line_dash_gap(ln, 6, 0);
lv_obj_clear_flag(ln, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(ln, LV_OBJ_FLAG_HIDDEN);
*setLines[i] = ln;
}
// Y-Skala-Beschriftung (Grad C) im linken Gutter: oben=hi, mitte, unten=lo
g_axHi = lv_label_create(g_chart); lv_obj_align(g_axHi, LV_ALIGN_TOP_LEFT, -38, -2);
@@ -1308,39 +1476,119 @@ static void build_chart(lv_obj_t* p) {
g_legC = lv_label_create(leg); lv_label_set_text(g_legC, "");
lv_obj_set_style_text_color(g_legC, COL_CASE, 0);
lv_obj_add_flag(g_legC, LV_OBJ_FLAG_HIDDEN); // nur wenn Zusatzsensor aktiv
g_legDuty = lv_label_create(leg); lv_label_set_text(g_legDuty, "Leistung --");
lv_obj_set_style_text_color(g_legDuty, COL_TEXT_DIM, 0);
if (!g_chartDuty) lv_obj_add_flag(g_legDuty, LV_OBJ_FLAG_HIDDEN);
}
// Zeichnet die 120 Chart-Punkte aus der 1-s-Historie (je Fenster dezimiert), skaliert die
// Y-Achse ueber das sichtbare Fenster (Werte < 30 Grad = kalter/inaktiver Sensor werden
// fuer die Skala ignoriert) und positioniert die gestrichelten Soll-Linien.
static void chart_redraw() {
if (!g_chart || !g_hist) return;
const int stride = (int)CHART_WIN_MIN[g_chartWin] * 60 / 120; // Sekunden je Chart-Punkt
int32_t lo = INT32_MAX, hi = INT32_MIN; // Zehntelgrad
bool anyW = false, anyD = false;
for (int i = 0; i < 120; i++) {
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 (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);
lv_chart_set_value_by_id(g_chart, g_serC, i, vC);
lv_chart_set_value_by_id(g_chart, g_serDutyW, i, dW);
lv_chart_set_value_by_id(g_chart, g_serDutyD, i, dD);
}
// Soll-Werte in die Skala einbeziehen, damit die Soll-Linien im Bild liegen -
// aber nur fuer Kreise, deren Ist-Kurve im Fenster ueberhaupt sichtbar ist
int32_t setWt = (int32_t)(g_state.setW * 10.0f + 0.5f);
int32_t setDt = (int32_t)(g_state.setD * 10.0f + 0.5f);
bool showLineW = anyW && setWt > 300;
bool showLineD = anyD && setDt > 300;
if (showLineW) { if (setWt < lo) lo = setWt; if (setWt > hi) hi = setWt; }
if (showLineD) { if (setDt < lo) lo = setDt; if (setDt > hi) hi = setDt; }
if (hi >= lo) {
int loDeg = ((int)(lo / 10) - 3) / 5 * 5; // auf 5 Grad abrunden (mit Rand)
int hiDeg = ((int)(hi / 10) + 7) / 5 * 5; // auf 5 Grad aufrunden (mit Rand)
if (hiDeg - loDeg < 10) hiDeg = loDeg + 10; // Mindestspanne 10 Grad
lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, loDeg * 10, hiDeg * 10);
if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hiDeg);
if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (loDeg + hiDeg) / 2);
if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", loDeg);
// Soll-Linien positionieren (horizontale Linie ueber die volle Plotbreite)
int32_t w = lv_obj_get_content_width(g_chart);
int32_t h = lv_obj_get_content_height(g_chart);
struct { lv_obj_t* line; lv_point_precise_t* pts; int32_t val; bool show; } L[2] = {
{ g_lineSetW, g_lineSetPts[0], setWt, showLineW },
{ g_lineSetD, g_lineSetPts[1], setDt, showLineD },
};
for (int i = 0; i < 2; i++) {
if (!L[i].line) continue;
int32_t span = (hiDeg - loDeg) * 10;
if (L[i].show && w > 0 && h > 0 && span > 0 &&
L[i].val >= loDeg * 10 && L[i].val <= hiDeg * 10) {
int32_t y = (int32_t)((int64_t)h * (hiDeg * 10 - L[i].val) / span);
L[i].pts[0].x = 0; L[i].pts[0].y = 0;
L[i].pts[1].x = w; L[i].pts[1].y = 0;
lv_line_set_points(L[i].line, L[i].pts, 2);
lv_obj_align(L[i].line, LV_ALIGN_TOP_LEFT, 0, y);
lv_obj_remove_flag(L[i].line, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(L[i].line, LV_OBJ_FLAG_HIDDEN);
}
}
}
lv_chart_refresh(g_chart);
}
static void chart_sample_cb(lv_timer_t*) {
if (!g_chart) return;
int wv = (int)(g_state.tempW + 0.5f);
int dv = (int)(g_state.tempD + 0.5f);
if (!g_chart || !g_hist) return;
// 1-s-Probe in die 60-min-Historie (Ringpuffer); Anzeige zeichnet chart_redraw
bool caseOn = g_state.caseSensorEnabled && g_state.hasCaseTemp;
int cv = caseOn ? (int)(g_state.caseTemp + 0.5f) : 0;
lv_chart_set_next_value(g_chart, g_serW, (int32_t)wv);
lv_chart_set_next_value(g_chart, g_serD, (int32_t)dv);
lv_chart_set_next_value(g_chart, g_serC, caseOn ? (int32_t)cv : LV_CHART_POINT_NONE);
g_hist->w[g_histPos] = (int16_t)(g_state.tempW * 10.0f + 0.5f);
g_hist->d[g_histPos] = (int16_t)(g_state.tempD * 10.0f + 0.5f);
g_hist->c[g_histPos] = caseOn ? (int16_t)(g_state.caseTemp * 10.0f + 0.5f) : CHART_HIST_NONE;
float dw = g_state.dutyW, dd = g_state.dutyD;
g_hist->dutyW[g_histPos] = (uint8_t)(dw < 0.0f ? 0 : (dw > 100.0f ? 100 : (int)(dw + 0.5f)));
g_hist->dutyD[g_histPos] = (uint8_t)(dd < 0.0f ? 0 : (dd > 100.0f ? 100 : (int)(dd + 0.5f)));
g_histPos = (uint16_t)((g_histPos + 1) % CHART_HIST_MAX);
if (g_histFill < CHART_HIST_MAX) g_histFill++;
// Automatische Skalierung der Temperatur-Achse ueber ein gleitendes 120-Werte-Fenster.
// Werte < 30 (kalter/inaktiver Sensor) werden ignoriert, damit sie die Skala nicht
// an den unteren Rand ziehen (sonst sehen die Kurven flach aus).
static int16_t hW[120], hD[120], hC[120];
static int pos = 0, fill = 0;
hW[pos] = (int16_t)wv; hD[pos] = (int16_t)dv; hC[pos] = caseOn ? (int16_t)cv : -1000;
pos = (pos + 1) % 120; if (fill < 120) fill++;
int lo = 1000, hi = -1000;
for (int i = 0; i < fill; i++) {
if (hW[i] > 30) { if (hW[i] < lo) lo = hW[i]; if (hW[i] > hi) hi = hW[i]; }
if (hD[i] > 30) { if (hD[i] < lo) lo = hD[i]; if (hD[i] > hi) hi = hD[i]; }
if (hC[i] > 30) { if (hC[i] < lo) lo = hC[i]; if (hC[i] > hi) hi = hC[i]; }
}
if (hi >= lo) {
lo = ((lo - 3) / 5) * 5; // auf 5 abrunden (mit Rand)
hi = ((hi + 7) / 5) * 5; // auf 5 aufrunden (mit Rand)
if (hi - lo < 10) hi = lo + 10; // Mindestspanne 10 Grad
lv_chart_set_range(g_chart, LV_CHART_AXIS_PRIMARY_Y, lo, hi);
if (g_axHi) lv_label_set_text_fmt(g_axHi, "%d", hi);
if (g_axMid) lv_label_set_text_fmt(g_axMid, "%d", (lo + hi) / 2);
if (g_axLo) lv_label_set_text_fmt(g_axLo, "%d", lo);
}
chart_redraw();
if (g_legDuty && g_chartDuty)
lv_label_set_text_fmt(g_legDuty, "Leistung W %d %% / D %d %%",
(int)(dw + 0.5f), (int)(dd + 0.5f));
if (g_legW) lv_label_set_text(g_legW, ("Wasser " + tempStr(g_state.tempW, 1)).c_str());
if (g_legD) lv_label_set_text(g_legD, ("Dampf " + tempStr(g_state.tempD, 0)).c_str());
@@ -2133,6 +2381,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);
@@ -2573,9 +2927,19 @@ void ui_init(ProtocolClient* client) {
g_client = client;
// Gespeicherte Darstellungs-Einstellungen laden (P4-lokal; Design-Standard: Rundinstrumente)
// Verlaufs-Historie (60 min à 1 s, ~29 KB) im PSRAM anlegen - wie die LVGL-Framebuffer.
// Schlaegt die Allokation fehl, bleibt das Chart schlicht leer (alle Zugriffe geprueft).
if (!g_hist) {
g_hist = (ChartHist*)heap_caps_malloc(sizeof(ChartHist), MALLOC_CAP_SPIRAM);
if (!g_hist) g_hist = (ChartHist*)heap_caps_malloc(sizeof(ChartHist), MALLOC_CAP_8BIT);
}
{ Preferences prefs; prefs.begin("ui", false);
g_tempDesign = prefs.getUChar("tempdsgn", TD_GAUGE);
g_brewLiveEnabled = prefs.getBool("brewlive", true);
g_chartWin = prefs.getUChar("chartwin", 0);
if (g_chartWin > 3) g_chartWin = 0;
g_chartDuty = prefs.getBool("chartduty", false);
prefs.end();
if (g_tempDesign >= TD_DESIGN_COUNT) g_tempDesign = TD_GAUGE; }
@@ -2607,6 +2971,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);
@@ -2815,7 +3195,7 @@ void ui_init(ProtocolClient* client) {
// Shot-Zusammenfassung (zentrierte Karte mit grossen Werten, antippbar zum Schliessen)
g_shotSum = lv_obj_create(lv_layer_top());
lv_obj_set_size(g_shotSum, 460, LV_SIZE_CONTENT);
lv_obj_set_size(g_shotSum, 560, LV_SIZE_CONTENT); // breit genug, damit die drei Werte nicht zusammenruecken
lv_obj_set_style_bg_color(g_shotSum, COL_CARD, 0);
lv_obj_set_style_bg_opa(g_shotSum, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(g_shotSum, COL_ACCENT, 0);
@@ -2841,7 +3221,7 @@ void ui_init(ProtocolClient* client) {
lv_obj_set_style_bg_opa(sumRow, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(sumRow, 0, 0);
lv_obj_set_style_pad_all(sumRow, 0, 0);
lv_obj_set_style_pad_column(sumRow, 10, 0);
lv_obj_set_style_pad_column(sumRow, 28, 0); // deutlicher Abstand zwischen Dauer/Gewicht/Flow
lv_obj_set_flex_flow(sumRow, LV_FLEX_FLOW_ROW);
lv_obj_clear_flag(sumRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(sumRow, LV_OBJ_FLAG_CLICKABLE); // Tipp faellt auf die Karte durch
@@ -2914,6 +3294,25 @@ static void appendTuneResult(String& out, const char* kreis, uint8_t status, int
if (s > sev) sev = s;
}
// Eine Live-Zeile der Tuning-Karte: Kreis, Laufzeit, Heiz-/Abkuehlphase und Schwingungshub
// (Min/Max der Ist-Temperatur seit Tuning-Start; zeigt, wie stark der Kessel pendelt).
static void appendTuneLive(String& out, const char* kreis, uint32_t elapsedMs,
float duty, float tMin, float tMax) {
if (out.length()) out += "\n";
uint32_t s = elapsedMs / 1000;
char b[16];
snprintf(b, sizeof(b), "%lu:%02lu", (unsigned long)(s / 60), (unsigned long)(s % 60));
out += kreis;
out += " · ";
out += b;
out += " min · ";
out += (duty >= 50.0f) ? "heizt" : "kühlt ab";
float hub = tMax - tMin;
if (hub >= 0.05f) {
out += " · Hub " + tempStr(hub, 1) + " um " + tempStr((tMin + tMax) * 0.5f, 1);
}
}
// =====================================================================================
// Live-Aktualisierung
// =====================================================================================
@@ -3041,10 +3440,15 @@ void ui_update(const MachineState& st) {
status_set(ST_CLEAN, false);
}
// PID-Tuning-Hinweis (AutoTune Wasser/Dampf laeuft)
// PID-Tuning: Statuszeile (antippbar -> Verlauf-Seite) + Live-Karte auf der Verlauf-Seite
{
// Fallende Flanke je Kreis erkennen -> Ergebnis ~30 s einblenden
uint32_t nowMs = millis();
// Steigende Flanke je Kreis: Startzeit latchen, Schwingungs-Min/Max zuruecksetzen
if (!g_tunePrevW && st.autoTuneW) { g_tuneStartW = nowMs; g_tuneMinW = g_tuneMaxW = st.tempW; }
if (!g_tunePrevD && st.autoTuneD) { g_tuneStartD = nowMs; g_tuneMinD = g_tuneMaxD = st.tempD; }
if (st.autoTuneW) { if (st.tempW < g_tuneMinW) g_tuneMinW = st.tempW; if (st.tempW > g_tuneMaxW) g_tuneMaxW = st.tempW; }
if (st.autoTuneD) { if (st.tempD < g_tuneMinD) g_tuneMinD = st.tempD; if (st.tempD > g_tuneMaxD) g_tuneMaxD = st.tempD; }
// Fallende Flanke je Kreis erkennen -> Ergebnis ~30 s einblenden
if (g_tunePrevW && !st.autoTuneW) { g_tuneResW = st.autoTuneWStatus; g_tuneResUntilW = nowMs + 30000; }
if (g_tunePrevD && !st.autoTuneD) { g_tuneResD = st.autoTuneDStatus; g_tuneResUntilD = nowMs + 30000; }
g_tunePrevW = st.autoTuneW;
@@ -3055,26 +3459,55 @@ void ui_update(const MachineState& st) {
if (g_tuneResUntilW != 0 && !showResW) g_tuneResUntilW = 0; // abgelaufen -> aus
if (g_tuneResUntilD != 0 && !showResD) g_tuneResUntilD = 0;
// Ergebnistext des/der zuletzt beendeten Laufs/Laeufe (Statuszeile und Karte teilen ihn)
String resTxt; int resSev = 0;
if (showResW) appendTuneResult(resTxt, "Wasser", g_tuneResW, resSev);
if (showResD) appendTuneResult(resTxt, "Dampf", g_tuneResD, resSev);
if (st.autoTuneW || st.autoTuneD) {
const char* txt = (st.autoTuneW && st.autoTuneD) ? LV_SYMBOL_SETTINGS " PID-Tuning läuft (Wasser + Dampf)..."
: st.autoTuneW ? LV_SYMBOL_SETTINGS " PID-Tuning Wasser läuft..."
: LV_SYMBOL_SETTINGS " PID-Tuning Dampf läuft...";
status_set(ST_TUNE, true, txt, COL_WARN, lv_color_hex(0x000000));
} else if (showResW || showResD) {
// Ergebnis des/der zuletzt beendeten Laufs/Laeufe anzeigen
String txt; int sev = 0;
if (showResW) appendTuneResult(txt, "Wasser", g_tuneResW, sev);
if (showResD) appendTuneResult(txt, "Dampf", g_tuneResD, sev);
if (txt.length()) {
lv_color_t bg = (sev >= 2) ? COL_DANGER : (sev == 1 ? COL_WARN : COL_OK);
lv_color_t fg = (sev == 1) ? lv_color_hex(0x000000) : COL_TEXT;
status_set(ST_TUNE, true, txt.c_str(), bg, fg);
} else {
status_set(ST_TUNE, false);
}
} else if (resTxt.length()) {
lv_color_t bg = (resSev >= 2) ? COL_DANGER : (resSev == 1 ? COL_WARN : COL_OK);
lv_color_t fg = (resSev == 1) ? lv_color_hex(0x000000) : COL_TEXT;
status_set(ST_TUNE, true, resTxt.c_str(), bg, fg);
} else {
status_set(ST_TUNE, false);
}
// Live-Karte auf der Verlauf-Seite: waehrend des Laufs Laufzeit/Phase/Schwingungshub,
// nach dem Ende ~30 s das Ergebnis; erscheint und verschwindet weich (Fade)
bool wantCard = st.autoTuneW || st.autoTuneD || resTxt.length() > 0;
if (g_tuneCard) {
if (st.autoTuneW || st.autoTuneD) {
if (g_tuneCardSpin) lv_obj_remove_flag(g_tuneCardSpin, LV_OBJ_FLAG_HIDDEN);
if (g_tuneCardTitle) {
lv_label_set_text(g_tuneCardTitle, "PID-Tuning läuft...");
lv_obj_set_style_text_color(g_tuneCardTitle, COL_WARN, 0);
}
String b;
if (st.autoTuneW) appendTuneLive(b, "Wasser", nowMs - g_tuneStartW, st.dutyW, g_tuneMinW, g_tuneMaxW);
if (st.autoTuneD) appendTuneLive(b, "Dampf", nowMs - g_tuneStartD, st.dutyD, g_tuneMinD, g_tuneMaxD);
if (g_tuneCardBody) label_set_if_changed(g_tuneCardBody, b.c_str());
} else if (wantCard) {
if (g_tuneCardSpin) lv_obj_add_flag(g_tuneCardSpin, LV_OBJ_FLAG_HIDDEN);
lv_color_t c = (resSev >= 2) ? COL_DANGER : (resSev == 1 ? COL_WARN : COL_OK);
if (g_tuneCardTitle) {
lv_label_set_text(g_tuneCardTitle, "PID-Tuning beendet");
lv_obj_set_style_text_color(g_tuneCardTitle, c, 0);
}
String b = resTxt;
b.replace(" | ", "\n"); // Karte darf mehrzeilig sein
if (g_tuneCardBody) label_set_if_changed(g_tuneCardBody, b.c_str());
}
if (wantCard != g_tuneCardShown) {
g_tuneCardShown = wantCard;
if (wantCard) ui_fade_in(g_tuneCard, UI_FADE_MS);
else ui_fade_out_hide(g_tuneCard, UI_FADE_MS);
}
}
}
// Reinigungsassistent-Sub-Screen: Fortschritt/Sichtbarkeit aktualisieren (nur wenn offen)
@@ -3132,6 +3565,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;
@@ -3324,12 +3775,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);
}
}