feat(mqtt,web): FR-015 + FR-016 sofortiger MQTT-Publish + WebSocket Live-Update (v1.6.0)

FR-015:
- publishHeartbeat() nach Display-Toggle, Session-Reset und Reboot-CMD
- publishHeartbeat() von private nach public (mqtt_client.h)
- Heartbeat-Intervall 60s -> 10s (config.h)
- HA Discovery Switch: state_on/state_off ergaenzt (mqtt_client.cpp)

FR-016:
- Neuer WebSocket-Endpunkt /status-ws (AsyncWebSocket)
- sendStatusWs() am Ende von publishHeartbeat() -> alle Ausloeser abgedeckt
- Statusseite: DOM-Updates via WebSocket, kein location.reload() mehr
- Config-Seite: Reboot-Button live deaktiviert wenn Laser aktiv
- Alle Action-Buttons auf fetch() umgestellt (Reboot, WLAN-Reset, Laufzeit-Reset)
- Display-Button: blau+gelb wenn an, grau+grau wenn aus
This commit is contained in:
MaPaLo76 2026-03-07 16:17:39 +01:00
parent e6ad5363ea
commit fc5e1694fd
7 changed files with 134 additions and 63 deletions

View File

@ -19,31 +19,28 @@ Status: `[ ]` = offen · `[x]` = erledigt
## Offen
### Version 1.6.0
- [ ] **FR-015** Feature: Sofortiger MQTT-Status-Publish nach jeder Statusänderung
- **Motivation**: HA zeigt aktuell veraltete Werte bis zum nächsten Heartbeat (60s). Änderungen durch Webinterface, MQTT-CMD oder lokale Buttons sollen sofort in HA sichtbar sein.
- **Auslöser für sofortigen Publish**:
- Display-Toggle (Web `/display-toggle` + MQTT CMD `{"display":...}`)
- Session-Reset (Web `/reset` + MQTT CMD `{"reset_session_ram":true}`)
- Reboot-CMD (MQTT CMD `{"reboot":true}`) — vor dem Neustart
- **Mechanismus**: Nach jeder der o.g. Aktionen wird `publishHeartbeat()` sofort aufgerufen (zusätzlich zum 60s-Timer)
- **Betroffene Dateien**: `src/web_server.cpp`, `src/mqtt_client.cpp`
- Commit: `TODO`
- Version: 1.6.0
- [ ] **FR-016** Feature: Webinterface Live-Aktualisierung ohne Neuladen (SSE oder Polling)
- **Motivation**: Nach einer Aktion im Webinterface (z.B. Display-Toggle, Session-Reset) bleibt die Statusseite veraltet bis der Nutzer manuell neu lädt. Die Seite soll sich automatisch aktualisieren.
- **Mechanismus**: Server-Sent Events (SSE) — ESP32 sendet bei Statusänderungen ein Event an alle offenen Browser-Verbindungen; die Seite aktualisiert daraufhin den Status-Bereich via JavaScript ohne vollständigen Reload. Alternativ: kurzes Polling-Intervall (z.B. alle 5s) als einfachere Fallback-Lösung.
- **Betroffene Dateien**: `src/web_server.cpp` (SSE-Endpoint + Event-Publisher), HTML/JS in `buildStatusPage()`
- Commit: `TODO`
- Version: 1.6.0
---
## Erledigt
### Version 1.6.0
- [x] **FR-015** Feature: Sofortiger MQTT-Status-Publish nach jeder Statusänderung ✅
- **Motivation**: HA zeigte veraltete Werte bis zum nächsten Heartbeat. Änderungen durch Webinterface, MQTT-CMD oder lokale Buttons sind nun sofort in HA sichtbar.
- **Umsetzung**: `publishHeartbeat()` von `private` nach `public` verschoben; wird nach Display-Toggle, Session-Reset und Reboot-CMD sofort aufgerufen (zusätzlich zum 10s-Timer). Heartbeat-Intervall von 60s auf 10s reduziert. HA MQTT Discovery Switch: `state_on`/`state_off` ergänzt damit Display-Zustand korrekt angezeigt wird.
- **Betroffene Dateien**: `include/mqtt_client.h`, `src/web_server.cpp`, `src/mqtt_client.cpp`, `include/config.h`
- Commit: `TODO`
- Version: 1.6.0
- [x] **FR-016** Feature: Webinterface Live-Aktualisierung ohne Neuladen ✅
- **Motivation**: Nach einer Aktion im Webinterface blieb die Statusseite veraltet bis zum manuellen Reload.
- **Umsetzung**: Neuer WebSocket-Endpunkt `/status-ws` (`AsyncWebSocket`). `sendStatusWs()` wird am Ende von `publishHeartbeat()` aufgerufen deckt damit alle Auslöser ab (10s-Timer, Web-Aktionen, Laser-Statuswechsel). Statusseite und Config-Seite nutzen `/status-ws` für Live-DOM-Updates ohne Reload. Reboot-Button auf Config-Seite wird automatisch deaktiviert wenn Laser aktiv. Alle Action-Buttons (Reboot, WLAN-Reset, Maschinenlaufzeit-Reset) auf `fetch()` umgestellt.
- **Betroffene Dateien**: `include/web_server.h`, `src/web_server.cpp`, `src/mqtt_client.cpp`
- Commit: `TODO`
- Version: 1.6.0
### Version 1.5.2
- [x] **FR-014** Bug: Display Störung bei Relais Umschaltung ✅

