MQTT-Display-LaserCutter/src/web_server.cpp
MaPaLo76 c61a67f812 feat(web+mqtt): FR-010 Webinterface-Redesign + MQTT-Steuerung
Webinterface:
- /: oeffentlich, Laser Cutter Status, m:ss, an/aus, Display-Toggle (fetch/204)
- /config: H2-Abschnitte (Status, MQTT, Webzugang, Geraet, Tools)
  Maschinenlaufzeit h:mm:ss, Reboot-Button (disabled bei aktivem Laser)
- /reset-total: neue POST-Route fuer NVS-Maschinenlaufzeit-Reset (Auth)
- /display-toggle: POST, 204, kein Seitenwechsel
- /reboot: POST, Auth, ESP.restart()

DisplayManager:
- setEnabled()/isEnabled() via MAX7219 SHUTDOWN-Modus

MQTT:
- lasercutter/reset -> lasercutter/cmd (MQTT_TOPIC_CMD)
- Kommandos: reset_session_nvs, reset_session_ram, display, reboot
- Heartbeat lasercutter/status: display_on-Feld hinzugefuegt
2026-03-01 17:28:47 +01:00

566 lines
26 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// =============================================================================
// web_server.cpp HTTP-Webinterface für Statusanzeige und Konfiguration
// Projekt: MQTT-Display LaserCutter
// =============================================================================
#include "web_server.h"
#include <ESPAsyncWebServer.h> // voller Include nur in .cpp (PIMPL) enthält AsyncWebSocket
#include <ArduinoOTA.h> // ESP32 built-in, kein lib_deps-Eintrag noetig
#include "config.h"
#include "settings.h"
#include "laser_tracker.h"
#include "mqtt_client.h"
#include "display_manager.h"
// wifi_connector.h wird hier NICHT eingebunden (Konflikt WiFiManager vs. ESPAsyncWebServer)
// stattdessen freie Funktion aus wifi_connector.cpp:
extern void wifiResetCredentialsAndRestart();
#include <ElegantOTA.h>
#include <WiFi.h>
// Globale Instanz
WebServerManager webServer;
// -----------------------------------------------------------------------------
// Datei-statischer WebSocket-Pointer für webLogForward() (gesetzt in begin())
// -----------------------------------------------------------------------------
static AsyncWebSocket* _wsPtr = nullptr;
// Ring-Buffer für Web-Log-Console.
// webLogForward() läuft auf Core 1 (Arduino loop) kein Task, kein Queue,
// kein Core-Problem. Browser pollt GET /log-data alle 2 s.
static const size_t LOG_BUF_MAX = 6000;
// Fester BSS-Puffer keinerlei Heap-Verwendung (kein Arduino-String!)
static char _logBuf[LOG_BUF_MAX + 1] = {0};
static size_t _logBufLen = 0;
void webLogForward(const char* msg) {
size_t msgLen = strlen(msg);
size_t addLen = msgLen + 1; // +1 für '\n'
// Wenn Nachricht nicht reinpasst: hintere Hälfte per memmove behalten
if (_logBufLen + addLen >= LOG_BUF_MAX) {
size_t keep = LOG_BUF_MAX / 2;
if (keep < _logBufLen) {
memmove(_logBuf, _logBuf + (_logBufLen - keep), keep);
_logBufLen = keep;
} else {
_logBufLen = 0;
}
_logBuf[_logBufLen] = '\0';
}
if (_logBufLen + addLen < LOG_BUF_MAX) {
memcpy(_logBuf + _logBufLen, msg, msgLen);
_logBufLen += msgLen;
_logBuf[_logBufLen++] = '\n';
_logBuf[_logBufLen] = '\0';
}
}
// -----------------------------------------------------------------------------
// Hilfsmakro: HTML-Template-Platzhalter ersetzen
// -----------------------------------------------------------------------------
static String htmlReplace(String html, const String& key, const String& value) {
html.replace(key, value);
return html;
}
// -----------------------------------------------------------------------------
// Konstruktor
// -----------------------------------------------------------------------------
WebServerManager::WebServerManager()
: _server(new AsyncWebServer(80))
, _ws(new AsyncWebSocket("/log-ws"))
{}
// -----------------------------------------------------------------------------
// begin() Routen registrieren und Server starten
// -----------------------------------------------------------------------------
void WebServerManager::begin() {
// WebSocket-Handler registrieren
_ws->onEvent([](AsyncWebSocket* server, AsyncWebSocketClient* client,
AwsEventType type, void* arg, uint8_t* data, size_t len) {
(void)server; (void)arg; (void)data; (void)len;
if (type == WS_EVT_CONNECT) {
LOG_I("WEB", "Log Console Client #%u verbunden", client->id());
} else if (type == WS_EVT_DISCONNECT) {
LOG_I("WEB", "Log Console Client #%u getrennt", client->id());
}
});
_server->addHandler(_ws);
_wsPtr = _ws; // ab jetzt ist webLogForward() aktiv
registerRoutes();
// ElegantOTA-Auth: nur wenn Passwort gesetzt
const Settings& s = settings.get();
if (s.webPassword[0] != '\0') {
ElegantOTA.setAuth(s.webUser, s.webPassword);
}
ElegantOTA.begin(_server);
_server->begin();
// ArduinoOTA erscheint als Netzwerk-Port in Arduino IDE und PlatformIO
const Settings& cfg = settings.get();
ArduinoOTA.setHostname("lasercutter-display");
if (cfg.webPassword[0] != '\0') {
ArduinoOTA.setPassword(cfg.webPassword); // gleiche Auth wie Webinterface
}
ArduinoOTA.onStart([]() { LOG_I("OTA", "Start OTA-Update..."); });
ArduinoOTA.onEnd([]() { LOG_I("OTA", "OTA fertig Neustart"); });
ArduinoOTA.onError([](ota_error_t e) { LOG_E("OTA", "Fehler [%u]", e); });
ArduinoOTA.begin();
LOG_I("WEB", "ArduinoOTA aktiv: lasercutter-display.local");
LOG_I("WEB", "gestartet auf http://%s", WiFi.localIP().toString().c_str());
LOG_I("WEB", "Auth: %s", s.webPassword[0] ? "aktiv" : "deaktiviert (kein Passwort)");
}
// -----------------------------------------------------------------------------
// loop() ArduinoOTA verarbeiten + WebSocket-Clients bereinigen (in main loop() aufrufen)
// -----------------------------------------------------------------------------
void WebServerManager::loop() {
ArduinoOTA.handle();
_ws->cleanupClients(); // abgemeldete Clients freigeben (verhindert Memory-Leak)
}
// -----------------------------------------------------------------------------
// requireAuth() HTTP Basic Auth pruefen (nur wenn Passwort konfiguriert)
// -----------------------------------------------------------------------------
bool WebServerManager::requireAuth(AsyncWebServerRequest* request) {
const Settings& s = settings.get();
if (s.webPassword[0] == '\0') return true; // kein Passwort = kein Schutz
if (!request->authenticate(s.webUser, s.webPassword)) {
request->requestAuthentication("LaserCutter Display", false); // false = Basic Auth
return false;
}
return true;
}
// -----------------------------------------------------------------------------
// registerRoutes()
// -----------------------------------------------------------------------------
void WebServerManager::registerRoutes() {
// --- GET / Statusseite (öffentlich, kein Auth) -------------------------
_server->on("/", HTTP_GET, [this](AsyncWebServerRequest* request) {
request->send(200, "text/html; charset=utf-8", buildStatusPage());
});
// --- GET /config Konfigurationsformular --------------------------------
_server->on("/config", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
request->send(200, "text/html; charset=utf-8", buildConfigPage());
});
// --- POST /config Konfiguration speichern ------------------------------
_server->on("/config", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
const Settings& s = settings.get();
// MQTT
const char* broker = s.mqttBroker;
uint16_t port = s.mqttPort;
const char* user = s.mqttUser;
const char* pass = s.mqttPassword;
if (request->hasParam("broker", true))
broker = request->getParam("broker", true)->value().c_str();
if (request->hasParam("port", true))
port = (uint16_t)request->getParam("port", true)->value().toInt();
if (request->hasParam("user", true))
user = request->getParam("user", true)->value().c_str();
if (request->hasParam("password", true))
pass = request->getParam("password", true)->value().c_str();
settings.saveMqttConfig(broker, port, user, pass);
// Gratiszeit
if (request->hasParam("gratis", true)) {
uint8_t g = (uint8_t)request->getParam("gratis", true)->value().toInt();
settings.saveGratisSeconds(g);
}
// Signal-Polarität
if (request->hasParam("polarity", true)) {
uint8_t pol = (uint8_t)request->getParam("polarity", true)->value().toInt();
settings.saveSignalPolarity(pol);
}
// Webinterface-Zugangsdaten
if (request->hasParam("web_user", true)) {
const char* wUser = request->getParam("web_user", true)->value().c_str();
bool clearAuth = request->hasParam("clear_auth", true) &&
request->getParam("clear_auth", true)->value() == "1";
if (clearAuth) {
// Passwortschutz explizit deaktivieren
settings.saveWebCredentials(wUser, "");
} else {
// Passwortfeld leer => bestehendes Passwort unveraendert lassen
String wPassStr = request->hasParam("web_password", true)
? request->getParam("web_password", true)->value()
: String("");
const char* effectivePass = (wPassStr.length() > 0)
? wPassStr.c_str()
: settings.get().webPassword;
settings.saveWebCredentials(wUser, effectivePass);
}
}
LOG_I("WEB", "Konfiguration gespeichert Neustart empfohlen");
// Weiterleitung mit Hinweis
request->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta http-equiv='refresh' content='3;url=/'></head>"
"<body style='font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem'>"
"<h2>&#10003; Gespeichert</h2>"
"<p>Einstellungen wurden in NVS geschrieben. "
"MQTT-&Auml;nderungen werden nach einem <strong>Neustart</strong> aktiv.</p>"
"<p>Weiterleitung in 3 s &hellip;</p>"
"</body></html>");
});
// --- POST /reset Session zurücksetzen ---------------------------------
_server->on("/reset", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
laserTracker.resetSessionSum();
LOG_I("WEB", "Session zurueckgesetzt");
request->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta http-equiv='refresh' content='2;url=/'></head>"
"<body style='font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem'>"
"<h2>&#10003; Session beendet</h2>"
"<p>Weiterleitung &hellip;</p>"
"</body></html>");
});
// --- POST /reset-total Maschinenlaufzeit (NVS) zurücksetzen ------------
_server->on("/reset-total", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
laserTracker.resetTotal();
mqttClient.resetSessionCounter();
LOG_I("WEB", "Maschinenlaufzeit zurueckgesetzt (NVS)");
request->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta http-equiv='refresh' content='2;url=/config'></head>"
"<body style='font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem'>"
"<h2>&#10003; Maschinenlaufzeit zur&uuml;ckgesetzt</h2>"
"<p>Weiterleitung &hellip;</p>"
"</body></html>");
});
// --- POST /display-toggle Display ein-/ausschalten (öffentlich) ----------
_server->on("/display-toggle", HTTP_POST, [this](AsyncWebServerRequest* request) {
display.setEnabled(!display.isEnabled());
LOG_I("WEB", "Display %s", display.isEnabled() ? "an" : "aus");
request->send(204); // No Content Browser bleibt auf aktueller Seite
});
// --- POST /reboot ESP32 neu starten (Auth erforderlich) ----------------
_server->on("/reboot", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
LOG_I("WEB", "Reboot angefordert (Web)");
request->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta http-equiv='refresh' content='5;url=/'></head>"
"<body style='font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem'>"
"<h2>&#128260; ESP32 startet neu &hellip;</h2>"
"<p>Weiterleitung in 5 Sekunden.</p>"
"</body></html>");
delay(200);
ESP.restart();
});
// --- POST /wifi-reset WiFi-Credentials löschen und neu starten --------
_server->on("/wifi-reset", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
LOG_I("WEB", "WiFi-Credentials geloescht Neustart");
request->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta http-equiv='refresh' content='10;url=/'></head>"
"<body style='font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem'>"
"<h2>&#10003; WLAN-Daten gel&ouml;scht</h2>"
"<p>Das Ger&auml;t startet neu.<br>"
"Verbinde dich dann mit dem WLAN <strong>LaserCutter-Setup</strong> "
"und &ouml;ffne <strong>http://192.168.4.1</strong> zur Konfiguration.</p>"
"</body></html>");
// Neustart nach kurzer Pause (Response muss zuerst gesendet werden)
wifiResetCredentialsAndRestart();
});
// --- GET /log Web-Log-Console ----------------------------------------
_server->on("/log", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
request->send(200, "text/html; charset=utf-8", buildLogPage());
});
// --- GET /log-data aktuellen Log-Puffer als Plain-Text liefern --------
_server->on("/log-data", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
request->send(200, "text/plain; charset=utf-8", _logBuf);
});
// 404
_server->onNotFound([](AsyncWebServerRequest* request) {
request->send(404, "text/plain", "Not found");
});
}
// -----------------------------------------------------------------------------
// buildStatusPage()
// -----------------------------------------------------------------------------
String WebServerManager::buildStatusPage() {
const Settings& s = settings.get();
// Laserzeit Aktuell: m:ss Format
int runSec = laserTracker.getRunningSessionSeconds();
int runMin = runSec / 60;
int runSecPart = runSec % 60;
char runBuf[16];
snprintf(runBuf, sizeof(runBuf), "%d:%02d", runMin, runSecPart);
// Laserstatus: an / aus
const char* laserStatus = laserTracker.isActive()
? "<span style='color:#2f9e44;font-weight:bold'>an</span>"
: "<span style='color:#868e96'>aus</span>";
String html = F(
"<!DOCTYPE html><html>"
"<head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Laser Cutter Status</title>"
"<style>"
"body{font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem;color:#222}"
"h1{color:#333;font-size:1.4rem;margin-bottom:1.2rem}"
"table{width:100%;border-collapse:collapse;margin-bottom:1.5rem}"
"td{padding:.55rem .3rem;border-bottom:1px solid #eee}"
"td:first-child{color:#555;width:55%}"
"td:last-child{font-weight:500}"
".btn{display:block;width:100%;padding:.55rem;border:none;border-radius:5px;"
"cursor:pointer;font-size:1rem;box-sizing:border-box;text-align:center;"
"text-decoration:none;margin-bottom:.5rem}"
".btn-blue{background:#3182ce;color:#fff}"
".btn-gray{background:#718096;color:#fff}"
"</style></head>"
"<body>"
"<h1>Laser Cutter Status</h1>"
"<table>"
"<tr><td>Laserzeit Summe</td><td>%ALLSESSIONS% Minuten</td></tr>"
"<tr><td>Laserzeit Aktuell</td><td>%CURRENTSESSION%</td></tr>"
"<tr><td>Laserstatus</td><td>%LASER%</td></tr>"
"<tr><td>Gratiszeit</td><td>%GRATIS% Sekunden</td></tr>"
"<tr><td>Firmware</td><td>v" FIRMWARE_VERSION " (" __DATE__ ")</td></tr>"
"</table>"
"<form action='/reset' method='post' "
"onsubmit=\"return confirm('Summe Laserzeit wirklich zur\\u00fccksetzen?')\">"
"<button class='btn btn-blue' type='submit'>Summe Laserzeit zur&uuml;cksetzen</button>"
"</form>"
"<button class='btn %DISP_BTN_CLASS%' type='button' "
"onclick=\"fetch('/display-toggle',{method:'POST'}).then(()=>location.reload())\""
">%DISP_BTN_LABEL%</button>"
"<a class='btn btn-gray' href='/config'>&#128274; Laser Cutter Setup &amp; Status</a>"
"<script>setTimeout(()=>location.reload(),10000)</script>"
"</body></html>"
);
html.replace("%ALLSESSIONS%", String(laserTracker.getAllSessionsSumMinutes()));
html.replace("%CURRENTSESSION%", String(runBuf));
html.replace("%LASER%", laserStatus);
html.replace("%GRATIS%", String(s.gratisSeconds));
if (display.isEnabled()) {
html.replace("%DISP_BTN_CLASS%", "btn-gray");
html.replace("%DISP_BTN_LABEL%", "&#128161; Display ausschalten");
} else {
html.replace("%DISP_BTN_CLASS%", "btn-blue");
html.replace("%DISP_BTN_LABEL%", "&#128161; Display einschalten");
}
return html;
}
// -----------------------------------------------------------------------------
// buildLogPage()
// -----------------------------------------------------------------------------
String WebServerManager::buildLogPage() {
return String(F(
"<!DOCTYPE html><html>"
"<head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Log Console &ndash; LaserCutter Display</title>"
"<style>"
"body{font-family:monospace;background:#1a1a1a;color:#e0e0e0;margin:0;padding:1rem}"
"h1{font-size:1.1rem;color:#90ee90;margin:0 0 .4rem}"
"#ctrl{margin:.4rem 0}"
"button{background:#333;color:#e0e0e0;border:1px solid #555;padding:.3rem .8rem;"
"cursor:pointer;border-radius:3px;margin-right:.4rem}"
"button:hover{background:#555}"
"#status{display:inline-block;font-size:.8rem;color:#888;margin-left:.5rem}"
"#log{background:#000;border:1px solid #333;padding:.6rem;height:78vh;"
"overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.82rem}"
".i{color:#90ee90}.e{color:#ff6b6b}.d{color:#87ceeb}"
"</style></head>"
"<body>"
"<h1>&#128191; Log Console</h1>"
"<div id='ctrl'>"
"<button onclick='lastLen=0;logDiv.innerHTML=\"\"'>Leeren</button>"
"<button id='asBtn' onclick='toggleAS()'>Auto-Scroll: AN</button>"
"<a href='/' style='margin-left:.5rem'><button>&#8592; Status</button></a>"
"<span id='status'>Lade&hellip;</span>"
"</div>"
"<div id='log'></div>"
"<script>"
"var logDiv=document.getElementById('log'),"
"st=document.getElementById('status'),"
"asBt=document.getElementById('asBtn'),"
"autoS=true,lastLen=0;"
"function toggleAS(){autoS=!autoS;asBt.textContent='Auto-Scroll:'+(autoS?' AN':' AUS');}"
"function renderLog(text){"
"if(text.length===lastLen)return;"
"lastLen=text.length;"
"logDiv.innerHTML='';"
"text.split('\\n').forEach(function(line){"
"if(!line)return;"
"var s=document.createElement('span');"
"s.className=line.indexOf('[E]')!==-1?'e':line.indexOf('[D]')!==-1?'d':'i';"
"s.textContent=line+'\\n';"
"logDiv.appendChild(s);"
"});"
"if(autoS)logDiv.scrollTop=logDiv.scrollHeight;"
"}"
"function poll(){"
"fetch('/log-data')"
".then(function(r){return r.text();})"
".then(function(t){st.textContent='OK';st.style.color='#90ee90';renderLog(t);})"
".catch(function(){st.textContent='Fehler';st.style.color='#ff6b6b';});"
"}"
"poll();setInterval(poll,2000);"
"</script></body></html>"
));
}
// -----------------------------------------------------------------------------
// buildConfigPage()
// -----------------------------------------------------------------------------
String WebServerManager::buildConfigPage() {
const Settings& s = settings.get();
// Maschinenlaufzeit als h:mm:ss
long totalSec = (long)(laserTracker.getTotalMinutes() * 60.0f);
long tH = totalSec / 3600;
long tM = (totalSec % 3600) / 60;
long tS = totalSec % 60;
char totalBuf[16];
snprintf(totalBuf, sizeof(totalBuf), "%ld:%02ld:%02ld", tH, tM, tS);
String html = F(
"<!DOCTYPE html><html>"
"<head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Laser Cutter Setup &amp; Status</title>"
"<style>"
"body{font-family:sans-serif;max-width:520px;margin:2rem auto;padding:0 1rem;color:#222}"
"h1{font-size:1.4rem;color:#333;margin-bottom:1.5rem}"
"h2{font-size:1.1rem;color:#444;border-bottom:2px solid #e2e8f0;padding-bottom:.3rem;margin-top:2rem}"
"label{display:block;margin-top:.9rem;color:#555;font-size:.9rem}"
"input[type=text],input[type=number],input[type=password]"
"{width:100%;padding:.4rem .5rem;box-sizing:border-box;border:1px solid #ccc;border-radius:4px}"
"input[type=range]{width:100%}"
".radio-label{display:flex;align-items:center;gap:.5rem;margin-top:.4rem}"
".btn{display:block;width:100%;padding:.55rem;border:none;border-radius:5px;cursor:pointer;"
"font-size:1rem;box-sizing:border-box;margin-top:.6rem;text-align:center;text-decoration:none}"
".btn-blue{background:#3182ce;color:#fff}"
".btn-gray{background:#718096;color:#fff}"
".btn-red{background:#c53030;color:#fff}"
".info{background:#fff8e1;border:1px solid #ffe082;border-radius:4px;"
"padding:.5rem .7rem;font-size:.85rem;color:#6d4c00;margin-top:.5rem}"
"</style></head>"
"<body>"
"<h1>Laser Cutter Setup &amp; Status</h1>"
"<a class='btn btn-gray' href='/'>&#8592; Laser Cutter Status</a>"
"<h2>Laser Cutter Status</h2>"
"<label>Maschinenlaufzeit (gesamt)</label>"
"<p style='font-size:1.1rem;font-weight:600;margin:.2rem 0'>%TOTAL%</p>"
"<div class='info'>&#9888;&#65039; Maschinenlaufzeit zur&uuml;cksetzen l&ouml;scht den Gesamtspeicher im NVS "
"unwiderruflich &ndash; diese Aktion kann nicht r&uuml;ckg&auml;ngig gemacht werden.</div>"
"<form action='/reset-total' method='post' "
"onsubmit=\"return confirm('Maschinenlaufzeit wirklich unwiderruflich aus dem NVS l\u00f6schen?')\">"
"<button class='btn btn-red' type='submit'>Maschinenlaufzeit zur&uuml;cksetzen</button>"
"</form>"
"<form action='/config' method='post'>"
"<label>Gratiszeit: <span id='gv'>%GRATIS%</span> Sekunden</label>"
"<input type='range' name='gratis' min='0' max='120' value='%GRATIS%'"
" oninput=\"document.getElementById('gv').textContent=this.value\">"
"<label>Signal-Polarit&auml;t</label>"
"<label class='radio-label'>"
"<input type='radio' name='polarity' value='0' %POL_LOW%>"
"LOW-aktiv (Schalter nach GND, Standard)</label>"
"<label class='radio-label'>"
"<input type='radio' name='polarity' value='1' %POL_HIGH%>"
"HIGH-aktiv</label>"
"<h2>MQTT Broker</h2>"
"<label>Broker</label>"
"<input type='text' name='broker' value='%BROKER%' maxlength='63' required>"
"<label>Port <small style='color:#888'>(8883 = TLS)</small></label>"
"<input type='number' name='port' value='%PORT%' min='1' max='65535'>"
"<label>Benutzer</label>"
"<input type='text' name='user' value='%USER%' maxlength='31'>"
"<label>Passwort</label>"
"<input type='password' name='password' value='%PASSWORD%' maxlength='31'>"
"<h2>Webzugang</h2>"
"<label>Benutzername</label>"
"<input type='text' name='web_user' value='%WEB_USER%' maxlength='31'>"
"<label>Passwort <small style='color:#888'>(leer lassen = unver&auml;ndert)</small></label>"
"<input type='password' name='web_password' value='' maxlength='63' "
"placeholder='Neues Passwort oder leer lassen'>"
"<label class='radio-label' style='margin-top:.4rem'>"
"<input type='checkbox' name='clear_auth' value='1'>"
"Passwortschutz deaktivieren (Passwort l&ouml;schen)</label>"
"<button class='btn btn-blue' type='submit' style='margin-top:1.5rem'>"
"Speichern &amp; Zur&uuml;ck</button>"
"</form>"
"<h2 style='color:#c53030'>WLAN</h2>"
"<p style='font-size:.9rem;color:#555'>L&ouml;scht gespeicherte WLAN-Daten und startet neu.<br>"
"Danach mit <strong>LaserCutter-Setup</strong> verbinden.</p>"
"<form action='/wifi-reset' method='post' "
"onsubmit=\"return confirm('WLAN-Daten wirklich l\u00f6schen und neu starten?')\">"
"<button class='btn btn-red' type='submit'>WLAN neu konfigurieren</button>"
"</form>"
"<h2>Ger&auml;t</h2>"
"<p style='font-size:.9rem;color:#555'>Der ESP32 wird sofort neu gestartet. "
"Eine laufende Laser-Session wird unterbrochen und zur&uuml;ckgesetzt.</p>"
"<form action='/reboot' method='post' "
"onsubmit=\"return confirm('ESP32 wirklich jetzt neu starten?')\">"
"<button class='btn btn-gray' type='submit' %REBOOT_DISABLED%>"
"&#128260; ESP32 neu starten</button>"
"</form>"
"<h2>Tools</h2>"
"<a class='btn btn-gray' href='/update'>OTA Update</a>"
"<a class='btn btn-gray' href='/log'>Log Console</a>"
"<footer style='margin-top:2rem;text-align:center;font-size:.8rem;color:#aaa'>"
"v" FIRMWARE_VERSION " (" __DATE__ ")"
"</footer>"
"</body></html>"
);
html.replace("%TOTAL%", String(totalBuf));
html.replace("%BROKER%", String(s.mqttBroker));
html.replace("%PORT%", String(s.mqttPort));
html.replace("%USER%", String(s.mqttUser));
html.replace("%PASSWORD%", String(s.mqttPassword));
html.replace("%GRATIS%", String(s.gratisSeconds));
html.replace("%POL_LOW%", s.signalPolarity == 0 ? "checked" : "");
html.replace("%POL_HIGH%", s.signalPolarity == 1 ? "checked" : "");
html.replace("%WEB_USER%", String(s.webUser));
html.replace("%REBOOT_DISABLED%", laserTracker.isActive() ?
"disabled title='Laser aktiv \u2013 bitte erst beenden'" : "");
return html;
}