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

This commit was merged in pull request #7.
This commit is contained in:
2026-07-13 20:48:40 +02:00
2 changed files with 158 additions and 7 deletions
+12
View File
@@ -1,3 +1,15 @@
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
+146 -7
View File
@@ -369,7 +369,7 @@ Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
* Firmware-Informationen
************************************************************************************/
String version = "5.1.0";
String version = "5.1.1";
String versionHersteller = "Thomas Müller";
String versionHerstellerMail =
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
@@ -942,7 +942,9 @@ const int EEPROM_ADDR_BACKLIGHT_ACTIVE_PERCENT = 908; // 1 Byte (uint8_t)
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
const int EEPROM_ADDR_DISPLAY_TYPE = 911; // 1 Byte (uint8_t) -> Ende 911
// Naechste freie Adresse: 912 (Innerhalb EEPROM_SIZE=1024)
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
@@ -1312,6 +1314,45 @@ 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)
************************************************************************************/
@@ -2925,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;
@@ -4264,6 +4308,28 @@ void setup() {
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);
@@ -6353,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.");
@@ -6411,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.");
@@ -6707,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) {
@@ -6715,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;
}
}
@@ -6724,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;
}
}
@@ -6733,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;
}
}
@@ -6778,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) {
@@ -8507,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"));
@@ -8999,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
@@ -9386,6 +9458,7 @@ void handleUpdateAutotuneSettings(AsyncWebServerRequest *request) {
if (changed) {
EEPROM.commit();
markProfileDirty(); // AutoTune-Parameter sind Profilfelder
}
AsyncWebServerResponse *response = request->beginResponse(303);
@@ -9733,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) {
@@ -9796,6 +9871,7 @@ void handleUpdatePiezoSettings(AsyncWebServerRequest *request) {
if (changed) {
EEPROM.commit();
markProfileDirty(); // piezoEnabled ist Teil des Profils
}
AsyncWebServerResponse *resp = request->beginResponse(303);
@@ -11271,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));
@@ -11418,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)));
}
@@ -11480,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.");
@@ -11745,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()) {
@@ -11756,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;
@@ -13559,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>
@@ -13866,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';
@@ -14739,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);
@@ -15031,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);
@@ -15323,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);
@@ -16258,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");