View File

@ -255,7 +255,7 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
| 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 |
| Publish | `lasercutter/status` | JSON (retained) | Heartbeat alle **10 Sekunden**; sofortiger Publish nach jeder Statusänderung |
| Publish | `lasercutter/availability` | `online` / `offline` | Verbindungsstatus (retained, LWT) |
| Subscribe | `lasercutter/cmd` | JSON | Steuerkommandos: Reset, Display, Reboot |
| Publish | `homeassistant/…/config` | JSON (retained) | HA MQTT Discovery nach jedem Connect |
@ -367,14 +367,15 @@ Das Webinterface ist über die IP-Adresse des ESP32 im Browser erreichbar.
| Seite | URL | Auth | Funktion |
|--------------------------|-------------------|------|-----------------------------------------------------------------------|
| Laser Cutter Status | `/` | | Laserzeit, Laserstatus, Display-Toggle, Session-Reset |
| Laser Cutter Status | `/` | | Laserzeit, Laserstatus, Display-Toggle, Session-Reset; Live-Updates via WebSocket |
| 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 |
| Log Console | `/log` | ✔ | Live-Log über WebSocket (`/log-ws`) |
| Status WebSocket | `/status-ws` | | Live-Status-Push für Statusseite und Config-Seite |
> `/` 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.
@ -416,10 +417,11 @@ Firmware-Updates können kabellos über die Weboberfläche unter `/update` einge
## Remote-Debugging (Web Console)
Geplant (FR-002): Neue Route `/log` im bestehenden Webinterface zeigt alle Log-Ausgaben live im Browser über WebSocket.
Die Route `/log` zeigt alle Log-Ausgaben live im Browser.
- Kein extra Library nötig nutzt den bestehenden `ESPAsyncWebServer`
- Aufruf: `http://ipadresse/log`
- Live-Streaming über WebSocket (`/log-ws`) kein Polling
- Auto-Scroll, manuelles Leeren, farbkodierte Log-Level (Info/Debug/Error)
- Aufruf: `http://<ip>/log` (Auth erforderlich wenn Web-Passwort gesetzt)
---

View File

@ -67,7 +67,7 @@
#define DEFAULT_MQTT_PASSWORD ""
#define MQTT_CLIENT_ID "lasercutter-display"
#define MQTT_RECONNECT_MS 10000 // Reconnect-Intervall in ms
#define MQTT_HEARTBEAT_MS 60000 // Heartbeat-Intervall in ms
#define MQTT_HEARTBEAT_MS 10000 // Heartbeat-Intervall in ms
// MQTT Topics
#define MQTT_TOPIC_SESSION "lasercutter/session" // Publish beim Session-Ende

View File

