fix(mqtt): MQTT-Task auf Core 0 auslagern - TLS-Blocking behebt WDT-Crash und Display-Freeze

- WiFiClientSecure/mbedtls ausschliesslich auf Core 0 initialisiert und verwendet
  (Cross-Core-Heap-Korruption durch mbedtls vermieden)
- xTaskCreatePinnedToCore('mqtt_task', Core 0, 16 KB Stack)
- begin() startet nur Task, kein Netzwerk-Zugriff auf Core 1
- mqttClient.loop() in main.cpp ist No-Op
- publishSession() von Core 1 via volatile-Flags an Core-0-Task uebergeben
- Version: 1.1.0 -> 1.1.1
This commit is contained in:
MaPaLo76 2026-02-28 18:07:27 +01:00
parent b70c459ca8
commit a7c6edb458
6 changed files with 170 additions and 52 deletions

View File

@ -27,3 +27,5 @@ vor jedem Commit wird die neue Funktionalität mit test_sketches/test_*.cpp gete
- **Bug Fix / kleines Feature** (kein neues Modul, keine neue Phase): Eintrag in `Feature-Requests.md` aktualisieren (Status auf ✅ setzen). `Implementation-Plan.md` wird **nicht** angefasst.
- **Neue Phase / größeres Feature** (neues Modul, neue Architektur): `Implementation-Plan.md` aktualisieren. `Feature-Requests.md` bleibt unberührt.
### Versions Nummer
Die Firmware-Version wird in `platformio.ini` als `build_flag` definiert. Erhöhe die Version hier bei jedem neuen Release (z.B. von `1.0.0` auf `1.1.0`). Frage den Benutzer ob die Version erhöht werden soll, wenn du einen Commit machst, der über Bug Fixes hinausgeht (neue Funktionalität, neues Modul, neue Phase). Bug Fixes werden in der 3. Stelle gepatcht (z.B. `1.0.0``1.0.1`), neue Funktionalität in der 2. Stelle (z.B. `1.0.0``1.1.0`).

View File

@ -147,3 +147,33 @@ stateDiagram-v2
| F | **WiFiManager als globales Objekt** | Crash vor Arduino-Framework-Init (globale Konstruktoren) | ✅ gelöst: `WiFiManager*` Pointer, lazy `new` in `begin()` |
| G | **Captive Portal nach 3 Fehlversuchen** | Falsche Credentials → kein Portal | ✅ gelöst: `_failCount` + `startConfigPortal()` nach 3 Versuchen |
| H | **Port-80-Konflikt nach Captive Portal** | AsyncWebServer kann Port 80 nicht belegen | ✅ gelöst: `_wm->stopWebPortal()` in `onConnect()` |
| I | **TLS-Handshake blockierte `loop()` auf Core 1** | WDT-Crash, Display/Browser eingefroren 15 s | ✅ gelöst: MqttClient-Task auf Core 0 ausgelagert |
---
## 7. CPU-Core-Verteilung
Der ESP32 ist Dual-Core (Xtensa LX6, Core 0 und Core 1).
| Core | Aufgaben | Hinweise |
|---|---|---|
| **Core 1** (Arduino-Loop) | `laserTracker.loop()`, `wifiConnector.loop()`, Display-Updates, `webServer.loop()` (ArduinoOTA), `main.cpp` orchestration | Darf **niemals** blockieren. Kein `delay()` außer 20 ms am Ende von `loop()`. |
| **Core 0** | `AsyncTCP` / `ESPAsyncWebServer` (intern), `mqtt_task` (`MqttClient::_taskLoop()`) | `mqtt_task` führt den blockierenden TLS-Handshake durch, ohne Core 1 zu beeinflussen. `WiFiClientSecure`/mbedtls **muss** auf Core 0 bleiben (nicht Cross-Core-safe). |
### Datenübergabe Core 1 → Core 0 (MqttClient)
`publishSession()` wird von Core 1 aufgerufen (nach `consumeSessionEnd()`).
Die Übergabe erfolgt über `volatile`-Flags (kein Mutex nötig für diese einfachen Integer-Werte):
```
Core 1: publishSession(sec, gratis)
→ _pendingSessionSec = sec
→ _pendingGratisSec = gratis
→ _pendingSession = true ← atomares Flag (volatile bool)
Core 0: _taskLoop()
→ if (_pendingSession) {
_pendingSession = false;
_doPublishSession(...);
}
```

