Compare commits

...
Author SHA1 Message Date
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
8 changed files with 553 additions and 90 deletions
+21
View File
@@ -1,3 +1,24 @@
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 |
+359 -86
View File
@@ -52,8 +52,17 @@
// Standard-Waagen-Typ (kann in /Sensoren geändert werden)
#define DEFAULT_SCALE_TYPE SCALE_ESPNOW
// Nachfolgende Zeile auskommentieren, falls die Steuerung ohne OLED-Display verwendet wird!
//#define ENABLE_DISPLAY
/************************************************************************************
* DISPLAY-KONFIGURATION
* Der Display-Typ wird zur Laufzeit in der Web-UI (/Sensoren) gewaehlt und im
* EEPROM gespeichert. Default ist "Ohne Display", damit die Steuerung ohne
* angeschlossene Anzeige immer sicher startet.
************************************************************************************/
#define DISPLAY_TYPE_NONE 0 // Ohne Display
#define DISPLAY_TYPE_OLED 1 // OLED (SH1106, I2C)
#define DISPLAY_TYPE_TOUCH_UART 2 // JC-UART-Touch-Display (Serial1)
#define DEFAULT_DISPLAY_TYPE DISPLAY_TYPE_NONE
#define OLED_I2C_ADDRESS 0x3C
/************************************************************************************
* Vorwärtsdeklarationen benutzerdefinierter Strukturen
@@ -80,6 +89,9 @@ bool executeDashboardAction(const String& action, const String& value,
bool& settingsChanged, bool& pidNeedsUpdate);
void touchUartRxTick();
void touchUartTick();
void initTouchUart();
bool initOledDisplay(bool showSplash);
void applyDisplayTypeChange(uint8_t newType);
void applyLightOutput();
void initResetDiagnostics();
void recordResetCheckpoint(uint16_t checkpoint);
@@ -91,10 +103,10 @@ void syncFullyKioskWithStandby(bool active);
************************************************************************************/
#include <Wire.h>
#ifdef ENABLE_DISPLAY
// OLED-Bibliotheken werden immer einkompiliert; ob das Display genutzt wird,
// entscheidet zur Laufzeit die Einstellung displayType (Web-UI /Sensoren).
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#endif // ENABLE_DISPLAY
#include <EEPROM.h>
#include <PID_v1.h>
@@ -154,9 +166,9 @@ const unsigned long DASHBOARD_WS_SHOT_END_GRACE_MS = 500;
#define DASHBOARD_JSON_BUFFER_SIZE 2400
unsigned long lastDashboardWsPushMs = 0;
// JC-Touch-Display (UART). Bewusst UNABHAENGIG von ENABLE_DISPLAY (OLED), damit alle
// Kombinationen baubar sind (nur OLED / nur JC / beide / keines). Auf 1 setzen, sobald
// das JC-Display an Serial1 (GPIO43/44) angeschlossen ist.
// JC-Touch-Display (UART). Ob die UART-Anbindung genutzt wird, entscheidet zur
// Laufzeit displayType (Web-UI /Sensoren); dieser Compile-Schalter bleibt als
// Notausstieg, um den Code komplett herauszubauen.
#define TOUCH_UART_ENABLED 1
#define TOUCH_UART_TX_PIN 43
#define TOUCH_UART_RX_PIN 44
@@ -179,6 +191,13 @@ const unsigned long TOUCH_UART_SHOT_END_GRACE_MS = 500;
const unsigned long TOUCH_UART_CLIENT_TIMEOUT_MS = 15000;
unsigned long lastTouchUartPushMs = 0;
// Aktiver Display-Typ (DISPLAY_TYPE_*). Wird beim Start aus dem EEPROM geladen
// und kann in der Web-UI (/Sensoren) zur Laufzeit umgestellt werden.
uint8_t displayType = DEFAULT_DISPLAY_TYPE;
// true, sobald das OLED per I2C gefunden UND initialisiert wurde. Schuetzt davor,
// dass Renderaufrufe ohne angeschlossenes Display laufen (Boot-Haenger-Schutz).
bool oledDisplayPresent = false;
HardwareSerial& touchUart = Serial1;
String touchUartRxLine;
bool touchUartRxOverflow = false;
@@ -342,19 +361,15 @@ MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO);
/************************************************************************************
* Display-Objekt (Adafruit SH1106G)
************************************************************************************/
#ifdef ENABLE_DISPLAY
/************************************************************************************
* Display-Objekt (Adafruit SH1106G)
************************************************************************************/
// Dieser Block muss vorhanden sein, wenn Display aktiviert!
// Objekt existiert immer; initialisiert wird es nur bei displayType == DISPLAY_TYPE_OLED
// und erfolgreicher I2C-Probe (siehe initOledDisplay()).
Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
#endif // ENABLE_DISPLAY
/************************************************************************************
* Firmware-Informationen
************************************************************************************/
String version = "5.0.12";
String version = "5.1.1";
String versionHersteller = "Thomas M&uuml;ller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -926,7 +941,10 @@ const int EEPROM_ADDR_HEATUP_MINUTES = 904; // 4 Bytes (int) -> Ende
const int EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT = 908; // 1 Byte (uint8_t) -> Ende 908
const int EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT = 909; // 1 Byte (uint8_t) -> Ende 909
const int EEPROM_ADDR_POWERON_STANDBY = 910; // 1 Byte (bool) -> Ende 910
// Naechste freie Adresse: 911 (Innerhalb EEPROM_SIZE=1024)
const int EEPROM_ADDR_DISPLAY_TYPE = 911; // 1 Byte (uint8_t) -> Ende 911
const int EEPROM_ADDR_ACTIVE_PROFILE_NAME = 912; // 31 Bytes (char[31], nullterminiert) -> Ende 942
const int EEPROM_ADDR_ACTIVE_PROFILE_DIRTY = 943; // 1 Byte (bool) -> Ende 943
// Naechste freie Adresse: 944 (Innerhalb EEPROM_SIZE=1024)
/************************************************************************************
* Eco-Mode Variablen
@@ -1296,15 +1314,52 @@ char hostname[33] = "Dual-PID"; // Max 32 Zeichen + Nullterminator
String profileStatusMessage = ""; // für Nachrichten auf der Profil-Seite
String standbyTimerStatusMessage = ""; // fuer Nachrichten auf der Timer-Seite
// Zuletzt geladenes/gespeichertes Profil (leer = keines). Wird im EEPROM gehalten,
// damit die Anzeige einen Neustart überlebt. activeProfileDirty markiert, dass nach
// dem Laden profil-relevante Einstellungen geändert wurden (Anzeige "geändert").
String activeProfileName = "";
bool activeProfileDirty = false;
// Aktives Profil + Dirty-Flag ins EEPROM schreiben (char[31], nullterminiert)
void persistActiveProfile() {
char nameBuf[31];
memset(nameBuf, 0, sizeof(nameBuf));
strncpy(nameBuf, activeProfileName.c_str(), sizeof(nameBuf) - 1);
for (size_t i = 0; i < sizeof(nameBuf); i++) {
EEPROM.write(EEPROM_ADDR_ACTIVE_PROFILE_NAME + i, (uint8_t)nameBuf[i]);
}
EEPROM.write(EEPROM_ADDR_ACTIVE_PROFILE_DIRTY, activeProfileDirty ? 1 : 0);
EEPROM.commit();
}
void setActiveProfile(const String& name) {
activeProfileName = name;
activeProfileDirty = false;
persistActiveProfile();
}
void clearActiveProfile() {
activeProfileName = "";
activeProfileDirty = false;
persistActiveProfile();
}
// Nach einer profil-relevanten Einstellungsänderung aufrufen: markiert das aktive
// Profil als "geändert". Ohne aktives Profil oder bei bereits gesetztem Flag ein No-Op.
void markProfileDirty() {
if (activeProfileName.length() == 0 || activeProfileDirty) return;
activeProfileDirty = true;
EEPROM.write(EEPROM_ADDR_ACTIVE_PROFILE_DIRTY, 1);
EEPROM.commit();
}
/************************************************************************************
* WiFi Signal Bitmap (Display-Anzeige)
************************************************************************************/
#ifdef ENABLE_DISPLAY
static const unsigned char PROGMEM wifiSymbol[] = {
0x1f, 0xc0, 0x20, 0x20, 0x4f, 0x90, 0x90, 0x48,
0x27, 0x20, 0x08, 0x80, 0x02, 0x00
};
#endif // ENABLE_DISPLAY
/************************************************************************************
* Gemeinsame CSS-Styles (PROGMEM) - Unverändert
@@ -2451,12 +2506,10 @@ void checkWifiConnection() {
}
}
#ifdef ENABLE_DISPLAY
// WiFi-Symbol aufs Display zeichnen
void drawwifiSymbol(int16_t x, int16_t y) {
display.drawBitmap(x, y, wifiSymbol, 13, 7, SH110X_WHITE);
}
#endif // ENABLE_DISPLAY
/************************************************************************************
* Handler zum Rücksetzen der Betriebszeit - URL angepasst
@@ -2896,6 +2949,7 @@ void resetToDefaults() {
EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, defaultBacklightActivePercent);
EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, defaultBacklightStandbyClockPercent);
EEPROM.put(EEPROM_ADDR_POWERON_STANDBY, (uint8_t)(defaultPowerOnStandby ? 1 : 0));
EEPROM.put(EEPROM_ADDR_DISPLAY_TYPE, (uint8_t)DEFAULT_DISPLAY_TYPE);
EEPROM.put(EEPROM_ADDR_OFFSET_COMPENSATION_ENABLED, (uint8_t)0);
EEPROM.put(EEPROM_ADDR_SCALE_ENABLED, (uint8_t)(defaultScaleEnabled ? 1 : 0));
EEPROM.put(EEPROM_ADDR_SCALE_TYPE, defaultScaleType);
@@ -2912,6 +2966,9 @@ void resetToDefaults() {
// Alle Änderungen ins EEPROM schreiben
EEPROM.commit();
// Werksreset -> kein Profil mehr aktiv
clearActiveProfile();
// WICHTIG: Globale Variablen auch auf Default setzen
SetpointWasser = defaultSetpointWasser; SetpointDampf = defaultSetpointDampf;
OffsetWasser = defaultOffsetWasser; OffsetDampf = defaultOffsetDampf;
@@ -2960,6 +3017,7 @@ void resetToDefaults() {
xSwitchLongAction = defaultXSwitchLongAction;
buttonLongPressMs = (uint16_t)DEFAULT_BUTTON_LONG_PRESS_MS;
dashboardTempDisplayMode = defaultDashboardTempDisplayMode;
applyDisplayTypeChange(DEFAULT_DISPLAY_TYPE); // deaktiviert ein ggf. aktives Display sauber
xSwitchActive = false;
ecoForcedActive = false;
standbyOverriddenByWeb = false;
@@ -3866,7 +3924,7 @@ void stopBrewSequence(unsigned long durationMillis, bool manualStop = false, boo
/************************************************************************************
* setup(): Initialisierung des Systems
* - Angepasst für optionale Display-Unterstützung via ENABLE_DISPLAY
* - Optionale Display-Unterstützung via Laufzeit-Einstellung displayType (Web-UI)
* - Korrekte Wire.begin() Logik für Display/Waage
* - Zusätzliche Serial-Ausgaben für Headless-Betrieb
************************************************************************************/
@@ -3995,15 +4053,6 @@ void setup() {
enterRescueMode(); // kehrt nie zurueck
}
#if TOUCH_UART_ENABLED
// TX-Puffer >= max. Zeilenlaenge (State-JSON bis TOUCH_UART_JSON_BUFFER_SIZE=3000
// + "\r\n"), damit println() die Hauptschleife NIE blockiert (sonst bis ~41 ms
// Stall -> PID-Jitter). Headroom deckt zudem OTA-Chunks (~1,4 KB) und gestaute
// Nachrichten (ack direkt vor State-Push) ab.
touchUart.setTxBufferSize(4096); // war 2048; nicht-blockierendes Senden bis 3000-Byte-Zeile
touchUart.begin(TOUCH_UART_BAUDRATE, SERIAL_8N1, TOUCH_UART_RX_PIN, TOUCH_UART_TX_PIN);
touchUartRxLine.reserve(TOUCH_UART_MAX_LINE_LEN);
#endif
EEPROM.begin(EEPROM_SIZE); // EEPROM initialisieren
loadFullyKioskConfig(fullyKioskConfig);
@@ -4253,6 +4302,34 @@ void setup() {
storedBacklightStandbyClock = defaultBacklightStandbyClockPercent;
}
backlightStandbyClockPercent = storedBacklightStandbyClock;
uint8_t storedDisplayType = DEFAULT_DISPLAY_TYPE;
EEPROM.get(EEPROM_ADDR_DISPLAY_TYPE, storedDisplayType);
if (storedDisplayType > DISPLAY_TYPE_TOUCH_UART) {
storedDisplayType = DEFAULT_DISPLAY_TYPE; // u. a. unprogrammierte 0xFF
}
displayType = storedDisplayType;
// Zuletzt geladenes Profil aus dem EEPROM holen. Unprogrammierter Bereich (0xFF)
// oder nicht druckbare Zeichen -> kein aktives Profil.
{
char nameBuf[31];
for (size_t i = 0; i < sizeof(nameBuf); i++) {
nameBuf[i] = (char)EEPROM.read(EEPROM_ADDR_ACTIVE_PROFILE_NAME + i);
}
nameBuf[sizeof(nameBuf) - 1] = '\0';
bool valid = true;
for (size_t i = 0; nameBuf[i] != '\0'; i++) {
// Steuerzeichen oder 0xFF (unprogrammiertes EEPROM) -> ungueltig.
// Bytes >= 0x80 bleiben erlaubt (UTF-8-Umlaute im Anzeigenamen).
if ((uint8_t)nameBuf[i] < 0x20 || (uint8_t)nameBuf[i] == 0xFF) { valid = false; break; }
}
if (valid && nameBuf[0] != '\0') {
activeProfileName = String(nameBuf);
activeProfileDirty = (EEPROM.read(EEPROM_ADDR_ACTIVE_PROFILE_DIRTY) == 1);
} else {
activeProfileName = "";
activeProfileDirty = false;
}
}
uint8_t storedScaleEnabled = 0;
uint8_t storedScaleType = defaultScaleType;
EEPROM.get(EEPROM_ADDR_SCALE_ENABLED, storedScaleEnabled);
@@ -4356,11 +4433,6 @@ void setup() {
// Serial.println("---------------------------------------------");
// Serial.print("Firmware Version: "); Serial.println(version);
// #ifdef ENABLE_DISPLAY
// Serial.println("Display: Aktiviert");
// #else
// Serial.println("Display: Deaktiviert");
// #endif
// if (WiFi.getMode() == WIFI_AP || WiFi.status() != WL_CONNECTED) {
// Serial.println(F("WiFi Modus: Access Point (AP)"));
// Serial.print(F("SSID: ")); Serial.println(WiFi.softAPSSID());
@@ -4393,51 +4465,13 @@ void setup() {
}
// --- Display-Initialisierung (nur wenn aktiviert) ---
#ifdef ENABLE_DISPLAY
// --- Display-Initialisierung gemaess Web-UI-Einstellung (displayType) ---
recordResetCheckpoint(RESET_CP_SETUP_DISPLAY);
if (!display.begin(0x3C, true)) { // display.begin benutzt das ggf. schon initialisierte Wire
Serial.println(F("Display konnte nicht initialisiert werden!"));
} else {
Serial.println(F("Display initialisiert."));
// Display-Ausgabe am Start (1)
display.clearDisplay();
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.println(infoHersteller);
display.println(infoModell);
display.println(infoZusatz);
display.println("");
display.println(F("Dual-PID"));
display.print(F("Version: "));
display.println(version);
display.println(F("von Thomas M\201ller")); // \201 ist ü für manche Displays
display.display();
delay(delayInit1); // Verzögerung nur bei aktiviertem Display
// Display-Ausgabe am Start (2) - WiFi Info
display.clearDisplay();
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.println(infoHersteller);
display.println(infoModell);
display.println(infoZusatz);
display.println("");
if (WiFi.status() != WL_CONNECTED) {
display.println(F("WiFi: AP-Modus"));
display.print(F("SSID: ")); display.println(WiFi.softAPSSID());
display.print(F("IP: ")); display.println(WiFi.softAPIP());
} else {
display.println(F("Webserver gestartet"));
display.print(F("IP: ")); display.println(WiFi.localIP());
display.print(F("Host: ")); display.print(hostname); display.println(".local");
}
display.display();
delay(delayInit2); // Verzögerung nur bei aktiviertem Display
if (displayType == DISPLAY_TYPE_OLED) {
initOledDisplay(true);
} else if (displayType == DISPLAY_TYPE_TOUCH_UART) {
initTouchUart();
}
#endif // ENABLE_DISPLAY
// --- Waage Initialisierung (nur ESP32) ---
@@ -5177,10 +5211,10 @@ void loop() {
applyLightOutput();
// Display regelmaessig updaten, wenn aktiviert / angeschlossen
#ifdef ENABLE_DISPLAY
if (displayType == DISPLAY_TYPE_OLED && oledDisplayPresent) {
recordResetCheckpoint(RESET_CP_LOOP_DISPLAY);
updateDisplay();
#endif // ENABLE_DISPLAY
}
// --- Waagen-spezifische Logik in der Hauptschleife ---
recordResetCheckpoint(RESET_CP_LOOP_SCALE);
@@ -5350,8 +5384,108 @@ double readCaseTemperature() {
return readNTCTemperatureFromPin(ANALOG_CASE_NTC_PIN);
}
#ifdef ENABLE_DISPLAY
/************************************************************************************
* OLED-Display initialisieren (Laufzeit, mit I2C-Probe als Boot-Schutz)
* Liefert true, wenn das Display gefunden und initialisiert wurde. Ohne ACK auf
* OLED_I2C_ADDRESS bleibt die Anzeige deaktiviert und die Steuerung startet normal.
************************************************************************************/
bool initOledDisplay(bool showSplash) {
oledDisplayPresent = false;
// I2C-Probe: haengt kein Display am Bus, wird der Init uebersprungen statt zu blockieren.
Wire.beginTransmission(OLED_I2C_ADDRESS);
if (Wire.endTransmission() != 0) {
Serial.println(F("OLED-Display nicht gefunden (I2C 0x3C) - Anzeige deaktiviert."));
return false;
}
if (!display.begin(OLED_I2C_ADDRESS, true)) { // display.begin benutzt das ggf. schon initialisierte Wire
Serial.println(F("Display konnte nicht initialisiert werden!"));
return false;
}
oledDisplayPresent = true;
Serial.println(F("Display initialisiert."));
if (showSplash) {
// Display-Ausgabe am Start (1)
display.clearDisplay();
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.println(infoHersteller);
display.println(infoModell);
display.println(infoZusatz);
display.println("");
display.println(F("Dual-PID"));
display.print(F("Version: "));
display.println(version);
display.println(F("von Thomas M\201ller")); // \201 ist ü für manche Displays
display.display();
delay(delayInit1); // Verzögerung nur bei aktiviertem Display
// Display-Ausgabe am Start (2) - WiFi Info
display.clearDisplay();
display.setCursor(0, 0);
display.setTextColor(SH110X_WHITE);
display.setTextSize(1);
display.println(infoHersteller);
display.println(infoModell);
display.println(infoZusatz);
display.println("");
if (WiFi.status() != WL_CONNECTED) {
display.println(F("WiFi: AP-Modus"));
display.print(F("SSID: ")); display.println(WiFi.softAPSSID());
display.print(F("IP: ")); display.println(WiFi.softAPIP());
} else {
display.println(F("Webserver gestartet"));
display.print(F("IP: ")); display.println(WiFi.localIP());
display.print(F("Host: ")); display.print(hostname); display.println(".local");
}
display.display();
delay(delayInit2); // Verzögerung nur bei aktiviertem Display
}
return true;
}
/************************************************************************************
* Display-Typ zur Laufzeit umschalten (Web-UI /Sensoren)
* Deaktiviert das bisherige Display sauber und initialisiert das neue - ganz ohne
* Neustart. Der Splash wird beim Laufzeit-Wechsel uebersprungen (keine delays im
* Web-Handler-Kontext).
************************************************************************************/
void applyDisplayTypeChange(uint8_t newType) {
if (newType > DISPLAY_TYPE_TOUCH_UART) { newType = DEFAULT_DISPLAY_TYPE; }
if (newType == displayType) { return; }
// Bisheriges Display deaktivieren
if (displayType == DISPLAY_TYPE_OLED && oledDisplayPresent) {
display.clearDisplay();
display.display();
oledDisplayPresent = false;
}
if (displayType == DISPLAY_TYPE_TOUCH_UART) {
#if TOUCH_UART_ENABLED
touchUart.end();
#endif
touchUartClientActive = false;
touchUartClientFirmware = "";
touchUartRxLine = "";
touchUartRxOverflow = false;
}
displayType = newType;
if (displayType == DISPLAY_TYPE_OLED) {
initOledDisplay(false);
} else if (displayType == DISPLAY_TYPE_TOUCH_UART) {
initTouchUart();
}
}
void updateDisplay() {
if (!oledDisplayPresent) { return; }
recordResetCheckpoint(RESET_CP_DISPLAY_RENDER);
double displayedWaterTemp = getDisplayedWaterTemperature();
double displayedSteamTemp = getDisplayedSteamTemperature();
@@ -5992,7 +6126,6 @@ if (scaleModeActive) {
yield();
display.display(); // Display *immer* am Ende aktualisieren
}
#endif // ENABLE_DISPLAY
void updateShotTimer() {
if (cleaningAssistantActive) {
@@ -6286,6 +6419,7 @@ void handleAutoTune(AsyncWebServerRequest *request) {
autoTuneWasserResultKi = (float)KiWasser;
autoTuneWasserResultKd = (float)KdWasser;
autoTuneWasserStatus = (peaks > 9) ? AT_FAILSAFE_PEAKS : AT_SUCCESS;
markProfileDirty(); // PID-Werte weichen jetzt vom geladenen Profil ab
}
// Serial.println("AutoTune Wasser erfolgreich abgeschlossen.");
@@ -6344,6 +6478,7 @@ void handleAutoTune(AsyncWebServerRequest *request) {
autoTuneDampfResultKi = (float)KiDampf;
autoTuneDampfResultKd = (float)KdDampf;
autoTuneDampfStatus = (peaks > 9) ? AT_FAILSAFE_PEAKS : AT_SUCCESS;
markProfileDirty(); // PID-Werte weichen jetzt vom geladenen Profil ab
}
// Serial.println("AutoTune Dampf erfolgreich abgeschlossen.");
@@ -6640,7 +6775,6 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
if (update.hasFlowGuardEnabled && update.flowGuardEnabled != flowGuardEnabled) {
flowGuardEnabled = update.flowGuardEnabled;
EEPROM.put(EEPROM_ADDR_FLOWGUARD_ENABLED, flowGuardEnabled);
changed = true;
flowGuardConfigChanged = true;
}
if (update.hasFlowGuardMinSecs) {
@@ -6648,7 +6782,6 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
if (newFlowGuardMinSecs >= FLOW_GUARD_MIN_SECONDS_MIN && newFlowGuardMinSecs <= FLOW_GUARD_MIN_SECONDS_MAX && fabs(newFlowGuardMinSecs - flowGuardMinBrewSeconds) > 0.01f) {
flowGuardMinBrewSeconds = newFlowGuardMinSecs;
EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_SECONDS, flowGuardMinBrewSeconds);
changed = true;
flowGuardConfigChanged = true;
}
}
@@ -6657,7 +6790,6 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
if (newPulseMs >= FLOW_GUARD_PULSE_PERIOD_MIN_MS && newPulseMs <= FLOW_GUARD_PULSE_PERIOD_MAX_MS && newPulseMs != flowGuardPulsePeriodMs) {
flowGuardPulsePeriodMs = newPulseMs;
EEPROM.put(EEPROM_ADDR_FLOWGUARD_PULSE_PERIOD_MS, flowGuardPulsePeriodMs);
changed = true;
flowGuardConfigChanged = true;
}
}
@@ -6666,7 +6798,6 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
if (newFlowGuardMinDuty >= FLOW_GUARD_MIN_DUTY_MIN && newFlowGuardMinDuty <= FLOW_GUARD_MIN_DUTY_MAX && fabs(newFlowGuardMinDuty - flowGuardMinDutyPercent) > 0.01f) {
flowGuardMinDutyPercent = newFlowGuardMinDuty;
EEPROM.put(EEPROM_ADDR_FLOWGUARD_MIN_DUTY, flowGuardMinDutyPercent);
changed = true;
flowGuardConfigChanged = true;
}
}
@@ -6711,10 +6842,16 @@ bool applyBrewControlUpdate(const BrewControlUpdate& update) {
}
}
// "changed" umfasst hier nur noch profil-relevante Felder (BBT/BBW/PI/SBT);
// FlowGuard ist kein Bestandteil von TemperatureProfile und laeuft ueber
// flowGuardConfigChanged separat -> markProfileDirty() bleibt praezise.
if (changed) {
markProfileDirty();
}
if (changed || flowGuardConfigChanged) {
EEPROM.commit();
}
return changed;
return changed || flowGuardConfigChanged;
}
void handleSaveBrewControl(AsyncWebServerRequest *request) {
@@ -7652,6 +7789,25 @@ static const char sensorsHtmlHeadEnd[] PROGMEM = R"rawliteral(
static const char sensorsBodyStart[] PROGMEM = R"rawliteral(
<body>
<h1>Sensoren</h1>
<form action='/updateSensorSettings' method='POST'>
<input type='hidden' name='sensorSection' value='display'>
<h3>Display</h3>
<label for='displayType'>Typ:</label>
<select id='displayType' name='displayType'>
<option value='0' )rawliteral";
static const char sensorsDisplayOptionMid1[] PROGMEM = R"rawliteral(>Ohne Display</option>
<option value='1' )rawliteral";
static const char sensorsDisplayOptionMid2[] PROGMEM = R"rawliteral(>OLED-Display (SH1106, I2C)</option>
<option value='2' )rawliteral";
static const char sensorsDisplaySectionEnd[] PROGMEM = R"rawliteral(>UART-Touch-Display</option>
</select>
<small class="select-description">Wird sofort &uuml;bernommen, kein Neustart n&ouml;tig. Das OLED wird nur aktiviert, wenn es am I2C-Bus (0x3C) gefunden wird &ndash; ohne angeschlossenes Display startet die Steuerung immer sicher.</small>
<br>
<input type='submit' value='Einstellungen speichern'>
</form>
<form action='/updateSensorSettings' method='POST'>
<input type='hidden' name='sensorSection' value='case'>
<h3>Zusatz-Temperatur-Sensor</h3>
@@ -7902,6 +8058,18 @@ void handleSensors(AsyncWebServerRequest *request) {
response->print(FPSTR(sensorsHtmlHeadEnd));
response->print(FPSTR(commonNav));
response->print(FPSTR(sensorsBodyStart));
if (displayType == DISPLAY_TYPE_NONE) {
response->print(F("selected"));
}
response->print(FPSTR(sensorsDisplayOptionMid1));
if (displayType == DISPLAY_TYPE_OLED) {
response->print(F("selected"));
}
response->print(FPSTR(sensorsDisplayOptionMid2));
if (displayType == DISPLAY_TYPE_TOUCH_UART) {
response->print(F("selected"));
}
response->print(FPSTR(sensorsDisplaySectionEnd));
if (caseSensorEnabled) {
response->print(F(" checked"));
}
@@ -8409,6 +8577,7 @@ void handleFastHeatUpSettings(AsyncWebServerRequest *request) {
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
EEPROM.commit();
markProfileDirty(); // fastHeatUpAktiv ist Profilfeld
AsyncWebServerResponse *resp = request->beginResponse(303);
resp->addHeader(F("Location"), F("/Fast-Heat-Up"));
@@ -8901,6 +9070,7 @@ void handleSettingsUpdate(AsyncWebServerRequest *request) {
// --- EEPROM speichern und PID aktualisieren ---
if (valueChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat
EEPROM.commit();
markProfileDirty(); // Setpoints/Offsets/PID/Boost/MaxTemp/Window sind Profilfelder
}
// PID-Parameter an die Regler übergeben
@@ -9288,6 +9458,7 @@ void handleUpdateAutotuneSettings(AsyncWebServerRequest *request) {
if (changed) {
EEPROM.commit();
markProfileDirty(); // AutoTune-Parameter sind Profilfelder
}
AsyncWebServerResponse *response = request->beginResponse(303);
@@ -9635,6 +9806,8 @@ void handleEcoUpdate(AsyncWebServerRequest *request) {
EEPROM.put(EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT, backlightActivePercent);
EEPROM.put(EEPROM_ADDR_BACKLIGHT_STANDBY_CLOCK_PERCENT, backlightStandbyClockPercent);
EEPROM.commit();
// Eco/Dampfverzoegerung/Wake-Verhalten sind Profilfelder -> Profil als geaendert markieren
markProfileDirty();
// Wenn Eco deaktiviert, Setpoints zurückladen
if (ecoModeMinutes == 0 && !ecoSwitchActive) {
@@ -9698,6 +9871,7 @@ void handleUpdatePiezoSettings(AsyncWebServerRequest *request) {
if (changed) {
EEPROM.commit();
markProfileDirty(); // piezoEnabled ist Teil des Profils
}
AsyncWebServerResponse *resp = request->beginResponse(303);
@@ -9806,6 +9980,10 @@ struct SensorSettingsUpdate {
uint8_t xSwitchLongAction = 0;
bool hasButtonLongPressMs = false;
uint16_t buttonLongPressMs = 0;
bool updateDisplaySection = false;
bool hasDisplayType = false;
uint8_t displayType = 0;
};
struct SensorSettingsApplyResult {
@@ -9944,6 +10122,16 @@ SensorSettingsApplyResult applySensorSettingsUpdate(const SensorSettingsUpdate&
}
}
if (update.updateDisplaySection && update.hasDisplayType) {
uint8_t newDisplayType = update.displayType;
if (newDisplayType > DISPLAY_TYPE_TOUCH_UART) { newDisplayType = DEFAULT_DISPLAY_TYPE; }
if (newDisplayType != displayType) {
applyDisplayTypeChange(newDisplayType);
EEPROM.put(EEPROM_ADDR_DISPLAY_TYPE, displayType);
result.changed = true;
}
}
if (result.changed) {
EEPROM.commit();
}
@@ -9959,11 +10147,13 @@ void handleUpdateSensorSettings(AsyncWebServerRequest *request) {
bool isCaseSection = (section.length() == 0 || section == "case");
bool isScaleSection = (section.length() == 0 || section == "scale");
bool isXSwitchSection = (section.length() == 0 || section == "xSwitch");
bool isDisplaySection = (section.length() == 0 || section == "display");
SensorSettingsUpdate update;
update.updateCaseSection = isCaseSection;
update.updateScaleSection = isScaleSection;
update.updateXSwitchSection = isXSwitchSection;
update.updateDisplaySection = isDisplaySection;
if (isCaseSection) {
update.hasCaseEnabled = true;
@@ -9989,6 +10179,11 @@ void handleUpdateSensorSettings(AsyncWebServerRequest *request) {
update.hx711DisplaySmoothing = request->hasArg("hx711DisplaySmoothing");
}
if (isDisplaySection) {
update.hasDisplayType = request->hasArg("displayType");
if (update.hasDisplayType) { update.displayType = (uint8_t)request->arg("displayType").toInt(); }
}
if (isXSwitchSection) {
update.hasXSwitchAction = request->hasArg("xSwitchAction");
if (update.hasXSwitchAction) { update.xSwitchAction = (uint8_t)request->arg("xSwitchAction").toInt(); }
@@ -11152,6 +11347,8 @@ bool saveProfile(const String& profileName, const TemperatureProfile& profileDat
if (bytesWritten == sizeof(TemperatureProfile)) {
// Serial.println(" Profile saved successfully (Binary Format).");
// Gespeicherter Ist-Zustand entspricht ab jetzt diesem Profil -> als aktiv merken
setActiveProfile(displayName);
return true;
} else {
// Serial.printf(" ERROR: Failed to write complete profile data! Bytes written: %d, Expected: %d\n", bytesWritten, sizeof(TemperatureProfile));
@@ -11299,6 +11496,9 @@ void applyProfileSettings(const TemperatureProfile& profileData) {
// Eco-Status zurücksetzen
ecoModeAktiv = false; ecoModeActivatedTime = 0;
// Serial.println(" Eco mode status reset.");
// Erfolgreich angewendet -> als aktives Profil merken (setzt Dirty-Flag zurück)
setActiveProfile(normalizeProfileDisplayName(String(profileData.profileName)));
}
@@ -11361,6 +11561,11 @@ bool deleteProfile(const String& profileName) {
if (LittleFS.exists(filePath)) {
if (LittleFS.remove(filePath)) {
// Serial.println(" Profile deleted successfully.");
// War das geloeschte Profil das aktive -> Anzeige zuruecksetzen
if (activeProfileName.length() > 0 &&
sanitizeProfileName(activeProfileName) == sanitizedName) {
clearActiveProfile();
}
return true;
} else {
// Serial.println(" ERROR: Failed to delete profile file.");
@@ -11626,6 +11831,19 @@ void handleProfilesPage(AsyncWebServerRequest *request) {
profileStatusMessage = ""; // Nachricht entfernen, nachdem sie angezeigt wurde
}
// Aktives Profil anzeigen (zuletzt geladen/gespeichert; Stern = danach geändert)
{
String activeDiv = "<div style=\"margin-bottom:12px;opacity:0.85;\">Aktives Profil: ";
if (activeProfileName.length() > 0) {
activeDiv += htmlEscape(activeProfileName);
if (activeProfileDirty) { activeDiv += " &#10033; (ge&auml;ndert)"; }
} else {
activeDiv += "&mdash;";
}
activeDiv += "</div>";
response->print(activeDiv);
}
// Profilliste generieren
response->print(FPSTR(profilesPageListStart)); // <ul>
if (profiles.empty()) {
@@ -11637,6 +11855,9 @@ void handleProfilesPage(AsyncWebServerRequest *request) {
String escapedConfirmJs = htmlEscape(confirmJs);
String listItem = FPSTR(profilesPageListItemStart);
listItem += escapedName;
if (name == activeProfileName) {
listItem += F(" <span style=\"color:#4CAF50;\" title=\"aktives Profil\">&#9679;</span>");
}
listItem += F("</span><div class=\"profile-actions\">");
listItem += F("<form action=\"/loadProfile\" method=\"POST\" class=\"profiles-form\"><input type=\"hidden\" name=\"profileName\" value=\"");
listItem += escapedName;
@@ -13440,6 +13661,7 @@ static const char dashboardControlPanelHTML[] PROGMEM = R"rawliteral(
</div>
<h3>Profile</h3>
<div id="active_profile_line" style="margin-bottom:8px;opacity:0.85;">Aktives Profil: <span id="active_profile_name">&mdash;</span></div>
<select id="profile_select">
<option value="">Profil wählen...</option>
{PROFILE_OPTIONS} </select>
@@ -13747,6 +13969,16 @@ static const char dashboardJavaScript[] PROGMEM = R"rawliteral(
// Status Text
if (statusSpan) { statusSpan.textContent = data.statusText || 'Unbekannt'; statusSpan.classList.toggle('error', data.errorW || data.errorD || data.safetyW || data.safetyD); }
// Aktives Profil (zuletzt geladen/gespeichert; Stern = danach geänderte Einstellungen)
const activeProfileEl = document.getElementById('active_profile_name');
if (activeProfileEl) {
if (data.profile && data.profile.length > 0) {
activeProfileEl.textContent = data.profile + (data.profDirty ? ' (geändert)' : '');
} else {
activeProfileEl.textContent = '';
}
}
if (caseTempDisplay) {
const showCaseTemp = (data.caseTempDashboard === true);
caseTempDisplay.style.display = showCaseTemp ? 'block' : 'none';
@@ -14620,6 +14852,14 @@ bool executeDashboardAction(const String& action, const String& value,
// --- Abschlussaktionen ---
if (settingsChanged) {
EEPROM.commit();
// Profil-relevante Aenderung (Feld ist Teil von TemperatureProfile) -> aktives
// Profil als "geaendert" markieren. Reine Anzeige-/Systemeinstellungen
// (setTempDisplayMode, setHeatUpMinutes, toggleLight) zaehlen nicht.
if (action == "setTempW" || action == "setTempD" ||
action == "toggleBoostW" || action == "toggleBoostD" ||
action == "togglePI" || action == "toggleBBT" || action == "toggleBBW") {
markProfileDirty();
}
}
if (pidNeedsUpdate) {
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
@@ -14912,6 +15152,15 @@ void buildDashboardJson(char *buffer, size_t bufferSize) {
offset += snprintf(buffer + offset, bufferSize - offset, "\"statusText\":\"%s\",", statusText.c_str());
offset += snprintf(buffer + offset, bufferSize - offset, "\"statusKey\":\"%s\",", statusKey.c_str());
// Zuletzt geladenes Profil (leer = keines) + "geaendert"-Flag
{
String escapedProfile = activeProfileName;
escapedProfile.replace("\\", "\\\\");
escapedProfile.replace("\"", "\\\"");
offset += snprintf(buffer + offset, bufferSize - offset, "\"profile\":\"%s\",", escapedProfile.c_str());
offset += snprintf(buffer + offset, bufferSize - offset, "\"profDirty\":%s,", activeProfileDirty ? "true" : "false");
}
bool caseTempDashboardActive = (caseTempOnDashboard && caseSensorEnabled);
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseTempDashboard\":%s,", caseTempDashboardActive ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"caseType\":%u,", (unsigned int)caseSensorType);
@@ -15204,6 +15453,14 @@ static void buildTouchUartStateJson(char *buffer, size_t bufferSize) {
offset += snprintf(buffer + offset, bufferSize - offset, "\"steamHeatDisabled\":%s,", steamHeatDisabledByUser ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"lightOn\":%s,", lightOn ? "true" : "false");
offset += snprintf(buffer + offset, bufferSize - offset, "\"piezoEnabled\":%s,", piezoEnabled ? "true" : "false");
// Zuletzt geladenes Profil (leer = keines) + "geaendert"-Flag fuer die Anzeige am Display
{
String escapedProfile = activeProfileName;
escapedProfile.replace("\\", "\\\\");
escapedProfile.replace("\"", "\\\"");
offset += snprintf(buffer + offset, bufferSize - offset, "\"profile\":\"%s\",", escapedProfile.c_str());
offset += snprintf(buffer + offset, bufferSize - offset, "\"profDirty\":%s,", activeProfileDirty ? "true" : "false");
}
dtostrf(getWeightReadingForUi(), 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"weight\":%s,", floatBuf);
dtostrf(brewByWeightTargetGrams, 4, 1, floatBuf); offset += snprintf(buffer + offset, bufferSize - offset, "\"targetWeight\":%s,", floatBuf);
@@ -15540,8 +15797,21 @@ static bool jsonGetUIntValue(const String& json, const char* key, uint32_t& outV
return true;
}
void initTouchUart() {
#if TOUCH_UART_ENABLED
// TX-Puffer >= max. Zeilenlaenge (State-JSON bis TOUCH_UART_JSON_BUFFER_SIZE=3000
// + "\r\n"), damit println() die Hauptschleife NIE blockiert (sonst bis ~41 ms
// Stall -> PID-Jitter). Headroom deckt zudem OTA-Chunks (~1,4 KB) und gestaute
// Nachrichten (ack direkt vor State-Push) ab.
touchUart.setTxBufferSize(4096);
touchUart.begin(TOUCH_UART_BAUDRATE, SERIAL_8N1, TOUCH_UART_RX_PIN, TOUCH_UART_TX_PIN);
touchUartRxLine.reserve(TOUCH_UART_MAX_LINE_LEN);
#endif
}
static void touchUartSendLine(const String& line) {
#if TOUCH_UART_ENABLED
if (displayType != DISPLAY_TYPE_TOUCH_UART) { return; }
touchUart.println(line);
#endif
}
@@ -16126,6 +16396,7 @@ static bool touchUartHandleCommandLine(const String& line) {
return false;
}
EEPROM.commit();
markProfileDirty(); // PID-Werte sind Profilfelder
if (changedW) { pidWasser.SetTunings(KpWasser, KiWasser, KdWasser); }
if (changedD) { pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); }
touchUartSendAck(id, true, "PID gespeichert");
@@ -16283,6 +16554,7 @@ static bool touchUartHandleCommandLine(const String& line) {
void touchUartRxTick() {
#if TOUCH_UART_ENABLED
if (displayType != DISPLAY_TYPE_TOUCH_UART) return;
if (displayOtaActive) return; // waehrend OTA hat der Upload-Handler die UART exklusiv
recordResetCheckpoint(RESET_CP_TOUCH_UART_RX);
while (touchUart.available() > 0) {
@@ -16359,6 +16631,7 @@ static void touchUartPollWifiScan() {
void touchUartTick() {
#if TOUCH_UART_ENABLED
if (displayType != DISPLAY_TYPE_TOUCH_UART) return;
if (displayOtaActive) return; // waehrend OTA keine State-Pushes
recordResetCheckpoint(RESET_CP_TOUCH_UART_TX);
unsigned long now = millis();
+13
View File
@@ -1,3 +1,16 @@
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
+1 -1
View File
@@ -65,7 +65,7 @@
// -------------------------------------------------------------------------------------
// Firmware
// -------------------------------------------------------------------------------------
#define DISPLAY_FW_VERSION "1.0.12" // 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;
+152 -2
View File
@@ -104,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;
@@ -2375,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);
@@ -2859,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);
@@ -3437,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;
@@ -3629,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);
}
}