From fc5e1694fd9224c0ecbf256f9a6b8571951c7d6e Mon Sep 17 00:00:00 2001 From: MaPaLo76 <72209721+MaPaLo76@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:17:39 +0100 Subject: [PATCH] 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 --- Feature-Requests.md | 37 ++++++------- README.md | 14 +++-- include/config.h | 2 +- include/mqtt_client.h | 7 ++- include/web_server.h | 8 ++- src/mqtt_client.cpp | 4 ++ src/web_server.cpp | 125 +++++++++++++++++++++++++++++++----------- 7 files changed, 134 insertions(+), 63 deletions(-) diff --git a/Feature-Requests.md b/Feature-Requests.md index e14faab..7ce6469 100644 --- a/Feature-Requests.md +++ b/Feature-Requests.md @@ -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 ✅ diff --git a/README.md b/README.md index 48741fd..11f24a0 100644 --- a/README.md +++ b/README.md @@ -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:///log` (Auth erforderlich wenn Web-Passwort gesetzt) --- diff --git a/include/config.h b/include/config.h index 722887a..db39322 100644 --- a/include/config.h +++ b/include/config.h @@ -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 diff --git a/include/mqtt_client.h b/include/mqtt_client.h index c532b95..f495489 100644 --- a/include/mqtt_client.h +++ b/include/mqtt_client.h @@ -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);; diff --git a/include/web_server.h b/include/web_server.h index 6679047..290f150 100644 --- a/include/web_server.h +++ b/include/web_server.h @@ -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(); diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp index 69ae622..54e2e17 100644 --- a/src/mqtt_client.cpp +++ b/src/mqtt_client.cpp @@ -6,6 +6,7 @@ #include "mqtt_client.h" #include "laser_tracker.h" #include "display_manager.h" +#include "web_server.h" #include "config.h" #include @@ -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}\"," diff --git a/src/web_server.cpp b/src/web_server.cpp index 77f9888..9ee5205 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -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", - "" - "" - "" - "

✓ Maschinenlaufzeit zurückgesetzt

" - "

Weiterleitung …

" - ""); + 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() { "" "

Laser Cutter Status

" "" - "" - "" - "" + "" + "" + "" "" "" "
Laserzeit Summe%ALLSESSIONS% Minuten
Laserzeit Aktuell%CURRENTSESSION%
Laserstatus%LASER%
Laserzeit Summe%ALLSESSIONS% Minuten
Laserzeit Aktuell%CURRENTSESSION%
Laserstatus%LASER%
Gratiszeit%GRATIS% Sekunden
Firmwarev" FIRMWARE_VERSION " (" __DATE__ ")
" "" - "" "🔒 Laser Cutter Setup & Status" - "" + "" "" ); @@ -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%", "💡 Display ausschalten"); } else { - html.replace("%DISP_BTN_CLASS%", "btn-blue"); - html.replace("%DISP_BTN_LABEL%", "💡 Display einschalten"); + html.replace("%DISP_BTN_CLASS%", "btn-gray"); + html.replace("%DISP_BTN_LABEL%", "💡 Display einschalten"); } return html; @@ -474,10 +526,10 @@ String WebServerManager::buildConfigPage() { "

%TOTAL%

" "
⚠️ Maschinenlaufzeit zurücksetzen löscht den Gesamtspeicher im NVS " "unwiderruflich – diese Aktion kann nicht rückgängig gemacht werden.
" - "
" - "" - "
" + "" "
" "" @@ -518,19 +570,21 @@ String WebServerManager::buildConfigPage() { "

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" @@ -539,6 +593,15 @@ String WebServerManager::buildConfigPage() { "
" "v" FIRMWARE_VERSION " (" __DATE__ ")" "
" + "" "" );