View File

@ -25,6 +25,18 @@ Status: `[ ]` = offen · `[x]` = erledigt
## Erledigt
- [x] **FR-005** Bug: WDT-Crash + Display-/Browser-Freeze durch blockierenden TLS-Handshake ✅
- MQTT `reconnect()` mit TLS (Port 8883) blockiert `loop()` auf Core 1 bis zu 15 s
- Folge: Task-Watchdog (30 s) feuert wenn zwei Reconnect-Versuche in einem 30s-Fenster → Neustart mit `WATCHDOG (Task)`
- Zusätzlich: Display-Updates, WebServer-Responses und WiFi-Stack auf Core 1 eingefroren während TLS-Handshake
- Fix: `MqttClient` komplett auf FreeRTOS-Task (Core 0) ausgelagert
- `begin()` startet nur Task (`xTaskCreatePinnedToCore`, Stack 16 KB), kein Netzwerk-Zugriff auf Core 1
- `WiFiClientSecure`/mbedtls wird **ausschließlich auf Core 0** initialisiert und verwendet (Cross-Core-Heap-Korruption vermieden)
- `mqttClient.loop()` in `main.cpp` ist No-Op alle MQTT-Arbeit im Task
- `publishSession()` von Core 1 safe: setzt nur `volatile`-Flags, Task auf Core 0 führt den Publish aus
- Version: 1.1.0 → 1.1.1
- Commit: (pending)
- [x] **FR-002** Web Console serieller Monitor über Browser (WebSocket) ✅
- Route `/log` liefert HTML-Seite mit automatisch scrollendem Terminal (dunkles Theme)
- WebSocket-Endpunkt `/log-ws` via `AsyncWebSocket` im bestehenden ESPAsyncWebServer

View File

@ -25,6 +25,8 @@
#include <WiFiClientSecure.h>
#include "config.h"
#include "settings.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
class MqttClient {
public:
@ -33,7 +35,8 @@ public:
// MQTT konfigurieren und erste Verbindung versuchen (einmalig in setup())
void begin();
// Verbindung halten, Reconnect, Heartbeat - in jedem loop()-Aufruf
// No-Op: Reconnect, Heartbeat und Publish werden vom internen FreeRTOS-Task erledigt.
// Diese Methode bleibt im Interface, damit main.cpp unveraendert bleibt.
void loop();
// Publish: Session-Ende (wird von LaserTracker-Logik in main aufgerufen)
@ -57,12 +60,27 @@ private:
uint32_t _lastHeartbeatMs;
char _clientId[32]; // MQTT_CLIENT_ID + MAC-Suffix (eindeutig auf Public Broker)
// Verbindungsaufbau (intern, non-blocking: gibt true zurueck wenn verbunden)
// FreeRTOS-Task: laeuft auf Core 0, erledigt Reconnect/Heartbeat/Publish
TaskHandle_t _taskHandle;
// Pending-Session-Daten: von Core 1 (main) gesetzt, von Core 0 (Task) gelesen
volatile bool _pendingSession;
volatile int _pendingSessionSec;
volatile int _pendingGratisSec;
// Verbindungsaufbau (blockierend - NUR aus MQTT-Task auf Core 0 aufrufen!)
bool reconnect();
// Heartbeat-Publish (lasercutter/status)
void publishHeartbeat();
// Eigentlicher Session-Publish (aus MQTT-Task auf Core 0)
void _doPublishSession(int lastSessionSec, int gratisSec);
// FreeRTOS-Task
static void _taskFn(void* param);
void _taskLoop();
// Callback fuer eingehende Nachrichten (static wegen PubSubClient-API)
static void onMessage(const char* topic, byte* payload, unsigned int length);
};

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.1.0"' ; Semantic Versioning hier erhöhen bei neuem Release
-DFIRMWARE_VERSION='"1.1.1"' ; Semantic Versioning hier erhöhen bei neuem Release
lib_deps =
majicDesigns/MD_Parola @ ^3.7.3
majicDesigns/MD_MAX72XX @ ^3.5.1

View File