@ -64,6 +64,10 @@ public:
// Home Assistant MQTT Discovery (retained Config-Nachrichten für alle Entities)
void publishDiscovery();
// Status-Heartbeat sofort senden (normalerweise alle 10s automatisch)
// Kann von außen aufgerufen werden um HA sofort zu aktualisieren (FR-015)
void publishHeartbeat();
// Debug-Ausgabe auf Serial
void printToSerial();
@ -105,9 +109,6 @@ private:
char _broker[64];
uint16_t _port;
// Heartbeat-Publish (lasercutter/status)
void publishHeartbeat();
// Eigentlicher Session-Publish (aus MQTT-Task auf Core 0)
// Gibt true zurück wenn Publish erfolgreich, false bei Fehler (Queue-Eintrag bleibt dann erhalten)
bool _doPublishSession(int lastSessionSec, int gratisSec, time_t startTime, uint32_t sessionId);;

View File

@ -39,9 +39,13 @@ public:
// ArduinoOTA verarbeiten in jedem loop()-Durchlauf aufrufen
void loop();
// WebSocket: Status-JSON an alle /status-ws Clients pushen
void sendStatusWs();
private:
AsyncWebServer* _server; // PIMPL: Pointer, full type only in web_server.cpp
AsyncWebSocket* _ws; // WebSocket-Endpunkt /log-ws
AsyncWebServer* _server; // PIMPL: Pointer, full type only in web_server.cpp
AsyncWebSocket* _ws; // WebSocket-Endpunkt /log-ws
AsyncWebSocket* _statusWs; // WebSocket-Endpunkt /status-ws
void registerRoutes();

View File

