// ============================================================================= // web_server.cpp – HTTP-Webinterface für Statusanzeige und Konfiguration // Projekt: MQTT-Display LaserCutter // ============================================================================= #include "web_server.h" #include // voller Include nur in .cpp (PIMPL) – enthält AsyncWebSocket #include // 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 #include // 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", "" "" "" "

✓ Gespeichert

" "

Einstellungen wurden in NVS geschrieben. " "MQTT-Änderungen werden nach einem Neustart aktiv.

" "

Weiterleitung in 3 s …

" ""); }); // --- 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", "" "" "" "

✓ Session beendet

" "

Weiterleitung …

" ""); }); // --- 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", "" "" "" "

✓ Maschinenlaufzeit zurückgesetzt

" "

Weiterleitung …

" ""); }); // --- 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", "" "" "" "

🔄 ESP32 startet neu …

" "

Weiterleitung in 5 Sekunden.

" ""); 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", "" "" "" "

✓ WLAN-Daten gelöscht

" "

Das Gerät startet neu.
" "Verbinde dich dann mit dem WLAN LaserCutter-Setup " "und öffne http://192.168.4.1 zur Konfiguration.

" ""); // 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() ? "an" : "aus"; String html = F( "" "" "" "Laser Cutter Status" "" "" "

Laser Cutter Status

" "" "" "" "" "" "" "
Laserzeit Summe%ALLSESSIONS% Minuten
Laserzeit Aktuell%CURRENTSESSION%
Laserstatus%LASER%
Gratiszeit%GRATIS% Sekunden
Firmwarev" FIRMWARE_VERSION " (" __DATE__ ")
" "
" "" "
" "" "🔒 Laser Cutter Setup & Status" "" "" ); 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%", "💡 Display ausschalten"); } else { html.replace("%DISP_BTN_CLASS%", "btn-blue"); html.replace("%DISP_BTN_LABEL%", "💡 Display einschalten"); } return html; } // ----------------------------------------------------------------------------- // buildLogPage() // ----------------------------------------------------------------------------- String WebServerManager::buildLogPage() { return String(F( "" "" "" "Log Console – LaserCutter Display" "" "" "

💿 Log Console

" "
" "" "" "" "Lade…" "
" "
" "" )); } // ----------------------------------------------------------------------------- // 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( "" "" "" "Laser Cutter Setup & Status" "" "" "

Laser Cutter Setup & Status

" "← Laser Cutter Status" "

Laser Cutter Status

" "" "

%TOTAL%

" "
⚠️ Maschinenlaufzeit zurücksetzen löscht den Gesamtspeicher im NVS " "unwiderruflich – diese Aktion kann nicht rückgängig gemacht werden.
" "
" "" "
" "
" "" "" "" "" "" "

MQTT Broker

" "" "" "" "" "" "" "" "" "

Webzugang

" "" "" "" "" "" "" "
" "

WLAN

" "

Löscht gespeicherte WLAN-Daten und startet neu.
" "Danach mit LaserCutter-Setup verbinden.

" "
" "" "
" "

Gerät

" "

Der ESP32 wird sofort neu gestartet. " "Eine laufende Laser-Session wird unterbrochen und zurückgesetzt.

" "
" "" "
" "

Tools

" "OTA Update" "Log Console" "" "" ); 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; }