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
This commit is contained in:
MaPaLo76 2026-03-01 17:28:47 +01:00
parent d9322f67d8
commit c61a67f812
9 changed files with 316 additions and 137 deletions

View File

@ -19,12 +19,36 @@ Status: `[ ]` = offen · `[x]` = erledigt
## Offen
*(keine offenen Punkte)*
---
## Erledigt
### Version 1.4.0
- [x] **FR-010** Feature: Webinterface-Redesign + MQTT-Steuerung ✅
- **`/` Laser Cutter Status** (öffentlich, kein Auth)
- Seitentitel `Laser Cutter Status`, bereinigte Tabelle: Laserzeit Summe, Laserzeit Aktuell (m:ss), Laserstatus (an/aus), Gratiszeit, Firmware
- Button `Summe Laserzeit zurücksetzen` (kein Auth, bewusst öffentlich)
- Button `💡 Display ausschalten/einschalten` Toggle via `fetch()` ohne Seitenwechsel, Route `POST /display-toggle` antwortet 204
- Button `🔒 Laser Cutter Setup & Status``/config`
- Auto-Refresh alle 10 s
- **`/config` Laser Cutter Setup & Status** (Auth erforderlich)
- H2: Laser Cutter Status Maschinenlaufzeit h:mm:ss, roter Reset-Button (`/reset-total`)
- H2: MQTT Broker, H2: Webzugang, WLAN-Abschnitt
- H2: Gerät Reboot-Button `🔄 ESP32 neu starten` (grau, bei aktivem Laser `disabled`)
- H2: Tools OTA Update, Log Console
- Button `← Laser Cutter Status` zurück auf `/`
- **MQTT-Steuerung**: `lasercutter/reset` ersetzt durch `lasercutter/cmd`
- `{"reset_session_nvs":true}` Session-Summe + NVS-Maschinenlaufzeit auf 0
- `{"reset_session_ram":true}` nur RAM-Session-Summe auf 0, NVS bleibt
- `{"display":true/false}` Display ein-/ausschalten
- `{"reboot":true}` ESP32 neu starten
- **Heartbeat `lasercutter/status`**: Feld `"display_on": true/false` hinzugefügt
- **`DisplayManager`**: `setEnabled()` / `isEnabled()` via MAX7219 SHUTDOWN-Modus
- Betroffene Dateien: `src/web_server.cpp`, `src/mqtt_client.cpp`, `include/config.h`, `include/display_manager.h`, `src/display_manager.cpp`
- Commit: `TODO`
- Version: 1.4.0
### Version 1.2.0
- [x] **FR-009** Bug: `session_start_time` bei nachgelieferten Sessions (Queue) falsch ✅

View File