@ -6,6 +6,7 @@
#include "mqtt_client.h"
#include "laser_tracker.h"
#include "display_manager.h"
#include "web_server.h"
#include "config.h"
#include <ArduinoJson.h>
@ -207,6 +208,7 @@ void MqttClient::publishHeartbeat() {
bool ok = _client->publish(MQTT_TOPIC_STATUS, buf, /*retained=*/true);
LOG_I("MQTT", "Heartbeat: %s -> %s", buf, ok ? "OK" : "FEHLER");
webServer.sendStatusWs(); // FR-016: Web-Status synchron aktualisieren
}
// --------------------------------------------------------------------------
@ -305,6 +307,8 @@ void MqttClient::publishDiscovery() {
"\"unique_id\":\"lc_display\","
"\"state_topic\":\"" MQTT_TOPIC_STATUS "\","
"\"value_template\":\"{{ 'ON' if value_json.display_on else 'OFF' }}\","
"\"state_on\":\"ON\","
"\"state_off\":\"OFF\","
"\"command_topic\":\"" MQTT_TOPIC_CMD "\","
"\"payload_on\":\"{\\\"display\\\":true}\","
"\"payload_off\":\"{\\\"display\\\":false}\","

View File

@ -69,6 +69,7 @@ static String htmlReplace(String html, const String& key, const String& value) {
WebServerManager::WebServerManager()
: _server(new AsyncWebServer(80))
, _ws(new AsyncWebSocket("/log-ws"))
, _statusWs(new AsyncWebSocket("/status-ws"))
{}
// -----------------------------------------------------------------------------
@ -88,6 +89,28 @@ void WebServerManager::begin() {
_server->addHandler(_ws);
_wsPtr = _ws; // ab jetzt ist webLogForward() aktiv
// WebSocket /status-ws sofortige Status-Updates bei Statusänderungen
_statusWs->onEvent([this](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) {
// Initialen Status direkt an neuen Client senden
int runSec = laserTracker.getRunningSessionSeconds();
char buf[160];
snprintf(buf, sizeof(buf),
"{\"allSessions\":%d,\"currentSec\":%d,\"laserActive\":%s,\"displayOn\":%s}",
laserTracker.getAllSessionsSumMinutes(),
runSec,
laserTracker.isActive() ? "true" : "false",
display.isEnabled() ? "true" : "false");
client->text(buf);
LOG_I("WEB", "Status-WS Client #%u verbunden", client->id());
} else if (type == WS_EVT_DISCONNECT) {
LOG_I("WEB", "Status-WS Client #%u getrennt", client->id());
}
});
_server->addHandler(_statusWs);
registerRoutes();
// ElegantOTA-Auth: nur wenn Passwort gesetzt
const Settings& s = settings.get();
@ -118,7 +141,8 @@ void WebServerManager::begin() {
// -----------------------------------------------------------------------------
void WebServerManager::loop() {
ArduinoOTA.handle();
_ws->cleanupClients(); // abgemeldete Clients freigeben (verhindert Memory-Leak)
_ws->cleanupClients(); // abgemeldete Clients freigeben (verhindert Memory-Leak)
_statusWs->cleanupClients();
}
// -----------------------------------------------------------------------------
@ -221,6 +245,7 @@ void WebServerManager::registerRoutes() {
// --- POST /reset Session zurücksetzen (öffentlich, kein Auth) ----------
_server->on("/reset", HTTP_POST, [this](AsyncWebServerRequest* request) {
laserTracker.resetSessionSum();
mqttClient.publishHeartbeat(); // FR-015+016: HA-Update + WS-Push
LOG_I("WEB", "Session zurueckgesetzt");
request->send(204); // No Content fetch() auf Client-Seite, kein Page-Redirect
});
@ -231,18 +256,13 @@ void WebServerManager::registerRoutes() {
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>");
request->send(204);
});
// --- POST /display-toggle Display ein-/ausschalten (öffentlich) ----------
_server->on("/display-toggle", HTTP_POST, [this](AsyncWebServerRequest* request) {
display.setEnabled(!display.isEnabled());
mqttClient.publishHeartbeat(); // FR-015+016: HA-Update + WS-Push
LOG_I("WEB", "Display %s", display.isEnabled() ? "an" : "aus");
request->send(204); // No Content Browser bleibt auf aktueller Seite
});
@ -297,6 +317,23 @@ void WebServerManager::registerRoutes() {
});
}
// -----------------------------------------------------------------------------
// sendStatusWs() Status-JSON via WebSocket an alle /status-ws Clients pushen
// -----------------------------------------------------------------------------
void WebServerManager::sendStatusWs() {
if (!_statusWs || _statusWs->count() == 0) return;
int runSec = laserTracker.getRunningSessionSeconds();
char data[160];
snprintf(data, sizeof(data),
"{\"allSessions\":%d,\"currentSec\":%d,\"laserActive\":%s,\"displayOn\":%s}",
laserTracker.getAllSessionsSumMinutes(),
runSec,
laserTracker.isActive() ? "true" : "false",
display.isEnabled() ? "true" : "false");
_statusWs->textAll(data);
LOG_D("WEB", "Status-WS push: %s", data);
}
// -----------------------------------------------------------------------------
// buildStatusPage()
// -----------------------------------------------------------------------------
@ -336,21 +373,36 @@ String WebServerManager::buildStatusPage() {
"<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>Laserzeit Summe</td><td id='tdAll'>%ALLSESSIONS% Minuten</td></tr>"
"<tr><td>Laserzeit Aktuell</td><td id='tdCurr'>%CURRENTSESSION%</td></tr>"
"<tr><td>Laserstatus</td><td id='tdLaser'>%LASER%</td></tr>"
"<tr><td>Gratiszeit</td><td>%GRATIS% Sekunden</td></tr>"
"<tr><td>Firmware</td><td>v" FIRMWARE_VERSION " (" __DATE__ ")</td></tr>"
"</table>"
"<button class='btn btn-blue' type='button' "
"onclick=\"if(confirm('Summe Laserzeit wirklich zur\\u00fccksetzen?'))"
"{fetch('/reset',{method:'POST'}).then(()=>location.reload())}\">"
"{fetch('/reset',{method:'POST'})}\">"
"Summe Laserzeit zur&uuml;cksetzen</button>"
"<button class='btn %DISP_BTN_CLASS%' type='button' "
"onclick=\"fetch('/display-toggle',{method:'POST'}).then(()=>location.reload())\""
"<button id='dispBtn' class='btn %DISP_BTN_CLASS%' type='button' "
"onclick=\"fetch('/display-toggle',{method:'POST'})\""
">%DISP_BTN_LABEL%</button>"
"<a class='btn btn-gray' href='/config'>&#128274; Laser Cutter Setup &amp; Status</a>"
"<script>setTimeout(()=>location.reload(),10000)</script>"
"<script>"
"function applyStatus(d){"
"document.getElementById('tdAll').textContent=d.allSessions+' Minuten';"
"var m=Math.floor(d.currentSec/60),s=d.currentSec%60;"
"document.getElementById('tdCurr').textContent=m+':'+(s<10?'0':'')+s;"
"document.getElementById('tdLaser').innerHTML=d.laserActive"
"?'<span style=\"color:#2f9e44;font-weight:bold\">an</span>'"
":'<span style=\"color:#868e96\">aus</span>';"
"var b=document.getElementById('dispBtn');"
"if(d.displayOn){b.className='btn btn-blue';b.innerHTML='&#128161; Display ausschalten';}"
"else{b.className='btn btn-gray';b.innerHTML='<span style=\"filter:grayscale(1)\">&#128161;</span> Display einschalten';}"
"}"
"var ws=new WebSocket('ws://'+location.host+'/status-ws');"
"ws.onmessage=function(e){applyStatus(JSON.parse(e.data));};"
"ws.onclose=function(){setTimeout(function(){location.reload();},3000);};"
"</script>"
"</body></html>"
);
@ -359,11 +411,11 @@ String WebServerManager::buildStatusPage() {
html.replace("%LASER%", laserStatus);
html.replace("%GRATIS%", String(s.gratisSeconds));
if (display.isEnabled()) {
html.replace("%DISP_BTN_CLASS%", "btn-gray");
html.replace("%DISP_BTN_CLASS%", "btn-blue");
html.replace("%DISP_BTN_LABEL%", "&#128161; Display ausschalten");
} else {
html.replace("%DISP_BTN_CLASS%", "btn-blue");
html.replace("%DISP_BTN_LABEL%", "&#128161; Display einschalten");
html.replace("%DISP_BTN_CLASS%", "btn-gray");
html.replace("%DISP_BTN_LABEL%", "<span style='filter:grayscale(1)'>&#128161;</span> Display einschalten");
}
return html;
@ -474,10 +526,10 @@ String WebServerManager::buildConfigPage() {
"<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>"
"<button class='btn btn-red' type='button' "
"onclick=\"if(confirm('Maschinenlaufzeit wirklich unwiderruflich aus dem NVS l\u00f6schen?'))"
"{fetch('/reset-total',{method:'POST'}).then(function(){location.reload();})}\">"
"Maschinenlaufzeit zur&uuml;cksetzen</button>"
"<form action='/config' method='post'>"
"<label>Gratiszeit: <span id='gv'>%GRATIS%</span> Sekunden</label>"
@ -518,19 +570,21 @@ String WebServerManager::buildConfigPage() {
"<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>"
"<button class='btn btn-red' type='button' "
"onclick=\"if(confirm('WLAN-Daten wirklich l\\u00f6schen und neu starten?'))"
"{fetch('/wifi-reset',{method:'POST'}).then(function(){"
"document.body.innerHTML='<p style=\\'font-family:sans-serif;padding:2rem\\'>WLAN-Daten gel\\u00f6scht. Bitte mit <strong>LaserCutter-Setup</strong> verbinden.</p>';"
"})}\">WLAN neu konfigurieren</button>"
"<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>"
"<button id='rebootBtn' class='btn btn-gray' type='button' %REBOOT_DISABLED% "
"onclick=\"if(confirm('ESP32 wirklich jetzt neu starten?'))"
"{fetch('/reboot',{method:'POST'}).then(function(){"
"document.body.innerHTML='<p style=\\'font-family:sans-serif;padding:2rem\\'>&#128260; ESP32 startet neu&hellip; <a href=\\'http://'+location.host+'\\'>Zur\\u00fcck</a></p>';"
"setTimeout(function(){location.href='/';},5000);"
"})}\">&#128260; ESP32 neu starten</button>"
"<h2>Tools</h2>"
"<a class='btn btn-gray' href='/update'>OTA Update</a>"
@ -539,6 +593,15 @@ String WebServerManager::buildConfigPage() {
"<footer style='margin-top:2rem;text-align:center;font-size:.8rem;color:#aaa'>"
"v" FIRMWARE_VERSION " (" __DATE__ ")"
"</footer>"
"<script>"
"var ws=new WebSocket('ws://'+location.host+'/status-ws');"
"ws.onmessage=function(e){"
"var d=JSON.parse(e.data);"
"var rb=document.getElementById('rebootBtn');"
"if(rb){rb.disabled=d.laserActive;"
"rb.title=d.laserActive?'Laser aktiv \u2013 bitte erst beenden':'';}"
"};"
"</script>"
"</body></html>"
);