MQTT-Display-LaserCutter/src/web_server.cpp
MaPaLo76 4dd4ce0620 feat(FR-002): Web Console via HTTP-Polling (/log + /log-data) -- v1.1.0
- Ring-Buffer _logBuf in web_server.cpp: webLogForward() schreibt auf Core 1
- GET /log-data liefert Puffer als Plain-Text (kein WebSocket, kein Core-Konflikt)
- Browser pollt alle 2 s, Auto-Scroll, dunkles Terminal-Theme
- LOG_I/LOG_E/LOG_D: Timestamp (HH:MM:SS nach NTP, sonst +Xs), webLogForward()
- Alle Serial.* in laser_tracker.cpp, mqtt_client.cpp, web_server.cpp auf LOG_I/LOG_E
- main.cpp: esp_reset_reason() beim Booten loggen (POWER_ON / WATCHDOG / PANIC...)
- telnet_logger.h entfernt (war nur noch Deprecated-Stub)
- Feature-Requests.md: FR-002 abgeschlossen
2026-02-28 17:24:56 +01:00

473 lines
22 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"
// 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;
static String _logBuf = "";
void webLogForward(const char* msg) {
_logBuf += msg;
_logBuf += '\n';
if (_logBuf.length() > LOG_BUF_MAX) {
_logBuf = _logBuf.substring(_logBuf.length() - LOG_BUF_MAX / 2);
}
}
// -----------------------------------------------------------------------------
// 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 -------------------------------------------------
_server->on("/", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
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 /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();
String mqttStatus = mqttClient.isConnected()
? "<span style='color:green'>&#10003; verbunden</span>"
: "<span style='color:red'>&#10007; getrennt</span>";
String laserStatus = laserTracker.isActive()
? "<span style='color:orange'>aktiv</span>"
: "inaktiv";
String ip = WiFi.isConnected() ? WiFi.localIP().toString() : "nicht verbunden";
// Gesamtzeit formatieren
char totalBuf[16];
snprintf(totalBuf, sizeof(totalBuf), "%.2f", laserTracker.getTotalMinutes());
String html = F(
"<!DOCTYPE html><html>"
"<head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>LaserCutter Display</title>"
"<style>"
"body{font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem}"
"h1{color:#333}table{width:100%;border-collapse:collapse;margin-bottom:1.5rem}"
"td{padding:.5rem;border-bottom:1px solid #eee}td:first-child{color:#666;width:50%}"
".btn{display:inline-block;padding:.5rem 1.2rem;border:none;border-radius:4px;"
"cursor:pointer;text-decoration:none;font-size:1rem}"
".btn-primary{background:#3182ce;color:#fff}"
"</style></head>"
"<body>"
"<h1>LaserCutter Display</h1>"
"<table>"
"<tr><td>Summe Laserzeit</td><td>%ALLSESSIONS% min</td></tr>"
"<tr><td>Aktuelle Laserzeit</td><td>%CURRENTSESSION% s</td></tr>"
"<tr><td>Letzte Laserzeit</td><td>%LASTSESSION% s</td></tr>"
"<tr><td>Laser</td><td>%LASER%</td></tr>"
"<tr><td>Maschinenlaufzeit (gesamt)</td><td>%TOTAL% min</td></tr>"
"<tr><td>IP-Adresse</td><td>%IP%</td></tr>"
"<tr><td>MQTT</td><td>%MQTT%</td></tr>"
"<tr><td>Broker</td><td>%BROKER%:%PORT%</td></tr>"
"<tr><td>Gratiszeit</td><td>%GRATIS% s</td></tr>"
"<tr><td>Firmware</td><td>v" FIRMWARE_VERSION " (" __DATE__ ")</td></tr>"
"</table>"
"<div style='display:flex;flex-direction:column;gap:.6rem;margin-top:.5rem'>"
"<form action='/reset' method='post' style='width:100%' "
"onsubmit=\"return confirm('Summe Laserzeit wirklich zur\\u00fccksetzen?')\">"
"<button class='btn btn-primary' type='submit' style='width:100%'>Summe Laserzeit zur&uuml;cksetzen</button>"
"</form>"
"<a href='/config' style='display:block'><button class='btn btn-primary' type='button' style='width:100%'>Konfiguration</button></a>"
"<a href='/update' style='display:block'><button class='btn btn-primary' type='button' style='width:100%'>OTA Update</button></a>"
"<a href='/log' style='display:block'><button class='btn btn-primary' type='button' style='width:100%'>&#128191; Log Console</button></a>"
"</div>"
"<script>setTimeout(()=>location.reload(),10000)</script>"
"</body></html>"
);
html.replace("%ALLSESSIONS%", String(laserTracker.getAllSessionsSumMinutes()));
html.replace("%CURRENTSESSION%", String(laserTracker.getRunningSessionSeconds()));
html.replace("%TOTAL%", String(totalBuf));
html.replace("%LASTSESSION%", String(laserTracker.getLastSessionSeconds()));
html.replace("%LASER%", laserStatus);
html.replace("%IP%", ip);
html.replace("%MQTT%", mqttStatus);
html.replace("%BROKER%", String(s.mqttBroker));
html.replace("%PORT%", String(s.mqttPort));
html.replace("%GRATIS%", String(s.gratisSeconds));
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();
String html = F(
"<!DOCTYPE html><html>"
"<head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Konfiguration &ndash; LaserCutter Display</title>"
"<style>"
"body{font-family:sans-serif;max-width:480px;margin:2rem auto;padding:0 1rem}"
"label{display:block;margin-top:1rem;color:#555;font-size:.9rem}"
"input[type=text],input[type=number],input[type=password]"
"{width:100%;padding:.4rem;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:.3rem}"
".btn{padding:.5rem 1.5rem;border:none;border-radius:4px;cursor:pointer;font-size:1rem;width:100%}"
".btn-primary{background:#3182ce;color:#fff}"
".btn-back{background:#718096;color:#fff}"
"</style></head>"
"<body>"
"<h1>Konfiguration</h1>"
"<form action='/config' method='post'>"
"<label>MQTT Broker</label>"
"<input type='text' name='broker' value='%BROKER%' maxlength='63' required>"
"<label>MQTT Port</label>"
"<input type='number' name='port' value='%PORT%' min='1' max='65535'>"
"<label>MQTT Benutzer</label>"
"<input type='text' name='user' value='%USER%' maxlength='31'>"
"<label>MQTT Passwort</label>"
"<input type='password' name='password' value='%PASSWORD%' maxlength='31'>"
"<label>Gratiszeit: <span id='gv'>%GRATIS%</span> s</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>"
"<hr style='margin-top:1.5rem'>"
"<h2 style='font-size:1.1rem'>Web-Zugang</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 eingeben oder leer lassen'>"
"<label class='radio-label' style='margin-top:0.4rem'>"
"<input type='checkbox' name='clear_auth' value='1'> "
"Passwortschutz deaktivieren (Passwort l&ouml;schen)</label>"
"<br>"
"<div style='display:flex;flex-direction:column;gap:.6rem;margin-top:1.5rem'>"
"<button class='btn btn-primary' type='submit'>Speichern &amp; Zur&uuml;ck</button>"
"</form>"
"<a href='/' style='display:block'><button class='btn btn-back' type='button'>Abbrechen</button></a>"
"</div>"
"<hr style='margin-top:1.5rem'>"
"<h2 style='font-size:1.1rem;color:#c53030'>WLAN &auml;ndern</h2>"
"<p style='font-size:.9rem;color:#555'>L&ouml;scht die gespeicherten WLAN-Daten und startet das Ger&auml;t neu.<br>"
"Danach mit <strong>LaserCutter-Setup</strong> verbinden und neues WLAN eingeben.</p>"
"<form action='/wifi-reset' method='post' "
"onsubmit=\"return confirm('WLAN-Daten wirklich l\u00f6schen und neu starten?')\">"
"<button class='btn' style='background:#c53030;color:#fff;width:100%' type='submit'>"
"WLAN neu konfigurieren</button>"
"</form>"
"<footer style='margin-top:2rem;text-align:center;font-size:.8rem;color:#aaa'>"
"v" FIRMWARE_VERSION " (" __DATE__ ")"
"</footer>"
"</body></html>"
);
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));
return html;
}