@ -211,11 +211,10 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
### Topics
| Richtung | Topic | Format | Beschreibung |
|------------|---------------------------|------------------------------|-------------------------------------------|
|------------|---------------------------|------------------------------|-----------------------------------------|
| Publish | `lasercutter/session` | JSON | Wird beim **Ende eines Laser-Bursts** oder nach **Session-Reset** gesendet |
| Publish | `lasercutter/status` | JSON (retained) | Heartbeat alle 60 Sekunden + LWT (online/offline) |
| Subscribe | `lasercutter/reset` | `{"reset":true}` oder `"1"` | Setzt die **gesamte** akkumulierte Laserzeit (NVS + RAM) auf 0 |
| Subscribe | `lasercutter/reset` | `{"reset_session":true}` | Setzt nur die **Session-Summe** (RAM) auf 0, NVS bleibt erhalten |
| Subscribe | `lasercutter/cmd` | JSON | Steuerkommandos: Reset, Display, Reboot |
### JSON-Format `lasercutter/session`
```json
@ -231,7 +230,7 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
> `session_start_time` ist ein Lokalzeit-Zeitstempel (ISO 8601, CET/CEST), synchronisiert via NTP (`pool.ntp.org`) unmittelbar nach WLAN-Connect. Zeitzone wird automatisch zwischen CET (UTC+1) und CEST (UTC+2) umgeschaltet.
> Wert `"unknown"` wenn die NTP-Synchronisation beim Session-Start noch nicht abgeschlossen war.
> Bei einem **Session-Reset** (Web oder MQTT `{"reset_session":true}`) wird ebenfalls ein `lasercutter/session`-Publish ausgelöst mit den bis dahin akkumulierten Werten identisches Format wie beim normalen Session-Ende.
> Bei einem **Session-Reset** (Web oder MQTT `{"reset_session_ram":true}`) wird ebenfalls ein `lasercutter/session`-Publish ausgelöst mit den bis dahin akkumulierten Werten identisches Format wie beim normalen Session-Ende.
### JSON-Format `lasercutter/status`
```json
@ -239,6 +238,7 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
"online": true,
"session_sum": "42.50",
"machine_running_time_min": "1234.75",
"display_on": true,
"ip": "192.168.1.100",
"uptime_s": 3600,
"firmware_version": "1.2.1 (Mar 1 2026)",
@ -248,6 +248,20 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
> `reset_reason` mögliche Werte: `POWERON`, `SOFTWARE`, `PANIC`, `TASK_WDT`, `INT_WDT`, `WDT`, `BROWNOUT`, `EXT_PIN`, `DEEPSLEEP`, `SDIO`, `UNKNOWN`. Der Wert wird einmalig beim Start gespeichert und bleibt für die gesamte Laufzeit konstant.
### JSON-Format `lasercutter/cmd`
Alle Steuerkommandos werden als JSON an `lasercutter/cmd` gesendet:
```json
{ "reset_session_nvs": true } // Session-Summe + NVS-Maschinenlaufzeit auf 0
{ "reset_session_ram": true } // nur RAM-Session-Summe auf 0, NVS bleibt
{ "display": true } // Display einschalten
{ "display": false } // Display ausschalten
{ "reboot": true } // ESP32 neu starten
```
> Mehrere Kommandos können in einem JSON kombiniert werden, z. B. `{"reset_session_ram":true, "display":false}`.
### LWT (Last Will and Testament)
Bei Verbindungsabbruch sendet der Broker automatisch auf `lasercutter/status`:
```json
@ -266,14 +280,18 @@ Bei Verbindungsabbruch sendet der Broker automatisch auf `lasercutter/status`:
Das Webinterface ist über die IP-Adresse des ESP32 im Browser erreichbar.
| Seite | URL | Funktion |
|------------------|-------------|--------------------------------------------------------------|
| Status | `/` | Aktuelle Laserzeit, letzter Session-Wert, Systemstatus |
| Konfiguration | `/config` | MQTT-Broker (IP, Port, User, Passwort), Gratiszeit, Polarität, Web-Passwort |
| Reset | `/reset` | Setzt die **Session-Summe** (RAM) auf 0 zurück NVS-Gesamtzeit bleibt erhalten |
| OTA Update | `/update` | Firmware-Update über Browser |
| Seite | URL | Auth | Funktion |
|--------------------------|-------------------|------|-----------------------------------------------------------------------|
| Laser Cutter Status | `/` | | Laserzeit, Laserstatus, Display-Toggle, Session-Reset |
| Laser Cutter Setup | `/config` | ✔ | MQTT, Web-Auth, WLAN, Maschinenlaufzeit-Reset, Gratiszeit, Polarität, Reboot |
| Session-Reset | `/reset` | | Setzt die **Session-Summe** (RAM) auf 0 NVS bleibt erhalten |
| Maschinenlaufzeit-Reset | `/reset-total` | ✔ | Setzt Maschinenlaufzeit im NVS auf 0 |
| Display-Toggle | `/display-toggle` | | Display ein-/ausschalten (POST, antwortet 204) |
| Reboot | `/reboot` | ✔ | ESP32 sofort neu starten |
| OTA Update | `/update` | | Firmware-Update über Browser (ElegantOTA) |
| Log Console | `/log` | ✔ | Live-Log über WebSocket |
> Alle Seiten sind per **HTTP Basic Auth** geschützt, wenn in der Konfiguration ein Web-Passwort gesetzt ist.
> `/` ist **öffentlich** (kein Passwortschutz) der Display-Toggle und Session-Reset sind bewusst ohne Auth erreichbar. Alle sicherheitsrelevanten Seiten (Setup, Reboot, Maschinenlaufzeit-Reset) erfordern HTTP Basic Auth, sofern ein Web-Passwort gesetzt ist.
---

View File

@ -0,0 +1,49 @@
<!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>8 min</td></tr>
<tr><td>Aktuelle Laserzeit</td><td>423 s</td></tr>
<tr><td>Letzte Laserzeit</td><td>0 s</td></tr>
<tr><td>Laser</td><td><span style='color:orange'>aktiv</span></td></tr>
<tr><td>Maschinenlaufzeit (gesamt)</td><td>619.50 min</td></tr>
<tr><td>IP-Adresse</td><td>192.168.2.62</td></tr>
<tr><td>MQTT</td><td><span style='color:green'>&#10003;verbunden</span></td></tr>
<tr><td>Broker</td><td>mqtt.majufilo.eu:8883</td></tr>
<tr><td>Gratiszeit</td><td>10 s</td></tr>
<tr><td>Firmware</td><td>v1.3.0 (Mar 1 2026)</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>

View File

@ -72,7 +72,7 @@
// MQTT Topics
#define MQTT_TOPIC_SESSION "lasercutter/session" // Publish beim Session-Ende
#define MQTT_TOPIC_STATUS "lasercutter/status" // Publish Heartbeat
#define MQTT_TOPIC_RESET "lasercutter/reset" // Subscribe Reset-Befehl
#define MQTT_TOPIC_CMD "lasercutter/cmd" // Subscribe Steuerkommandos
// -----------------------------------------------------------------------------
// WiFiManager

View File

@ -99,11 +99,18 @@ public:
void allLedsOn();
void allLedsOff();
// Display ein-/ausschalten (SHUTDOWN-Modus des MAX7219)
// setEnabled(false) → alle LEDs aus (MAX7219 Shutdown)
// setEnabled(true) → normaler Betrieb
void setEnabled(bool enabled);
bool isEnabled() const { return _enabled; }
// Debug-Ausgabe auf Serial
void printToSerial() const;
private:
MD_MAX72XX _mx;
bool _enabled = true;
// Schreibt genau DISP_CHARS_PER_ZONE Zeichen auf eine Zone
// zone 0 → Module 0..3 (oben)

View File

@ -23,7 +23,7 @@ build_flags =
-DCORE_DEBUG_LEVEL=1 ; 0=keine, 1=Fehler, 3=Info, 5=Verbose
-DARDUINO_LOOP_STACK_SIZE=8192
-DELEGANTOTA_USE_ASYNC_WEBSERVER=1
-DFIRMWARE_VERSION='"1.3.0"' ; Semantic Versioning hier erhöhen bei neuem Release
-DFIRMWARE_VERSION='"1.4.0"' ; Semantic Versioning hier erhöhen bei neuem Release
lib_deps =
majicDesigns/MD_Parola @ ^3.7.3
majicDesigns/MD_MAX72XX @ ^3.5.1

View File

@ -215,6 +215,13 @@ void DisplayManager::setBrightness(uint8_t level) {
_mx.control(MD_MAX72XX::INTENSITY, level);
}
// ---------------------------------------------------------------------------
void DisplayManager::setEnabled(bool enabled) {
_enabled = enabled;
// SHUTDOWN ON = LEDs aus; SHUTDOWN OFF = normaler Betrieb (invertierte Logik)
_mx.control(MD_MAX72XX::SHUTDOWN, enabled ? MD_MAX72XX::OFF : MD_MAX72XX::ON);
}
// ---------------------------------------------------------------------------
// Einzelnes Zeichen auf ein Modul schreiben (mit CCW Rotation)
// ---------------------------------------------------------------------------

View File

@ -101,7 +101,7 @@ void MqttClient::loop() {
// --------------------------------------------------------------------------
// resetSessionCounter() - Session-ID Zähler auf 0 zurücksetzen
// Wird bei reset / reset_session MQTT-Kommando aufgerufen.
// Wird bei reset_session_nvs / reset_session_ram MQTT-Kommando aufgerufen.
// --------------------------------------------------------------------------
void MqttClient::resetSessionCounter() {
_sessionCounter = 0;
@ -178,17 +178,19 @@ void MqttClient::publishHeartbeat() {
snprintf(ipBuf, sizeof(ipBuf), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
// Kein ArduinoJson (JsonDocument) reine snprintf-Formatierung, kein Heap-Alloc
char buf[280];
char buf[300];
snprintf(buf, sizeof(buf),
"{\"online\":true,"
"\"session_sum\":%d,"
"\"machine_running_time_min\":\"%s\","
"\"display_on\":%s,"
"\"ip\":\"%s\","
"\"uptime_s\":%lu,"
"\"firmware_version\":\"%s (%s)\","
"\"reset_reason\":\"%s\"}",
laserTracker.getAllSessionsSumMinutes(),
totalBuf,
display.isEnabled() ? "true" : "false",
ipBuf,
(unsigned long)(millis() / 1000UL),
FIRMWARE_VERSION, __DATE__,
@ -224,8 +226,8 @@ bool MqttClient::reconnect() {
if (ok) {
LOG_I("MQTT", "Verbunden!");
_client->subscribe(MQTT_TOPIC_RESET);
LOG_I("MQTT", "Abonniert: %s", MQTT_TOPIC_RESET);
_client->subscribe(MQTT_TOPIC_CMD);
LOG_I("MQTT", "Abonniert: %s", MQTT_TOPIC_CMD);
publishHeartbeat();
_lastHeartbeatMs = millis();
} else {
@ -324,51 +326,46 @@ void MqttClient::onMessage(const char* topic, byte* payload, unsigned int length
LOG_I("MQTT", "Nachricht: topic=%s payload=%s", topic, msg);
// Reset-Kommando
if (strcmp(topic, MQTT_TOPIC_RESET) == 0) {
bool doReset = false;
// Plain "1" (rueckwaertskompatibel)
if (strcmp(msg, "1") == 0) {
doReset = true;
}
// JSON: {"reset":true} oder {"reset":1}
else if (msg[0] == '{') {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, msg);
if (!err) {
JsonVariant v = doc["reset"];
if (!v.isNull()) {
doReset = v.as<bool>() || v.as<int>() == 1;
}
} else {
LOG_E("MQTT", "JSON-Parse-Fehler: %s", err.c_str());
}
// Steuerkommandos (lasercutter/cmd)
if (strcmp(topic, MQTT_TOPIC_CMD) == 0 && msg[0] == '{') {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, msg);
if (err) {
LOG_E("MQTT", "JSON-Parse-Fehler: %s", err.c_str());
return;
}
if (doReset) {
LOG_I("MQTT", "RESET-Kommando empfangen -> laserTracker.resetTotal()");
// {"reset_session_nvs":true} Session-Summe + NVS zurücksetzen
JsonVariant vNvs = doc["reset_session_nvs"];
if (!vNvs.isNull() && (vNvs.as<bool>() || vNvs.as<int>() == 1)) {
LOG_I("MQTT", "CMD reset_session_nvs -> laserTracker.resetTotal()");
laserTracker.resetTotal();
mqttClient.resetSessionCounter();
}
// reset_session: nur RAM-Session-Summe auf 0, NVS bleibt
bool doResetSession = false;
if (msg[0] == '{') {
JsonDocument doc2;
DeserializationError err = deserializeJson(doc2, msg);
if (!err) {
JsonVariant v = doc2["reset_session"];
if (!v.isNull()) {
doResetSession = v.as<bool>() || v.as<int>() == 1;
}
}
}
if (doResetSession) {
LOG_I("MQTT", "RESET_SESSION-Kommando empfangen -> laserTracker.resetSessionSum()");
// {"reset_session_ram":true} nur RAM-Session-Summe auf 0, NVS bleibt
JsonVariant vRam = doc["reset_session_ram"];
if (!vRam.isNull() && (vRam.as<bool>() || vRam.as<int>() == 1)) {
LOG_I("MQTT", "CMD reset_session_ram -> laserTracker.resetSessionSum()");
laserTracker.resetSessionSum();
mqttClient.resetSessionCounter();
}
// {"display":true/false} Display ein-/ausschalten
JsonVariant vDisplay = doc["display"];
if (!vDisplay.isNull()) {
bool on = vDisplay.as<bool>();
display.setEnabled(on);
LOG_I("MQTT", "CMD display -> %s", on ? "an" : "aus");
}
// {"reboot":true} ESP32 neu starten
JsonVariant vReboot = doc["reboot"];
if (!vReboot.isNull() && (vReboot.as<bool>() || vReboot.as<int>() == 1)) {
LOG_I("MQTT", "CMD reboot -> ESP.restart()");
delay(200);
ESP.restart();
}
}
}

View File

@ -10,6 +10,7 @@
#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();
@ -138,9 +139,8 @@ bool WebServerManager::requireAuth(AsyncWebServerRequest* request) {
// -----------------------------------------------------------------------------
void WebServerManager::registerRoutes() {
// --- GET / Statusseite -------------------------------------------------
// --- GET / Statusseite (öffentlich, kein Auth) -------------------------
_server->on("/", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
request->send(200, "text/html; charset=utf-8", buildStatusPage());
});
@ -232,6 +232,43 @@ void WebServerManager::registerRoutes() {
"</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;
@ -273,70 +310,68 @@ void WebServerManager::registerRoutes() {
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>";
// 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);
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());
// 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>LaserCutter Display</title>"
"<title>Laser Cutter Status</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}"
"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>LaserCutter Display</h1>"
"<h1>Laser Cutter Status</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>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>"
"<div style='display:flex;flex-direction:column;gap:.6rem;margin-top:.5rem'>"
"<form action='/reset' method='post' style='width:100%' "
"<form action='/reset' method='post' "
"onsubmit=\"return confirm('Summe Laserzeit wirklich zur\\u00fccksetzen?')\">"
"<button class='btn btn-primary' type='submit' style='width:100%'>Summe Laserzeit zur&uuml;cksetzen</button>"
"<button class='btn btn-blue' type='submit'>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>"
"<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(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));
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;
}
@ -407,34 +442,52 @@ String WebServerManager::buildLogPage() {
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>Konfiguration &ndash; LaserCutter Display</title>"
"<title>Laser Cutter Setup &amp; Status</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}"
"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;box-sizing:border-box;border:1px solid #ccc;border-radius:4px}"
"{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:.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}"
".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>Konfiguration</h1>"
"<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>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>"
"<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>"
@ -444,37 +497,59 @@ String WebServerManager::buildConfigPage() {
"<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>"
"<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 eingeben oder leer lassen'>"
"<label class='radio-label' style='margin-top:0.4rem'>"
"<input type='checkbox' name='clear_auth' value='1'> "
"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>"
"<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>"
"<button class='btn btn-blue' type='submit' style='margin-top:1.5rem'>"
"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>"
"<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' style='background:#c53030;color:#fff;width:100%' type='submit'>"
"WLAN neu konfigurieren</button>"
"<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));
@ -483,6 +558,8 @@ String WebServerManager::buildConfigPage() {
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;
}