4592 lines
196 KiB
Arduino
4592 lines
196 KiB
Arduino
/************************************************************************************
|
|
* Dual-PID Controller Firmware (Zeitproportional)
|
|
* Version (Siehe unten im Code)
|
|
*
|
|
* Dieses Programm steuert Wasser- und Dampftemperatur über zwei unabhängige PID-Regler
|
|
* mit zeitproportionaler Ansteuerung der SSRs.
|
|
* Sensoren: MAX6675 (Dampf), NTC (Wasser).
|
|
* Funktionen: Eco-Modus, Profile, Fast-Heat-Up, WLAN-Weboberfläche, AutoTune,
|
|
* Wartungserinnerung, Werkseinstellungen, Hostname-Konfiguration.
|
|
*
|
|
* Compiler- und Hardware-Umgebung:
|
|
* - ESP8266: Wemos D1 mini, 4MB
|
|
* - Bibliotheken: Wire, Adafruit_GFX, Adafruit_SH110X, EEPROM, PID_v1, PID_AutoTune_v0,
|
|
* max6675, ESP8266WiFi, ESP8266WebServer, ESP8266mDNS
|
|
************************************************************************************/
|
|
|
|
#include <Wire.h>
|
|
#include <Adafruit_GFX.h>
|
|
#include <Adafruit_SH110X.h>
|
|
#include <EEPROM.h>
|
|
#include <PID_v1.h>
|
|
#include <PID_AutoTune_v0.h> // Modifizierte AutoTune-Funktion - Muss als .zip eingebunden werden
|
|
#include "max6675.h"
|
|
#include <ESP8266WiFi.h>
|
|
#include <ESP8266WebServer.h>
|
|
#include <ESP8266mDNS.h>
|
|
#include <math.h> // Für isnan()
|
|
#include <FS.h> // Dateisystem-Basis
|
|
#include <LittleFS.h> // LittleFS-Implementierung
|
|
|
|
/************************************************************************************
|
|
* Hardware-Pinbelegung
|
|
************************************************************************************/
|
|
|
|
#define SSR_WASSER_PIN D2 // SSR für Wasser-Heizung
|
|
#define SSR_DAMPF_PIN D1 // SSR für Dampf-Heizung
|
|
#define SHOT_TIMER_PIN D8 // Input für Shot-Timer
|
|
#define OLED_SDA D3 // SDA-Leitung für OLED
|
|
#define OLED_SCK D4 // SCK-Leitung für OLED
|
|
#define ANALOG_NTC_PIN A0 // Analoger Eingang für den NTC-Sensor
|
|
|
|
// Display-Objekt (Adafruit SH1106G)
|
|
Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire);
|
|
|
|
/************************************************************************************
|
|
* MAX6675 (Dampf-Temperatursensor)
|
|
************************************************************************************/
|
|
|
|
#define MAX6675_SCK D7 // Serial Clock
|
|
#define MAX6675_CS D6 // Chip Select
|
|
#define MAX6675_SO D5 // Serial Out
|
|
MAX6675 thermocouple(MAX6675_SCK, MAX6675_CS, MAX6675_SO);
|
|
|
|
/************************************************************************************
|
|
* Firmware-Informationen
|
|
************************************************************************************/
|
|
|
|
String version = "2.8.3";
|
|
String versionHersteller = "Thomas Müller";
|
|
String versionHerstellerMail =
|
|
"<a href='mailto:thomas@mueller.black' class='info-link'>thomas@mueller.black</a>";
|
|
String versionHerstellerWeb =
|
|
"<a href='https://raw-designs.de/' target='_blank' class='info-link'>https://raw-designs.de/</a> | "
|
|
"<a href='https://mueller.black/' target='_blank' class='info-link'>https://mueller.black</a>";
|
|
|
|
// Demo-Modus: Hier können bestimmte Funktionen (WLAN-Anpassungen etc.) eingeschränkt werden.
|
|
// Entweder hier pauschal auf true setzen oder über IP-Adresse im Setup()-Teil auf true setzen lassen.
|
|
bool demoModus = false;
|
|
IPAddress demoModusIP(192, 168, 178, 67);
|
|
|
|
// Dieses Kennwort muss in einer Firmware-Binärdatei vorhanden sein, damit ein Update gültig ist.
|
|
static const char FIRMWARE_PASSWORD[] = "FWKennwort123";
|
|
static bool passwordFound = false;
|
|
static int passMatchPos = 0;
|
|
const int passLength = sizeof(FIRMWARE_PASSWORD) - 1;
|
|
|
|
/************************************************************************************
|
|
* Geräte-Infos
|
|
************************************************************************************/
|
|
|
|
char infoHersteller[50];
|
|
char infoModell[50];
|
|
char infoZusatz[50];
|
|
|
|
/************************************************************************************
|
|
* NTC-Sensor-Parameter (Wasser)
|
|
* - A0 wird verwendet, um die Temperatur über einen Spannungsteiler zu messen
|
|
************************************************************************************/
|
|
|
|
const double V_SUPPLY = 3.3; // Versorgungsspannung
|
|
const double ADC_REF = 3.3; // ADC-Referenz
|
|
const double R_FIXED = 3000.0; // Festwiderstand 3 kΩ
|
|
|
|
// Daten des NTC
|
|
const double T0 = 298.15; // Referenztemperatur in Kelvin (25°C)
|
|
const double R0 = 50000.0; // NTC-Widerstand bei 25°C
|
|
const double beta = 3950.0; // Beta-Wert
|
|
|
|
/************************************************************************************
|
|
* Temperatur-Profil-Struktur
|
|
* - Speichert Wasser- und Dampf-Setpoint, Eco-Einstellungen und Profilname
|
|
************************************************************************************/
|
|
|
|
struct TemperatureProfile
|
|
{
|
|
double setpointWasser;
|
|
double setpointDampf;
|
|
int ecoModeMinutes;
|
|
bool dynamicEcoActive;
|
|
int dampfVerzoegerung;
|
|
char profileName[20];
|
|
};
|
|
|
|
/************************************************************************************
|
|
* PID-Regler-Konfiguration & Zeitproportionale Steuerung
|
|
************************************************************************************/
|
|
|
|
// PID Input/Output/Setpoint Variablen
|
|
double SetpointDampf, InputDampf, OutputDampf;
|
|
double SetpointWasser, InputWasser, OutputWasser;
|
|
double OffsetDampf = 0.0, OffsetWasser = 0.0;
|
|
|
|
// PID-Konstanten (Regelungsparameter)
|
|
double KpDampf = 3.0, KiDampf = 0.1, KdDampf = 0.5;
|
|
double KpWasser = 5.0, KiWasser = 0.2, KdWasser = 1.0;
|
|
|
|
// Zeitproportionale Steuerung
|
|
unsigned long windowSizeWasser; // Fenstergröße für Wasser-SSR (in ms)
|
|
unsigned long windowSizeDampf; // Fenstergröße für Dampf-SSR (in ms)
|
|
unsigned long windowStartTimeWasser; // Startzeit des aktuellen Fensters für Wasser
|
|
unsigned long windowStartTimeDampf; // Startzeit des aktuellen Fensters für Dampf
|
|
|
|
// PID-Regler Instanzen
|
|
// WICHTIG: Die Output-Grenzen werden in setup() auf die Fenstergröße gesetzt!
|
|
PID pidDampf(&InputDampf, &OutputDampf, &SetpointDampf, KpDampf, KiDampf, KdDampf, DIRECT);
|
|
PID pidWasser(&InputWasser, &OutputWasser, &SetpointWasser, KpWasser, KiWasser, KdWasser, DIRECT);
|
|
|
|
// Variablen für Sicherheitsabschaltung
|
|
double maxTempWasser = 110.0; // Standardwert, wie bisher hardcoded
|
|
double maxTempDampf = 180.0; // Standardwert, wie bisher hardcoded
|
|
|
|
/************************************************************************************
|
|
* Standardwerte und Default-Einstellungen
|
|
************************************************************************************/
|
|
|
|
double defaultSetpointDampf = 165.0;
|
|
double defaultSetpointWasser = 93.0;
|
|
double defaultOffsetDampf = 0.0;
|
|
double defaultOffsetWasser = 0.0;
|
|
double defaultKpDampf = 3.0;
|
|
double defaultKiDampf = 0.1;
|
|
double defaultKdDampf = 0.5;
|
|
double defaultKpWasser = 5.0;
|
|
double defaultKiWasser = 0.2;
|
|
double defaultKdWasser = 1.0;
|
|
int defaultEcoModeMinutes = 0;
|
|
int defaultEcoModeTempWasser = 60;
|
|
int defaultEcoModeTempDampf = 60;
|
|
String defaultInfoHersteller = "";
|
|
String defaultInfoModell = "";
|
|
String defaultInfoZusatz = "";
|
|
int defaultDampfVerzoegerung = 0;
|
|
int dampfVerzoegerung = 0;
|
|
int defaultMaintenanceInterval = 0;
|
|
String defaultHostname = "Dual-PID-Controller";
|
|
unsigned long defaultWindowSizeWasser = 2000;
|
|
unsigned long defaultWindowSizeDampf = 2000;
|
|
|
|
/************************************************************************************
|
|
* Webserver und zugehörige Objekte
|
|
************************************************************************************/
|
|
|
|
ESP8266WebServer server(80); // Webserver auf Port 80
|
|
|
|
// MagicValue zum Erkennen bereits gespeicherter EEPROM-Daten
|
|
const char storageMagicValue[5] = "MGVE";
|
|
|
|
/************************************************************************************
|
|
* EEPROM Adressen für das Speichern/Laden von Werten
|
|
************************************************************************************/
|
|
const int EEPROM_SIZE = 1024; // Gesamtgröße des verwendeten EEPROM-Speichers
|
|
|
|
/************************************************************************************
|
|
* EEPROM Adressen für das Speichern/Laden von Werten
|
|
************************************************************************************/
|
|
|
|
const int EEPROM_ADDR_MAGICVALUE = 0; // 5 Bytes (char[5])
|
|
const int EEPROM_ADDR_SETPOINT_DAMPF = 5; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_SETPOINT_WASSER = 13; // 8 Bytes
|
|
const int EEPROM_ADDR_OFFSET_DAMPF = 21; // 8 Bytes
|
|
const int EEPROM_ADDR_OFFSET_WASSER = 29; // 8 Bytes
|
|
const int EEPROM_ADDR_KP_DAMPF = 37; // 8 Bytes
|
|
const int EEPROM_ADDR_KI_DAMPF = 45; // 8 Bytes
|
|
const int EEPROM_ADDR_KD_DAMPF = 53; // 8 Bytes
|
|
const int EEPROM_ADDR_KP_WASSER = 61; // 8 Bytes
|
|
const int EEPROM_ADDR_KI_WASSER = 69; // 8 Bytes
|
|
const int EEPROM_ADDR_KD_WASSER = 77; // 8 Bytes
|
|
const int EEPROM_ADDR_ECOMODE_MINUTES = 85; // 4 Bytes (int)
|
|
const int EEPROM_ADDR_ECOMODE_TEMP_WASSER = 89; // 4 Bytes
|
|
const int EEPROM_ADDR_ECOMODE_TEMP_DAMPF = 93; // 4 Bytes
|
|
const int EEPROM_ADDR_INFO_HERSTELLER = 97; // 50 Bytes (char[50])
|
|
const int EEPROM_ADDR_INFO_MODELL = 147; // 50 Bytes
|
|
const int EEPROM_ADDR_INFO_ZUSATZ = 197; // 50 Bytes
|
|
const int EEPROM_ADDR_FASTHEATUP_DATA = 247; // 1 Byte (bool)
|
|
const int EEPROM_ADDR_RUNTIME = 248; // 4 Bytes (unsigned long)
|
|
const int EEPROM_ADDR_SHOTCOUNTER = 252; // 4 Bytes (unsigned long)
|
|
const int EEPROM_ADDR_DYNAMIC_ECO_MODE = 256; // 1 Byte (bool)
|
|
const int EEPROM_ADDR_PROFILE_1 = 257; // 50 Bytes für Profil 1 (struct TemperatureProfile)
|
|
const int EEPROM_ADDR_PROFILE_2 = 307; // 50 Bytes für Profil 2 (struct TemperatureProfile)
|
|
// --- AutoTune Parameter Wasser ---
|
|
const int EEPROM_ADDR_TUNING_STEP_WASSER = 357; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_TUNING_NOISE_WASSER = 365; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_TUNING_STARTVALUE_WASSER = 373; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_TUNING_LOOKBACK_WASSER = 381; // 4 Bytes (unsigned int) -> Ende 384
|
|
// --- WiFi Konfiguration ---
|
|
const int EEPROM_ADDR_WIFI_CONFIG_MAGIC = 385; // 5 Bytes (char[5])
|
|
const int EEPROM_ADDR_WIFI_CONFIG_DATA = 390; // 113 Bytes (struct WiFiConfig) -> Ende ca. 503
|
|
// --- Sonstige Einstellungen ---
|
|
const int EEPROM_ADDR_STEAM_DELAY = 527; // 4 Bytes (int) -> Ende 530
|
|
// --- AutoTune Parameter Dampf ---
|
|
const int EEPROM_ADDR_TUNING_STEP_DAMPF = 531; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_TUNING_NOISE_DAMPF = 539; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_TUNING_STARTVALUE_DAMPF = 547; // 8 Bytes (double)
|
|
const int EEPROM_ADDR_TUNING_LOOKBACK_DAMPF = 555; // 4 Bytes (unsigned int) -> Ende 558
|
|
// --- Adressen für Übertemperatursicherung / Sicherheitsabschaltung ---
|
|
const int EEPROM_ADDR_MAX_TEMP_WASSER = 559; // 8 Bytes (double) -> Ende 566
|
|
const int EEPROM_ADDR_MAX_TEMP_DAMPF = 567; // 8 Bytes (double) -> Ende 574
|
|
// --- NEUE ADRESSEN ---
|
|
const int EEPROM_ADDR_MAINTENANCE_INTERVAL = 575; // 4 Bytes (int) -> Ende 578
|
|
const int EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER = 579; // 4 Bytes (int) -> Ende 578
|
|
const int EEPROM_ADDR_HOSTNAME = 583; // 33 Bytes (char[33]) -> Ende 611
|
|
// --- Boost-Funktion
|
|
const int EEPROM_ADDR_BOOST_WASSER_ACTIVE = 612; // 1 Byte (bool) -> Ende 612
|
|
const int EEPROM_ADDR_BOOST_DAMPF_ACTIVE = 613; // 1 Byte (bool) -> Ende 613
|
|
// --- Zeitfenster-Größen für Dampf und Wasser
|
|
const int EEPROM_ADDR_WINDOWSIZE_WASSER = 614; // 4 Bytes (unsigned long) -> Ende 617
|
|
const int EEPROM_ADDR_WINDOWSIZE_DAMPF = 618; // 4 Bytes (unsigned long) -> Ende 621
|
|
// --- Heiz-Begrenzung oberhalb Setpoints
|
|
const int EEPROM_ADDR_PREVENTHEAT_WASSER = 622; // 1 Byte (bool) -> Ende 622
|
|
const int EEPROM_ADDR_PREVENTHEAT_DAMPF = 623; // 1 Byte (bool) -> Ende 623
|
|
|
|
|
|
/************************************************************************************
|
|
* Eco-Mode Variablen
|
|
************************************************************************************/
|
|
|
|
unsigned long lastShotTime = 0; // Wann wurde zuletzt ein Shot beendet
|
|
int ecoModeMinutes = 0; // Zeit (Minuten) bis Eco-Modus
|
|
bool ecoModeAktiv = 0; // Flag, ob Eco läuft
|
|
int ecoModeTempWasser = 50; // Zieltemp Wasser im Eco
|
|
int ecoModeTempDampf = 50; // Zieltemp Dampf im Eco
|
|
bool dynamicEcoActive = false;
|
|
unsigned long ecoModeActivatedTime = 0;
|
|
|
|
/************************************************************************************
|
|
* Fast-Heat-Up Variablen
|
|
************************************************************************************/
|
|
|
|
bool fastHeatUpAktiv = 0; // Ist Fast-Heat-Up aktiviert?
|
|
bool fastHeatUpHeating = 0; // Wird gerade hochgeheizt (Wasser >130)?
|
|
int fastHeatUpSetpoint = 130;
|
|
|
|
/************************************************************************************
|
|
* Sicherheits- und Fehlerstatus
|
|
************************************************************************************/
|
|
|
|
bool wasserSensorError = false; // Flag für Fehler am Wassertemperatursensor
|
|
bool dampfSensorError = false; // Flag für Fehler am Dampftemperatursensor
|
|
bool wasserSafetyShutdown = false;
|
|
bool dampfSafetyShutdown = false;
|
|
|
|
/************************************************************************************
|
|
* Auto-Tune-Einstellungen
|
|
************************************************************************************/
|
|
|
|
// Parameter für Wasser
|
|
double tuningStepWasser;
|
|
double tuningNoiseWasser;
|
|
double tuningStartValueWasser;
|
|
unsigned int tuningLookBackWasser;
|
|
const double defaultTuningStepWasser = 500.0;
|
|
const double defaultTuningNoiseWasser = 1.0;
|
|
const double defaultTuningStartValueWasser = 1000.0;
|
|
const unsigned int defaultTuningLookBackWasser = 60;
|
|
|
|
// Parameter für Dampf
|
|
double tuningStepDampf;
|
|
double tuningNoiseDampf;
|
|
double tuningStartValueDampf;
|
|
unsigned int tuningLookBackDampf;
|
|
const double defaultTuningStepDampf = 300.0;
|
|
const double defaultTuningNoiseDampf = 1.0;
|
|
const double defaultTuningStartValueDampf = 1000.0;
|
|
const unsigned int defaultTuningLookBackDampf = 30;
|
|
|
|
PID_ATune* autoTuneWasser;
|
|
PID_ATune* autoTuneDampf;
|
|
bool autoTuneWasserActive = false;
|
|
bool autoTuneDampfActive = false;
|
|
|
|
// Boost-Feature
|
|
bool boostWasserActive = false;
|
|
const bool defaultBoostWasserActive = false;
|
|
bool boostDampfActive = false;
|
|
const bool defaultBoostDampfActive = false;
|
|
|
|
// Heizen oberhalb Setpoint verhindern
|
|
bool preventHeatAboveSetpointWasser = false; // Standardmäßig AUS
|
|
const bool defaultPreventHeatAboveSetpointWasser = false;
|
|
bool preventHeatAboveSetpointDampf = false; // Standardmäßig AUS
|
|
const bool defaultPreventHeatAboveSetpointDampf = false;
|
|
|
|
/************************************************************************************
|
|
* Shot-Timer Variablen
|
|
************************************************************************************/
|
|
|
|
unsigned long shotStartTime = 0;
|
|
unsigned long shotEndTime = 0;
|
|
bool shotActive = false;
|
|
unsigned long lastShotDurationMillis = 0; // Speichert die Dauer des letzten Shots in ms
|
|
|
|
/************************************************************************************
|
|
* Shot-Zähler und Betriebszeit
|
|
************************************************************************************/
|
|
|
|
unsigned long shotCounter = 0; // Shots > 20 Sekunden
|
|
unsigned long totalRuntime = 0; // Gesamt-Betriebszeit in Sekunden
|
|
unsigned long lastRuntimeSave = 0; // Wann zuletzt gespeichert
|
|
const unsigned long initialRuntimeSaveDelay = 10 * 60 * 1000UL; // 10 Minuten
|
|
const unsigned long subsequentRuntimeSaveInterval = 5 * 60 * 1000UL; // 5 Minuten
|
|
bool initialRuntimeSaveDone = false;
|
|
|
|
/************************************************************************************
|
|
* Wartungserinnerung Variablen
|
|
************************************************************************************/
|
|
int maintenanceInterval = 0; // Intervall in Shots (0 = deaktiviert)
|
|
unsigned long maintenanceIntervalCounter = 0; // Zähler für Wartungsintervall
|
|
bool displayMaintenanceMessage = false; // Flag, ob Meldung aktiv angezeigt wird
|
|
unsigned long maintenanceMessageStartTime = 0; // Startzeit der Meldungsanzeige
|
|
|
|
/************************************************************************************
|
|
* Verzögerungen für Anzeigen
|
|
************************************************************************************/
|
|
|
|
int delayInit1 = 1500; // Display-Bild am Anfang
|
|
int delayInit2 = 3000; // Warte für Anzeige
|
|
unsigned long previousMillis = 0; // für Intervall z.B. 250ms
|
|
const unsigned long interval = 250; // PID-Berechnungsintervall
|
|
|
|
/************************************************************************************
|
|
* LittleFS
|
|
************************************************************************************/
|
|
File fsUploadFile; // Globale Variable für die Datei während des Uploads
|
|
|
|
/************************************************************************************
|
|
* Hostname Variable
|
|
************************************************************************************/
|
|
char hostname[33] = "Dual-PID-Controller"; // Max 32 Zeichen + Nullterminator
|
|
|
|
/************************************************************************************
|
|
* WiFi Signal Bitmap (Display-Anzeige)
|
|
************************************************************************************/
|
|
|
|
static const unsigned char PROGMEM wifiSymbol[] = {
|
|
0x1f, 0xc0, 0x20, 0x20, 0x4f, 0x90, 0x90, 0x48,
|
|
0x27, 0x20, 0x08, 0x80, 0x02, 0x00
|
|
};
|
|
|
|
/************************************************************************************
|
|
* Gemeinsame CSS-Styles (PROGMEM) - Unverändert
|
|
************************************************************************************/
|
|
|
|
static const char commonStyle[] PROGMEM = R"rawliteral(
|
|
<style>
|
|
/* Farben und Grundvariablen */
|
|
:root {
|
|
--primary-bg: #000000; /* Schwarz */
|
|
--secondary-bg: #1a1a1a; /* Dunkles Grau/Schwarz */
|
|
--accent-color: #d89904; /* Gold-/Kupferton */
|
|
--text-color: #ffffff; /* Weiß für Schrift */
|
|
--card-bg-opacity: 0.15; /* Opazität für Form-Karten-Hintergrund */
|
|
}
|
|
|
|
/* Seitenhintergrund: Farbverlauf */
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
|
|
background: linear-gradient(120deg, #000000 0%, #1a1a1a 100%);
|
|
background-repeat: no-repeat;
|
|
background-attachment: fixed;
|
|
background-size: cover;
|
|
min-height: 100vh;
|
|
color: #ffffff;
|
|
}
|
|
|
|
/************************************************************************
|
|
* Links / Anker-Tags
|
|
************************************************************************/
|
|
a,
|
|
a:visited {
|
|
text-decoration: none;
|
|
transition: color 0.2s ease, background-color 0.2s ease;
|
|
}
|
|
a:hover,
|
|
a:focus {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.info-link,
|
|
.info-link:visited {
|
|
color: #d89904; /* Oder var(--accent-color), falls das die Akzentfarbe ist */
|
|
text-decoration: none; /* Unterstreichung entfernen (wie schon bei 'a') */
|
|
/* !important ist hier wahrscheinlich nicht mehr nötig, da die Klasse spezifischer ist als 'a' */
|
|
}
|
|
|
|
.info-link:hover {
|
|
/* z.B. Helligkeit ändern oder doch unterstreichen */
|
|
filter: brightness(1.2);
|
|
/* text-decoration: underline; */
|
|
}
|
|
|
|
/************************************************************************
|
|
* Navigation (Sticky Top Bar)
|
|
************************************************************************/
|
|
nav {
|
|
background: rgba(0, 0, 0, 0.5);
|
|
backdrop-filter: blur(6px);
|
|
padding: 10px 0;
|
|
text-align: center;
|
|
position: sticky;
|
|
top: 0;
|
|
/* <b style='color: #FFCC00;'>WICHTIG:</b>: z-index erhöhen, damit das X-Icon klickbar bleibt */
|
|
z-index: 1000;
|
|
position: relative; /* Basis für absolute Positionierung von Kindern */
|
|
}
|
|
|
|
/* Wrapper für die Navigationslinks */
|
|
.nav-links {
|
|
display: inline;
|
|
}
|
|
|
|
/* Desktop Navigationslinks */
|
|
.nav-links a {
|
|
color: #fff;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
margin: 0 10px;
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
transition: background 0.3s ease, color 0.3s ease;
|
|
display: inline-block;
|
|
}
|
|
|
|
/* Desktop Hover/Focus/Active States */
|
|
.nav-links a:hover,
|
|
.nav-links a:focus,
|
|
.nav-links a:active,
|
|
.nav-links a.active {
|
|
background: var(--accent-color);
|
|
color: #000000;
|
|
}
|
|
|
|
/* Hamburger-Icon / Close-Icon Styling */
|
|
.hamburger {
|
|
display: none;
|
|
font-size: 30px; /* Etwas größer für bessere Klickbarkeit */
|
|
font-weight: bold; /* Macht das 'X' oft etwas klarer */
|
|
line-height: 1;
|
|
position: absolute;
|
|
/* Anpassung - weiter nach unten verschoben */
|
|
top: calc(50% + 25px); /* Passe '25px' nach Bedarf an */
|
|
transform: translateY(-50%);
|
|
right: 20px;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
padding: 5px; /* Etwas Padding für größere Klickfläche */
|
|
z-index: 1001; /* Sicherstellen, dass der Button über dem Overlay liegt */
|
|
}
|
|
.hamburger:hover {
|
|
color: #ddd;
|
|
}
|
|
|
|
|
|
/************************************************************************
|
|
* Formulare (Card-Design, Glas-Effekt)
|
|
************************************************************************/
|
|
form {
|
|
background: rgba(255, 255, 255, var(--card-bg-opacity));
|
|
backdrop-filter: blur(8px);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin: 20px auto;
|
|
width: 90%;
|
|
max-width: 600px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
/* Überschriften */
|
|
h1 {
|
|
text-align: center;
|
|
margin-top: 20px;
|
|
font-size: 2rem;
|
|
}
|
|
h3 {
|
|
margin-top: 0;
|
|
font-weight: 600;
|
|
padding-bottom: 3px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/* Labels */
|
|
label {
|
|
display: block;
|
|
margin: 10px 0 5px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/************************************************************************
|
|
* Eingabefelder (Inputs, Select, Textarea) MIT FOKUS-EFFEKT
|
|
************************************************************************/
|
|
input[type="text"],
|
|
input[type="password"],
|
|
select,
|
|
textarea {
|
|
width: auto;
|
|
min-width: 200px;
|
|
max-width: 300px;
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
color: var(--text-color);
|
|
border: 1px solid #555; /* Standard-Rand */
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
/* Fokus-Effekt FÜR ALLE INPUTS/SELECT/TEXTAREA */
|
|
input[type="text"]:focus,
|
|
input[type="password"]:focus,
|
|
select:focus,
|
|
textarea:focus {
|
|
outline: none;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
/* Orangener Rand und Schein bei Fokus */
|
|
border-color: var(--accent-color, #d89904);
|
|
box-shadow: 0 0 8px rgba(216, 153, 4, 0.3);
|
|
}
|
|
|
|
/************************************************************************
|
|
* Buttons
|
|
************************************************************************/
|
|
input[type="submit"],
|
|
button {
|
|
background: var(--accent-color);
|
|
color: #000000;
|
|
border: none;
|
|
border-radius: 30px;
|
|
padding: 10px 20px;
|
|
margin-top: 12px;
|
|
width: auto;
|
|
min-width: 100px;
|
|
max-width: 200px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
box-shadow: 0 4px 12px rgba(216, 153, 4, 0.3);
|
|
transition: all 0.3s ease;
|
|
}
|
|
input[type="submit"]:hover,
|
|
button:hover {
|
|
opacity: 0.85;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
/* Style für Danger-Button (Werkseinstellungen) */
|
|
button.danger {
|
|
background: #dc3545; /* Roter Hintergrund */
|
|
color: #ffffff; /* Weiße Schrift */
|
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3); /* Roter Schatten */
|
|
}
|
|
button.danger:hover {
|
|
background: #c82333; /* Dunkleres Rot bei Hover */
|
|
opacity: 1; /* Opazität zurücksetzen, da Hintergrund dunkler wird */
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
/************************************************************************
|
|
* Fehlermeldungen / Sensorfehler
|
|
************************************************************************/
|
|
.sensor-error-message {
|
|
color: #FFCC00; /* Auffälliges Gelb/Gold für Warnungen im Dark Mode */
|
|
text-align: center;
|
|
margin-top: 5px; /* Weniger Abstand nach oben */
|
|
margin-bottom: 15px; /* Abstand zum Chart-Container */
|
|
padding: 8px 10px;
|
|
background-color: rgba(255, 204, 0, 0.1); /* Sehr dezenter gelber Hintergrund */
|
|
border: 1px solid rgba(255, 204, 0, 0.2); /* Sehr dezenter gelber Rand */
|
|
border-radius: 8px;
|
|
font-weight: 500; /* Etwas fetter als normal */
|
|
font-size: 0.9em;
|
|
max-width: 600px; /* Verhindert zu breite Box auf großen Screens */
|
|
margin-left: auto; /* Zentriert die Box selbst */
|
|
margin-right: auto;
|
|
}
|
|
|
|
|
|
/************************************************************************
|
|
* Responsive Design - MOBILE OVERLAY HIER IMPLEMENTIERT
|
|
************************************************************************/
|
|
@media (max-width: 768px) {
|
|
.hamburger {
|
|
display: block; /* Hamburger anzeigen */
|
|
}
|
|
|
|
/* Der Link-Wrapper wird zum absolut positionierten Overlay */
|
|
.nav-links {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%; /* Unter der Nav-Leiste */
|
|
left: 0;
|
|
width: 100%;
|
|
background: rgba(26, 26, 26, 0.95);
|
|
backdrop-filter: blur(8px);
|
|
padding: 10px 0;
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
/* <b style='color: #FFCC00;'>WICHTIG:</b>: z-index niedriger als der Hamburger/Close-Button */
|
|
z-index: 1000;
|
|
}
|
|
|
|
/* Wenn Menü aktiv, Wrapper sichtbar */
|
|
nav.active .nav-links {
|
|
display: block;
|
|
}
|
|
|
|
/* Styling der Links im mobilen Overlay */
|
|
nav .nav-links a {
|
|
display: block;
|
|
margin: 0;
|
|
padding: 15px 25px;
|
|
text-align: center; /* Links zentrieren */
|
|
border-radius: 0;
|
|
color: #eee;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
nav .nav-links a:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
/* Hover/Active für mobile Links */
|
|
nav .nav-links a:hover,
|
|
nav .nav-links a:active,
|
|
nav .nav-links a.active {
|
|
background: var(--accent-color);
|
|
color: #000000;
|
|
}
|
|
|
|
} /* Ende @media (max-width: 768px) */
|
|
|
|
|
|
@media (max-width: 480px) {
|
|
h1 {
|
|
font-size: 1.4rem;
|
|
}
|
|
/* Buttons auf 100% Breite */
|
|
input[type="submit"],
|
|
button {
|
|
width: 100%;
|
|
max-width: none;
|
|
}
|
|
/* Eingabefelder evtl. auch anpassen */
|
|
input[type="text"],
|
|
input[type="password"],
|
|
select,
|
|
textarea {
|
|
max-width: 90%;
|
|
min-width: 150px;
|
|
}
|
|
} /* Ende @media (max-width: 480px) */
|
|
|
|
</style>
|
|
<script>
|
|
// Angepasste JavaScript-Funktion für Icon-Wechsel
|
|
function toggleMenu() {
|
|
var nav = document.querySelector('nav');
|
|
var hamburger = document.querySelector('.hamburger');
|
|
nav.classList.toggle('active');
|
|
|
|
// Prüfen, ob das Menü JETZT aktiv ist und Icon entsprechend ändern
|
|
if (nav.classList.contains('active')) {
|
|
hamburger.innerHTML = '×'; // Ändert zu einem 'X' (Schließen-Symbol)
|
|
} else {
|
|
hamburger.innerHTML = '☰'; // Ändert zurück zum Hamburger-Symbol
|
|
}
|
|
}
|
|
|
|
// OPTIONAL: Schließen des Menüs bei Klick außerhalb (Verbessert Usability)
|
|
document.addEventListener('click', function(event) {
|
|
var nav = document.querySelector('nav');
|
|
var hamburger = document.querySelector('.hamburger');
|
|
var navLinks = document.querySelector('.nav-links');
|
|
|
|
// Prüfen ob Menü überhaupt offen ist
|
|
if (nav.classList.contains('active')) {
|
|
// Prüfen ob Klick außerhalb des Menüs UND außerhalb des Buttons war
|
|
var isClickInsideNavLinks = navLinks.contains(event.target);
|
|
var isClickOnHamburger = hamburger.contains(event.target);
|
|
|
|
if (!isClickInsideNavLinks && !isClickOnHamburger) {
|
|
toggleMenu(); // Schließe das Menü
|
|
}
|
|
}
|
|
});
|
|
|
|
// Bestätigungsdialog für Werkseinstellungen
|
|
function confirmResetDefaults() {
|
|
return confirm("Sicher, dass Sie alle Einstellungen auf die Werkseinstellungen geladen werden sollen?");
|
|
}
|
|
|
|
</script>
|
|
)rawliteral";
|
|
|
|
|
|
/************************************************************************************
|
|
* Gemeinsame Navigation (PROGMEM) - URLs angepasst
|
|
************************************************************************************/
|
|
|
|
static const char commonNav[] PROGMEM = R"rawliteral(
|
|
<nav>
|
|
<span class="hamburger" onclick="toggleMenu()">☰</span>
|
|
<div class="nav-links"> <a href="/">PID-Einstellung</a>
|
|
<a href="/PID-Tuning">PID-Tuning</a>
|
|
<a href="/Profile">Profile</a>
|
|
<a href="/Charts">Temperaturverlauf</a>
|
|
<a href="/Fast-Heat-Up">Fast-Heat-Up</a>
|
|
<a href="/ECO">Eco</a>
|
|
<a href="/Info">Info</a>
|
|
<a href="/Netzwerk">WiFi</a>
|
|
<a href="/Firmware">Firmware</a>
|
|
</div>
|
|
</span>
|
|
</nav>
|
|
)rawliteral";
|
|
|
|
/************************************************************************************
|
|
* WiFi-Konfigurationsstrukturen und Funktionen - Hostname hinzugefügt
|
|
************************************************************************************/
|
|
|
|
struct WiFiConfig
|
|
{
|
|
char ssid[32] = "";
|
|
char password[64] = "";
|
|
bool useStaticIP = false;
|
|
IPAddress staticIP = IPAddress(192, 168, 4, 1);
|
|
IPAddress gateway = IPAddress(192, 168, 4, 1);
|
|
IPAddress subnet = IPAddress(255, 255, 255, 0);
|
|
IPAddress dns = IPAddress(8, 8, 8, 8);
|
|
// Hostname in WiFi-Config (obwohl global gespeichert, hier für Vollständigkeit)
|
|
// Wird nicht direkt hier gespeichert, sondern über globale Variable `hostname`
|
|
};
|
|
|
|
// Liest die WLAN-Konfiguration aus dem EEPROM
|
|
// Gibt true zurück, wenn eine gültige Konfiguration (SSID) geladen wurde
|
|
bool loadWiFiConfig(WiFiConfig& config)
|
|
{
|
|
char magic[5] = { 0 };
|
|
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic);
|
|
|
|
// Lade Hostname (immer, auch wenn keine WiFi-Config da ist)
|
|
EEPROM.get(EEPROM_ADDR_HOSTNAME, hostname);
|
|
hostname[sizeof(hostname)-1] = '\0'; // Nullterminierung sicherstellen
|
|
// Wenn Hostname leer ist (z.B. nach Reset), Default setzen
|
|
if (strlen(hostname) == 0) {
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
|
|
hostname[sizeof(hostname) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Default im EEPROM speichern
|
|
EEPROM.commit();
|
|
}
|
|
|
|
|
|
// Falls MagicValue nicht übereinstimmt, Standardwerte setzen
|
|
if (strncmp(magic, storageMagicValue, 4) != 0)
|
|
{
|
|
strcpy(config.ssid, "");
|
|
strcpy(config.password, "");
|
|
config.useStaticIP = false;
|
|
config.staticIP = IPAddress(192, 168, 4, 1);
|
|
config.gateway = IPAddress(192, 168, 4, 1);
|
|
config.subnet = IPAddress(255, 255, 255, 0);
|
|
config.dns = IPAddress(8, 8, 8, 8);
|
|
return false; // Keine gültige Konfiguration gefunden
|
|
}
|
|
|
|
// Laden der gespeicherten Daten
|
|
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_DATA, config);
|
|
// Nullterminierung sicherstellen (wichtig!)
|
|
config.ssid[sizeof(config.ssid)-1] = '\0';
|
|
config.password[sizeof(config.password)-1] = '\0';
|
|
|
|
// Prüfen, ob SSID leer ist (kann nach AP-Modus-Erzwingung passieren)
|
|
return (strlen(config.ssid) > 0); // True, wenn SSID nicht leer ist
|
|
}
|
|
|
|
// Schreibt die WLAN-Konfiguration ins EEPROM
|
|
void saveWiFiConfig(const WiFiConfig& config)
|
|
{
|
|
EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, storageMagicValue);
|
|
EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_DATA, config);
|
|
// Hostname wird separat gespeichert (in handleSaveWiFiConfig)
|
|
EEPROM.commit();
|
|
}
|
|
|
|
// Aktiviert den Access Point Modus und zeigt IP an
|
|
void startAPMode()
|
|
{
|
|
WiFi.hostname(hostname); // Hostname auch für AP setzen
|
|
WiFi.softAP(hostname); // AP-Name = Hostname
|
|
Serial.print(F("AP-Mode IP: "));
|
|
Serial.println(WiFi.softAPIP());
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Webserver-Handler: WLAN-Konfigurations-Seite - Hostname hinzugefügt
|
|
************************************************************************************/
|
|
|
|
// === PROGMEM Teile definieren (angepasst für Signalstärke & Hostname) ===
|
|
static const char wifiPageChunk1[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html><head><title>WLAN-Konfiguration</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
static const char wifiPageChunk2[] PROGMEM = R"rawliteral(</head>)rawliteral"; // Head Ende
|
|
|
|
// Teil 3a: Body bis VOR Signalstärke-Wert
|
|
static const char wifiPageChunk3_a[] PROGMEM = R"rawliteral(
|
|
<body><h1>WLAN-Konfiguration</h1><form action='/saveWiFiConfig' method='POST'><h3>Netzwerk</h3><div style='margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.1);'><label style='margin-right: 5px;'>Aktuelle Signalstärke:</label><span>)rawliteral"; // Endet vor dem Wert
|
|
|
|
// Teil 3b: NACH Signalstärke-Wert bis VOR Hostname Value
|
|
static const char wifiPageChunk3_b[] PROGMEM = R"rawliteral(</span></div><label for='hostname'>Hostname:</label><input type='text' id='hostname' name='hostname' value=')rawliteral"; // Endet mit value='
|
|
|
|
// Teil 3c: NACH Hostname Value bis VOR SSID Value
|
|
static const char wifiPageChunk3_c[] PROGMEM = R"rawliteral('' required><label for='ssid'>SSID:</label><input type='text' id='ssid' name='ssid' value=')rawliteral"; // Beginnt mit ', endet mit value='
|
|
|
|
// Restliche Teile wie zuvor, nur Nummerierung angepasst
|
|
static const char wifiPageChunk4[] PROGMEM = R"rawliteral('' required><label for='password'>Passwort:</label><input type='text' id='password' name='password' value=')rawliteral"; // Beginnt mit ', endet mit value='
|
|
static const char wifiPageChunk5_format[] PROGMEM = R"rawliteral(''><h3>IP-Einstellungen</h3><label><input type='radio' name='ipType' value='dhcp' %s> DHCP</label><label><input type='radio' name='ipType' value='static' %s> Statische IP</label><div id='staticFields' style='display:%s'>)rawliteral"; // Beginnt mit '', endet mit display:%s'>
|
|
static const char wifiPageChunk6[] PROGMEM = R"rawliteral(<label for='ip'>IP-Adresse:</label><input type='text' id='ip' name='ip' value=')rawliteral"; // Beginnt normal, endet mit value='
|
|
static const char wifiPageChunk7[] PROGMEM = R"rawliteral('><label for='gateway'>Gateway:</label><input type='text' id='gateway' name='gateway' value=')rawliteral"; // Beginnt mit ', endet mit value='
|
|
static const char wifiPageChunk8[] PROGMEM = R"rawliteral('><label for='subnet'>Subnetzmaske:</label><input type='text' id='subnet' name='subnet' value=')rawliteral"; // Beginnt mit ', endet mit value='
|
|
static const char wifiPageChunk9[] PROGMEM = R"rawliteral(''></div><input type='submit' value='Speichern'></form><form action='/forceAPMode' method='POST' style='margin-top: 20px;'><h3>AP-Modus</h3>Verwendung im Access Point-Modus (AP).<br>Erreichbarkeit unter IP-Adresse: 192.168.4.1<br><input type='submit' value='AP-Modus verwenden'></form><script>document.querySelectorAll('input[name="ipType"]').forEach(radio => {radio.addEventListener('change', () => {document.getElementById('staticFields').style.display = radio.value === 'static' ? 'block' : 'none';});});</script></body></html>)rawliteral"; // Beginnt mit '', Rest bis Ende
|
|
|
|
// --- Hilfsfunktion zur Umrechnung von RSSI in Prozent (Approximation) ---
|
|
int calculateSignalPercentage(int rssi) {
|
|
int quality = 0;
|
|
if (rssi <= -100) {
|
|
quality = 0;
|
|
} else if (rssi >= -50) { // Guter Empfang
|
|
quality = 100;
|
|
} else {
|
|
// Lineare Skalierung zwischen -100dBm (0%) und -50dBm (100%)
|
|
quality = 2 * (rssi + 100);
|
|
}
|
|
return quality;
|
|
}
|
|
|
|
void handleWiFiConfig() {
|
|
Serial.println(F("Executing handleWiFiConfig (v4: Hostname added)"));
|
|
|
|
// EEPROM Lese-Logik (inkl. Hostname aus globaler Variable)
|
|
WiFiConfig currentConfig;
|
|
char magic[5] = { 0 };
|
|
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_MAGIC, magic);
|
|
bool configValid = (strncmp(magic, storageMagicValue, 4) == 0);
|
|
if (configValid) {
|
|
EEPROM.get(EEPROM_ADDR_WIFI_CONFIG_DATA, currentConfig);
|
|
currentConfig.ssid[sizeof(currentConfig.ssid)-1] = '\0';
|
|
currentConfig.password[sizeof(currentConfig.password)-1] = '\0';
|
|
} else {
|
|
strcpy(currentConfig.ssid, "");
|
|
strcpy(currentConfig.password, "");
|
|
currentConfig.useStaticIP = false;
|
|
currentConfig.staticIP = IPAddress(192, 168, 4, 1);
|
|
currentConfig.gateway = IPAddress(192, 168, 4, 1);
|
|
currentConfig.subnet = IPAddress(255, 255, 255, 0);
|
|
}
|
|
// Hostname wird aus globaler Variable `hostname` gelesen (wurde in loadWiFiConfig geladen)
|
|
Serial.println(F("EEPROM logic done."));
|
|
|
|
// --- Signalstärke ermitteln ---
|
|
char signalBuffer[50]; // Puffer für den Anzeigetext
|
|
if (WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) {
|
|
long rssi = WiFi.RSSI(); // Signalstärke in dBm holen
|
|
int percentage = calculateSignalPercentage(rssi);
|
|
// Text formatieren (z.B. "-65 dBm (70%)")
|
|
snprintf(signalBuffer, sizeof(signalBuffer), "%d dBm (%d%%)", (int)rssi, percentage);
|
|
Serial.print(F("WiFi Signal: ")); Serial.println(signalBuffer);
|
|
} else if (WiFi.getMode() == WIFI_AP) {
|
|
strcpy(signalBuffer, "AP-Modus (keine Messung)");
|
|
Serial.println(F("WiFi Signal: AP Mode"));
|
|
} else {
|
|
strcpy(signalBuffer, "Nicht verbunden");
|
|
Serial.println(F("WiFi Signal: Not Connected"));
|
|
}
|
|
// --- Ende Signalstärke ---
|
|
|
|
// Temporäre Puffer für Senden
|
|
char formatBuffer[256]; // Für snprintf_P
|
|
char ipBuffer[16];
|
|
|
|
// ---- Beginn des Sendens ----
|
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
|
server.send(200, F("text/html"), "");
|
|
|
|
// Chunk 1: HTML-Start
|
|
server.sendContent_P(wifiPageChunk1);
|
|
// Chunk 1.5: Common Style (global)
|
|
server.sendContent_P(commonStyle);
|
|
// Chunk 2: Head Ende
|
|
server.sendContent_P(wifiPageChunk2);
|
|
// Chunk 2.5: Common Nav (global, URLs angepasst)
|
|
server.sendContent_P(commonNav);
|
|
// Chunk 3a: Body bis VOR Signalwert
|
|
server.sendContent_P(wifiPageChunk3_a); // Endet mit <span>
|
|
// Chunk 3.1: Signalwert senden
|
|
server.sendContent(signalBuffer); // Sendet den formatierten Signal-String
|
|
// Chunk 3b: NACH Signalwert bis VOR Hostname Value
|
|
server.sendContent_P(wifiPageChunk3_b); // Endet mit value='
|
|
// Chunk 3.2: Hostname senden
|
|
server.sendContent(hostname); // Sendet den aktuellen Hostnamen
|
|
// Chunk 3c: NACH Hostname bis VOR SSID Value
|
|
server.sendContent_P(wifiPageChunk3_c); // Beginnt mit ', endet mit value='
|
|
|
|
// Chunk 4: SSID einfügen (Handle Empty)
|
|
if (strlen(currentConfig.ssid) == 0) {
|
|
server.sendContent(F("SSID eingeben"));
|
|
} else {
|
|
server.sendContent(currentConfig.ssid);
|
|
}
|
|
// Chunk 5: Nach SSID bis vor Passwort
|
|
server.sendContent_P(wifiPageChunk4); // Beginnt mit ', endet mit value='
|
|
// Chunk 6: Passwort einfügen (Handle Empty)
|
|
if (!demoModus) {
|
|
if (strlen(currentConfig.password) == 0) {
|
|
server.sendContent(F("Passwort eingeben"));
|
|
} else {
|
|
server.sendContent(currentConfig.password);
|
|
}
|
|
} else {
|
|
server.sendContent(F("*** *** *** ***"));
|
|
}
|
|
|
|
// Chunk 7: Radiobuttons und Div-Start (formatiert)
|
|
snprintf_P(formatBuffer, sizeof(formatBuffer), wifiPageChunk5_format,
|
|
currentConfig.useStaticIP ? "" : "checked",
|
|
currentConfig.useStaticIP ? "checked" : "",
|
|
currentConfig.useStaticIP ? "block" : "none"
|
|
);
|
|
server.sendContent(formatBuffer);
|
|
|
|
// === IP Felder (wie zuvor) ===
|
|
server.sendContent_P(wifiPageChunk6);
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.staticIP[0], currentConfig.staticIP[1], currentConfig.staticIP[2], currentConfig.staticIP[3]);
|
|
server.sendContent(ipBuffer);
|
|
server.sendContent_P(wifiPageChunk7);
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.gateway[0], currentConfig.gateway[1], currentConfig.gateway[2], currentConfig.gateway[3]);
|
|
server.sendContent(ipBuffer);
|
|
server.sendContent_P(wifiPageChunk8);
|
|
snprintf(ipBuffer, sizeof(ipBuffer), "%d.%d.%d.%d", currentConfig.subnet[0], currentConfig.subnet[1], currentConfig.subnet[2], currentConfig.subnet[3]);
|
|
server.sendContent(ipBuffer);
|
|
|
|
// Chunk 9: Rest der Seite
|
|
server.sendContent_P(wifiPageChunk9);
|
|
|
|
// Letzter Chunk zum Abschließen
|
|
server.sendContent(F(""));
|
|
// ---- Ende des Sendens ----
|
|
}
|
|
|
|
// Handler zum Speichern der WLAN-Konfiguration - Hostname hinzugefügt
|
|
void handleSaveWiFiConfig()
|
|
{
|
|
if (!server.hasArg(F("ssid")))
|
|
{
|
|
server.send(500, F("text/plain"), F("Fehler: SSID nicht gefunden"));
|
|
return;
|
|
}
|
|
|
|
WiFiConfig newConfig;
|
|
strncpy(newConfig.ssid, server.arg(F("ssid")).c_str(), sizeof(newConfig.ssid) - 1);
|
|
newConfig.ssid[sizeof(newConfig.ssid) - 1] = '\0'; // Sicherstellen der Nullterminierung
|
|
|
|
strncpy(newConfig.password, server.arg(F("password")).c_str(), sizeof(newConfig.password) - 1);
|
|
newConfig.password[sizeof(newConfig.password) - 1] = '\0'; // Sicherstellen der Nullterminierung
|
|
|
|
newConfig.useStaticIP = (server.arg(F("ipType")) == "static");
|
|
|
|
if (newConfig.useStaticIP)
|
|
{
|
|
newConfig.staticIP.fromString(server.arg(F("ip")));
|
|
newConfig.gateway.fromString(server.arg(F("gateway")));
|
|
newConfig.subnet.fromString(server.arg(F("subnet")));
|
|
// DNS wird hier nicht gespeichert, Standard bleibt
|
|
}
|
|
|
|
// Hostname speichern
|
|
if (server.hasArg(F("hostname"))) {
|
|
strncpy(hostname, server.arg(F("hostname")).c_str(), sizeof(hostname) - 1);
|
|
hostname[sizeof(hostname) - 1] = '\0'; // Sicherstellen der Nullterminierung
|
|
// Validierung (optional, z.B. auf erlaubte Zeichen prüfen)
|
|
if (strlen(hostname) == 0) { // Wenn leer, Default verwenden
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
|
|
hostname[sizeof(hostname) - 1] = '\0';
|
|
}
|
|
EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname); // Hostname ins EEPROM schreiben
|
|
}
|
|
|
|
|
|
// Im Demo-Modus wird nichts gespeichert, sonst schon
|
|
if(!demoModus) {
|
|
saveWiFiConfig(newConfig); // Speichert SSID, PW, IP-Config
|
|
// EEPROM.commit() wird in saveWiFiConfig gemacht (inkl. Hostname-Speicherung)
|
|
}
|
|
|
|
static const char saveConfigHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Einstellungen gespeichert</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char saveConfigHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char saveConfigHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Einstellungen gespeichert</h1>
|
|
<form><p>Die Einstellungen wurden gespeichert. Neustart ...</p></form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String html = FPSTR(saveConfigHtml);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(saveConfigHtml2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
html += FPSTR(saveConfigHtml3);
|
|
server.send(200, F("text/html"), html);
|
|
|
|
delay(5000);
|
|
ESP.restart();
|
|
}
|
|
|
|
// Handler, um den AP-Modus per Knopfdruck zu erzwingen - Unverändert
|
|
void handleForceAPMode()
|
|
{
|
|
// Leer-Konfiguration speichern, damit beim nächsten Start AP aktiv wird
|
|
WiFiConfig emptyConfig;
|
|
// Wichtig: Hostname NICHT löschen!
|
|
if(!demoModus) { saveWiFiConfig(emptyConfig); }
|
|
|
|
static const char forceAPHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>AP-Modus aktivieren</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char forceAPHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char forceAPHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>AP-Modus wird aktiviert...</h1>
|
|
<form><p>Die Einstellungen wurden gespeichert. Neustart im AP-Modus ...</p></form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String html = FPSTR(forceAPHtml);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(forceAPHtml2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
html += FPSTR(forceAPHtml3);
|
|
server.send(200, F("text/html"), html);
|
|
|
|
delay(1000);
|
|
ESP.restart();
|
|
}
|
|
|
|
// Prüfung der WiFi-Verbindung (Reconnect falls getrennt) - Unverändert
|
|
void checkWifiConnection()
|
|
{
|
|
if (WiFi.status() != WL_CONNECTED)
|
|
{
|
|
WiFi.reconnect();
|
|
if (WiFi.waitForConnectResult() != WL_CONNECTED)
|
|
{
|
|
Serial.println(F("WiFi-Verbindung kann nicht hergestellt werden!"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// WiFi-Symbol aufs Display zeichnen - Unverändert
|
|
void drawwifiSymbol(int16_t x, int16_t y)
|
|
{
|
|
display.drawBitmap(x, y, wifiSymbol, 13, 7, SH110X_WHITE);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Rücksetzen der Betriebszeit - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleResetRuntime()
|
|
{
|
|
totalRuntime = 0;
|
|
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime);
|
|
EEPROM.commit();
|
|
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Rücksetzen des Shotzählers - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleResetShots()
|
|
{
|
|
shotCounter = 0;
|
|
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
EEPROM.commit();
|
|
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Rücksetzen des Wartungszählers (über Shot-Zähler)
|
|
************************************************************************************/
|
|
void handleResetMaintenance()
|
|
{
|
|
maintenanceIntervalCounter = 0; // Wartungszähler-Reset auf 0
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
EEPROM.commit();
|
|
displayMaintenanceMessage = false; // Meldung ggf. ausblenden
|
|
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für den Export der EEPROM-Einstellungen - Unverändert
|
|
************************************************************************************/
|
|
void handleExportSettings() {
|
|
|
|
// Header für Dateidownload setzen
|
|
server.sendHeader("Content-Disposition", "attachment; filename=\"dual_pid_settings.bin\"");
|
|
server.sendHeader("Content-Type", "application/octet-stream");
|
|
server.setContentLength(EEPROM_SIZE); // Wichtig: Korrekte Größe senden
|
|
|
|
// EEPROM-Inhalt byteweise senden
|
|
// Puffergröße klein halten, um Speicher zu sparen
|
|
const size_t bufferSize = 64;
|
|
byte buffer[bufferSize];
|
|
size_t bytesSent = 0;
|
|
|
|
// Start des Sendens (Status 200 OK)
|
|
server.send(200, "application/octet-stream", "");
|
|
|
|
// EEPROM lesen und in Chunks senden
|
|
for (int i = 0; i < EEPROM_SIZE; i += bufferSize) {
|
|
size_t currentChunkSize = (EEPROM_SIZE - i < bufferSize) ? (EEPROM_SIZE - i) : bufferSize;
|
|
for (size_t j = 0; j < currentChunkSize; j++) {
|
|
buffer[j] = EEPROM.read(i + j);
|
|
}
|
|
server.sendContent((const char*)buffer, currentChunkSize);
|
|
bytesSent += currentChunkSize;
|
|
yield(); // Wichtig für Netzwerkstabilität bei längeren Operationen
|
|
}
|
|
// Kein server.stop() hier, da die Verbindung automatisch geschlossen wird.
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler für den Import der EEPROM-Einstellungen (Upload-Verarbeitung) - Unverändert
|
|
************************************************************************************/
|
|
// Globale Variablen bleiben gleich
|
|
static int eepromWriteAddress = 0;
|
|
static bool importSuccessful = false;
|
|
static String importStatusMessage = "";
|
|
|
|
void handleImportUpload() {
|
|
HTTPUpload& upload = server.upload();
|
|
|
|
// Demo-Modus Prüfung (Bleibt bestehen)
|
|
if (demoModus) {
|
|
if (upload.status == UPLOAD_FILE_START) {
|
|
importStatusMessage = F("Import im Demo-Modus nicht erlaubt!");
|
|
importSuccessful = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (upload.status) {
|
|
case UPLOAD_FILE_START:
|
|
|
|
eepromWriteAddress = 0; // Zähler zurücksetzen
|
|
importSuccessful = false; // Status zurücksetzen
|
|
importStatusMessage = ""; // Nachricht zurücksetzen
|
|
break;
|
|
|
|
case UPLOAD_FILE_WRITE:
|
|
// Prüfen, ob bereits ein Fehler aufgetreten ist (z.B. durch zu viele Daten im vorherigen Chunk)
|
|
if (importStatusMessage.length() > 0) {
|
|
yield();
|
|
break; // Beende die Verarbeitung für diesen Status
|
|
}
|
|
|
|
// Schreibe die empfangenen Bytes, aber schütze vor Überlauf
|
|
for (size_t i = 0; i < upload.currentSize; i++) {
|
|
// Prüfen, ob das Schreiben das EEPROM_SIZE Limit überschreiten würde
|
|
if (eepromWriteAddress < EEPROM_SIZE) {
|
|
EEPROM.write(eepromWriteAddress, upload.buf[i]);
|
|
eepromWriteAddress++;
|
|
} else {
|
|
// Es kommen MEHR Daten als erwartet!
|
|
Serial.printf("FEHLER: Mehr als %d Bytes empfangen! Adresse: %d. Breche ab.\n", EEPROM_SIZE, eepromWriteAddress);
|
|
importStatusMessage = F("Importfehler: Empfangene Datei zu groß!"); // 'ß' für ß
|
|
// Breche die Verarbeitung dieses Chunks und zukünftiger Chunks ab
|
|
goto end_write_processing; // Springe aus der Schleife und dem Case
|
|
}
|
|
}
|
|
end_write_processing:; // Sprungziel für goto
|
|
yield();
|
|
break;
|
|
|
|
case UPLOAD_FILE_END:
|
|
// FINALE GRÖSSENPRÜFUNG und Fehlerprüfung
|
|
|
|
// 1. Gab es einen Fehler während des Schreibens (z.B. Datei zu groß)?
|
|
if (importStatusMessage.length() > 0) {
|
|
Serial.println("Import nicht erfolgreich aufgrund vorheriger Fehler (z.B. Datei zu groß).");
|
|
importSuccessful = false; // Sicherstellen, dass Flag false ist
|
|
}
|
|
// 2. Wurden exakt EEPROM_SIZE Bytes verarbeitet?
|
|
else if (eepromWriteAddress == EEPROM_SIZE) {
|
|
if (EEPROM.commit()) {
|
|
importSuccessful = true;
|
|
importStatusMessage = F("Einstellungen erfolgreich importiert! Neustart wird durchgeführt..."); // 'ü' für ü
|
|
} else {
|
|
Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!");
|
|
importSuccessful = false;
|
|
importStatusMessage = F("Import fehlgeschlagen: EEPROM Commit Fehler!");
|
|
}
|
|
}
|
|
// 3. Wurden weniger oder mehr Bytes verarbeitet (und kein Fehler vorher gesetzt)?
|
|
else {
|
|
Serial.printf("FEHLER: Upload beendet, aber falsche Anzahl Bytes verarbeitet! Verarbeitet: %d, Erwartet: %d\n", eepromWriteAddress, EEPROM_SIZE);
|
|
importSuccessful = false;
|
|
importStatusMessage = F("Import fehlgeschlagen: Falsche Dateigröße!"); // 'ö', 'ß'
|
|
}
|
|
|
|
// Bereinigung für den nächsten Upload
|
|
eepromWriteAddress = 0;
|
|
break;
|
|
|
|
case UPLOAD_FILE_ABORTED:
|
|
Serial.println("EEPROM Import vom Client abgebrochen.");
|
|
importSuccessful = false;
|
|
// Nur Nachricht setzen, wenn nicht schon ein Fehler vorliegt
|
|
if (importStatusMessage.length() == 0) {
|
|
importStatusMessage = F("Import abgebrochen!");
|
|
}
|
|
eepromWriteAddress = 0;
|
|
break;
|
|
|
|
default:
|
|
Serial.printf("Unbekannter Upload-Status: %d\n", upload.status);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Import-Seite (nach dem Upload) - Unverändert
|
|
************************************************************************************/
|
|
void handleImportSettings() {
|
|
// Verwende die Statusvariablen, die von handleImportUpload gesetzt wurden
|
|
|
|
// Diese HTML-Teile werden im Browser angezeigt -> Umlaute ersetzen
|
|
static const char importDoneHtmlStart[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html><head><title>Einstellungen Import</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
static const char importDoneHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
|
|
static const char importDoneHtmlBody[] PROGMEM = R"rawliteral(
|
|
<body><h1>Einstellungen Import</h1>
|
|
<form><p>{status_message}</p></form>
|
|
</body></html>
|
|
)rawliteral";
|
|
|
|
String htmlResponse = FPSTR(importDoneHtmlStart);
|
|
htmlResponse += FPSTR(commonStyle); // Annahme: commonStyle enthält keine Umlaute/ß für Browserausgabe oder diese sind bereits korrekt kodiert
|
|
htmlResponse += FPSTR(importDoneHtmlHeadEnd);
|
|
htmlResponse += FPSTR(commonNav); // URLs angepasst
|
|
String body = FPSTR(importDoneHtmlBody);
|
|
|
|
body.replace(F("{status_message}"), importStatusMessage); // Nachricht einfügen (Umlaute wurden bereits in handleImportUpload ersetzt)
|
|
|
|
htmlResponse += body;
|
|
|
|
server.send(200, F("text/html"), htmlResponse);
|
|
|
|
// Wenn erfolgreich, Neustart nach kurzer Verzögerung
|
|
if (importSuccessful) {
|
|
Serial.println("Neustart in 5 Sekunden nach EEPROM-Import...");
|
|
delay(5000);
|
|
ESP.restart();
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Funktion zum Zurücksetzen auf Werkseinstellungen
|
|
************************************************************************************/
|
|
void resetToDefaults() {
|
|
Serial.println("Zurücksetzen auf Werkseinstellungen...");
|
|
|
|
// PID & Offset
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, defaultSetpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, defaultSetpointWasser);
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, defaultOffsetDampf);
|
|
EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, defaultOffsetWasser);
|
|
EEPROM.put(EEPROM_ADDR_KP_DAMPF, defaultKpDampf);
|
|
EEPROM.put(EEPROM_ADDR_KI_DAMPF, defaultKiDampf);
|
|
EEPROM.put(EEPROM_ADDR_KD_DAMPF, defaultKdDampf);
|
|
EEPROM.put(EEPROM_ADDR_KP_WASSER, defaultKpWasser);
|
|
EEPROM.put(EEPROM_ADDR_KI_WASSER, defaultKiWasser);
|
|
EEPROM.put(EEPROM_ADDR_KD_WASSER, defaultKdWasser);
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, defaultWindowSizeWasser);
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, defaultWindowSizeDampf);
|
|
|
|
// Boost-Funktion
|
|
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, defaultBoostWasserActive);
|
|
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, defaultBoostDampfActive);
|
|
|
|
// Heiz-Begrenzung
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, defaultPreventHeatAboveSetpointWasser);
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, defaultPreventHeatAboveSetpointDampf);
|
|
|
|
// Eco Modus
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, defaultEcoModeMinutes);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, defaultEcoModeTempWasser);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, defaultEcoModeTempDampf);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, false); // Default für Dynamic Eco
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, defaultDampfVerzoegerung);
|
|
|
|
// Geräteinfo
|
|
char tempInfo[50];
|
|
strncpy(tempInfo, defaultInfoHersteller.c_str(), sizeof(tempInfo) - 1);
|
|
tempInfo[sizeof(tempInfo) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, tempInfo);
|
|
strncpy(tempInfo, defaultInfoModell.c_str(), sizeof(tempInfo) - 1);
|
|
tempInfo[sizeof(tempInfo) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_MODELL, tempInfo);
|
|
strncpy(tempInfo, defaultInfoZusatz.c_str(), sizeof(tempInfo) - 1);
|
|
tempInfo[sizeof(tempInfo) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, tempInfo);
|
|
|
|
// Fast Heat Up
|
|
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, false); // Default für Fast Heat Up
|
|
|
|
// Profile (mit Default-Werten überschreiben)
|
|
TemperatureProfile defaultProfile;
|
|
defaultProfile.setpointWasser = defaultSetpointWasser;
|
|
defaultProfile.setpointDampf = defaultSetpointDampf;
|
|
defaultProfile.ecoModeMinutes = defaultEcoModeMinutes;
|
|
defaultProfile.dynamicEcoActive = false;
|
|
defaultProfile.dampfVerzoegerung = defaultDampfVerzoegerung;
|
|
strncpy(defaultProfile.profileName, "Unbenannt", sizeof(defaultProfile.profileName));
|
|
defaultProfile.profileName[sizeof(defaultProfile.profileName) - 1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_PROFILE_1, defaultProfile);
|
|
EEPROM.put(EEPROM_ADDR_PROFILE_2, defaultProfile);
|
|
|
|
// AutoTune Parameter
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, defaultTuningStepWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, defaultTuningNoiseWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, defaultTuningStartValueWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, defaultTuningLookBackWasser);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, defaultTuningStepDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, defaultTuningNoiseDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, defaultTuningStartValueDampf);
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, defaultTuningLookBackDampf);
|
|
|
|
// Sicherheitslimits (verwenden die globalen Variablen, die beim Start initialisiert wurden)
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); // Diese sollten eigentlich auch Defaults haben
|
|
EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); // TODO: Eigene Defaults definieren?
|
|
|
|
// // WiFi Konfiguration löschen (erzwingt AP-Modus beim nächsten Start)
|
|
// char emptyMagic[5] = {0};
|
|
// EEPROM.put(EEPROM_ADDR_WIFI_CONFIG_MAGIC, emptyMagic);
|
|
|
|
// // Hostname zurücksetzen
|
|
// strncpy(hostname, defaultHostname.c_str(), sizeof(hostname) - 1);
|
|
// hostname[sizeof(hostname) - 1] = '\0';
|
|
// EEPROM.put(EEPROM_ADDR_HOSTNAME, hostname);
|
|
|
|
// Wartungsintervall zurücksetzen
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, 0UL);
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, defaultMaintenanceInterval);
|
|
|
|
// // Zähler zurücksetzen (optional, aber sinnvoll)
|
|
// EEPROM.put(EEPROM_ADDR_RUNTIME, 0UL);
|
|
// EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, 0UL);
|
|
|
|
// Magic Value für allgemeine Einstellungen setzen (wichtig!)
|
|
EEPROM.put(EEPROM_ADDR_MAGICVALUE, storageMagicValue);
|
|
|
|
// Alle Änderungen ins EEPROM schreiben
|
|
if (EEPROM.commit()) {
|
|
} else {
|
|
Serial.println("FEHLER: Schreiben der Werkseinstellungen ins EEPROM fehlgeschlagen!");
|
|
}
|
|
|
|
// WICHTIG: Globale Variablen auch auf Default setzen, damit der laufende Betrieb korrekt ist
|
|
// bis zum Neustart (obwohl ein Neustart nach Reset empfohlen wird)
|
|
maintenanceInterval = defaultMaintenanceInterval;
|
|
maintenanceIntervalCounter = 0;
|
|
boostWasserActive = defaultBoostWasserActive;
|
|
boostDampfActive = defaultBoostDampfActive;
|
|
preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser;
|
|
preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf;
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname)-1); hostname[sizeof(hostname)-1] = '\0';
|
|
|
|
windowSizeWasser = defaultWindowSizeWasser;
|
|
windowSizeDampf = defaultWindowSizeDampf;
|
|
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser);
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für das Zurücksetzen auf Werkseinstellungen
|
|
************************************************************************************/
|
|
void handleResetDefaults() {
|
|
if (!demoModus) {
|
|
resetToDefaults(); // Führt das eigentliche Reset durch
|
|
} else {
|
|
// Optional: Fehlermeldung an den Client senden
|
|
server.send(403, F("text/plain"), F("Reset im Demo-Modus nicht erlaubt."));
|
|
return;
|
|
}
|
|
|
|
// Bestätigungsseite senden und Neustart auslösen
|
|
static const char resetDoneHtmlStart[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html><html><head><title>Werkseinstellungen</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
static const char resetDoneHtmlHeadEnd[] PROGMEM = R"rawliteral(</head>)rawliteral";
|
|
static const char resetDoneHtmlBody[] PROGMEM = R"rawliteral(
|
|
<body><h1>Werkseinstellungen</h1>
|
|
<form><p>Alle Einstellungen wurden auf die Werkseinstellungen zurückgesetzt. Das Gerät wird neu gestartet...</p></form>
|
|
</body></html>
|
|
)rawliteral";
|
|
|
|
String htmlResponse = FPSTR(resetDoneHtmlStart);
|
|
htmlResponse += FPSTR(commonStyle);
|
|
htmlResponse += FPSTR(resetDoneHtmlHeadEnd);
|
|
htmlResponse += FPSTR(commonNav); // URLs angepasst
|
|
htmlResponse += FPSTR(resetDoneHtmlBody);
|
|
|
|
server.send(200, F("text/html"), htmlResponse);
|
|
|
|
delay(5000); // Kurz warten, damit der Browser die Seite anzeigen kann
|
|
ESP.restart();
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Variable für Systemstartzeit (relevant für die Dampf-Verzögerung) - Unverändert
|
|
************************************************************************************/
|
|
|
|
unsigned long startupTime;
|
|
|
|
/************************************************************************************
|
|
* setup(): Initialisierung des Systems - Angepasst für neue EEPROM-Werte, Hostname, URLs
|
|
************************************************************************************/
|
|
|
|
void setup()
|
|
{
|
|
Serial.begin(115200);
|
|
EEPROM.begin(EEPROM_SIZE); // Größe angepasst
|
|
|
|
pinMode(SSR_DAMPF_PIN, OUTPUT);
|
|
pinMode(SSR_WASSER_PIN, OUTPUT);
|
|
pinMode(SHOT_TIMER_PIN, INPUT_PULLUP);
|
|
|
|
// Initialisiere SSRs als AUS
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
digitalWrite(SSR_WASSER_PIN, LOW);
|
|
|
|
// Prüfen, ob MagicValue bereits im EEPROM liegt
|
|
char storedMagicValue[5] = { 0 };
|
|
bool magicValueVorhanden = false;
|
|
EEPROM.get(EEPROM_ADDR_MAGICVALUE, storedMagicValue);
|
|
|
|
if (strncmp(storedMagicValue, storageMagicValue, 4) == 0)
|
|
{
|
|
magicValueVorhanden = true;
|
|
}
|
|
else
|
|
{
|
|
magicValueVorhanden = false;
|
|
// Hier noch KEINE Defaults schreiben, das passiert unten gebündelt
|
|
// Nur Magic Value setzen, falls er fehlt
|
|
// EEPROM.put(EEPROM_ADDR_MAGICVALUE, storageMagicValue);
|
|
// EEPROM.commit(); // Commit erfolgt später
|
|
}
|
|
|
|
// Werte aus dem EEPROM laden, wenn vorhanden, sonst mit Default-Werten initialisieren
|
|
if (magicValueVorhanden)
|
|
{
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf);
|
|
EEPROM.get(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser);
|
|
EEPROM.get(EEPROM_ADDR_KP_DAMPF, KpDampf);
|
|
EEPROM.get(EEPROM_ADDR_KI_DAMPF, KiDampf);
|
|
EEPROM.get(EEPROM_ADDR_KD_DAMPF, KdDampf);
|
|
EEPROM.get(EEPROM_ADDR_KP_WASSER, KpWasser);
|
|
EEPROM.get(EEPROM_ADDR_KI_WASSER, KiWasser);
|
|
EEPROM.get(EEPROM_ADDR_KD_WASSER, KdWasser);
|
|
EEPROM.get(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
|
|
EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser);
|
|
EEPROM.get(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf);
|
|
EEPROM.get(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller);
|
|
EEPROM.get(EEPROM_ADDR_INFO_MODELL, infoModell);
|
|
EEPROM.get(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz);
|
|
EEPROM.get(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
|
|
EEPROM.get(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
|
|
EEPROM.get(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
|
|
// --- Bestehende Tuning-Parameter laden (jetzt Wasser) ---
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser); // Name geändert
|
|
EEPROM.get(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser); // Name geändert
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser); // Name geändert
|
|
EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser); // Name geändert
|
|
// --- Dampf-Tuning-Parameter laden ---
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf);
|
|
EEPROM.get(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf);
|
|
// Sicherheitslimits laden
|
|
EEPROM.get(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser);
|
|
EEPROM.get(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf);
|
|
// Wartungsintervall laden
|
|
EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
|
|
// Boost-Funktion
|
|
EEPROM.get(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
|
|
EEPROM.get(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
|
|
// Heiz-Begrenzung
|
|
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
|
|
// Zeitfenster laden
|
|
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
|
|
EEPROM.get(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
|
|
|
|
// Sicherstellen, dass Strings nullterminiert sind
|
|
infoHersteller[sizeof(infoHersteller)-1] = '\0';
|
|
infoModell[sizeof(infoModell)-1] = '\0';
|
|
infoZusatz[sizeof(infoZusatz)-1] = '\0';
|
|
|
|
}
|
|
else // Magic Value fehlt -> ALLES auf Default setzen
|
|
{
|
|
// Setze globale Variablen auf Defaults
|
|
SetpointDampf = defaultSetpointDampf;
|
|
SetpointWasser = defaultSetpointWasser;
|
|
OffsetDampf = defaultOffsetDampf;
|
|
OffsetWasser = defaultOffsetWasser;
|
|
KpDampf = defaultKpDampf;
|
|
KiDampf = defaultKiDampf;
|
|
KdDampf = defaultKdDampf;
|
|
KpWasser = defaultKpWasser;
|
|
KiWasser = defaultKiWasser;
|
|
KdWasser = defaultKdWasser;
|
|
ecoModeMinutes = defaultEcoModeMinutes;
|
|
ecoModeTempWasser = defaultEcoModeTempWasser;
|
|
ecoModeTempDampf = defaultEcoModeTempDampf;
|
|
strncpy(infoHersteller, defaultInfoHersteller.c_str(), sizeof(infoHersteller)-1); infoHersteller[sizeof(infoHersteller)-1] = '\0';
|
|
strncpy(infoModell, defaultInfoModell.c_str(), sizeof(infoModell)-1); infoModell[sizeof(infoModell)-1] = '\0';
|
|
strncpy(infoZusatz, defaultInfoZusatz.c_str(), sizeof(infoZusatz)-1); infoZusatz[sizeof(infoZusatz)-1] = '\0';
|
|
dynamicEcoActive = false;
|
|
fastHeatUpAktiv = false;
|
|
dampfVerzoegerung = defaultDampfVerzoegerung;
|
|
tuningStepWasser = defaultTuningStepWasser;
|
|
tuningNoiseWasser = defaultTuningNoiseWasser;
|
|
tuningStartValueWasser = defaultTuningStartValueWasser;
|
|
tuningLookBackWasser = defaultTuningLookBackWasser;
|
|
tuningStepDampf = defaultTuningStepDampf;
|
|
tuningNoiseDampf = defaultTuningNoiseDampf;
|
|
tuningStartValueDampf = defaultTuningStartValueDampf;
|
|
tuningLookBackDampf = defaultTuningLookBackDampf;
|
|
maintenanceInterval = defaultMaintenanceInterval;
|
|
maintenanceIntervalCounter = 0;
|
|
boostWasserActive = defaultBoostWasserActive;
|
|
boostDampfActive = defaultBoostDampfActive;
|
|
preventHeatAboveSetpointWasser = defaultPreventHeatAboveSetpointWasser;
|
|
preventHeatAboveSetpointDampf = defaultPreventHeatAboveSetpointDampf;
|
|
strncpy(hostname, defaultHostname.c_str(), sizeof(hostname)-1); hostname[sizeof(hostname)-1] = '\0';
|
|
|
|
// Rufe resetToDefaults auf, um EEPROM zu schreiben
|
|
resetToDefaults(); // Schreibt alle Defaults und committet
|
|
}
|
|
|
|
// WiFi-Einstellungen laden und ggf. herstellen
|
|
WiFiConfig wifiConfig;
|
|
bool hasWiFiConfig = loadWiFiConfig(wifiConfig); // Lädt auch Hostname
|
|
|
|
WiFi.hostname(hostname); // Hostname setzen VOR begin/softAP
|
|
|
|
if (hasWiFiConfig)
|
|
{
|
|
WiFi.mode(WIFI_STA);
|
|
|
|
if (wifiConfig.useStaticIP)
|
|
{
|
|
WiFi.config(wifiConfig.staticIP, wifiConfig.gateway, wifiConfig.subnet);
|
|
}
|
|
|
|
Serial.printf("Verbinde mit SSID: %s, Hostname: %s\n", wifiConfig.ssid, hostname);
|
|
WiFi.begin(wifiConfig.ssid, wifiConfig.password);
|
|
int retries = 0;
|
|
|
|
while (WiFi.status() != WL_CONNECTED && retries < 20)
|
|
{
|
|
delay(500);
|
|
Serial.print(F("."));
|
|
retries++;
|
|
}
|
|
|
|
if (WiFi.status() == WL_CONNECTED)
|
|
{
|
|
Serial.print(F("IP-Adresse: ")); Serial.println(WiFi.localIP());
|
|
// Demo-Modus aktivieren, wenn es sich beim verwendeten Controller um den Demo-Controller handelt
|
|
if(WiFi.localIP() == demoModusIP) {demoModus = true;}
|
|
// mDNS starten
|
|
if (MDNS.begin(hostname)) {
|
|
Serial.println("mDNS Responder gestartet");
|
|
MDNS.addService("http", "tcp", 80); // Service für Webserver anmelden
|
|
} else {
|
|
Serial.println("Fehler beim Starten des mDNS Responders!");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Serial.println(F("\nKonnte keine Verbindung herstellen! Starte AP-Modus..."));
|
|
startAPMode();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Serial.println("Keine WLAN-Konfiguration gefunden. Starte AP-Modus...");
|
|
startAPMode();
|
|
}
|
|
|
|
// Wartungszähler, Betriebszeit und Shotcounter aus EEPROM laden
|
|
EEPROM.get(EEPROM_ADDR_RUNTIME, totalRuntime);
|
|
if (totalRuntime == ULONG_MAX || totalRuntime > (86400UL * 365 * 10)) { // Plausibilitätscheck (z.B. > 10 Jahre)
|
|
Serial.println("Ungültige Betriebszeit im EEPROM, setze auf 0.");
|
|
totalRuntime = 0;
|
|
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); // Korrigierten Wert speichern
|
|
EEPROM.commit();
|
|
}
|
|
|
|
EEPROM.get(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
if (shotCounter == ULONG_MAX || shotCounter > 1000000UL) // Plausibilitätscheck
|
|
{
|
|
Serial.println("Ungültiger Shotcounter im EEPROM, setze auf 0.");
|
|
shotCounter = 0;
|
|
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
EEPROM.commit();
|
|
}
|
|
|
|
EEPROM.get(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
if (maintenanceIntervalCounter == ULONG_MAX || maintenanceIntervalCounter > 1000000UL) // Plausibilitätscheck
|
|
{
|
|
Serial.println("Ungültiger Wartungszähler im EEPROM, setze auf 0.");
|
|
maintenanceIntervalCounter = 0;
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
EEPROM.commit();
|
|
}
|
|
|
|
if (fastHeatUpAktiv)
|
|
{
|
|
fastHeatUpHeating = true;
|
|
}
|
|
|
|
// OLED-Initialisierung
|
|
Wire.begin(OLED_SDA, OLED_SCK);
|
|
if (!display.begin(0x3C, true))
|
|
{
|
|
Serial.println(F("Display konnte nicht initialisiert werden!"));
|
|
}
|
|
|
|
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-Controller"));
|
|
display.print(F("Version: "));
|
|
display.println(version);
|
|
display.println(F("von Thomas M\201ller")); // \201 ist ü für manche Displays
|
|
display.display();
|
|
delay(delayInit1);
|
|
|
|
// --- PID-Regler Konfiguration ---
|
|
// Plausibilitätsprüfung - Bei Bedarf auf Standard-Wert setzen, wenn nicht plausibel
|
|
if (windowSizeWasser == 0 || windowSizeWasser > 60000) {
|
|
windowSizeWasser = defaultWindowSizeWasser;
|
|
}
|
|
if (windowSizeDampf == 0 || windowSizeDampf > 60000) {
|
|
windowSizeDampf = defaultWindowSizeDampf;
|
|
}
|
|
// Setze Output-Grenzen auf die Fenstergröße für zeitproportionale Steuerung
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser);
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
|
|
// Setze PID-Parameter
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Setze PID-Modus auf AUTOMATIC
|
|
pidDampf.SetMode(AUTOMATIC);
|
|
pidWasser.SetMode(AUTOMATIC);
|
|
|
|
// Initialisiere Startzeiten für die Zeitfenster
|
|
windowStartTimeWasser = millis();
|
|
windowStartTimeDampf = millis();
|
|
|
|
// Anzeige zur WiFi-Verbindung
|
|
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(hostname);
|
|
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);
|
|
|
|
// --- LittleFS Initialisierung ---
|
|
if (!LittleFS.begin()) {
|
|
Serial.println("LittleFS Mount fehlgeschlagen!");
|
|
// Hier könntest du entscheiden, ob der ESP anhalten soll oder
|
|
// versuchen soll, das Dateisystem zu formatieren (LittleFS.format())
|
|
// Fürs Erste geben wir nur einen Fehler aus.
|
|
display.clearDisplay();
|
|
display.setCursor(0,0);
|
|
display.println("FEHLER:");
|
|
display.println("LittleFS konnte");
|
|
display.println("nicht initialisiert");
|
|
display.println("werden!");
|
|
display.display();
|
|
while(1) delay(100); // Anhalten bei Fehler
|
|
} else {
|
|
Serial.println("LittleFS erfolgreich gemountet.");
|
|
// Optional: Liste beim Start Dateien auf
|
|
Dir dir = LittleFS.openDir("/");
|
|
Serial.println("Dateien im LittleFS:");
|
|
while (dir.next()) {
|
|
Serial.print(" - ");
|
|
Serial.print(dir.fileName());
|
|
Serial.print(" (");
|
|
Serial.print(dir.fileSize());
|
|
Serial.println(" Bytes)");
|
|
}
|
|
Serial.println("--------------------");
|
|
}
|
|
|
|
// Routen für Webserver (URLs angepasst)
|
|
server.on("/", handleRoot);
|
|
server.on("/Info", handleInfo); // URL geändert
|
|
server.on("/ECO", handleEco); // URL geändert
|
|
server.on("/Fast-Heat-Up", handleFastHeatUp); // URL geändert
|
|
server.on("/updateFast-Heat-Up-Settings", handleFastHeatUpSettings);
|
|
server.on("/updateSettings", handleUpdate);
|
|
server.on("/updateInfoSettings", handleInfoUpdate);
|
|
server.on("/updateMaintenanceSettings", handleMaintenanceUpdate);
|
|
server.on("/updateEcoSettings", handleEcoUpdate);
|
|
server.on("/Dateimanager", HTTP_GET, handleFileManager);
|
|
server.on("/uploadFile", HTTP_POST, handleFileUploaded, handleFileUpload);
|
|
server.on("/deleteFile", HTTP_GET, handleFileDelete);
|
|
server.on("/chart.umd.min.js", HTTP_GET, handleServeChartJs);
|
|
server.on("/downloadFile", HTTP_GET, handleFileDownload);
|
|
|
|
// AutoTune-Route (Wasser)
|
|
server.on("/autoTuneWasser", []() {
|
|
String page = F("<!DOCTYPE html>\n<html>\n<head>\n<title>AutoTune Wasser</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>\n");
|
|
page += FPSTR(commonStyle);
|
|
page += F("</head>\n");
|
|
page += FPSTR(commonNav); // URLs angepasst
|
|
page += F("<body>\n<h1>PID-Tuning</h1>\n");
|
|
if (!autoTuneWasserActive && !autoTuneDampfActive)
|
|
{
|
|
startAutoTuneWasser();
|
|
page += F("<form action='/abbruch-pid-tuning' method='POST' >\n"
|
|
"<p>AutoTune gestartet: Wasser-PID</p>\n"
|
|
"<br>\n"
|
|
"<input type='submit' value='PID-Tuning abbrechen'>\n"
|
|
"</form>\n");
|
|
}
|
|
else
|
|
{
|
|
page += F("<form action='/abbruch-pid-tuning' method='POST' >\n"
|
|
"<p style=\"color: red;\">AutoTune ist bereits aktiv!</p>\n"
|
|
"<br>\n"
|
|
"<input type='submit' value='PID-Tuning abbrechen'>\n"
|
|
"</form>\n");
|
|
}
|
|
page += F("</body>\n</html>");
|
|
server.send(200, F("text/html"), page);
|
|
});
|
|
|
|
// AutoTune-Route (Dampf)
|
|
server.on("/autoTuneDampf", []() {
|
|
String page = F("<!DOCTYPE html>\n<html>\n<head>\n<title>AutoTune Dampf</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>\n");
|
|
page += FPSTR(commonStyle);
|
|
page += F("</head>\n");
|
|
page += FPSTR(commonNav); // URLs angepasst
|
|
page += F("<body>\n<h1>PID-Tuning</h1>\n");
|
|
if (!autoTuneWasserActive && !autoTuneDampfActive)
|
|
{
|
|
startAutoTuneDampf();
|
|
page += F("<form action='/abbruch-pid-tuning' method='POST' >\n"
|
|
"<p>AutoTune gestartet: Dampf-PID</p>\n"
|
|
"<br>\n"
|
|
"<input type='submit' value='PID-Tuning abbrechen'>\n"
|
|
"</form>\n");
|
|
}
|
|
else
|
|
{
|
|
page += F("<form action='/abbruch-pid-tuning' method='POST' >\n"
|
|
"<p style=\"color: red;\">AutoTune ist bereits aktiv!</p>\n"
|
|
"<br>\n"
|
|
"<input type='submit' value='PID-Tuning abbrechen'>\n"
|
|
"</form>\n");
|
|
}
|
|
page += F("</body>\n</html>");
|
|
server.send(200, F("text/html"), page);
|
|
});
|
|
|
|
// Hauptseite PID-Tuning (Info, Starten etc.)
|
|
server.on("/PID-Tuning", HTTP_GET, []() {
|
|
String page = F("<!DOCTYPE html>\n<html>\n<head>\n<title>PID-Tuning</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>\n");
|
|
page += FPSTR(commonStyle);
|
|
page += F("</head>\n");
|
|
page += FPSTR(commonNav); // URLs angepasst
|
|
page += F("<body>\n");
|
|
if (autoTuneWasserActive || autoTuneDampfActive)
|
|
{
|
|
page += F("<form action='/abbruch-pid-tuning' method='POST' >\n"
|
|
"<h3>Laufendes PID-Tuning abbrechen?</h3>\n"
|
|
"<input type='submit' value='Abbrechen'>\n"
|
|
"</form></br>\n");
|
|
}
|
|
page += F("<h1>PID-Tuning</h1>\n"
|
|
"<form>Das automatische PID-Tuning dient dazu, die optimalen Parameter für den PID-Regler (Proportional-, Integral- und Differentialanteil) selbständig zu ermitteln. Dabei analysiert das System mithilfe von Algorithmen das Regelverhalten (z. B. Reaktion auf einen Testimpuls) und passt Kp, Ki und Kd so an, dass gewünschte Kriterien wie kurze Einschwingzeit, geringe Überschwingung und stabile Regelung erreicht werden"
|
|
"<br><br>"
|
|
"Ein AutoTune-Vorgang kann durchaus bis zu 60 Minuten in Anspruch nehmen!"
|
|
"<br><br>"
|
|
"<b style='color: #FFCC00;'>WICHTIG:</b><br>"
|
|
"Vor dem Start des PID-Tunings sollte die Maschine die gewünschte Betriebstemperatur erreicht haben und stabil sein.<br>"
|
|
"Andernfalls könnten die automatisch ermittelten PID-Werte für diesen Temperaturbereich ungenau oder nicht optimal sein."
|
|
"<br><br>"
|
|
"</form>"
|
|
"<form action='/autoTuneWasser' method='POST' >\n"
|
|
"<h3>Automatisches PID-Tuning für Wasser</h3>\n"
|
|
"Automatische Ermittlung der PID-Werte für Wasser / Kessel<br>"
|
|
"<input type='submit' value='Tuning starten'>\n"
|
|
"</form>\n"
|
|
"<form action='/autoTuneDampf' method='POST' >\n"
|
|
"<h3>Automatisches PID-Tuning für Dampf</h3>\n"
|
|
"Automatische Ermittlung der PID-Werte für Dampf / Thermoblock<br>"
|
|
"<input type='submit' value='Tuning starten'>\n"
|
|
"</form>\n");
|
|
|
|
// Form für AutoTune-Parameter
|
|
page += F("<form action='/updateAutotuneSettings' method='POST'>\n"
|
|
"<h3>AutoTune-Parameter (Zeitproportional)</h3>\n" // Titel angepasst
|
|
"Hier können die AutoTune-Parameter geändert werden.<br>Änderungen sollten mit Vorsicht durchgeführt werden!<br><br>"
|
|
"<h3>Parameter Wasser / Kessel</h3>"
|
|
"<label for='tuningStepWasser'>Step (Output 0-{WINDOW_SIZE_WASSER}):</label>" // Beschreibung angepasst
|
|
"<input type='text' id='tuningStepWasser' name='tuningStepWasser' value='{TUNING_STEP_WASSER}'>"
|
|
"<label for='tuningNoiseWasser'>Noise (°C):</label>" // Einheit hinzugefügt
|
|
"<input type='text' id='tuningNoiseWasser' name='tuningNoiseWasser' value='{TUNING_NOISE_WASSER}'>"
|
|
"<label for='tuningStartValueWasser'>StartValue (Output 0-{WINDOW_SIZE_WASSER}):</label>" // Beschreibung angepasst
|
|
"<input type='text' id='tuningStartValueWasser' name='tuningStartValueWasser' value='{TUNING_STARTVALUE_WASSER}'>"
|
|
"<label for='tuningLookBackWasser'>LookBack (Sekunden):</label>"
|
|
"<input type='text' id='tuningLookBackWasser' name='tuningLookBackWasser' value='{TUNING_LOOKBACK_WASSER}'>"
|
|
|
|
"<br><h3>Parameter Dampf / Thermoblock</h3>"
|
|
"<label for='tuningStepDampf'>Step (Output 0-{WINDOW_SIZE_DAMPF}):</label>" // Beschreibung angepasst
|
|
"<input type='text' id='tuningStepDampf' name='tuningStepDampf' value='{TUNING_STEP_DAMPF}'>"
|
|
"<label for='tuningNoiseDampf'>Noise (°C):</label>" // Einheit hinzugefügt
|
|
"<input type='text' id='tuningNoiseDampf' name='tuningNoiseDampf' value='{TUNING_NOISE_DAMPF}'>"
|
|
"<label for='tuningStartValueDampf'>StartValue (Output 0-{WINDOW_SIZE_DAMPF}):</label>" // Beschreibung angepasst
|
|
"<input type='text' id='tuningStartValueDampf' name='tuningStartValueDampf' value='{TUNING_STARTVALUE_DAMPF}'>"
|
|
"<label for='tuningLookBackDampf'>LookBack (Sekunden):</label>"
|
|
"<input type='text' id='tuningLookBackDampf' name='tuningLookBackDampf' value='{TUNING_LOOKBACK_DAMPF}'>"
|
|
"<input type='submit' value='Parameter speichern'>\n"
|
|
"</form>\n"
|
|
"<br><hr><br>"
|
|
"<form>"
|
|
"<h3>Erklärung der Parameter (Zeitproportional):</h3>\n"
|
|
"<p><strong>Noise (°C):</strong><br>\n" // Grad-Symbol ersetzt
|
|
"Beobachte das \"Zittern\" der Temperaturanzeige, wenn die Temperatur stabil (nahe am Zielwert orientiert) ist.<br>Wenn sie z.B. um +/- 0.5°C schwankt, setze Noise auf <code>1.0</code>.<br>Dieser Wert definiert ein Toleranzband, um das Sensorrauschen zu ignorieren.</p>\n" // Grad-Symbol ersetzt
|
|
|
|
"<p><strong>StartValue (ms):</strong><br>\n"
|
|
"Schätze, wie viel Heizzeit (in Millisekunden, innerhalb des eingestellten Zeitfensters (siehe PID-Einstellung) nötig ist, um die Zieltemperatur konstant zu halten.<br>Dies ist die durchschnittliche Heizzeit im stabilen Zustand (z.B. <code>250</code> ms).</p>\n" // ä, ö ersetzt
|
|
|
|
"<p><strong>Step (ms):</strong><br>\n"
|
|
"Die Größe des \"Sprungs\" der Heizzeit (in ms) nach oben/unten um den <code>StartValue</code>, den AutoTune nutzt, um die Temperatur zum Schwingen zu zwingen.<br>Muss groß genug für eine Reaktion sein, aber nicht 0-100% (z.B. <code>500</code> ms).</p>\n" // ö, ß, ü ersetzt
|
|
|
|
"<p><strong>LookBack (s):</strong><br>\n"
|
|
"AutoTune erzeugt eine *langsame* Welle der Temperatur. Miss (in einem Testlauf) oder schätze, wie lange eine volle Welle dauert (Spitze zu Spitze, in Sekunden).<br>Setze Lookback auf einen Wert, der *deutlich länger* ist als diese Dauer (z.B. 1.5x so lang; Welle=130s => Lookback=<code>180</code>s).<br>Dieser Wert muss *vor* dem Start festgelegt werden und sagt AutoTune, wie weit es zurückschauen soll, um die Welle zu messen.</p>\n" // ä, ö, ü ersetzt
|
|
|
|
"<p><strong>Kontrolle per Chart:</strong><br>\n"
|
|
"Während AutoTune läuft, beobachte das Temperaturchart.<br>Wenn sich dort eine langsame, regelmäßige Welle (wie ein \"langgezogener Sinus\") um den Sollwert entwickelt, ist das ein gutes Zeichen, dass der Prozess funktioniert.</p>\n" // Ä, ä, ö, ä, ß ersetzt
|
|
"</form>"
|
|
"</body>\n</html>");
|
|
|
|
// --- Placeholders ersetzen ---
|
|
page.replace("{WINDOW_SIZE_WASSER}", String(windowSizeWasser)); // NEU
|
|
page.replace("{WINDOW_SIZE_DAMPF}", String(windowSizeDampf)); // NEU
|
|
page.replace("{TUNING_STEP_WASSER}", String(tuningStepWasser, 2));
|
|
page.replace("{TUNING_NOISE_WASSER}", String(tuningNoiseWasser, 2));
|
|
page.replace("{TUNING_STARTVALUE_WASSER}", String(tuningStartValueWasser, 2));
|
|
page.replace("{TUNING_LOOKBACK_WASSER}", String(tuningLookBackWasser));
|
|
|
|
page.replace("{TUNING_STEP_DAMPF}", String(tuningStepDampf, 2));
|
|
page.replace("{TUNING_NOISE_DAMPF}", String(tuningNoiseDampf, 2));
|
|
page.replace("{TUNING_STARTVALUE_DAMPF}", String(tuningStartValueDampf, 2));
|
|
page.replace("{TUNING_LOOKBACK_DAMPF}", String(tuningLookBackDampf));
|
|
|
|
server.send(200, F("text/html"), page);
|
|
});
|
|
|
|
// Speichern der AutoTune-Einstellungen
|
|
server.on("/updateAutotuneSettings", HTTP_POST, []() {
|
|
bool changed = false; // Flag, um zu prüfen, ob EEPROM.commit() nötig ist
|
|
|
|
// --- Wasser Parameter ---
|
|
if (server.hasArg(F("tuningStepWasser")))
|
|
{
|
|
tuningStepWasser = server.arg(F("tuningStepWasser")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_WASSER, tuningStepWasser);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("tuningNoiseWasser")))
|
|
{
|
|
tuningNoiseWasser = server.arg(F("tuningNoiseWasser")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_WASSER, tuningNoiseWasser);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("tuningStartValueWasser")))
|
|
{
|
|
tuningStartValueWasser = server.arg(F("tuningStartValueWasser")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_WASSER, tuningStartValueWasser);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("tuningLookBackWasser")))
|
|
{
|
|
tuningLookBackWasser = server.arg(F("tuningLookBackWasser")).toInt();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_WASSER, tuningLookBackWasser);
|
|
changed = true;
|
|
}
|
|
|
|
// --- Dampf Parameter (NEU) ---
|
|
if (server.hasArg(F("tuningStepDampf")))
|
|
{
|
|
tuningStepDampf = server.arg(F("tuningStepDampf")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STEP_DAMPF, tuningStepDampf);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("tuningNoiseDampf")))
|
|
{
|
|
tuningNoiseDampf = server.arg(F("tuningNoiseDampf")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_NOISE_DAMPF, tuningNoiseDampf);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("tuningStartValueDampf")))
|
|
{
|
|
tuningStartValueDampf = server.arg(F("tuningStartValueDampf")).toFloat();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_STARTVALUE_DAMPF, tuningStartValueDampf);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("tuningLookBackDampf")))
|
|
{
|
|
tuningLookBackDampf = server.arg(F("tuningLookBackDampf")).toInt();
|
|
EEPROM.put(EEPROM_ADDR_TUNING_LOOKBACK_DAMPF, tuningLookBackDampf);
|
|
changed = true;
|
|
}
|
|
|
|
// Nur speichern, wenn sich etwas geändert hat
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
server.sendHeader(F("Location"), F("/PID-Tuning")); // URL bleibt gleich
|
|
server.send(303);
|
|
});
|
|
|
|
// Chart-Anzeige-Handler - URL angepasst
|
|
server.on("/Charts", handleCharts); // URL geändert
|
|
|
|
// JSON-Daten für Temperaturwerte - Unverändert
|
|
server.on("/data", []() {
|
|
String json = F("{");
|
|
json += F("\"wasser\":") + String(InputWasser) + F(",");
|
|
json += F("\"setpointwasser\":") + String(SetpointWasser) + F(",");
|
|
json += F("\"dampf\":") + String(InputDampf) + F(",");
|
|
json += F("\"setpointdampf\":") + String(SetpointDampf);
|
|
json += F("}");
|
|
server.send(200, F("application/json"), json);
|
|
});
|
|
|
|
// Firmware-Update-Seite - URL angepasst, Reset-Button hinzugefügt
|
|
server.on("/Firmware", HTTP_GET, []() { // URL geändert
|
|
String page = F("<!DOCTYPE html>\n<html>\n<head>\n<title>Firmware & Einstellungen</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>\n"); // Titel angepasst
|
|
page += FPSTR(commonStyle);
|
|
page += F("</head>\n");
|
|
page += FPSTR(commonNav); // URLs angepasst
|
|
// ----- HIER BEGINNT DER GROSSE F() BLOCK -----
|
|
page += F("<body>\n"
|
|
"<h1>Firmware & Einstellungen</h1>\n" // Titel angepasst
|
|
// --- Firmware Info Sektion (unverändert) ---
|
|
"<form>\n"
|
|
"<h3>Firmware-Info</h3>\n"
|
|
"Firmware-Version: {FIRMWAREVERSION}<br>\n"
|
|
"Hersteller: {SWHERSTELLER}<br>\n"
|
|
"E-Mail: {SWHERSTELLERMAIL}<br>\n"
|
|
"Website: {SWHERSTELLERWEBSITE}<br>\n"
|
|
"</form>\n"
|
|
// --- Firmware Update Sektion (unverändert) ---
|
|
"<form method='POST' action='/update' enctype='multipart/form-data'>\n"
|
|
"<h3>Firmware-Update</h3>\n"
|
|
"Das Firmware-Update kann durch den Upload einer .bin-Datei durchgeführt werden.<br>\n"
|
|
"Nach dem Update wird ein automatischer Neustart durchgeführt.<br>\n"
|
|
"Sollte der Neustart nicht erfolgen, so kann dieser auch durch kurzzeitiges Trennen der Stromversorgung erfolgen.<br>\n"
|
|
"<br>\n"
|
|
"<input type='file' name='firmware' accept='.bin'>\n" // accept hinzugefügt
|
|
"<br>"
|
|
"<button>Update starten</button>\n"
|
|
"</form>\n"
|
|
|
|
// --- Dateimanager ---
|
|
|
|
// Export
|
|
"<form method='GET' action='/Dateimanager' target='_blank'enctype='multipart/form-data'>\n"
|
|
"<h3>Dateimanager</h3>"
|
|
"Verwaltung von Dateien des Systems.<br>\n"
|
|
"<button type='submit' >Dateimanager</button>\n"
|
|
"</form>\n"
|
|
|
|
// --- NEUE SEKTION: Einstellungen Import/Export ---
|
|
|
|
// Export
|
|
"<form method='GET' action='/exportSettings' enctype='multipart/form-data'>\n"
|
|
"<h3>Einstellungen exportieren</h3>"
|
|
"Sicherung aller aktuellen Einstellungen in einer Datei.<br>\n"
|
|
"<button type='submit' >Exportieren</button>\n"
|
|
"</form>\n"
|
|
|
|
// Import
|
|
"<form method='POST' action='/importSettings' enctype='multipart/form-data'>\n"
|
|
"<h3>Einstellungen importieren</h3>"
|
|
"Alle Einstellungen aus einer zuvor exportierten .bin-Datei wiederherstellen.<br><br>\n"
|
|
"<b style='color: #FFCC00;'>ACHTUNG:</b><br>"
|
|
"Dieser Vorgang überschreibt ALLE aktuellen Einstellungen!<br>\n"
|
|
"Nach dem Import einfolgt ein automatischer Neustart.<br>\n"
|
|
"<br>\n"
|
|
"<input type='file' name='settings' accept='.bin' required>\n" // accept und required hinzugefügt
|
|
"<br>\n"
|
|
"<button>Import & Neustart</button>\n"
|
|
"</form>\n"
|
|
// --- ENDE IMPORT/EXPORT SEKTION ---
|
|
|
|
// --- Werkseinstellungen ---
|
|
"<form action='/resetDefaults' method='POST' onsubmit='return confirmResetDefaults();'>\n" // Bestätigungsdialog hinzugefügt
|
|
"<h3>Werkseinstellungen</h3>\n"
|
|
"Setzt ALLE Einstellungen (mit Ausnahme der WiFi-Konfiguration, der Shot- und Betriebsstundenzähler) auf die Standardwerte zurück.<br><br>\n"
|
|
"<b style='color: #FFCC00;'>ACHTUNG:</b><br>"
|
|
"Es ist zu empfehlen, vorher einen Export der Einstellungen durchzuführen.\n"
|
|
"<br>\n"
|
|
"<button type='submit' class='danger'>Werkseinstellungen laden</button>\n" // Roter Button
|
|
"</form>\n"
|
|
// --- ENDE WERKSEINSTELLUNGEN ---
|
|
|
|
"</body>\n</html>");
|
|
// ----- HIER ENDET DER GROSSE F() BLOCK -----
|
|
|
|
// --- Platzhalter ersetzen (NACHDEM der F()-Block hinzugefügt wurde) ---
|
|
page.replace(F("{FIRMWAREVERSION}"), version);
|
|
page.replace(F("{SWHERSTELLER}"), versionHersteller);
|
|
page.replace(F("{SWHERSTELLERMAIL}"), versionHerstellerMail);
|
|
page.replace(F("{SWHERSTELLERWEBSITE}"), versionHerstellerWeb);
|
|
page.replace(F("{EEPROM_SIZE_BYTES}"), String(EEPROM_SIZE));
|
|
|
|
server.send(200, F("text/html"), page);
|
|
});
|
|
|
|
// Firmware-Upload-Handler - Unverändert
|
|
server.on("/update", HTTP_POST, []() {
|
|
static const char updateDone[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Firmware-Update</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char updateDone2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char updateDone3[] PROGMEM = R"rawliteral(
|
|
<head>
|
|
<meta http-equiv="refresh" content="30;url=/" />
|
|
<script>
|
|
setTimeout(function() {
|
|
window.location.href = "/";
|
|
}, 30000);
|
|
</script>
|
|
<body>
|
|
<h1>{status_message}</h1>
|
|
<form><p>Weiterleitung zur Startseite in 30 Sekunden ...</p></form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String htmlResponse = FPSTR(updateDone);
|
|
htmlResponse += FPSTR(commonStyle);
|
|
htmlResponse += FPSTR(updateDone2);
|
|
htmlResponse += FPSTR(commonNav); // URLs angepasst
|
|
htmlResponse += FPSTR(updateDone3);
|
|
|
|
String statusMessage;
|
|
if (!passwordFound)
|
|
{
|
|
statusMessage = F("Update abgebrochen! Die Firmware konnte nicht validiert werden.");
|
|
}
|
|
else
|
|
{
|
|
if (Update.hasError())
|
|
{
|
|
statusMessage = F("Update fehlgeschlagen!");
|
|
}
|
|
else
|
|
{
|
|
statusMessage = F("Update erfolgreich!");
|
|
}
|
|
}
|
|
|
|
htmlResponse.replace(F("{status_message}"), statusMessage);
|
|
server.send(200, F("text/html"), htmlResponse);
|
|
|
|
delay(1000);
|
|
ESP.restart();
|
|
},
|
|
[]() {
|
|
HTTPUpload& upload = server.upload();
|
|
if (upload.status == UPLOAD_FILE_START)
|
|
{
|
|
Serial.printf("Update gestartet: %s\n", upload.filename.c_str());
|
|
passwordFound = false;
|
|
passMatchPos = 0;
|
|
|
|
if (!Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000))
|
|
{
|
|
Update.printError(Serial);
|
|
}
|
|
}
|
|
else if (upload.status == UPLOAD_FILE_WRITE)
|
|
{
|
|
// Prüfen auf Firmware-Passwort
|
|
for (size_t i = 0; i < upload.currentSize; i++)
|
|
{
|
|
char c = (char)upload.buf[i];
|
|
if (c == FIRMWARE_PASSWORD[passMatchPos])
|
|
{
|
|
passMatchPos++;
|
|
if (passMatchPos == passLength)
|
|
{
|
|
passwordFound = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (c == FIRMWARE_PASSWORD[0])
|
|
{
|
|
passMatchPos = 1;
|
|
}
|
|
else
|
|
{
|
|
passMatchPos = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Schreibvorgang
|
|
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize)
|
|
{
|
|
Update.printError(Serial);
|
|
}
|
|
}
|
|
else if (upload.status == UPLOAD_FILE_END)
|
|
{
|
|
if (!passwordFound)
|
|
{
|
|
Serial.println("Kennwort nicht gefunden -> Abbruch!");
|
|
Update.end(false);
|
|
}
|
|
else
|
|
{
|
|
if (Update.end(true))
|
|
{
|
|
Serial.printf("Update erfolgreich: %u Bytes\n", upload.totalSize);
|
|
}
|
|
else
|
|
{
|
|
Update.printError(Serial);
|
|
}
|
|
}
|
|
}
|
|
else if (upload.status == UPLOAD_FILE_ABORTED)
|
|
{
|
|
Update.end();
|
|
Serial.println(F("Update abgebrochen."));
|
|
}
|
|
yield();
|
|
});
|
|
|
|
// Handler zum Abbrechen des PID-AutoTune - Unverändert
|
|
server.on("/abbruch-pid-tuning", HTTP_POST, []() {
|
|
static const char abortTuning[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Info</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char abortTuning2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char abortTuning3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>PID-Tuning Abbruch</h1>
|
|
<form>
|
|
<h3>Das PID-Tuning wurde erfolgreich abgebrochen.</h3>
|
|
<p>Der Normalbetrieb wird nun fortgesetzt.</p>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String html = FPSTR(abortTuning);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(abortTuning2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
html += FPSTR(abortTuning3);
|
|
server.send(200, F("text/html"), html);
|
|
|
|
stopAutoTuneWasser();
|
|
stopAutoTuneDampf();
|
|
});
|
|
|
|
// Routen für WLAN-Einstellungen - URL angepasst
|
|
server.on("/Netzwerk", handleWiFiConfig); // URL geändert
|
|
server.on("/saveWiFiConfig", handleSaveWiFiConfig);
|
|
server.on("/forceAPMode", handleForceAPMode);
|
|
|
|
// Routen zum Zurücksetzen von Betriebszeit und ShotCounter - URL angepasst
|
|
server.on("/resetRuntime", handleResetRuntime);
|
|
server.on("/resetShots", handleResetShots);
|
|
// Route zum Zurücksetzen des Wartungszählers
|
|
server.on("/resetMaintenance", handleResetMaintenance);
|
|
|
|
// Routen für Profile - URL angepasst
|
|
server.on("/Profile", handleProfiles); // URL geändert
|
|
server.on("/saveProfile1", handleSaveProfile1);
|
|
server.on("/loadProfile1", handleLoadProfile1);
|
|
server.on("/saveProfile2", handleSaveProfile2);
|
|
server.on("/loadProfile2", handleLoadProfile2);
|
|
|
|
// Routen für EEPROM Import/Export - Unverändert
|
|
server.on("/exportSettings", HTTP_GET, handleExportSettings);
|
|
server.on("/importSettings", HTTP_POST, handleImportSettings, handleImportUpload);
|
|
|
|
// Route für Werkseinstellungen
|
|
server.on("/resetDefaults", HTTP_POST, handleResetDefaults);
|
|
|
|
// Webserver starten
|
|
server.begin();
|
|
Serial.println(F("Webserver gestartet"));
|
|
startupTime = millis();
|
|
}
|
|
|
|
/************************************************************************************
|
|
* loop(): Hauptschleife - mDNS Update hinzugefügt
|
|
************************************************************************************/
|
|
|
|
void loop()
|
|
{
|
|
unsigned long currentMillis = millis();
|
|
|
|
// --- Temperaturmessung und PID-Berechnung (im Intervall) ---
|
|
if (currentMillis - previousMillis >= interval)
|
|
{
|
|
previousMillis = currentMillis;
|
|
|
|
// Temperaturmessung
|
|
InputDampf = round(thermocouple.readCelsius() + OffsetDampf);
|
|
InputWasser = round(readNTCTemperature() + OffsetWasser);
|
|
|
|
// --- Angepasste Sicherheitsüberprüfung Wasser ---
|
|
// 1. Echter Sensorfehler? (NaN oder negativ)
|
|
if (InputWasser < 0.0 || isnan(InputWasser))
|
|
{
|
|
wasserSensorError = true; // Echter Sensorfehler
|
|
wasserSafetyShutdown = false; // Kein Shutdown-Grund, da Wert ungültig
|
|
OutputWasser = 0; // Heizen stoppen
|
|
if (!wasserSensorError) { // Nur einmal loggen, wenn Fehler neu auftritt
|
|
Serial.println("WARNUNG: Wassertemperatursensor liefert ungültigen Wert!");
|
|
}
|
|
}
|
|
// 2. Sensor OK, aber Sicherheitslimit überschritten?
|
|
else if (InputWasser > maxTempWasser)
|
|
{
|
|
wasserSensorError = false; // Sensor liefert ja gültigen Wert
|
|
wasserSafetyShutdown = true; // Sicherheitsabschaltung aktiv!
|
|
OutputWasser = 0; // Heizen stoppen
|
|
if (!wasserSafetyShutdown) { // Nur einmal loggen, wenn Shutdown neu auftritt
|
|
Serial.printf("WARNUNG: Wassertemperatur (%.1f°C) über Sicherheitslimit (%.1f°C)! Sicherheitsabschaltung.\n", InputWasser, maxTempWasser);
|
|
}
|
|
}
|
|
// 3. Sensor OK und Temperatur OK
|
|
else
|
|
{
|
|
// Beide Fehler/Warn-Flags zurücksetzen, wenn alles in Ordnung ist
|
|
if (wasserSensorError) Serial.println("INFO: Wassertemperatursensor wieder OK.");
|
|
wasserSensorError = false;
|
|
if (wasserSafetyShutdown) Serial.println("INFO: Wassertemperatur wieder im sicheren Bereich.");
|
|
wasserSafetyShutdown = false;
|
|
|
|
// --- NEUE BOOST LOGIK (oder normale PID-Berechnung) ---
|
|
// Bedingungen für Boost: Feature aktiv UND nicht im Eco-Modus UND kein AutoTune Wasser aktiv UND Temp < X % vom Sollwert
|
|
bool activateBoost = boostWasserActive
|
|
&& !ecoModeAktiv
|
|
&& !autoTuneWasserActive
|
|
&& (InputWasser < (SetpointWasser * 0.95));
|
|
|
|
if (activateBoost)
|
|
{
|
|
OutputWasser = windowSizeWasser; // Boost: Volle Leistung
|
|
}
|
|
else if (!autoTuneWasserActive) // Kein Boost ODER Bedingung nicht erfüllt UND kein AutoTune
|
|
{
|
|
pidWasser.Compute(); // Normaler PID-Betrieb
|
|
}
|
|
// Bei AutoTune wird OutputWasser in handleAutoTune() gesetzt
|
|
} // Ende Wasser Prüfung
|
|
|
|
// --- Angepasste Sicherheitsüberprüfung Dampf ---
|
|
// 1. Echter Sensorfehler? (NaN oder negativ)
|
|
if (isnan(InputDampf) || InputDampf < 0.0)
|
|
{
|
|
dampfSensorError = true; // Echter Sensorfehler
|
|
dampfSafetyShutdown = false;// Kein Shutdown-Grund, da Wert ungültig
|
|
OutputDampf = 0; // Heizen stoppen
|
|
if (!dampfSensorError) { // Nur einmal loggen
|
|
Serial.println("WARNUNG: Dampftemperatursensor liefert ungültigen Wert!");
|
|
}
|
|
}
|
|
// 2. Sensor OK, aber Sicherheitslimit überschritten?
|
|
else if (InputDampf > maxTempDampf)
|
|
{
|
|
dampfSensorError = false; // Sensor liefert ja gültigen Wert
|
|
dampfSafetyShutdown = true; // Sicherheitsabschaltung aktiv!
|
|
OutputDampf = 0; // Heizen stoppen
|
|
if (!dampfSafetyShutdown) { // Nur einmal loggen
|
|
Serial.printf("WARNUNG: Dampftemperatur (%.1f°C) über Sicherheitslimit (%.1f°C)! Sicherheitsabschaltung.\n", InputDampf, maxTempDampf);
|
|
}
|
|
}
|
|
// 3. Sensor OK und Temperatur OK
|
|
else
|
|
{
|
|
// Beide Fehler/Warn-Flags zurücksetzen, wenn alles in Ordnung ist
|
|
if (dampfSensorError) Serial.println("INFO: Dampftemperatursensor wieder OK.");
|
|
dampfSensorError = false;
|
|
if (dampfSafetyShutdown) Serial.println("INFO: Dampftemperatur wieder im sicheren Bereich.");
|
|
dampfSafetyShutdown = false;
|
|
|
|
// Prüfen, ob die Startverzögerung für Dampf noch aktiv ist
|
|
bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
|
|
if (steamDelayActive)
|
|
{
|
|
OutputDampf = 0; // Während Verzögerung heizen verhindern
|
|
}
|
|
else // Keine Startverzögerung mehr aktiv
|
|
{
|
|
// --- NEUE DAMPF BOOST LOGIK (oder normale PID-Berechnung) ---
|
|
bool activateBoostDampf = boostDampfActive
|
|
&& !ecoModeAktiv
|
|
&& !autoTuneDampfActive
|
|
&& (InputDampf < (SetpointDampf * 0.75));
|
|
|
|
if (activateBoostDampf)
|
|
{
|
|
OutputDampf = windowSizeDampf; // Boost Dampf aktiv
|
|
}
|
|
else if (!autoTuneDampfActive) // Kein Boost ODER Bedingung nicht erfüllt UND kein AutoTune
|
|
{
|
|
pidDampf.Compute(); // Normaler PID-Betrieb für Dampf
|
|
}
|
|
// Bei AutoTune wird OutputDampf in handleAutoTune() gesetzt
|
|
} // Ende "Keine Startverzögerung"
|
|
} // Ende Dampf Prüfung
|
|
|
|
// AutoTune-Logik (unverändert)
|
|
handleAutoTune();
|
|
} // Ende des Temperaturmessungs-/PID-Berechnungsintervalls
|
|
|
|
// --- Zeitproportionale SSR-Ansteuerung (wird kontinuierlich geprüft) ---
|
|
unsigned long now = millis();
|
|
|
|
// --- Wasser SSR ---
|
|
// Heizen wird gestoppt, wenn ein Sensorfehler ODER eine Sicherheitsabschaltung vorliegt
|
|
if (wasserSensorError || wasserSafetyShutdown)
|
|
{
|
|
digitalWrite(SSR_WASSER_PIN, LOW); // Sicherheit: Bei Fehler/Shutdown immer AUS
|
|
}
|
|
else // Nur heizen, wenn KEIN Fehler UND KEIN Shutdown
|
|
{
|
|
// Fenster neu starten, wenn Zeit abgelaufen
|
|
if (now - windowStartTimeWasser > windowSizeWasser)
|
|
{
|
|
windowStartTimeWasser += windowSizeWasser;
|
|
}
|
|
// Prüfen, ob geheizt werden soll/darf ***
|
|
// Bedingung 1: PID/Boost will heizen (innerhalb des Zeitfensters)
|
|
bool pidWantsHeat = (OutputWasser > 0) && (now < (windowStartTimeWasser + (unsigned long)OutputWasser));
|
|
// Bedingung 2: Überhitzungsschutz-Feature aktiv UND Temperatur ist bereits über Sollwert
|
|
bool preventOverheatActive = preventHeatAboveSetpointWasser && (InputWasser > SetpointWasser);
|
|
|
|
// Nur heizen, wenn Bedingung 1 wahr ist UND Bedingung 2 falsch ist
|
|
if (pidWantsHeat && !preventOverheatActive)
|
|
{
|
|
digitalWrite(SSR_WASSER_PIN, HIGH);
|
|
}
|
|
else // Sonst (Fenster vorbei ODER PID=0 ODER Feature verhindert Heizen)
|
|
{
|
|
digitalWrite(SSR_WASSER_PIN, LOW);
|
|
}
|
|
}
|
|
|
|
// --- Dampf SSR ---
|
|
// Heizen wird gestoppt, wenn ein Sensorfehler ODER eine Sicherheitsabschaltung vorliegt
|
|
if (dampfSensorError || dampfSafetyShutdown)
|
|
{
|
|
digitalWrite(SSR_DAMPF_PIN, LOW); // Sicherheit: Bei Fehler/Shutdown immer AUS
|
|
}
|
|
else // Nur heizen, wenn KEIN Fehler UND KEIN Shutdown
|
|
{
|
|
// Fenster neu starten, wenn Zeit abgelaufen
|
|
if (now - windowStartTimeDampf > windowSizeDampf)
|
|
{
|
|
windowStartTimeDampf += windowSizeDampf;
|
|
}
|
|
|
|
// Prüfen, ob geheizt werden soll/darf
|
|
// Bedingung 1: PID/Boost will heizen (innerhalb des Zeitfensters)
|
|
// Beachte: OutputDampf kann durch Startverzögerung auf 0 sein
|
|
bool pidWantsHeat = (OutputDampf > 0) && (now < (windowStartTimeDampf + (unsigned long)OutputDampf));
|
|
// Bedingung 2: Überhitzungsschutz-Feature aktiv UND Temperatur ist bereits über Sollwert
|
|
bool preventOverheatActive = preventHeatAboveSetpointDampf && (InputDampf > SetpointDampf);
|
|
|
|
// Nur heizen, wenn Bedingung 1 wahr ist UND Bedingung 2 falsch ist
|
|
if (pidWantsHeat && !preventOverheatActive)
|
|
{
|
|
digitalWrite(SSR_DAMPF_PIN, HIGH);
|
|
}
|
|
else // Sonst (Fenster vorbei ODER PID=0 ODER Feature verhindert Heizen)
|
|
{
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
}
|
|
}
|
|
|
|
// Shot-Timer verarbeiten (unverändert)
|
|
updateShotTimer();
|
|
|
|
// Eco-Modus (unverändert - Sicherheitschecks haben Vorrang vor Eco-Sollwerten)
|
|
if (!autoTuneWasserActive && !autoTuneDampfActive)
|
|
{
|
|
if (ecoModeMinutes > 0 && !shotActive)
|
|
{
|
|
unsigned long currentTimeEco = millis();
|
|
if (currentTimeEco - lastShotTime > (ecoModeMinutes * 60UL * 1000UL))
|
|
{
|
|
if (!ecoModeAktiv) { ecoModeActivatedTime = currentTimeEco; }
|
|
ecoModeAktiv = true;
|
|
if (dynamicEcoActive)
|
|
{
|
|
int minutesPassed = (currentTimeEco - ecoModeActivatedTime) / (60UL * 1000UL);
|
|
SetpointWasser = (ecoModeTempWasser - minutesPassed > 0 ? ecoModeTempWasser - minutesPassed : 0);
|
|
SetpointDampf = (ecoModeTempDampf - minutesPassed > 0 ? ecoModeTempDampf - minutesPassed : 0);
|
|
}
|
|
else
|
|
{
|
|
SetpointWasser = ecoModeTempWasser;
|
|
SetpointDampf = ecoModeTempDampf;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Wenn Eco-Zeit noch nicht erreicht ODER Shot aktiv war, normale Sollwerte laden
|
|
// Nur laden, wenn Eco gerade deaktiviert wird, um ständiges EEPROM-Lesen zu vermeiden
|
|
if (ecoModeAktiv) {
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
}
|
|
} else if (ecoModeAktiv) {
|
|
// Sicherstellen, dass Eco deaktiviert wird, wenn ecoModeMinutes=0 gesetzt wird o.ä.
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
}
|
|
|
|
// Display regelmäßig updaten (Logik für Wartungsmeldung hinzugefügt)
|
|
updateDisplay();
|
|
server.handleClient(); // Webserver (unverändert)
|
|
MDNS.update(); // mDNS-Anfragen bearbeiten
|
|
|
|
// Betriebszeit zählen (unverändert)
|
|
static unsigned long lastRuntimeSecond = 0;
|
|
if (millis() - lastRuntimeSecond >= 1000)
|
|
{
|
|
totalRuntime++;
|
|
lastRuntimeSecond = millis();
|
|
}
|
|
|
|
// Speichern der Betriebszeit (NEUE LOGIK) und WiFi-Check - Unverändert
|
|
if (!demoModus) // Bedingung hinzugefügt: Nur speichern, wenn KEIN Demo-Modus aktiv ist
|
|
{
|
|
unsigned long currentMillisLoop = millis(); // Aktuelle Zeit für diese Prüfung holen
|
|
unsigned long intervalToUse;
|
|
|
|
// Bestimme, welches Intervall verwendet werden soll
|
|
if (!initialRuntimeSaveDone) {
|
|
// Wenn das erste Speichern noch nicht erfolgt ist, nutze die 10-Minuten-Verzögerung
|
|
intervalToUse = initialRuntimeSaveDelay;
|
|
} else {
|
|
// Wenn das erste Speichern bereits erfolgt ist, nutze das 5-Minuten-Intervall
|
|
intervalToUse = subsequentRuntimeSaveInterval;
|
|
}
|
|
|
|
// Prüfe, ob das ermittelte Intervall seit dem letzten Speichern abgelaufen ist
|
|
if (currentMillisLoop - lastRuntimeSave >= intervalToUse)
|
|
{
|
|
EEPROM.put(EEPROM_ADDR_RUNTIME, totalRuntime); // Schreibe die aktuelle Betriebszeit ins EEPROM
|
|
EEPROM.commit(); // Führe das Schreiben physisch durch
|
|
|
|
lastRuntimeSave = currentMillisLoop; // Aktualisiere den Zeitpunkt des letzten Speicherns
|
|
|
|
// Wenn dies das erste Speichern war, setze das Flag
|
|
if (!initialRuntimeSaveDone) {
|
|
initialRuntimeSaveDone = true;
|
|
Serial.println("Betriebszeit: Erstes Speichern nach 10 Minuten erfolgt."); // Optional: Log-Ausgabe
|
|
} else {
|
|
Serial.println("Betriebszeit: Regelmäßiges Speichern (alle 5 Minuten) erfolgt."); // Optional: Log-Ausgabe
|
|
}
|
|
|
|
// Den WiFi-Check können wir auch hier durchführen, da er nun seltener aufgerufen wird
|
|
if (WiFi.getMode() == WIFI_STA) {
|
|
checkWifiConnection();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Liest die Temperatur vom NTC-Sensor (Wasser) über den Spannungsteiler (ADC) - Unverändert
|
|
************************************************************************************/
|
|
|
|
double readNTCTemperature()
|
|
{
|
|
int adcValue = analogRead(A0);
|
|
// Prüfen auf ungültige ADC-Werte (können bei Problemen auftreten)
|
|
if (adcValue <= 0 || adcValue >= 1023) // 1023 ist max. Wert, kann auf Kurzschluss hindeuten
|
|
{
|
|
Serial.print("WARNUNG: Ungültiger ADC-Wert vom NTC-Sensor: ");
|
|
Serial.println(adcValue);
|
|
return NAN; // Not-a-Number als Fehlerwert zurückgeben
|
|
}
|
|
|
|
double sensorVoltage = adcValue * (ADC_REF / 1023.0);
|
|
// Vermeide Division durch Null, falls sensorVoltage == V_SUPPLY
|
|
if (sensorVoltage >= V_SUPPLY) {
|
|
Serial.println("WARNUNG: NTC-Spannung entspricht Versorgungsspannung, R_ntc wäre unendlich.");
|
|
return NAN; // Fehler
|
|
}
|
|
// Vermeide Division durch Null, falls sensorVoltage == 0 (bereits oben abgefangen, aber zur Sicherheit)
|
|
if (sensorVoltage <= 0) {
|
|
Serial.println("WARNUNG: NTC-Spannung ist 0 oder negativ.");
|
|
return NAN; // Fehler
|
|
}
|
|
|
|
double R_ntc = R_FIXED * ((V_SUPPLY / sensorVoltage) - 1.0);
|
|
|
|
// Prüfen auf negativen oder sehr kleinen Widerstandswert
|
|
if (R_ntc <= 0) {
|
|
Serial.print("WARNUNG: Berechneter NTC-Widerstand ungültig: ");
|
|
Serial.println(R_ntc);
|
|
return NAN; // Fehler
|
|
}
|
|
|
|
// Temperaturberechnung über die Beta-Gleichung
|
|
// log() erwartet einen positiven Wert
|
|
double log_R_R0 = log(R_ntc / R0);
|
|
double temp_inv_K = (1.0 / T0) + (1.0 / beta) * log_R_R0;
|
|
|
|
// Prüfen auf Division durch Null bei der Invertierung
|
|
if (temp_inv_K == 0) {
|
|
Serial.println("WARNUNG: Kehrwert der Kelvin-Temperatur ist 0.");
|
|
return NAN; // Fehler
|
|
}
|
|
|
|
double temperatureK = 1.0 / temp_inv_K;
|
|
double temperatureC = temperatureK - 273.15;
|
|
|
|
// Optional: Plausibilitätsprüfung des Ergebnisses
|
|
// if (temperatureC < -50 || temperatureC > 200) { // Beispielgrenzen
|
|
// Serial.print("WARNUNG: Berechnete NTC-Temperatur unplausibel: ");
|
|
// Serial.println(temperatureC);
|
|
// return NAN;
|
|
// }
|
|
|
|
return temperatureC;
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) - Angepasst
|
|
************************************************************************************/
|
|
|
|
/************************************************************************************
|
|
* Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) - Angepasst
|
|
************************************************************************************/
|
|
|
|
/************************************************************************************
|
|
* Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) - Angepasst
|
|
* Priorität: PID Tuning > Fast Heat Up > Aktiver Shot > Letzte Shot-Zeit > Wartung > Temperaturen
|
|
************************************************************************************/
|
|
|
|
/************************************************************************************
|
|
* Aktualisiert das Display (Temperaturanzeige, Shot-Timer, Wartung etc.) - Angepasst
|
|
* Priorität: PID Tuning > Fast Heat Up > Aktiver Shot > Letzte Shot-Zeit > Wartung > Temp/Shutdown/Fehler
|
|
************************************************************************************/
|
|
void updateDisplay()
|
|
{
|
|
// 1. Höchste Priorität: PID Tuning aktiv? -> Eigene Anzeige, dann Ende.
|
|
if (autoTuneWasserActive || autoTuneDampfActive) {
|
|
display.clearDisplay();
|
|
display.setCursor(0, 0);
|
|
display.setTextSize(1);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.println(F("PID-Tuning aktiv:"));
|
|
display.println(autoTuneWasserActive ? F("Wasser-PID") : F("Dampf-PID"));
|
|
display.println("");
|
|
|
|
// Statusvariablen abfragen und anzeigen
|
|
int currentPeakCount = 0;
|
|
int currentPeakType = 0;
|
|
if (autoTuneWasserActive && autoTuneWasser != nullptr) { // Sicherstellen, dass Objekt existiert
|
|
currentPeakCount = autoTuneWasser->getPeakCount();
|
|
currentPeakType = autoTuneWasser->getPeakType();
|
|
} else if (autoTuneDampfActive && autoTuneDampf != nullptr) { // Sicherstellen, dass Objekt existiert
|
|
currentPeakCount = autoTuneDampf->getPeakCount();
|
|
currentPeakType = autoTuneDampf->getPeakType();
|
|
}
|
|
display.print(F("Cycle: "));
|
|
if(currentPeakCount < 0 || currentPeakCount > 10)
|
|
{
|
|
// Falls kleiner 0 oder größer 10 (kann bei Start vorkommen)
|
|
display.print(F("0"));
|
|
} else {
|
|
// Zeigt an, wie viele Zyklen/Phasen erkannt wurden
|
|
display.print(currentPeakCount);
|
|
}
|
|
|
|
display.print(F(" von 5-10"));
|
|
display.println(); // Zeilenumbruch
|
|
display.print(F("Phase:"));
|
|
if (currentPeakType == 1) {
|
|
display.print(F("Heizen")); // Letztes war/suche Maximum
|
|
} else if (currentPeakType == -1) {
|
|
display.print(F("K\201hlen")); // Letztes war/suche Minimum
|
|
} else {
|
|
display.print(F("Init")); // Initialisierungsphase
|
|
}
|
|
display.println(); // Zeilenumbruch
|
|
if(autoTuneWasserActive)
|
|
{
|
|
display.print("Temperatur:");
|
|
display.print((int)InputWasser);
|
|
display.print(F(" "));
|
|
display.print((char)247); // Grad-Symbol
|
|
display.print(F("C"));
|
|
}
|
|
if(autoTuneDampfActive)
|
|
{
|
|
display.print("Temperatur:");
|
|
display.print((int)InputDampf);
|
|
display.print(F(" "));
|
|
display.print((char)247); // Grad-Symbol
|
|
display.print(F("C"));
|
|
}
|
|
// WiFi Symbol trotzdem anzeigen, falls verbunden
|
|
if (WiFi.status() == WL_CONNECTED)
|
|
{
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
display.display();
|
|
return; // Funktion hier beenden
|
|
}
|
|
|
|
// --- Keine Tuning-Anzeige ---
|
|
display.clearDisplay();
|
|
|
|
// WiFi Symbol (immer anzeigen, wenn verbunden)
|
|
if (WiFi.status() == WL_CONNECTED)
|
|
{
|
|
drawwifiSymbol(113, 0);
|
|
}
|
|
|
|
display.setCursor(0, 0);
|
|
display.setTextColor(SH110X_WHITE);
|
|
display.setTextSize(1);
|
|
|
|
// 2. Priorität: Fast-Heat-Up aktiv und relevant?
|
|
if (fastHeatUpHeating && !shotActive && !wasserSensorError && ((int)InputWasser < fastHeatUpSetpoint))
|
|
{
|
|
SetpointWasser = fastHeatUpSetpoint; // Setpoint temporär anpassen für Anzeige
|
|
display.println(F("Fast-Heat-Up aktiv:"));
|
|
display.println("");
|
|
display.println(F("20 Sek. Flushen,"));
|
|
display.println(F("wenn Temp. erreicht!"));
|
|
display.println(F(" ")); // Leere Zeile
|
|
display.println(F("Temperatur: "));
|
|
display.print((int)InputWasser);
|
|
display.print(F(" / "));
|
|
display.print((int)SetpointWasser); // Zeigt den F-H-U Setpoint
|
|
display.print(F(" "));
|
|
display.print((char)247); // Grad-Symbol
|
|
display.print(F("C"));
|
|
// Hier keine Dampfanzeige während Fast-Heat-Up, um Platz zu sparen
|
|
}
|
|
// 3. Kein Fast-Heat-Up: Was soll sonst angezeigt werden?
|
|
else
|
|
{
|
|
// 3a. Priorität: Shot läuft *aktuell*?
|
|
if (shotActive)
|
|
{
|
|
unsigned long elapsed = millis() - shotStartTime;
|
|
display.println(F("Shot-Timer:"));
|
|
display.println("");
|
|
display.setTextSize(3); // Größere Schrift für den Timer
|
|
// Zeige nur eine Nachkommastelle an
|
|
char buffer[6]; // Puffer für "xx.y" + Nullterminator
|
|
snprintf(buffer, sizeof(buffer), "%.1f", elapsed / 1000.0);
|
|
display.println(buffer);
|
|
display.setTextSize(1); // Zurück zur normalen Schriftgröße
|
|
display.println(F("Sekunden"));
|
|
fastHeatUpHeating = false; // Laufender Shot beendet F-H-U Anzeige visuell
|
|
}
|
|
// 3b. Priorität: Letzte Shot-Zeit anzeigen (für 2 Sekunden)?
|
|
else if (shotEndTime > 0 && millis() < shotEndTime + 2000)
|
|
{
|
|
display.println(F("Bezugszeit:"));
|
|
display.println("");
|
|
display.setTextSize(3); // Größere Schrift
|
|
// Zeige nur eine Nachkommastelle an
|
|
char buffer[6];
|
|
snprintf(buffer, sizeof(buffer), "%.1f", lastShotDurationMillis / 1000.0);
|
|
display.println(buffer);
|
|
display.setTextSize(1); // Zurück zur normalen Schriftgröße
|
|
display.println(F("Sekunden"));
|
|
}
|
|
// 3c. Priorität: Wartungserinnerung anzeigen (für 6 Sekunden nach Trigger)
|
|
// Diese Bedingung wird nur erreicht, wenn kein Shot aktiv ist UND die letzte Shot-Zeit nicht angezeigt wird.
|
|
else if (displayMaintenanceMessage && millis() < maintenanceMessageStartTime + 6000)
|
|
{
|
|
display.println(F("Erinnerung:")); // Titel
|
|
display.println(F("Reinigung /")); // Zeile 1
|
|
display.println(F("Wartung fällig!")); // Zeile 2
|
|
// Restliche Zeilen leer lassen oder für Details nutzen
|
|
display.println("");
|
|
display.println("");
|
|
display.println("");
|
|
}
|
|
// 3d. Standardanzeige: Temperaturen / Fehler / Sicherheitsabschaltung
|
|
else
|
|
{
|
|
display.println(F("Temperaturen:")); // Titelzeile
|
|
display.println(""); // Leerzeile
|
|
|
|
// Wasser Temperatur / Sicherheitsabschaltung / Fehleranzeige
|
|
display.println(F("Wasser: ")); // Print, kein Zeilenumbruch
|
|
if (wasserSafetyShutdown) { // Prüfung 1: Sicherheitsabschaltung
|
|
display.print(F("Sicherheitsabsch."));
|
|
} else if (wasserSensorError) { // Prüfung 2: Echter Sensorfehler
|
|
display.print(F("Sensorfehler!"));
|
|
} else { // Prüfung 3: Alles OK -> Temperatur anzeigen
|
|
display.print((int)InputWasser);
|
|
display.print(F(" / "));
|
|
display.print((int)SetpointWasser);
|
|
display.print(F(" "));
|
|
display.print((char)247); // Grad-Symbol
|
|
display.print(F("C"));
|
|
// ECO-Modus Anzeige anhängen, falls aktiv
|
|
if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)"));
|
|
else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)"));
|
|
}
|
|
display.println(""); // Zeilenumbruch nach der Wasserzeile
|
|
display.println(""); // Leere Zeile für Abstand
|
|
|
|
// Dampf Temperatur / Sicherheitsabschaltung / Fehleranzeige
|
|
display.println(F("Dampf: ")); // Print, kein Zeilenumbruch, 2 Leerzeichen für Ausrichtung
|
|
if (dampfSafetyShutdown) { // Prüfung 1: Sicherheitsabschaltung
|
|
display.print(F("Sicherheitsabsch."));
|
|
} else if (dampfSensorError) { // Prüfung 2: Echter Sensorfehler
|
|
display.print(F("Sensorfehler!"));
|
|
} else { // Prüfung 3: Alles OK -> Temperatur anzeigen (oder Verzögerung)
|
|
// Prüfen auf Startverzögerung (wie vorher)
|
|
bool steamDelayActive = (dampfVerzoegerung > 0 && (millis() - startupTime < (unsigned long)dampfVerzoegerung * 60000UL));
|
|
if (!steamDelayActive) // Wenn keine Verzögerung aktiv ist
|
|
{
|
|
display.print((int)InputDampf);
|
|
display.print(F(" / "));
|
|
display.print((int)SetpointDampf);
|
|
display.print(F(" "));
|
|
display.print((char)247); // Grad-Symbol
|
|
display.print(F("C"));
|
|
// ECO-Modus Anzeige anhängen, falls aktiv
|
|
if (ecoModeAktiv && dynamicEcoActive) display.print(F(" (ECO+)"));
|
|
else if (ecoModeAktiv && !dynamicEcoActive) display.print(F(" (ECO)"));
|
|
} else { // Wenn Startverzögerung aktiv ist
|
|
display.print(F("Startverz\x94gerung"));
|
|
}
|
|
}
|
|
// Kein println() am Ende der letzten Zeile nötig
|
|
} // Ende Standardanzeige (Temperaturen etc.)
|
|
} // Ende "Kein Fast-Heat-Up"
|
|
|
|
display.display(); // Display *immer* am Ende aktualisieren, egal was angezeigt wurde
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Verwaltet den Shot-Timer (wenn z.B. der Nutzer den Bezug aktiviert) - Wartungscheck hinzugefügt
|
|
************************************************************************************/
|
|
|
|
void updateShotTimer()
|
|
{
|
|
if (digitalRead(SHOT_TIMER_PIN) == HIGH) // Annahme: HIGH = Bezug aktiv
|
|
{
|
|
// Shot beginnt
|
|
if (!shotActive)
|
|
{
|
|
shotActive = true;
|
|
shotStartTime = millis();
|
|
lastShotDurationMillis = 0; // Dauer für neuen Shot zurücksetzen
|
|
shotEndTime = 0; // Endzeit für neuen Shot zurücksetzen
|
|
displayMaintenanceMessage = false; // Sicherstellen, dass alte Meldung weg ist
|
|
}
|
|
}
|
|
else // Annahme: LOW = Bezug inaktiv
|
|
{
|
|
// Shot endet
|
|
if (shotActive)
|
|
{
|
|
unsigned long shotDuration = millis() - shotStartTime;
|
|
lastShotDurationMillis = shotDuration; // *** Letzte Dauer speichern ***
|
|
shotEndTime = millis(); // *** Endzeitpunkt speichern ***
|
|
|
|
// nur Shots > 20 Sekunden zählen
|
|
if (shotDuration > 20000)
|
|
{
|
|
shotCounter++;
|
|
maintenanceIntervalCounter++;
|
|
if (!demoModus) { // Nur speichern, wenn kein Demo-Modus
|
|
EEPROM.put(EEPROM_ADDR_SHOTCOUNTER, shotCounter);
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL_COUNTER, maintenanceIntervalCounter);
|
|
EEPROM.commit();
|
|
}
|
|
Serial.print(F("Shots: "));
|
|
Serial.println(shotCounter);
|
|
|
|
// Prüfen, ob Wartungsintervall erreicht ist
|
|
if (maintenanceInterval > 0 && maintenanceIntervalCounter >= maintenanceInterval) {
|
|
displayMaintenanceMessage = true; // Flag für Anzeige setzen
|
|
maintenanceMessageStartTime = millis(); // Startzeit für Anzeige merken
|
|
Serial.println("Wartungsintervall erreicht!");
|
|
}
|
|
}
|
|
shotActive = false; // Shot als beendet markieren
|
|
lastShotTime = millis(); // Wichtig für Eco-Modus (bleibt wie es ist)
|
|
// shotEndTime und delayDisplayUpdate werden nicht mehr benötigt (bzw. shotEndTime hat neue Bedeutung)
|
|
}
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Startet AutoTune für Wasser-PID (Angepasst für Zeitproportional)
|
|
************************************************************************************/
|
|
|
|
void startAutoTuneWasser()
|
|
{
|
|
// Eco-Modus deaktivieren, falls aktiv
|
|
if(ecoModeAktiv)
|
|
{
|
|
ecoModeAktiv = false;
|
|
// Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil)
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
// Optional: Auch Dampf-Setpoint wiederherstellen, falls Eco beide beeinflusst
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
|
|
// Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt
|
|
pidWasser.SetMode(AUTOMATIC);
|
|
|
|
// Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser);
|
|
|
|
autoTuneWasser = new PID_ATune(&InputWasser, &OutputWasser);
|
|
// Verwende die Wasser-spezifischen Parameter
|
|
autoTuneWasser->SetOutputStep(tuningStepWasser); // Angepasst
|
|
autoTuneWasser->SetControlType(1); // DIRECT control (heating)
|
|
autoTuneWasser->SetNoiseBand(tuningNoiseWasser); // Angepasst
|
|
autoTuneWasser->SetLookbackSec((int)tuningLookBackWasser); // Angepasst (Cast zu int für Funktion)
|
|
|
|
// Setze den Startwert für den Output
|
|
// WICHTIG: AutoTune manipuliert OutputWasser direkt. Die zeitproportionale
|
|
// Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern.
|
|
OutputWasser = tuningStartValueWasser; // Angepasst
|
|
|
|
// KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das.
|
|
|
|
autoTuneWasserActive = true;
|
|
Serial.println("Starte AutoTune für Wasser (Zeitproportional)...");
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Startet AutoTune für Dampf-PID (Angepasst für Zeitproportional)
|
|
************************************************************************************/
|
|
|
|
void startAutoTuneDampf()
|
|
{
|
|
// Eco-Modus deaktivieren, falls aktiv
|
|
if(ecoModeAktiv)
|
|
{
|
|
ecoModeAktiv = false;
|
|
// Ursprünglichen Setpoint wiederherstellen (aus EEPROM oder Profil)
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
// Optional: Auch Wasser-Setpoint wiederherstellen, falls Eco beide beeinflusst
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
}
|
|
|
|
// Stelle sicher, dass der PID im Automatikmodus ist bevor Tuning beginnt
|
|
pidDampf.SetMode(AUTOMATIC);
|
|
|
|
// Sicherstellen, dass die Output-Grenzen korrekt gesetzt sind
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf);
|
|
|
|
autoTuneDampf = new PID_ATune(&InputDampf, &OutputDampf);
|
|
// Verwende die Dampf-spezifischen Parameter
|
|
autoTuneDampf->SetOutputStep(tuningStepDampf); // Angepasst
|
|
autoTuneDampf->SetControlType(1); // DIRECT control (heating)
|
|
autoTuneDampf->SetNoiseBand(tuningNoiseDampf); // Angepasst
|
|
autoTuneDampf->SetLookbackSec((int)tuningLookBackDampf); // Angepasst (Cast zu int für Funktion)
|
|
|
|
// Setze den Startwert für den Output
|
|
// WICHTIG: AutoTune manipuliert OutputDampf direkt. Die zeitproportionale
|
|
// Logik in loop() wird diesen Wert verwenden, um den SSR zu steuern.
|
|
OutputDampf = tuningStartValueDampf; // Angepasst
|
|
|
|
// KEIN direktes digitalWrite mehr hier! Die loop() übernimmt das.
|
|
|
|
autoTuneDampfActive = true;
|
|
Serial.println("Starte AutoTune für Dampf (Zeitproportional)...");
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Stoppt AutoTune für Wasser - Unverändert
|
|
************************************************************************************/
|
|
|
|
void stopAutoTuneWasser()
|
|
{
|
|
if (autoTuneWasserActive)
|
|
{
|
|
autoTuneWasserActive = false;
|
|
delete autoTuneWasser;
|
|
autoTuneWasser = nullptr;
|
|
// Setze PID zurück in den Normalbetrieb (ggf. Output auf 0)
|
|
pidWasser.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft
|
|
OutputWasser = 0; // Optional: Output zurücksetzen
|
|
// Stelle sicher, dass SSR ausgeschaltet ist
|
|
digitalWrite(SSR_WASSER_PIN, LOW);
|
|
Serial.println("AutoTune Wasser gestoppt.");
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Stoppt AutoTune für Dampf - Unverändert
|
|
************************************************************************************/
|
|
|
|
void stopAutoTuneDampf()
|
|
{
|
|
if (autoTuneDampfActive)
|
|
{
|
|
autoTuneDampfActive = false;
|
|
delete autoTuneDampf;
|
|
autoTuneDampf = nullptr;
|
|
// Setze PID zurück in den Normalbetrieb (ggf. Output auf 0)
|
|
pidDampf.SetMode(AUTOMATIC); // Sicherstellen, dass PID wieder normal läuft
|
|
OutputDampf = 0; // Optional: Output zurücksetzen
|
|
// Stelle sicher, dass SSR ausgeschaltet ist
|
|
digitalWrite(SSR_DAMPF_PIN, LOW);
|
|
Serial.println("AutoTune Dampf gestoppt.");
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Überwacht die AutoTune-Prozesse (Dampf/Wasser) - Display-Logik ausgelagert
|
|
************************************************************************************/
|
|
|
|
/************************************************************************************
|
|
* Überwacht die AutoTune-Prozesse (Dampf/Wasser)
|
|
* - Prüft auf Sensorfehler ODER Sicherheitsabschaltung vor Ausführung
|
|
* - Display-Logik ist in updateDisplay() ausgelagert
|
|
************************************************************************************/
|
|
void handleAutoTune()
|
|
{
|
|
// --- AutoTune Wasser ---
|
|
if (autoTuneWasserActive)
|
|
{
|
|
// Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings
|
|
if (wasserSensorError || wasserSafetyShutdown) {
|
|
// Logge den spezifischen Grund für den Abbruch
|
|
if (wasserSensorError) {
|
|
Serial.println("FEHLER: Wassertemperatursensor während AutoTune ausgefallen! Breche Tuning ab.");
|
|
} else { // Muss wasserSafetyShutdown sein
|
|
Serial.println("WARNUNG: Sicherheitslimit Wasser während AutoTune überschritten! Breche Tuning ab.");
|
|
}
|
|
stopAutoTuneWasser(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet
|
|
return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen
|
|
}
|
|
|
|
// Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus.
|
|
// Diese Funktion berechnet und SETZT OutputWasser für den nächsten Schritt!
|
|
if (autoTuneWasser->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen
|
|
{
|
|
// Tuning abgeschlossen, Werte holen
|
|
KpWasser = autoTuneWasser->GetKp();
|
|
KiWasser = autoTuneWasser->GetKi();
|
|
KdWasser = autoTuneWasser->GetKd();
|
|
|
|
// WICHTIG: PID-Tunings sofort anwenden
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Neue Werte im EEPROM speichern
|
|
EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser);
|
|
EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser);
|
|
EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser);
|
|
EEPROM.commit();
|
|
|
|
Serial.println("AutoTune Wasser erfolgreich abgeschlossen.");
|
|
Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpWasser, KiWasser, KdWasser);
|
|
|
|
// AutoTune-Prozess korrekt beenden
|
|
stopAutoTuneWasser();
|
|
}
|
|
// Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt.
|
|
}
|
|
|
|
// --- AutoTune Dampf ---
|
|
if (autoTuneDampfActive)
|
|
{
|
|
// Erweiterte Prüfung auf Sensorfehler ODER Sicherheitsabschaltung während des Tunings
|
|
if (dampfSensorError || dampfSafetyShutdown) {
|
|
// Logge den spezifischen Grund für den Abbruch
|
|
if (dampfSensorError) {
|
|
Serial.println("FEHLER: Dampftemperatursensor während AutoTune ausgefallen! Breche Tuning ab.");
|
|
} else { // Muss dampfSafetyShutdown sein
|
|
Serial.println("WARNUNG: Sicherheitslimit Dampf während AutoTune überschritten! Breche Tuning ab.");
|
|
}
|
|
stopAutoTuneDampf(); // Ruft Funktion auf, die Flag zurücksetzt, Speicher freigibt und SSR ausschaltet
|
|
return; // Verlasse die Funktion für diesen Durchlauf, um Runtime() nicht auszuführen
|
|
}
|
|
|
|
// Nur wenn kein Fehler/Shutdown vorliegt, führe AutoTune->Runtime() aus.
|
|
// Diese Funktion berechnet und SETZT OutputDampf für den nächsten Schritt!
|
|
if (autoTuneDampf->Runtime() == 1) // Runtime() gibt 1 zurück, wenn Tuning abgeschlossen
|
|
{
|
|
// Tuning abgeschlossen, Werte holen
|
|
KpDampf = autoTuneDampf->GetKp();
|
|
KiDampf = autoTuneDampf->GetKi();
|
|
KdDampf = autoTuneDampf->GetKd();
|
|
|
|
// WICHTIG: PID-Tunings sofort anwenden
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
|
|
// Neue Werte im EEPROM speichern
|
|
EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf);
|
|
EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf);
|
|
EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf);
|
|
EEPROM.commit();
|
|
|
|
Serial.println("AutoTune Dampf erfolgreich abgeschlossen.");
|
|
Serial.printf("Neue Werte: Kp=%.2f, Ki=%.2f, Kd=%.2f\n", KpDampf, KiDampf, KdDampf);
|
|
|
|
// AutoTune-Prozess korrekt beenden
|
|
stopAutoTuneDampf();
|
|
}
|
|
// Hinweis: Die Display-Anzeige für laufendes Tuning wird in updateDisplay() gehandhabt.
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Info-Seite - URL angepasst, Wartungsintervall hinzugefügt
|
|
************************************************************************************/
|
|
|
|
void handleInfo() // URL: /Info
|
|
{
|
|
static const char infoHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Info</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char infoHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char infoHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Info</h1>
|
|
<form action='/updateInfoSettings' method='POST'>
|
|
<h3>Geräteinfo</h3>
|
|
Die Geräteinformationen werden beim Start der Maschine, bzw. PID-Controllers im Display angezeigt.
|
|
<br><br>
|
|
<label for='hersteller'>Hersteller:</label>
|
|
<input type='text' id='hersteller' name='hersteller' value='{HERSTELLER}'>
|
|
<label for='modell'>Modell:</label>
|
|
<input type='text' id='modell' name='modell' value='{MODELL}'>
|
|
<label for='zusatz'>Zusatz (z.B. Limited Edition):</label>
|
|
<input type='text' id='zusatz' name='zusatz' value='{ZUSATZ}'>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
|
|
<form action='/updateMaintenanceSettings' method='POST'>
|
|
<h3>Reinigung & Wartung</h3>
|
|
Zeigt nach der eingestellten Anzahl von Bezügen eine Reinigungs-/ Wartungserinnerung im Display an.<br>
|
|
<label for='maintenanceInterval'>Anzahl an Bezügen bis Erinnerung: (0 = deaktiviert)</label>
|
|
<input type='text' id='maintenanceInterval' name='maintenanceInterval' value='{MAINTENANCE_INTERVAL}'>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
|
|
<form action='/resetMaintenance' method='POST'>
|
|
<h3>Wartungszähler zurücksetzen</h3>
|
|
Aktueller Zählerstand: {MAINTENANCECOUNTER}<br>
|
|
<br>
|
|
Setzt den Zähler für die Reinigungs- & Wartungserinnerung zurück.<br>
|
|
Dies ist nach der erfolgreichen Durchführung notwendig,<br>
|
|
damit die Meldung nicht nach jedem Bezug erneut scheint.<br>
|
|
<input type='submit' value='Zurücksetzen'>
|
|
</form>
|
|
|
|
<form action='/resetRuntime' method='POST'>
|
|
<h3>Betriebszeit:</h3>
|
|
{RUNTIME}<br>
|
|
<input type='submit' value='Zurücksetzen'>
|
|
</form>
|
|
<form action='/resetShots' method='POST'>
|
|
<h3>Shots: (Bezüge über 20 Sekunden)</h3>
|
|
Anzahl: {SHOT_COUNT}<br>
|
|
<input type='submit' value='Zurücksetzen'>
|
|
</form>
|
|
<form>
|
|
<h3>Systeminfo</h3>
|
|
Freier Heap: {FREE_HEAP} Bytes<br>
|
|
SDK-Version: {SDK_VERSION}<br>
|
|
Core-Version: {CORE_VERSION}<br>
|
|
Boot-Version: {BOOT_VERSION}<br>
|
|
Chip-ID: {CHIP_ID}<br>
|
|
CPU-Takt: {CPU_MHZ} MHz<br>
|
|
Letzter Reset-Grund: {RESET_REASON}<br>
|
|
<br>
|
|
Sketch-Größe: {SKETCH_SIZE} Bytes<br>
|
|
Freier Sketch-Speicher: {FREE_SKETCH} Bytes<br>
|
|
Flash-Größe (Chip): {FLASH_SIZE} Bytes<br>
|
|
<br>
|
|
MAC-Adresse: {MAC_ADDRESS}<br>
|
|
WLAN-Signalstärke (RSSI): {WIFI_RSSI} dBm<br>
|
|
Hostname: {HOSTNAME}.local<br> </form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
unsigned long totalSeconds = totalRuntime;
|
|
unsigned long days = totalSeconds / 86400;
|
|
unsigned long hours = (totalSeconds % 86400) / 3600;
|
|
unsigned long minutes = (totalSeconds % 3600) / 60;
|
|
String runtimeStr = String(days) + F(" Tag(e), ") +
|
|
String(hours) + F(" Stunde(n) und ") +
|
|
String(minutes) + F(" Minuten");
|
|
|
|
String html = FPSTR(infoHtml);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(infoHtml2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
String body = FPSTR(infoHtml3); // Temporäre Variable für Ersetzungen
|
|
|
|
body.replace(F("{HERSTELLER}"), String(infoHersteller));
|
|
body.replace(F("{MODELL}"), String(infoModell));
|
|
body.replace(F("{ZUSATZ}"), String(infoZusatz));
|
|
body.replace(F("{RUNTIME}"), runtimeStr);
|
|
body.replace(F("{SHOT_COUNT}"), String(shotCounter));
|
|
body.replace(F("{MAINTENANCECOUNTER}"), String(maintenanceIntervalCounter));
|
|
body.replace(F("{MAINTENANCE_INTERVAL}"), String(maintenanceInterval)); // NEU
|
|
|
|
// Systeminfo-Ersetzungen
|
|
body.replace(F("{FREE_HEAP}"), String(ESP.getFreeHeap()));
|
|
body.replace(F("{SDK_VERSION}"), String(ESP.getSdkVersion()));
|
|
body.replace(F("{CORE_VERSION}"), ESP.getCoreVersion()); // NEU - Ist schon String
|
|
body.replace(F("{BOOT_VERSION}"), String(ESP.getBootVersion()));
|
|
body.replace(F("{CHIP_ID}"), String(ESP.getChipId(), HEX)); // NEU - ChipID oft als HEX angezeigt
|
|
body.replace(F("{CPU_MHZ}"), String(ESP.getCpuFreqMHz()));
|
|
body.replace(F("{RESET_REASON}"), ESP.getResetReason()); // NEU - Ist schon String
|
|
|
|
body.replace(F("{SKETCH_SIZE}"), String(ESP.getSketchSize()));
|
|
body.replace(F("{FREE_SKETCH}"), String(ESP.getFreeSketchSpace()));
|
|
body.replace(F("{FLASH_SIZE}"), String(ESP.getFlashChipSize())); // NEU
|
|
|
|
body.replace(F("{MAC_ADDRESS}"), WiFi.macAddress()); // NEU - Ist schon String
|
|
|
|
// RSSI nur anzeigen, wenn verbunden, sonst "N/A"
|
|
String rssi_str = F("N/A");
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
rssi_str = String(WiFi.RSSI());
|
|
}
|
|
body.replace(F("{WIFI_RSSI}"), rssi_str); // NEU
|
|
body.replace(F("{HOSTNAME}"), String(hostname)); // NEU
|
|
|
|
html += body; // Füge den modifizierten Body zum Gesamt-HTML hinzu
|
|
|
|
server.send(200, F("text/html"), html);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Eco-Modus-Konfiguration - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleEco() // URL: /ECO
|
|
{
|
|
static const char ecoHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Eco-Modus</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char ecoHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char ecoHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Eco-Modus</h1>
|
|
<form action='/updateEcoSettings' method='POST'>
|
|
<h3>Eco-Modus</h3>
|
|
Der Eco-Modus senkt die voreingestellten Temperaturen nach der angegebenen Zeit auf die Eco-Temperaturen ab.<br>
|
|
<label for='ecoMode'>Eco-Modus (Minuten, 0 = deaktiviert):</label>
|
|
<input type='text' id='ecoMode' name='ecoMode' value='{ECO_MODE}'>
|
|
<label for='ecoModeTempWasser'>Eco-Temperatur Wasser:</label>
|
|
<input type='text' id='ecoModeTempWasser' name='ecoModeTempWasser' value='{ECO_MODE_TEMP_WASSER}'>
|
|
<label for='ecoModeTempDampf'>Eco-Temperatur Dampf:</label>
|
|
<input type='text' id='ecoModeTempDampf' name='ecoModeTempDampf' value='{ECO_MODE_TEMP_DAMPF}'>
|
|
<div style='margin-bottom:15px;'>
|
|
<br>
|
|
<h3>Dynamischer Eco-Modus</h3>
|
|
<label for='fast-heat-up-aktivieren' style='display:inline-block; margin-right:10px;'>
|
|
Dynamischen Eco-Modus aktivieren (ECO+):
|
|
<input type='checkbox' id='dynamicEcoMode' name='dynamicEcoMode' value='1' {DYNAMIC_ECO_MODE_CHECKBOX}>
|
|
</label>
|
|
</div>
|
|
<div style='margin-top:15px;'>
|
|
Der dynamische Eco-Modus verhindert, dass die Maschine bis in die Unendlichkeit eine gewisse Temperatur aufrecht erhält.<br>
|
|
Er senkt die Temperatur pro Minute um ein weiteres Grad ab, bis letzlich das Heizen vollständig beendet wird.<br>
|
|
Der dynamische Eco-Modus ist nur in Kombination mit dem Eco-Modus nutzbar!<br>
|
|
<br>
|
|
<b>Beispiel für eine praktische Anwendung:</b><br>
|
|
Peter hat an seiner Maschine den Eco-Modus auf 45 Minuten gestellt, mit Eco-Temperatur von 60 Grad. Nun kommt ihm etwas dazwischen und er kommt erst bei Minute 60 an die Maschine, um sich einen Espresso zuzubereiten. Die Maschine ist nun allerdings schon auf 60 Grad herabgekühlt und er muss erneut das Aufheizen abwarten ...<br>
|
|
Markus passiert das gleiche, doch er hat den dynamischen Eco-Modus mit einer initialen Eco-Temperatur von 95 Grad aktiviert - Seine Maschine ist nun zumindest noch bei 80 Grad.<br>
|
|
Markus muss zwar auch warten, kann jedoch noch vor Peter einen Espresso trinken.
|
|
</div><br>
|
|
<h3>Verzögerung für Dampf</h3>
|
|
<label for='dampfDelay'>Aufheizen für Dampf verzögern (Minuten, 0 = deaktiviert):</label>
|
|
<input type='text' id='dampfDelay' name='dampfDelay' value='{DAMPF_DELAY}'>
|
|
<b style='color: #FFCC00;'>ACHTUNG:</b><br>
|
|
Um Konflikte zu vermeiden, sollte der Wert geringer sein, als der des Eco-Modus, falls dieser aktiviert ist!
|
|
<br>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String html = FPSTR(ecoHtml);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(ecoHtml2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
html += FPSTR(ecoHtml3);
|
|
|
|
html.replace(F("{ECO_MODE}"), String(ecoModeMinutes));
|
|
html.replace(F("{ECO_MODE_TEMP_WASSER}"), String(ecoModeTempWasser));
|
|
html.replace(F("{ECO_MODE_TEMP_DAMPF}"), String(ecoModeTempDampf));
|
|
html.replace(F("{DYNAMIC_ECO_MODE_CHECKBOX}"), dynamicEcoActive ? F("checked") : F(""));
|
|
html.replace(F("{DAMPF_DELAY}"), String(dampfVerzoegerung));
|
|
|
|
server.send(200, F("text/html"), html);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Fast-Heat-Up Seite - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleFastHeatUp() // URL: /Fast-Heat-Up
|
|
{
|
|
static const char fhuHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Fast-Heat-Up-Modus</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char fhuHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char fhuHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Fast-Heat-Up-Modus</h1>
|
|
<form action='/updateFast-Heat-Up-Settings' method='POST'>
|
|
<h3>Fast-Heat-Up-Modus</h3>
|
|
<div style='margin-bottom:15px;'>
|
|
<label for='fast-heat-up-aktivieren' style='display:inline-block; margin-right:10px;'>
|
|
Fast-Heat-Up aktivieren:
|
|
</label>
|
|
<input type='checkbox' id='fast-heat-up-aktivieren' name='fastHeatUpAktiv' value='1' {FASTHEATUP_MODE_CHECKBOX}>
|
|
</div>
|
|
<div style='margin-top:15px;'>
|
|
Der Fast-Heat-Up-Modus ermöglicht es, die Maschine noch schneller aufzuheizen.<br>
|
|
Der Kessel wird beim Start auf 130 Grad Celsius erhitzt.<br>
|
|
Nachdem die Temperatur erreicht ist, muss ein Flush von ca. 20 Sekunden durchgeführt werden.<br>
|
|
<br>
|
|
<b style='color: #FFCC00;'>ACHTUNG:</b><br>
|
|
Bitte beachten, dass auf der Seite PID-Einstellung eventuell die max. Wassertemperatur der Sicherheitsabschaltung angepasst werden muss!<br>
|
|
</div>
|
|
<input type='submit' value='Einstellungen speichern' style='margin-top:20px;'>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String html = FPSTR(fhuHtml);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(fhuHtml2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
html += FPSTR(fhuHtml3);
|
|
html.replace(F("{FASTHEATUP_MODE_CHECKBOX}"), fastHeatUpAktiv ? F("checked") : F(""));
|
|
server.send(200, F("text/html"), html);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Speichern der Fast-Heat-Up-Einstellung - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleFastHeatUpSettings()
|
|
{
|
|
if (server.hasArg(F("fastHeatUpAktiv")))
|
|
{
|
|
fastHeatUpAktiv = true;
|
|
}
|
|
else
|
|
{
|
|
fastHeatUpAktiv = false;
|
|
}
|
|
|
|
EEPROM.put(EEPROM_ADDR_FASTHEATUP_DATA, fastHeatUpAktiv);
|
|
EEPROM.commit();
|
|
|
|
server.sendHeader(F("Location"), F("/Fast-Heat-Up")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für Root (PID-Einstellungen) - Unverändert
|
|
************************************************************************************/
|
|
|
|
void handleRoot()
|
|
{
|
|
static const char rootHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>PID-Einstellung</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char rootHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char rootHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>PID-Einstellung</h1>
|
|
{SENSOR_ERROR_MESSAGES}
|
|
<form action='/updateSettings' method='POST'>
|
|
<h3>Temperatur: Wasser / Kessel</h3>
|
|
<label for='wasser'>Setpoint (°C):</label>
|
|
<input type='text' id='wasser' name='wasser' value='{WASSER}'>
|
|
<label for='offsetWasser'>Offset (°C):</label>
|
|
<input type='text' id='offsetWasser' name='offsetWasser' value='{OFFSET_WASSER}'>
|
|
<label for='boostWasser' style='display:inline-block; margin-right:10px;'>
|
|
Boost-Funktion:
|
|
</label>
|
|
<input type='checkbox' id='boostWasser' name='boostWasser' value='1' {BOOST_WASSER_CHECKED}>
|
|
<br>
|
|
<small style='color: #ccc;'>(Volle Heizleistung erzwingen bis 95% der Zieltemperatur)</small>
|
|
<br>
|
|
<label for='preventHeatWasser' style='display:inline-block; margin-right:10px;'>
|
|
Heizen oberhalb Setpoint verhindern:
|
|
</label>
|
|
<input type='checkbox' id='preventHeatWasser' name='preventHeatWasser' value='1' {PREVENTHEAT_WASSER_CHECKED}>
|
|
<br>
|
|
<small style='color: #ccc;'>(Kein Heizen, wenn Temp. > Sollwert)</small>
|
|
<br><br>
|
|
<h3>Temperatur: Dampf / Thermoblock</h3>
|
|
<label for='dampf'>Setpoint (°C):</label>
|
|
<input type='text' id='dampf' name='dampf' value='{DAMPF}'>
|
|
<label for='offsetDampf'>Offset (°C):</label>
|
|
<input type='text' id='offsetDampf' name='offsetDampf' value='{OFFSET_DAMPF}'>
|
|
<label for='boostDampf' style='display:inline-block; margin-right:10px;'>
|
|
Boost-Funktion:
|
|
</label>
|
|
<input type='checkbox' id='boostDampf' name='boostDampf' value='1' {BOOST_DAMPF_CHECKED}>
|
|
<br>
|
|
<small style='color: #ccc;'>(Volle Heizleistung erzwingen bis 75% der Zieltemperatur)</small>
|
|
<br>
|
|
<label for='preventHeatDampf' style='display:inline-block; margin-right:10px;'>
|
|
Heizen oberhalb Setpoint verhindern:
|
|
</label>
|
|
<input type='checkbox' id='preventHeatDampf' name='preventHeatDampf' value='1' {PREVENTHEAT_DAMPF_CHECKED}>
|
|
<br>
|
|
<small style='color: #ccc;'>(Kein Heizen, wenn Temp. > Sollwert)</small>
|
|
<br><br>
|
|
<h3>PID-Parameter: Wasser / Kessel</h3>
|
|
<label for='kpWasser'>Kp:</label>
|
|
<input type='text' id='kpWasser' name='kpWasser' value='{KP_WASSER}'>
|
|
<label for='kiWasser'>Ki:</label>
|
|
<input type='text' id='kiWasser' name='kiWasser' value='{KI_WASSER}'>
|
|
<label for='kdWasser'>Kd:</label>
|
|
<input type='text' id='kdWasser' name='kdWasser' value='{KD_WASSER}'>
|
|
<label for='windowSizeWasser'>Zeitfenster (ms):</label>
|
|
<input type='text' id='windowSizeWasser' name='windowSizeWasser' value='{WINDOW_SIZE_WASSER}'>
|
|
</br>
|
|
<h3>PID-Parameter: Dampf / Thermoblock</h3>
|
|
<label for='kpDampf'>Kp:</label>
|
|
<input type='text' id='kpDampf' name='kpDampf' value='{KP_DAMPF}'>
|
|
<label for='kiDampf'>Ki:</label>
|
|
<input type='text' id='kiDampf' name='kiDampf' value='{KI_DAMPF}'>
|
|
<label for='kdDampf'>Kd:</label>
|
|
<input type='text' id='kdDampf' name='kdDampf' value='{KD_DAMPF}'>
|
|
<label for='windowSizeDampf'>Zeitfenster (ms):</label>
|
|
<input type='text' id='windowSizeDampf' name='windowSizeDampf' value='{WINDOW_SIZE_DAMPF}'>
|
|
</br>
|
|
<h3>Sicherheitsabschaltung</h3>
|
|
Die Sicherheitsabschaltung bietet eine softwareseitige Übertemperatursicherung,<br>
|
|
ersetzt jedoch keine hardwareseitige Lösung durch z.B. Bimetall Temperaturschalter!<br>
|
|
Bei der Verwendung von Fast-Heat-Up muss der Wert für die Maximaltemperatur angepasst werden!<br>
|
|
<br>
|
|
<label for='maxTempWasser'>Max. Temp Wasser (°C):</label>
|
|
<input type='text' id='maxTempWasser' name='maxTempWasser' value='{MAX_TEMP_WASSER}'>
|
|
<label for='maxTempDampf'>Max. Temp Dampf (°C):</label>
|
|
<input type='text' id='maxTempDampf' name='maxTempDampf' value='{MAX_TEMP_DAMPF}'>
|
|
<input type='submit' value='Einstellungen speichern'>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String html = FPSTR(rootHtml);
|
|
html += FPSTR(commonStyle);
|
|
html += FPSTR(rootHtml2);
|
|
html += FPSTR(commonNav); // URLs angepasst
|
|
html += FPSTR(rootHtml3);
|
|
|
|
html.replace(F("{MAX_TEMP_WASSER}"), String(maxTempWasser, 2)); // Mit einer Nachkommastelle
|
|
html.replace(F("{MAX_TEMP_DAMPF}"), String(maxTempDampf, 2)); // Mit einer Nachkommastelle
|
|
html.replace(F("{WASSER}"), String((int)SetpointWasser));
|
|
html.replace(F("{OFFSET_WASSER}"), String(OffsetWasser, 2));
|
|
html.replace(F("{DAMPF}"), String((int)SetpointDampf));
|
|
html.replace(F("{OFFSET_DAMPF}"), String(OffsetDampf, 2));
|
|
html.replace(F("{KP_WASSER}"), String(KpWasser, 2));
|
|
html.replace(F("{KI_WASSER}"), String(KiWasser, 2));
|
|
html.replace(F("{KD_WASSER}"), String(KdWasser, 2));
|
|
html.replace(F("{KP_DAMPF}"), String(KpDampf, 2));
|
|
html.replace(F("{KI_DAMPF}"), String(KiDampf, 2));
|
|
html.replace(F("{KD_DAMPF}"), String(KdDampf, 2));
|
|
html.replace(F("{BOOST_WASSER_CHECKED}"), boostWasserActive ? F("checked") : F(""));
|
|
html.replace(F("{BOOST_DAMPF_CHECKED}"), boostDampfActive ? F("checked") : F(""));
|
|
html.replace(F("{PREVENTHEAT_WASSER_CHECKED}"), preventHeatAboveSetpointWasser ? F("checked") : F(""));
|
|
html.replace(F("{PREVENTHEAT_DAMPF_CHECKED}"), preventHeatAboveSetpointDampf ? F("checked") : F(""));
|
|
html.replace(F("{WINDOW_SIZE_WASSER}"), String(windowSizeWasser));
|
|
html.replace(F("{WINDOW_SIZE_DAMPF}"), String(windowSizeDampf));
|
|
|
|
// --- Dynamische Fehlermeldungen generieren ---
|
|
String errorMessageHtml = ""; // Standardmäßig leer
|
|
// Extern deklarierte Flags `wasserSensorError` und `dampfSensorError` verwenden
|
|
if (wasserSensorError && dampfSensorError) {
|
|
if (!demoModus)
|
|
{
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Die Daten der Temperatursensoren sind derzeit nicht verfügbar!<br>Bitte Temperatur-Sensoren prüfen!</div>";
|
|
} else {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Demo-Betrieb: Es sind keine Temperatur-Daten verfügbar, bzw. keine Sensoren angeschlossen</div>";
|
|
}
|
|
} else if (wasserSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert für Wasser nicht verfügbar</div>";
|
|
} else if (dampfSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert für Dampf nicht verfügbar</div>";
|
|
}
|
|
|
|
// Platzhalter im HTML durch die generierte Fehlermeldung ersetzen (oder durch Leerstring, falls kein Fehler)
|
|
html.replace(F("{SENSOR_ERROR_MESSAGES}"), errorMessageHtml);
|
|
|
|
server.send(200, F("text/html"), html);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Schreibt die Werte in den EEPROM (PID/Eco) - Unverändert
|
|
************************************************************************************/
|
|
|
|
void handleUpdate()
|
|
{
|
|
bool valueChanged = false; // Flag um zu prüfen, ob EEPROM.commit() nötig ist
|
|
|
|
// --- Bestehende Werte lesen ---
|
|
// Sicherheitslimits
|
|
if (server.hasArg(F("maxTempWasser"))) {
|
|
float newVal = server.arg(F("maxTempWasser")).toFloat();
|
|
if (newVal != maxTempWasser) { maxTempWasser = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_WASSER, maxTempWasser); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("maxTempDampf"))) {
|
|
float newVal = server.arg(F("maxTempDampf")).toFloat();
|
|
if (newVal != maxTempDampf) { maxTempDampf = newVal; EEPROM.put(EEPROM_ADDR_MAX_TEMP_DAMPF, maxTempDampf); valueChanged = true; }
|
|
}
|
|
// Setpoints
|
|
if (server.hasArg(F("wasser"))) {
|
|
float newVal = server.arg(F("wasser")).toFloat();
|
|
if (newVal != SetpointWasser) { SetpointWasser = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("dampf"))) {
|
|
float newVal = server.arg(F("dampf")).toFloat();
|
|
if (newVal != SetpointDampf) { SetpointDampf = newVal; EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf); valueChanged = true; }
|
|
}
|
|
// Offsets
|
|
if (server.hasArg(F("offsetWasser"))) {
|
|
float newVal = server.arg(F("offsetWasser")).toFloat();
|
|
if (newVal != OffsetWasser) { OffsetWasser = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_WASSER, OffsetWasser); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("offsetDampf"))) {
|
|
float newVal = server.arg(F("offsetDampf")).toFloat();
|
|
if (newVal != OffsetDampf) { OffsetDampf = newVal; EEPROM.put(EEPROM_ADDR_OFFSET_DAMPF, OffsetDampf); valueChanged = true; }
|
|
}
|
|
// PID Wasser
|
|
if (server.hasArg(F("kpWasser"))) {
|
|
float newVal = server.arg(F("kpWasser")).toFloat();
|
|
if (newVal != KpWasser) { KpWasser = newVal; EEPROM.put(EEPROM_ADDR_KP_WASSER, KpWasser); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("kiWasser"))) {
|
|
float newVal = server.arg(F("kiWasser")).toFloat();
|
|
if (newVal != KiWasser) { KiWasser = newVal; EEPROM.put(EEPROM_ADDR_KI_WASSER, KiWasser); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("kdWasser"))) {
|
|
float newVal = server.arg(F("kdWasser")).toFloat();
|
|
if (newVal != KdWasser) { KdWasser = newVal; EEPROM.put(EEPROM_ADDR_KD_WASSER, KdWasser); valueChanged = true; }
|
|
}
|
|
// PID Dampf
|
|
if (server.hasArg(F("kpDampf"))) {
|
|
float newVal = server.arg(F("kpDampf")).toFloat();
|
|
if (newVal != KpDampf) { KpDampf = newVal; EEPROM.put(EEPROM_ADDR_KP_DAMPF, KpDampf); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("kiDampf"))) {
|
|
float newVal = server.arg(F("kiDampf")).toFloat();
|
|
if (newVal != KiDampf) { KiDampf = newVal; EEPROM.put(EEPROM_ADDR_KI_DAMPF, KiDampf); valueChanged = true; }
|
|
}
|
|
if (server.hasArg(F("kdDampf"))) {
|
|
float newVal = server.arg(F("kdDampf")).toFloat();
|
|
if (newVal != KdDampf) { KdDampf = newVal; EEPROM.put(EEPROM_ADDR_KD_DAMPF, KdDampf); valueChanged = true; }
|
|
}
|
|
// Boost Flags
|
|
bool newBoostWasser = server.hasArg(F("boostWasser"));
|
|
if (newBoostWasser != boostWasserActive) {
|
|
boostWasserActive = newBoostWasser;
|
|
EEPROM.put(EEPROM_ADDR_BOOST_WASSER_ACTIVE, boostWasserActive);
|
|
valueChanged = true;
|
|
}
|
|
bool newBoostDampf = server.hasArg(F("boostDampf"));
|
|
if (newBoostDampf != boostDampfActive) {
|
|
boostDampfActive = newBoostDampf;
|
|
EEPROM.put(EEPROM_ADDR_BOOST_DAMPF_ACTIVE, boostDampfActive);
|
|
valueChanged = true;
|
|
}
|
|
|
|
// Prevent Heat Above Setpoint Flags
|
|
bool newPreventHeatWasser = server.hasArg(F("preventHeatWasser"));
|
|
if (newPreventHeatWasser != preventHeatAboveSetpointWasser) {
|
|
preventHeatAboveSetpointWasser = newPreventHeatWasser;
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_WASSER, preventHeatAboveSetpointWasser);
|
|
valueChanged = true;
|
|
Serial.printf("PreventHeat Wasser %s\n", preventHeatAboveSetpointWasser ? "aktiviert" : "deaktiviert");
|
|
}
|
|
bool newPreventHeatDampf = server.hasArg(F("preventHeatDampf"));
|
|
if (newPreventHeatDampf != preventHeatAboveSetpointDampf) {
|
|
preventHeatAboveSetpointDampf = newPreventHeatDampf;
|
|
EEPROM.put(EEPROM_ADDR_PREVENTHEAT_DAMPF, preventHeatAboveSetpointDampf);
|
|
valueChanged = true;
|
|
Serial.printf("PreventHeat Dampf %s\n", preventHeatAboveSetpointDampf ? "aktiviert" : "deaktiviert");
|
|
}
|
|
|
|
unsigned long newWindowSizeWasser = windowSizeWasser; // Startwert mit aktuellem Wert
|
|
if (server.hasArg(F("windowSizeWasser"))) {
|
|
char* endptr;
|
|
// Konvertiere den String-Parameter zu unsigned long
|
|
unsigned long val = strtoul(server.arg(F("windowSizeWasser")).c_str(), &endptr, 10);
|
|
// Prüfe, ob Konvertierung erfolgreich war und Wert sinnvoll ist (z.B. >0 und nicht zu groß)
|
|
if (*endptr == '\0' && val > 0 && val <= 60000) { // Beispiel: max 60 Sekunden
|
|
newWindowSizeWasser = val;
|
|
} else {
|
|
Serial.printf("WARNUNG: Ungültiger Wert für windowSizeWasser empfangen: %s\n", server.arg(F("windowSizeWasser")).c_str());
|
|
}
|
|
}
|
|
// Wenn sich der Wert geändert hat:
|
|
if (newWindowSizeWasser != windowSizeWasser) {
|
|
windowSizeWasser = newWindowSizeWasser;
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_WASSER, windowSizeWasser);
|
|
pidWasser.SetOutputLimits(0, windowSizeWasser); // <-- WICHTIG: PID Limit sofort aktualisieren!
|
|
valueChanged = true;
|
|
Serial.printf("Fenstergröße Wasser aktualisiert auf: %lu ms\n", windowSizeWasser);
|
|
}
|
|
|
|
unsigned long newWindowSizeDampf = windowSizeDampf; // Startwert mit aktuellem Wert
|
|
if (server.hasArg(F("windowSizeDampf"))) {
|
|
char* endptr;
|
|
unsigned long val = strtoul(server.arg(F("windowSizeDampf")).c_str(), &endptr, 10);
|
|
if (*endptr == '\0' && val > 0 && val <= 60000) {
|
|
newWindowSizeDampf = val;
|
|
} else {
|
|
Serial.printf("WARNUNG: Ungültiger Wert für windowSizeDampf empfangen: %s\n", server.arg(F("windowSizeDampf")).c_str());
|
|
}
|
|
}
|
|
// Wenn sich der Wert geändert hat:
|
|
if (newWindowSizeDampf != windowSizeDampf) {
|
|
windowSizeDampf = newWindowSizeDampf;
|
|
EEPROM.put(EEPROM_ADDR_WINDOWSIZE_DAMPF, windowSizeDampf);
|
|
pidDampf.SetOutputLimits(0, windowSizeDampf); // <-- WICHTIG: PID Limit sofort aktualisieren!
|
|
valueChanged = true;
|
|
Serial.printf("Fenstergröße Dampf aktualisiert auf: %lu ms\n", windowSizeDampf);
|
|
}
|
|
|
|
|
|
// --- EEPROM speichern und PID aktualisieren ---
|
|
if (valueChanged) { // Nur speichern, wenn sich mindestens ein Wert geändert hat
|
|
if (EEPROM.commit()) {
|
|
Serial.println("Einstellungen erfolgreich in EEPROM gespeichert.");
|
|
} else {
|
|
Serial.println("FEHLER: EEPROM Commit fehlgeschlagen!");
|
|
}
|
|
}
|
|
|
|
// PID-Parameter an die Regler übergeben
|
|
pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Zurück zur Hauptseite (bereits vorhanden)
|
|
server.sendHeader(F("Location"), F("/"));
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert Eco-Einstellungen - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleEcoUpdate()
|
|
{
|
|
if (server.hasArg(F("ecoMode"))) ecoModeMinutes = server.arg(F("ecoMode")).toInt();
|
|
if (server.hasArg(F("ecoModeTempWasser"))) ecoModeTempWasser = server.arg(F("ecoModeTempWasser")).toInt();
|
|
if (server.hasArg(F("ecoModeTempDampf"))) ecoModeTempDampf = server.arg(F("ecoModeTempDampf")).toInt();
|
|
|
|
if (server.hasArg(F("dynamicEcoMode")))
|
|
{
|
|
dynamicEcoActive = true;
|
|
}
|
|
else
|
|
{
|
|
dynamicEcoActive = false;
|
|
}
|
|
|
|
if (server.hasArg(F("dampfDelay")))
|
|
{
|
|
dampfVerzoegerung = server.arg(F("dampfDelay")).toInt();
|
|
}
|
|
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_WASSER, ecoModeTempWasser);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_TEMP_DAMPF, ecoModeTempDampf);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
|
|
EEPROM.commit();
|
|
|
|
// Wenn Eco deaktiviert, Setpoints zurückladen
|
|
if (ecoModeMinutes == 0)
|
|
{
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
}
|
|
|
|
server.sendHeader(F("Location"), F("/ECO")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert Geräte-Infos
|
|
************************************************************************************/
|
|
|
|
void handleInfoUpdate()
|
|
{
|
|
bool changed = false;
|
|
if (server.hasArg(F("hersteller")))
|
|
{
|
|
strncpy(infoHersteller, server.arg(F("hersteller")).c_str(), sizeof(infoHersteller)-1);
|
|
infoHersteller[sizeof(infoHersteller)-1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_HERSTELLER, infoHersteller);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("modell")))
|
|
{
|
|
strncpy(infoModell, server.arg(F("modell")).c_str(), sizeof(infoModell)-1);
|
|
infoModell[sizeof(infoModell)-1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_MODELL, infoModell);
|
|
changed = true;
|
|
}
|
|
if (server.hasArg(F("zusatz")))
|
|
{
|
|
strncpy(infoZusatz, server.arg(F("zusatz")).c_str(), sizeof(infoZusatz)-1);
|
|
infoZusatz[sizeof(infoZusatz)-1] = '\0';
|
|
EEPROM.put(EEPROM_ADDR_INFO_ZUSATZ, infoZusatz);
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Speichert das Wartungsintervall
|
|
************************************************************************************/
|
|
|
|
void handleMaintenanceUpdate()
|
|
{
|
|
bool changed = false;
|
|
// Wartungsintervall speichern
|
|
if (server.hasArg(F("maintenanceInterval")))
|
|
{
|
|
int newInterval = server.arg(F("maintenanceInterval")).toInt();
|
|
if (newInterval < 0) newInterval = 0; // Sicherstellen, dass nicht negativ
|
|
if (newInterval != maintenanceInterval) {
|
|
maintenanceInterval = newInterval;
|
|
EEPROM.put(EEPROM_ADDR_MAINTENANCE_INTERVAL, maintenanceInterval);
|
|
changed = true;
|
|
Serial.printf("Wartungsintervall gesetzt auf: %d Shots\n", maintenanceInterval);
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
EEPROM.commit();
|
|
}
|
|
|
|
server.sendHeader(F("Location"), F("/Info")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für Temperatur-Charts (Live) - URL angepasst
|
|
* Lädt chart.js bevorzugt aus LittleFS, sonst CDN, sonst Fehler.
|
|
************************************************************************************/
|
|
void handleCharts() // URL: /Charts
|
|
{
|
|
bool chartJsLocal = LittleFS.exists("/chart.umd.min.js");
|
|
bool isConnected = (WiFi.status() == WL_CONNECTED);
|
|
String chartJsUrl = "";
|
|
bool canLoadChartJs = false;
|
|
|
|
if (chartJsLocal) {
|
|
chartJsUrl = "/chart.umd.min.js"; // Lokale Datei verwenden
|
|
canLoadChartJs = true;
|
|
Serial.println("Chart-Handler: Verwende lokale chart.umd.min.js");
|
|
} else if (isConnected) {
|
|
chartJsUrl = "https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"; // Fallback auf CDN
|
|
canLoadChartJs = true;
|
|
Serial.println("Chart-Handler: Lade chart.umd.min.js vom CDN (lokal nicht gefunden).");
|
|
} else {
|
|
// Weder lokal noch Internet -> Chart kann nicht geladen werden
|
|
canLoadChartJs = false;
|
|
Serial.println("Chart-Handler: Kann chart.js nicht laden (nicht lokal, keine Verbindung).");
|
|
}
|
|
|
|
// --- HTML-Teile definieren (PROGMEM für Effizienz) ---
|
|
|
|
// Teil 1: HTML-Start, Meta-Tags, spezifische Chart-Styles
|
|
static const char chartsHtml_HeadStart[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<title>Live-Temperaturverlauf</title>
|
|
<meta charset="UTF-8">
|
|
<meta name='theme-color' content='#111111'>
|
|
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral"; // Script-Tag kommt dynamisch
|
|
|
|
// Teil 1b: Spezifische Styles (wie vorher)
|
|
static const char chartsHtml_SpecificStyles[] PROGMEM = R"rawliteral(
|
|
<style>
|
|
/* *** Body und Haupt-Wrapper für Flexbox *** */
|
|
html { height: 100%; } /* Wichtig für 100vh */
|
|
body {
|
|
margin: 0; /* Standard-Margin entfernen */
|
|
display: flex;
|
|
flex-direction: column; /* Stapelt Navigation und main-content */
|
|
min-height: 100vh; /* Mindestens die Höhe des Viewports */
|
|
/* Ggf. allgemeine Body-Styles wie Hintergrund hierher verschieben,
|
|
falls sie nicht in commonStyle sind */
|
|
}
|
|
/* Optional: Wenn die Navigation eine fixe Höhe hat */
|
|
/* .your-nav-class { flex-shrink: 0; } */
|
|
|
|
main.chart-page-content { /* *** NEUER Wrapper *** */
|
|
flex: 1; /* Nimmt den Restplatz nach der Navigation ein */
|
|
display: flex;
|
|
flex-direction: column; /* Stapelt Titel, Selektor, Chart usw. */
|
|
padding: 15px; /* Außenabstand für den Inhalt */
|
|
box-sizing: border-box;
|
|
overflow: hidden; /* Verhindert, dass Inhalt überläuft */
|
|
max-width: 1600px; /* Optionale Maximalbreite für sehr breite Bildschirme */
|
|
margin-left: auto; /* Zentriert den Inhalt horizontal */
|
|
margin-right: auto; /* Zentriert den Inhalt horizontal */
|
|
width: 100%; /* Nimmt volle Breite bis max-width ein */
|
|
}
|
|
/* Spezifische Styles für die Chart-Seite (Ergänzung zu commonStyle) */
|
|
h1.chart-title { /* Eigene Klasse um Konflikte zu vermeiden */
|
|
text-align: center; color: #efefef; margin-top: 20px; margin-bottom: 10px;
|
|
font-weight: 300; font-size: 1.8rem;
|
|
}
|
|
.chart-container {
|
|
width: 95%; height: 65vh; min-height: 350px;
|
|
margin: 15px auto; background: rgba(0, 0, 0, 0.2);
|
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); border-radius: 15px;
|
|
position: relative; border: 1px solid rgba(255, 255, 255, 0.1);
|
|
padding: 20px; box-sizing: border-box;
|
|
}
|
|
.chart-container canvas { width: 100% !important; height: 100% !important; }
|
|
.update-rate-selector {
|
|
margin: 10px auto 20px auto; display: flex; justify-content: center;
|
|
align-items: center; gap: 12px;
|
|
}
|
|
.update-rate-selector label { color: #ccc; font-size: 0.95em; }
|
|
.update-rate-selector select {
|
|
background: #252525; color: #e0e0e0; border: 1px solid #555;
|
|
padding: 8px 12px; border-radius: 8px; font-size: 0.9em; cursor: pointer;
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
.update-rate-selector select:focus {
|
|
outline: none; border-color: var(--accent-color, #d89904);
|
|
box-shadow: 0 0 8px rgba(216, 153, 4, 0.3);
|
|
}
|
|
.sensor-error-message { /* Unverändert von vorher */
|
|
color: #FFCC00; text-align: center; margin-top: 5px; margin-bottom: 15px;
|
|
padding: 8px 10px; background-color: rgba(255, 204, 0, 0.1);
|
|
border: 1px solid rgba(255, 204, 0, 0.2); border-radius: 8px;
|
|
font-weight: 500; font-size: 0.9em; max-width: 600px;
|
|
margin-left: auto; margin-right: auto;
|
|
}
|
|
.chart-error-box { /* Style für die Fehlermeldung, wenn Chart.js fehlt */
|
|
margin: 20px auto; padding: 20px; max-width: 600px;
|
|
background-color: rgba(255, 221, 221, 0.9); /* Heller Rotton */
|
|
border: 1px solid #dc3545; /* Roter Rand */
|
|
color: #333; /* Dunkle Schrift für Kontrast */
|
|
border-radius: 8px; text-align: center;
|
|
}
|
|
.chart-error-box h2 { color: #dc3545; margin-bottom: 10px;}
|
|
.chart-error-box a { color: #0056b3; text-decoration: underline; }
|
|
|
|
@media (max-width: 600px) { /* Unverändert von vorher */
|
|
h1.chart-title { font-size: 1.5rem; }
|
|
.chart-container { width: 98%; padding: 10px; height: 60vh; }
|
|
.update-rate-selector { flex-direction: column; gap: 5px; margin-bottom: 15px; }
|
|
.sensor-error-message { width: 90%; font-size: 0.85em; }
|
|
.chart-error-box { width: 90%; padding: 15px;}
|
|
}
|
|
</style>
|
|
)rawliteral";
|
|
|
|
// Teil 2: Schließendes Head-Tag
|
|
static const char chartsHtml_HeadEnd[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
// Teil 3: Body-Start, Titel, Update-Rate-Selektor, Placeholder für Sensorfehler, Chart-Container-Div
|
|
static const char chartsHtml_BodyStartAndUI[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1 class="chart-title">Live-Temperaturverlauf</h1>
|
|
|
|
<div class="update-rate-selector">
|
|
<label for="updateRate">Aktualisierungsrate:</label>
|
|
<select id="updateRate">
|
|
<option value="1000">1 Sekunde</option>
|
|
<option value="2000">2 Sekunden</option>
|
|
<option value="3000" selected>3 Sekunden</option>
|
|
<option value="5000">5 Sekunden</option>
|
|
<option value="10000">10 Sekunden</option>
|
|
<option value="15000">15 Sekunden</option>
|
|
<option value="30000">30 Sekunden</option>
|
|
</select>
|
|
</div>
|
|
|
|
{SENSOR_ERROR_MESSAGES_PLACEHOLDER} <div class="chart-container"> )rawliteral"; // Div wird später geschlossen ODER Inhalt geändert
|
|
|
|
// Teil 4: Der eigentliche Chart.js Initialisierungs- und Update-Code
|
|
// (Der lange <script>...</script> Block aus der vorherigen Version)
|
|
static const char chartsHtml_ChartJSCode[] PROGMEM = R"rawliteral(
|
|
const ctx = document.getElementById('combinedChart').getContext('2d');
|
|
let currentSetpointDampf = 0; let currentSetpointWasser = 0;
|
|
const wasserColor = '#00AEEF'; const dampfColor = '#F7941D';
|
|
const setpointWasserColor = 'rgba(0, 174, 239, 0.7)';
|
|
const setpointDampfColor = 'rgba(247, 148, 29, 0.7)';
|
|
const gridColor = 'rgba(255, 255, 255, 0.1)'; const textColor = '#E0E0E0';
|
|
const combinedChart = new Chart(ctx, {
|
|
type: 'line', data: { labels: [],
|
|
datasets: [
|
|
{ label: 'Wasser IST', data: [], borderColor: wasserColor, backgroundColor: 'rgba(0, 174, 239, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: wasserColor },
|
|
{ label: 'Dampf IST', data: [], borderColor: dampfColor, backgroundColor: 'rgba(247, 148, 29, 0.1)', borderWidth: 2, fill: 'start', tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 10, pointHoverBackgroundColor: dampfColor },
|
|
{ label: 'Wasser SOLL', data: [], borderColor: setpointWasserColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 },
|
|
{ label: 'Dampf SOLL', data: [], borderColor: setpointDampfColor, borderWidth: 1.5, borderDash: [6, 3], fill: false, tension: 0.1, pointRadius: 0, pointHoverRadius: 0 }
|
|
]
|
|
}, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
x: { type: 'category', title: { display: true, text: 'Uhrzeit', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, grid: { color: gridColor, drawBorder: false } },
|
|
y: { suggestedMin: 0, suggestedMax: 180, title: { display: true, text: 'Temperatur (°C)', color: textColor, font: { size: 13, weight: '300' } }, ticks: { color: textColor, font: { size: 11 }, stepSize: 20, callback: function(value, index, values) { return value + ' °C'; } }, grid: { color: gridColor, drawBorder: false } }
|
|
}, plugins: {
|
|
legend: { position: 'bottom', align: 'center', labels: { color: textColor, font: { size: 12 }, usePointStyle: true, pointStyle: 'rectRounded', boxWidth: 15, padding: 15 } },
|
|
tooltip: { enabled: true, mode: 'index', intersect: false, backgroundColor: 'rgba(0, 0, 0, 0.85)', titleColor: '#ffffff', titleFont: { size: 13, weight: 'bold' }, bodyColor: '#dddddd', bodyFont: { size: 12 }, bodySpacing: 4, borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, padding: 10, cornerRadius: 8, displayColors: true, boxPadding: 4,
|
|
callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } if (context.parsed.y !== null) { label += context.parsed.y.toFixed(1) + ' °C'; } return label; } }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let updateInterval = 3000; let intervalId = null; const MAX_DATA_POINTS = 100;
|
|
function fetchData() {
|
|
fetch('/data').then(response => { if (!response.ok) { console.error('Netzwerkantwort war nicht ok:', response.statusText); throw new Error('Network response was not ok'); } return response.json(); })
|
|
.then(data => {
|
|
if (data.setpointwasser !== undefined) currentSetpointWasser = data.setpointwasser; if (data.setpointdampf !== undefined) currentSetpointDampf = data.setpointdampf;
|
|
const now = new Date(); const timeLabel = now.toLocaleTimeString('de-DE');
|
|
const labels = combinedChart.data.labels; const datasets = combinedChart.data.datasets;
|
|
labels.push(timeLabel);
|
|
datasets[0].data.push(data.wasser); datasets[1].data.push(data.dampf); datasets[2].data.push(currentSetpointWasser); datasets[3].data.push(currentSetpointDampf);
|
|
while (labels.length > MAX_DATA_POINTS) { labels.shift(); datasets.forEach(dataset => { dataset.data.shift(); }); }
|
|
combinedChart.update('none');
|
|
}).catch(err => { console.error("Fehler beim Abrufen der Daten:", err); });
|
|
}
|
|
function startFetching() { if (intervalId) { clearInterval(intervalId); intervalId = null; } fetchData(); intervalId = setInterval(fetchData, updateInterval); console.log(`Chart-Aktualisierung gestartet: Intervall ${updateInterval / 1000} Sekunden.`); }
|
|
document.getElementById('updateRate').addEventListener('change', function(event) { const newInterval = parseInt(event.target.value, 10); if (!isNaN(newInterval) && newInterval > 0) { updateInterval = newInterval; startFetching(); } });
|
|
startFetching();
|
|
)rawliteral";
|
|
|
|
// Teil 5: Schließendes Body- und HTML-Tag
|
|
static const char chartsHtml_BodyEnd[] PROGMEM = R"rawliteral(
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
|
|
// --- Seitenaufbau starten ---
|
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
|
server.send(200, F("text/html"), ""); // Header senden
|
|
|
|
server.sendContent_P(chartsHtml_HeadStart);
|
|
server.sendContent_P(commonStyle); // Allgemeine Styles
|
|
server.sendContent_P(chartsHtml_SpecificStyles); // Chart-spezifische Styles
|
|
|
|
if (canLoadChartJs) {
|
|
// Script-Tag für Chart.js einfügen (lokal oder CDN)
|
|
String scriptTag = "<script src=\"";
|
|
scriptTag += chartJsUrl;
|
|
scriptTag += "\"></script>\n";
|
|
server.sendContent(scriptTag);
|
|
}
|
|
// Head immer schließen
|
|
server.sendContent_P(chartsHtml_HeadEnd);
|
|
// Body-Tag senden
|
|
server.sendContent("<body>\n");
|
|
// Navigation immer einfügen
|
|
server.sendContent_P(commonNav);
|
|
// Haupt-Wrapper für den Inhalt öffnen
|
|
server.sendContent("<main class=\"chart-page-content\">\n");
|
|
|
|
// --- Body-Inhalt basierend auf Verfügbarkeit von Chart.js ---
|
|
|
|
if (canLoadChartJs) {
|
|
// Normaler Body mit Chart
|
|
String bodyStart = FPSTR(chartsHtml_BodyStartAndUI); // Platzhalter holen
|
|
|
|
// Platzhalter für Sensorfehler ersetzen (wie in der vorherigen handleRoot)
|
|
String errorMessageHtml = "";
|
|
if (wasserSensorError && dampfSensorError) {
|
|
if (!demoModus) errorMessageHtml = "<div class=\"sensor-error-message\">Die Daten der Temperatursensoren sind derzeit nicht verfügbar!<br>Bitte Temperatur-Sensoren prüfen!</div>";
|
|
else errorMessageHtml = "<div class=\"sensor-error-message\">Demo-Betrieb: Es sind keine Temperatur-Daten verfügbar, bzw. keine Sensoren angeschlossen</div>";
|
|
} else if (wasserSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert für Wasser nicht verfügbar</div>";
|
|
} else if (dampfSensorError) {
|
|
errorMessageHtml = "<div class=\"sensor-error-message\">Temperaturwert für Dampf nicht verfügbar</div>";
|
|
}
|
|
bodyStart.replace(F("{SENSOR_ERROR_MESSAGES_PLACEHOLDER}"), errorMessageHtml);
|
|
|
|
server.sendContent(bodyStart); // Modifizierten Body-Start senden
|
|
|
|
// Canvas-Element für Chart.js einfügen
|
|
server.sendContent("<canvas id=\"combinedChart\"></canvas>");
|
|
// Chart-Container-Div schließen
|
|
server.sendContent("</div>\n");
|
|
|
|
// Chart Initialisierungs-JavaScript senden
|
|
server.sendContent("<script>\n");
|
|
server.sendContent_P(chartsHtml_ChartJSCode);
|
|
server.sendContent("</script>\n");
|
|
|
|
} else {
|
|
// Fehlerfall: Chart.js nicht verfügbar
|
|
server.sendContent("<body>\n"); // Body trotzdem öffnen
|
|
server.sendContent("<h1 class='chart-title'>Live-Temperaturverlauf</h1>\n"); // Titel
|
|
|
|
// Fehlermeldung anzeigen
|
|
server.sendContent("<div class='chart-error-box'>"); // Eigene CSS-Klasse verwenden
|
|
server.sendContent("<h2>Chart kann nicht angezeigt werden</h2>");
|
|
server.sendContent("<p>Die Chart-Bibliothek (chart.umd.min.js) wurde weder im Dateisystem gefunden, noch besteht eine Internetverbindung zum Laden vom CDN.</p>");
|
|
server.sendContent("<p>Bitte laden Sie die Datei <a href='https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js' target='_blank'>chart.umd.min.js</a> herunter und laden Sie sie über den <a href='/Dateimanager' target='_blank'>Dateimanager</a> hoch, oder verbinden Sie das Gerät mit dem Internet.</p>");
|
|
server.sendContent("</div>\n");
|
|
|
|
// Kein Chart-Container oder JS hier
|
|
}
|
|
server.sendContent("</main>\n");
|
|
// Body und HTML immer schließen
|
|
server.sendContent_P(chartsHtml_BodyEnd);
|
|
|
|
// Leeren Chunk senden, um die Übertragung abzuschließen
|
|
server.sendContent("");
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Profil-Funktionen (Speichern/Laden von Profil 1 und 2) - URL angepasst
|
|
************************************************************************************/
|
|
|
|
void handleProfiles() // URL: /Profile
|
|
{
|
|
TemperatureProfile p1;
|
|
TemperatureProfile p2;
|
|
EEPROM.get(EEPROM_ADDR_PROFILE_1, p1);
|
|
EEPROM.get(EEPROM_ADDR_PROFILE_2, p2);
|
|
// Sicherstellen, dass Namen nullterminiert sind
|
|
p1.profileName[sizeof(p1.profileName)-1] = '\0';
|
|
p2.profileName[sizeof(p2.profileName)-1] = '\0';
|
|
|
|
static const char profilesHtml[] PROGMEM = R"rawliteral(
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Profile</title><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'><meta name='theme-color' content='#000000'><meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
)rawliteral";
|
|
|
|
static const char profilesHtml2[] PROGMEM = R"rawliteral(
|
|
</head>
|
|
)rawliteral";
|
|
|
|
static const char profilesHtml3[] PROGMEM = R"rawliteral(
|
|
<body>
|
|
<h1>Temperaturprofile</h1>
|
|
<form>
|
|
Es können zwei unabhängige Profile angelegt oder geladen werden.<br>
|
|
<br>
|
|
<b>In den Profilen werden gespeichert:</b><br>
|
|
- Setpoint Wasser<br>
|
|
- Setpoint Dampf<br>
|
|
- Eco-Modus-Einstellungen<br>
|
|
- Aufheizen für Dampf verzögern<br>
|
|
- Profilname<br>
|
|
</form>
|
|
<form action='/saveProfile1' method='POST'>
|
|
<h3>Profil 1 (Name: {PROFILE1NAME})</h3>
|
|
<label for='profileName1'>Neuen Profilnamen eingeben:</label>
|
|
<input type='text' id='profileName1' name='profileName' value='{PROFILE1NAME}' maxlength='19'>
|
|
<input type='submit' value='Speichern in Profil 1'>
|
|
</form>
|
|
<form action='/loadProfile1' method='POST'>
|
|
<h3>Profil 1 laden</h3>
|
|
Temperatureinstellungen aus <b>Profil 1</b> laden.<br>
|
|
<input type='submit' value='Profil 1 laden'>
|
|
</form>
|
|
<form action='/saveProfile2' method='POST'>
|
|
<h3>Profil 2 (Name: {PROFILE2NAME})</h3>
|
|
<label for='profileName2'>Neuen Profilnamen eingeben:</label>
|
|
<input type='text' id='profileName2' name='profileName' value='{PROFILE2NAME}' maxlength='19'>
|
|
<input type='submit' value='Speichern in Profil 2'>
|
|
</form>
|
|
<form action='/loadProfile2' method='POST'>
|
|
<h3>Profil 2 laden</h3>
|
|
Temperatureinstellungen aus <b>Profil 2</b> laden<br>
|
|
<input type='submit' value='Profil 2 laden'>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
)rawliteral";
|
|
|
|
String page = FPSTR(profilesHtml);
|
|
page += FPSTR(commonStyle);
|
|
page += FPSTR(profilesHtml2);
|
|
page += FPSTR(commonNav); // URLs angepasst
|
|
|
|
String htmlBody = FPSTR(profilesHtml3);
|
|
htmlBody.replace("{PROFILE1NAME}", String(p1.profileName));
|
|
htmlBody.replace("{PROFILE2NAME}", String(p2.profileName));
|
|
page += htmlBody;
|
|
|
|
server.send(200, F("text/html"), page);
|
|
}
|
|
|
|
// Speichert aktuelles Setup in Profil 1 - URL angepasst
|
|
void handleSaveProfile1()
|
|
{
|
|
TemperatureProfile p;
|
|
EEPROM.get(EEPROM_ADDR_PROFILE_1, p);
|
|
|
|
if (server.hasArg("profileName"))
|
|
{
|
|
strncpy(p.profileName, server.arg("profileName").c_str(), sizeof(p.profileName)-1);
|
|
p.profileName[sizeof(p.profileName)-1] = '\0'; // Nullterminierung
|
|
}
|
|
|
|
// Wir verwenden die Werte aus dem EEPROM, damit nicht fälschlicherweise ECO-Werte genommen werden
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, p.setpointWasser); // Korrigiert: Lade Wert aus EEPROM
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, p.setpointDampf); // Korrigiert: Lade Wert aus EEPROM
|
|
|
|
p.ecoModeMinutes = ecoModeMinutes;
|
|
p.dynamicEcoActive = dynamicEcoActive;
|
|
p.dampfVerzoegerung = dampfVerzoegerung;
|
|
|
|
EEPROM.put(EEPROM_ADDR_PROFILE_1, p);
|
|
EEPROM.commit();
|
|
|
|
server.sendHeader(F("Location"), F("/Profile")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
|
|
// Lädt Profil 1 in die aktuellen Parameter - URL angepasst
|
|
void handleLoadProfile1()
|
|
{
|
|
TemperatureProfile p;
|
|
EEPROM.get(EEPROM_ADDR_PROFILE_1, p);
|
|
|
|
SetpointWasser = p.setpointWasser;
|
|
SetpointDampf = p.setpointDampf;
|
|
ecoModeMinutes = p.ecoModeMinutes;
|
|
dynamicEcoActive = p.dynamicEcoActive;
|
|
dampfVerzoegerung = p.dampfVerzoegerung;
|
|
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
|
|
EEPROM.commit();
|
|
|
|
// PID-Parameter neu setzen, falls sie sich durch das Profil geändert haben
|
|
// (Obwohl Profile aktuell keine PID-Parameter speichern, ist dies gute Praxis)
|
|
// pidDampf.SetTunings(KpDampf, KiDampf, KdDampf); // Wenn Profile Kp/Ki/Kd enthalten würden
|
|
// pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Eco-Status zurücksetzen, falls nötig
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
|
|
|
|
server.sendHeader(F("Location"), F("/Profile")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
// Speichert aktuelles Setup in Profil 2 - URL angepasst
|
|
void handleSaveProfile2()
|
|
{
|
|
TemperatureProfile p;
|
|
EEPROM.get(EEPROM_ADDR_PROFILE_2, p);
|
|
|
|
if (server.hasArg("profileName"))
|
|
{
|
|
strncpy(p.profileName, server.arg("profileName").c_str(), sizeof(p.profileName)-1);
|
|
p.profileName[sizeof(p.profileName)-1] = '\0'; // Nullterminierung
|
|
}
|
|
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_WASSER, p.setpointWasser); // Korrigiert
|
|
EEPROM.get(EEPROM_ADDR_SETPOINT_DAMPF, p.setpointDampf); // Korrigiert
|
|
|
|
p.ecoModeMinutes = ecoModeMinutes;
|
|
p.dynamicEcoActive = dynamicEcoActive;
|
|
p.dampfVerzoegerung = dampfVerzoegerung;
|
|
|
|
EEPROM.put(EEPROM_ADDR_PROFILE_2, p);
|
|
EEPROM.commit();
|
|
|
|
server.sendHeader(F("Location"), F("/Profile")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
// Lädt Profil 2 in die aktuellen Parameter - URL angepasst
|
|
void handleLoadProfile2()
|
|
{
|
|
TemperatureProfile p;
|
|
EEPROM.get(EEPROM_ADDR_PROFILE_2, p);
|
|
|
|
SetpointWasser = p.setpointWasser;
|
|
SetpointDampf = p.setpointDampf;
|
|
ecoModeMinutes = p.ecoModeMinutes;
|
|
dynamicEcoActive = p.dynamicEcoActive;
|
|
dampfVerzoegerung = p.dampfVerzoegerung;
|
|
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_WASSER, SetpointWasser);
|
|
EEPROM.put(EEPROM_ADDR_SETPOINT_DAMPF, SetpointDampf);
|
|
EEPROM.put(EEPROM_ADDR_ECOMODE_MINUTES, ecoModeMinutes);
|
|
EEPROM.put(EEPROM_ADDR_DYNAMIC_ECO_MODE, dynamicEcoActive);
|
|
EEPROM.put(EEPROM_ADDR_STEAM_DELAY, dampfVerzoegerung);
|
|
EEPROM.commit();
|
|
|
|
// PID-Parameter neu setzen, falls sie sich durch das Profil geändert haben
|
|
// pidDampf.SetTunings(KpDampf, KiDampf, KdDampf);
|
|
// pidWasser.SetTunings(KpWasser, KiWasser, KdWasser);
|
|
|
|
// Eco-Status zurücksetzen, falls nötig
|
|
ecoModeAktiv = false;
|
|
ecoModeActivatedTime = 0;
|
|
|
|
server.sendHeader(F("Location"), F("/Profile")); // URL angepasst
|
|
server.send(303);
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler für die Dateimanager-Seite (/Dateimanager) - Minimalistisch
|
|
************************************************************************************/
|
|
void handleFileManager() {
|
|
Serial.println("Anfrage für /Dateimanager empfangen.");
|
|
String pageContent = "<!DOCTYPE html><html lang='de'><head><meta charset='UTF-8'><title>Dateimanager</title>";
|
|
// Etwas mehr CSS für Button-ähnliches Aussehen und Abstände
|
|
pageContent += "<style>";
|
|
pageContent += "body { font-family: sans-serif; background-color: #f0f0f0; margin: 15px; }";
|
|
pageContent += "ul { list-style: none; padding: 0; }"; // Keine Aufzählungszeichen
|
|
pageContent += "li { margin-bottom: 8px; background: #fff; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; }"; // Flexbox für Ausrichtung in einer Zeile
|
|
pageContent += "li span { flex-grow: 1; margin-right: 15px; }"; // Span für Dateiname/Größe, nimmt Platz ein
|
|
pageContent += ".file-actions { display: flex; align-items: center; gap: 5px; }"; // Container für Aktionen
|
|
pageContent += "a.button-like, input[type='submit'] { text-decoration: none; background: #eee; padding: 4px 8px; border: 1px solid #ccc; border-radius: 3px; color: #333; cursor: pointer; font-size: 0.9em; }"; // Basis-Styling für Link und Button
|
|
pageContent += "a.button-like:hover, input[type='submit']:hover { background: #ddd; }";
|
|
pageContent += "form { margin: 0; padding: 0; border: none; display: inline; }"; // Formular-Reset für inline
|
|
pageContent += "hr { margin: 20px 0; }";
|
|
pageContent += "</style>";
|
|
pageContent += "</head><body>";
|
|
pageContent += "<h1>Dateimanager (LittleFS)</h1>";
|
|
|
|
// --- Dateien auflisten ---
|
|
pageContent += "<h2>Vorhandene Dateien</h2>";
|
|
pageContent += "<ul>"; // Geänderter Style oben entfernt Punkte
|
|
int fileCount = 0;
|
|
Dir dir = LittleFS.openDir("/");
|
|
while (dir.next()) {
|
|
fileCount++;
|
|
pageContent += "<li>"; // Start list item (NUR EINMAL)
|
|
|
|
// Dateiname und Größe in einem Span für bessere Kontrolle mit Flexbox
|
|
pageContent += "<span>";
|
|
pageContent += dir.fileName();
|
|
pageContent += " (" + String(dir.fileSize()) + " Bytes)";
|
|
pageContent += "</span>";
|
|
|
|
// Container für die Aktionen (Download/Löschen)
|
|
pageContent += "<div class='file-actions'>";
|
|
|
|
// --- Download Link (wieder als <a>, gestyled wie Button) ---
|
|
pageContent += "<a href='/downloadFile?name=" + dir.fileName() + "' class='button-like' download>Download</a>"; // `download` Attribut hinzugefügt
|
|
|
|
// --- Löschen-Formular (innerhalb des li und des div) ---
|
|
pageContent += "<form action='/deleteFile' method='GET'>"; // display:inline nicht mehr nötig wegen Flexbox auf Elternelement
|
|
pageContent += "<input type='hidden' name='name' value='" + dir.fileName() + "'>";
|
|
pageContent += "<input type='submit' value='Entfernen'>";
|
|
pageContent += "</form>";
|
|
|
|
pageContent += "</div>"; // Schließt file-actions div
|
|
|
|
pageContent += "</li>"; // Ende list item (NUR EINMAL)
|
|
}
|
|
|
|
// --- Restlicher Code (unverändert) ---
|
|
if (fileCount == 0) {
|
|
// Prüfen ob wirklich leer oder nur Fehler beim Iterieren
|
|
File root = LittleFS.open("/", "r");
|
|
if (root) {
|
|
File file = root.openNextFile();
|
|
if (!file) {
|
|
pageContent += "<li><i>Keine Dateien im Dateisystem gefunden.</i></li>";
|
|
} else {
|
|
pageContent += "<li><i>Fehler beim Auflisten der Dateien.</i></li>"; // Sollte nicht passieren wenn LittleFS.openDir geht
|
|
file.close();
|
|
}
|
|
root.close();
|
|
} else {
|
|
pageContent += "<li><i>Fehler beim Öffnen des Root-Verzeichnisses.</i></li>";
|
|
}
|
|
}
|
|
pageContent += "</ul><hr>";
|
|
|
|
// --- Datei-Upload-Formular ---
|
|
pageContent += "<h2>Datei hochladen</h2>";
|
|
pageContent += "<form method='POST' action='/uploadFile' enctype='multipart/form-data'>";
|
|
pageContent += "<input type='file' name='upload' required><br><br>";
|
|
pageContent += "<input type='submit' value='Datei Hochladen'>";
|
|
pageContent += "</form>";
|
|
|
|
// --- Dateisystem-Informationen (Optional) ---
|
|
pageContent += "<hr><h2>Info</h2>";
|
|
FSInfo fs_info;
|
|
if (LittleFS.info(fs_info)) {
|
|
pageContent += "Gesamtspeicher: " + String(fs_info.totalBytes) + " Bytes<br>";
|
|
pageContent += "Belegt: " + String(fs_info.usedBytes) + " Bytes<br>";
|
|
pageContent += "Frei: " + String(fs_info.totalBytes - fs_info.usedBytes) + " Bytes<br>";
|
|
} else {
|
|
pageContent += "Fehler beim Abrufen der Dateisystem-Informationen.<br>";
|
|
}
|
|
|
|
pageContent += "</body></html>";
|
|
server.send(200, "text/html", pageContent);
|
|
}
|
|
|
|
|
|
/************************************************************************************
|
|
* Handler während des Datei-Uploads
|
|
************************************************************************************/
|
|
void handleFileUpload() {
|
|
HTTPUpload& upload = server.upload();
|
|
switch (upload.status) {
|
|
case UPLOAD_FILE_START: {
|
|
String filename = upload.filename;
|
|
// Basisschutz gegen Pfadmanipulation (obwohl LittleFS das meiste handhaben sollte)
|
|
if (filename.indexOf('/') != -1 && filename.indexOf('/') == 0) {
|
|
// Einfach den Dateinamen nehmen, wenn Pfade enthalten sind
|
|
filename = filename.substring(filename.lastIndexOf('/') + 1);
|
|
}
|
|
filename = "/" + filename; // Immer im Root speichern
|
|
|
|
Serial.printf("Upload Start: %s\n", filename.c_str());
|
|
fsUploadFile = LittleFS.open(filename, "w"); // Öffnen zum Schreiben (überschreibt ggf. existierende Datei)
|
|
if (!fsUploadFile) {
|
|
Serial.println("Konnte Datei für Upload nicht erstellen/öffnen!");
|
|
// Hier könnten wir theoretisch den Upload abbrechen, ist aber komplex
|
|
}
|
|
break;
|
|
}
|
|
case UPLOAD_FILE_WRITE: {
|
|
if (fsUploadFile) {
|
|
size_t bytesWritten = fsUploadFile.write(upload.buf, upload.currentSize);
|
|
if (bytesWritten != upload.currentSize) {
|
|
Serial.printf("Fehler beim Schreiben! Erwartet: %d, Geschrieben: %d\n", upload.currentSize, bytesWritten);
|
|
// Datei schließen und löschen?
|
|
fsUploadFile.close();
|
|
LittleFS.remove(fsUploadFile.name()); // Versuch, unvollständige Datei zu löschen
|
|
} else {
|
|
// Serial.printf("Schreibe Chunk: %d Bytes\n", bytesWritten); // Debug
|
|
}
|
|
} else {
|
|
Serial.println("Upload WRITE, aber keine Datei geöffnet!");
|
|
}
|
|
break;
|
|
}
|
|
case UPLOAD_FILE_END: {
|
|
if (fsUploadFile) {
|
|
fsUploadFile.close();
|
|
Serial.printf("Upload Ende. Gesamtgröße: %d Bytes\n", upload.totalSize);
|
|
} else {
|
|
Serial.println("Upload END, aber keine Datei geöffnet!");
|
|
}
|
|
break;
|
|
}
|
|
case UPLOAD_FILE_ABORTED: {
|
|
Serial.println("Upload abgebrochen!");
|
|
if (fsUploadFile) {
|
|
fsUploadFile.close();
|
|
// Optional: Versuchen, die (möglicherweise unvollständige) Datei zu löschen
|
|
Serial.printf("Versuche, abgebrochene Upload-Datei zu löschen: %s\n", fsUploadFile.name());
|
|
if (LittleFS.exists(fsUploadFile.name())) {
|
|
if (LittleFS.remove(fsUploadFile.name())) {
|
|
Serial.println("Abgebrochene Datei gelöscht.");
|
|
} else {
|
|
Serial.println("Fehler beim Löschen der abgebrochenen Datei.");
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler NACHDEM der Upload abgeschlossen (oder fehlgeschlagen) ist
|
|
************************************************************************************/
|
|
void handleFileUploaded() {
|
|
// Einfach zurück zum Dateimanager leiten.
|
|
// Eine Statusmeldung wäre besser, aber würde mehr Aufwand bedeuten (z.B. über URL-Parameter).
|
|
server.sendHeader("Location", "/Dateimanager");
|
|
server.send(303); // 303 See Other - Korrekt für Redirect nach POST
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Herunterladen einer Datei
|
|
************************************************************************************/
|
|
void handleFileDownload() {
|
|
if (!server.hasArg("name") || server.arg("name") == NULL || server.arg("name") == "") {
|
|
server.send(400, "text/plain", "FEHLER: Dateiname zum Herunterladen fehlt!");
|
|
return;
|
|
}
|
|
|
|
String filenameParam = server.arg("name");
|
|
String filePath = filenameParam; // Kopieren, um ggf. zu modifizieren
|
|
|
|
// Sicherstellen, dass der Pfad mit '/' beginnt (Basis-Sicherheit)
|
|
if (!filePath.startsWith("/")) {
|
|
filePath = "/" + filePath;
|
|
}
|
|
|
|
// Verhindern, dass Root selbst angefordert wird
|
|
if (filePath == "/") {
|
|
server.send(400, "text/plain", "FEHLER: Root-Verzeichnis kann nicht heruntergeladen werden.");
|
|
return;
|
|
}
|
|
|
|
Serial.printf("Download-Anfrage für: %s (Parameter: %s)\n", filePath.c_str(), filenameParam.c_str());
|
|
|
|
if (LittleFS.exists(filePath)) {
|
|
File file = LittleFS.open(filePath, "r");
|
|
if (file) {
|
|
// Setze den Content-Type auf 'application/octet-stream',
|
|
// um den Browser zum Download zu zwingen, anstatt die Datei anzuzeigen.
|
|
// Alternativ könntest du versuchen, den Typ anhand der Endung zu erraten.
|
|
String contentType = "application/octet-stream";
|
|
|
|
// Setze den Content-Disposition Header, um den Dateinamen vorzuschlagen
|
|
// filenameParam enthält hier nur den Dateinamen ohne führenden Slash
|
|
server.sendHeader("Content-Disposition", "attachment; filename=\"" + filenameParam + "\"");
|
|
|
|
// Sende die Datei als Stream (effizient für größere Dateien)
|
|
// sendHeader("Content-Length", String(file.size())); // Optional aber gut für Fortschrittsanzeige
|
|
size_t sentBytes = server.streamFile(file, contentType);
|
|
file.close();
|
|
Serial.printf("Datei '%s' (%d Bytes) gesendet.\n", filePath.c_str(), sentBytes);
|
|
return; // Wichtig: Funktion nach dem Senden beenden
|
|
} else {
|
|
Serial.println("Fehler beim Öffnen der Datei für den Download!");
|
|
server.send(500, "text/plain", "FEHLER: Datei konnte nicht geöffnet werden.");
|
|
}
|
|
} else {
|
|
Serial.println("Datei zum Herunterladen nicht gefunden!");
|
|
server.send(404, "text/plain", "FEHLER: Datei nicht gefunden.");
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Löschen einer Datei
|
|
************************************************************************************/
|
|
void handleFileDelete() {
|
|
if (!server.hasArg("name") || server.arg("name") == NULL || server.arg("name") == "") {
|
|
server.send(400, "text/plain", "FEHLER: Dateiname zum Löschen fehlt!");
|
|
return;
|
|
}
|
|
|
|
String filename = server.arg("name");
|
|
// Sicherstellen, dass der Pfad relativ zum Root ist (Basis-Sicherheit)
|
|
if (!filename.startsWith("/")) {
|
|
filename = "/" + filename;
|
|
}
|
|
// Verhindere das Löschen des Root-Verzeichnisses selbst (obwohl LittleFS.remove("/") fehlschlagen sollte)
|
|
if (filename == "/") {
|
|
server.send(400, "text/plain", "FEHLER: Root-Verzeichnis kann nicht gelöscht werden.");
|
|
return;
|
|
}
|
|
|
|
|
|
Serial.printf("Versuche Datei zu löschen: %s\n", filename.c_str());
|
|
|
|
if (LittleFS.exists(filename)) {
|
|
if (LittleFS.remove(filename)) {
|
|
Serial.println("Datei erfolgreich gelöscht.");
|
|
// Redirect zurück zum Dateimanager
|
|
server.sendHeader("Location", "/Dateimanager");
|
|
server.send(303);
|
|
} else {
|
|
Serial.println("Fehler beim Löschen der Datei!");
|
|
server.send(500, "text/plain", "FEHLER: Datei konnte nicht gelöscht werden.");
|
|
}
|
|
} else {
|
|
Serial.println("Datei zum Löschen nicht gefunden!");
|
|
server.send(404, "text/plain", "FEHLER: Datei nicht gefunden.");
|
|
}
|
|
}
|
|
|
|
/************************************************************************************
|
|
* Handler zum Ausliefern der lokalen Chart.js Datei aus LittleFS
|
|
************************************************************************************/
|
|
void handleServeChartJs() {
|
|
const char* chartJsPath = "/chart.umd.min.js";
|
|
if (LittleFS.exists(chartJsPath)) {
|
|
File file = LittleFS.open(chartJsPath, "r");
|
|
if (file) {
|
|
server.streamFile(file, "text/javascript"); // Effizientes Senden
|
|
file.close();
|
|
Serial.println("Lokale chart.umd.min.js ausgeliefert.");
|
|
return; // Wichtig: Beenden nach erfolgreichem Senden
|
|
} else {
|
|
Serial.println("Fehler beim Öffnen der lokalen chart.umd.min.js!");
|
|
}
|
|
} else {
|
|
Serial.println("Angeforderte lokale chart.umd.min.js nicht gefunden!");
|
|
}
|
|
// Wenn hier angekommen, ist etwas schiefgelaufen
|
|
server.send(404, "text/plain", "Chart.js nicht gefunden");
|
|
} |