@ -19,12 +19,18 @@ MqttClient::MqttClient()
: _client(_wifiClient)
, _lastReconnectMs(0)
, _lastHeartbeatMs(0)
, _taskHandle(nullptr)
, _pendingSession(false)
, _pendingSessionSec(0)
, _pendingGratisSec(0)
{
_clientId[0] = '\0';
}
// --------------------------------------------------------------------------
// begin() - Broker konfigurieren und erste Verbindung versuchen
// begin() - Client-ID setzen und MQTT-Task auf Core 0 starten.
// WICHTIG: _client und _secureClient werden AUSSCHLIESSLICH in _taskLoop()
// auf Core 0 initialisiert und verwendet (mbedtls nicht Core-safe).
// --------------------------------------------------------------------------
void MqttClient::begin() {
const auto& cfg = settings.get();
@ -34,21 +40,7 @@ void MqttClient::begin() {
: DEFAULT_MQTT_BROKER;
uint16_t port = cfg.mqttPort > 0 ? cfg.mqttPort : DEFAULT_MQTT_PORT;
_client.setServer(broker, port);
_client.setCallback(MqttClient::onMessage);
_client.setKeepAlive(60);
_client.setBufferSize(512);
// TLS: Bei Port 8883 WiFiClientSecure (ohne Zertifikatspruefung) verwenden
if (port == 8883) {
_secureClient.setInsecure();
_client.setClient(_secureClient);
LOG_I("MQTT", "TLS aktiv (setInsecure - kein Cert-Verify)");
} else {
_client.setClient(_wifiClient);
}
// Client-ID mit MAC-Suffix eindeutig machen (wichtig bei Public Broker)
// Client-ID jetzt setzen (nur String-Op, kein Netzwerk)
uint8_t mac[6];
WiFi.macAddress(mac);
snprintf(_clientId, sizeof(_clientId), "%s-%02X%02X%02X",
@ -57,44 +49,39 @@ void MqttClient::begin() {
LOG_I("MQTT", "Broker: %s:%u", broker, port);
LOG_I("MQTT", "Client-ID: %s", _clientId);
reconnect();
// MQTT-Task auf Core 0 starten.
// 16 KB Stack: TLS/mbedtls benoetigt ~12 KB.
// Alle _client.*-Aufrufe laufen ausschliesslich im Task auf Core 0.
xTaskCreatePinnedToCore(_taskFn, "mqtt_task", 16384, this, 1, &_taskHandle, 0);
LOG_I("MQTT", "MQTT-Task gestartet (Core 0)");
}
// --------------------------------------------------------------------------
// loop() - Reconnect und Heartbeat
// loop() - No-Op: Arbeit erledigt _taskLoop() auf Core 0
// Bleibt im Interface, damit main.cpp unveraendert bleibt.
// --------------------------------------------------------------------------
void MqttClient::loop() {
if (!WiFi.isConnected()) {
return; // Kein WiFi - nichts tun
}
if (!_client.connected()) {
uint32_t now = millis();
if (now - _lastReconnectMs >= MQTT_RECONNECT_MS) {
_lastReconnectMs = now;
reconnect();
}
} else {
_client.loop(); // PubSubClient interne Verarbeitung
// Heartbeat
uint32_t now = millis();
if (now - _lastHeartbeatMs >= MQTT_HEARTBEAT_MS) {
_lastHeartbeatMs = now;
publishHeartbeat();
}
}
// Display MQTT-Fehler aktualisieren
display.showMqttError(!_client.connected());
// Reconnect, _client.loop(), Heartbeat und Publish
// werden vollstaendig vom MQTT-Task auf Core 0 erledigt.
}
// --------------------------------------------------------------------------
// publishSession() - beim Ende einer Session
// publishSession() - beim Ende einer Session (Core 1 safe, kein Blocking)
// Daten werden via volatile Flag an den MQTT-Task auf Core 0 uebergeben.
// --------------------------------------------------------------------------
void MqttClient::publishSession(int lastSessionSec, int gratisSec) {
_pendingSessionSec = lastSessionSec;
_pendingGratisSec = gratisSec;
_pendingSession = true; // Task auf Core 0 fuehrt den Publish aus
LOG_I("MQTT", "publishSession vorgemerkt: %d s (gratis %d s)", lastSessionSec, gratisSec);
}
// --------------------------------------------------------------------------
// _doPublishSession() - eigentlicher Publish, wird aus MQTT-Task aufgerufen
// --------------------------------------------------------------------------
void MqttClient::_doPublishSession(int lastSessionSec, int gratisSec) {
if (!_client.connected()) {
LOG_E("MQTT", "publishSession: nicht verbunden, uebersprungen");
LOG_E("MQTT", "_doPublishSession: nicht verbunden, uebersprungen");
return;
}
@ -110,11 +97,11 @@ void MqttClient::publishSession(int lastSessionSec, int gratisSec) {
}
JsonDocument doc;
doc["session_minutes"] = sessionMinuten;
doc["session_seconds"] = lastSessionSec;
doc["session_start_time"] = (startTime > 0) ? startTimeBuf : "unknown";
doc["freetime_s"] = gratisSec;
doc["ip"] = WiFi.localIP().toString();
doc["session_minutes"] = sessionMinuten;
doc["session_seconds"] = lastSessionSec;
doc["session_start_time"] = (startTime > 0) ? startTimeBuf : "unknown";
doc["freetime_s"] = gratisSec;
doc["ip"] = WiFi.localIP().toString();
char buf[128];
serializeJson(doc, buf, sizeof(buf));
@ -156,6 +143,8 @@ bool MqttClient::reconnect() {
const char* lwtPayload = "{\"online\":false}";
LOG_I("MQTT", "Verbinde als '%s'...", _clientId);
// TLS-Handshake kann 2-15 s dauern. Laeuft auf Core 0 (MQTT-Task),
// blockiert Core 1 (loop/LaserTracker/Display) nicht mehr.
bool ok;
if (user && pass) {
@ -178,10 +167,77 @@ bool MqttClient::reconnect() {
LOG_E("MQTT", "Verbindung fehlgeschlagen, rc=%d", _client.state());
}
display.showMqttError(!ok);
return ok;
}
// --------------------------------------------------------------------------
// _taskFn() / _taskLoop() - FreeRTOS-Task auf Core 0
// Erledigt: Reconnect, PubSubClient.loop(), Heartbeat, pending Session-Publish
// --------------------------------------------------------------------------
void MqttClient::_taskFn(void* param) {
static_cast<MqttClient*>(param)->_taskLoop();
}
void MqttClient::_taskLoop() {
// ------------------------------------------------------------------
// Einmalige Initialisierung auf Core 0.
// WiFiClientSecure (mbedtls) MUSS auf demselben Core laufen, auf dem
// es konfiguriert und verwendet wird sonst Heap-Korruption!
// ------------------------------------------------------------------
const auto& cfg = settings.get();
const char* broker = cfg.mqttBroker[0] != '\0' ? cfg.mqttBroker : DEFAULT_MQTT_BROKER;
uint16_t port = cfg.mqttPort > 0 ? cfg.mqttPort : DEFAULT_MQTT_PORT;
if (port == 8883) {
_secureClient.setInsecure();
_client.setClient(_secureClient);
LOG_I("MQTT", "TLS aktiv (setInsecure, Core 0)");
} else {
_client.setClient(_wifiClient);
}
_client.setServer(broker, port);
_client.setCallback(MqttClient::onMessage);
_client.setKeepAlive(60);
_client.setBufferSize(512);
for (;;) {
// Kein WiFi: warten
if (!WiFi.isConnected()) {
vTaskDelay(pdMS_TO_TICKS(500));
continue;
}
// Nicht verbunden: Reconnect-Intervall abwarten, dann versuchen
if (!_client.connected()) {
uint32_t now = millis();
if (now - _lastReconnectMs >= MQTT_RECONNECT_MS) {
_lastReconnectMs = now;
reconnect(); // blockiert hier (TLS); Core 1 laeuft unbehelligt
}
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
// PubSubClient-interne Verarbeitung (eingehende Nachrichten)
_client.loop();
// Pending Session-Publish (von Core 1 via volatile Flag gesetzt)
if (_pendingSession) {
_pendingSession = false;
_doPublishSession(_pendingSessionSec, _pendingGratisSec);
}
// Heartbeat
uint32_t now = millis();
if (now - _lastHeartbeatMs >= MQTT_HEARTBEAT_MS) {
_lastHeartbeatMs = now;
publishHeartbeat();
}
vTaskDelay(pdMS_TO_TICKS(50));
}
}
// --------------------------------------------------------------------------
// onMessage() - Callback fuer eingehende MQTT-Nachrichten
// --------------------------------------------------------------------------