MQTT-Display-LaserCutter/Implementation-Plan.md
MaPaLo76 234a3a4b9b docs(plan): Phase 10 abgeschlossen, Projekt vollstaendig dokumentiert
- Implementation-Plan.md: Phase 10 alle Tasks als erledigt markiert
- README.md: Implementierungsstand-Tabelle aktualisiert (Phase 8-10 abgeschlossen)
2026-02-26 21:41:08 +01:00

20 KiB
Raw Permalink Blame History

Implementation Plan Laser Cutter MQTT Display

Dieses Dokument beschreibt die Entwicklungsreihenfolge des Projekts.
Tasks werden während der Entwicklung mit [x] abgehakt.


Phase 1 Projekt-Setup & Konfiguration

  • 1.1 platformio.ini mit allen benötigten Bibliotheken erweitern

    • MD_Parola, MD_MAX72XX
    • PubSubClient
    • WiFiManager (tzapu/WiFiManager)
    • ESPAsyncWebServer + AsyncTCP
    • ArduinoJson
    • ElegantOTA
    • Preferences (ESP32 built-in, kein Extra-Eintrag nötig)
  • 1.2 include/config.h erstellen zentrale Pin-Definitionen und Konstanten

    • GPIO-Pins (MAX7219 SPI, Laser-Signal)
    • Anzahl der Module (8), Zonen-Aufteilung
    • Standard-Werte (Gratiszeit 20s, MQTT Port 1883, etc.)
    • MQTT-Topic-Konstanten
  • 1.3 Build prüfen (leeres Projekt kompiliert fehlerfrei)

  • 1.4 Test Verdrahtung Dot-Matrix-Display

    • GYMAX7219-Module gemäß Pinbelegung angeschlossen (MOSI GPIO 23, CLK GPIO 18, CS GPIO 5)
    • Hardware-Typ GENERIC_HW (verifiziert; physische 90° Verdrehung per Software CCW kompensiert)
    • Externes 5 V-Netzteil erforderlich (~0,5 A / ~2,5 W gemessen; USB-Port reicht nicht)
    • SPI-Taktrate auf 1 MHz reduziert für stabile Ansteuerung aller 8 Module
    • Modul 1 oben-links, Modul 4 oben-rechts, Modul 5 unten-links, Modul 8 unten-rechts
    • Zone 0 (0-idx 03) = obere Reihe, Zone 1 (0-idx 47) = untere Reihe
  • 1.5 Test Verdrahtung Potentialfreier Schalter (Phase 1: Push Button)

    • Push Button an GPIO 4 und GND angeschlossen (INPUT_PULLUP, kein externer Widerstand nötig)
    • Pegel verifiziert: Button offen = HIGH, Button gedrückt = LOW → Polarität: LOW_ACTIVE
    • Debounce 50 ms funktioniert

Phase 2 NVS Persistenz (Settings)

  • 2.1 include/settings.h + src/settings.cpp erstellen
    Speichert und lädt alle Konfigurationswerte über ESP32 Preferences (NVS):

    • MQTT Broker IP, Port, User, Passwort
    • Gratiszeit (0120 s)
    • Signal-Polarität (LOW_ACTIVE / HIGH_ACTIVE)
    • Akkumulierte Laserzeit (float, Minuten)
    • Globale Instanz settings, settings.begin() in main.cpp
  • 2.2 Unit-Test (Serial-Output): Werte schreiben, ESP32 neu starten, Werte lesen und verifizieren

    • test_sketches/test_nvs.cpp erstellt, test-nvs Environment in platformio.ini
    • Round-Trip-Test + Persistenz-Check über Neustart

Phase 3 WiFi & WiFiManager

  • 3.1 WiFiManager einbinden

    • include/wifi_connector.h + src/wifi_connector.cpp erstellt
    • AP-Name: LaserCutter-Setup (aus config.h)
    • Captive-Portal-Seite für WLAN-Credentials
    • Timeout: 120 s, danach ESP.restart()
  • 3.2 Verbindungsstatus-Callback implementieren (für spätere Fehleranzeige)

    • WifiStatusCallback-Typ + onStatusChange() Methode
    • Status-Enum: DISCONNECTED, AP_ACTIVE, CONNECTING, CONNECTED
    • Globale Instanz wifiConnector, wifiConnector.begin() + .loop() in main.cpp
  • 3.3 Test: Erstverbindung mit neuem AP, gespeichertes WLAN nach Neustart automatisch verbinden

    • test_sketches/test_wifi.cpp erstellt, test-wifi Environment in platformio.ini
    • BOOT-Taste (GPIO 0, 3 s) löscht Credentials und erzwingt Portal
    • Erstverbindung via Captive Portal , Auto-Reconnect nach Neustart

Phase 4 Dot-Matrix-Display (DisplayManager)

  • 4.1 include/display_manager.h + src/display_manager.cpp erstellen
    Implementierung mit rohem MD_MAX72XX (nicht MD_Parola) für vollständige Rotationskontrolle:

    • Zone 0 (Module 03, obere Reihe): Laserzeit in Minuten
    • Zone 1 (Module 47, untere Reihe): Countdown / Statusmeldungen
    • rotateCCW() kompensiert physische 90°-CW-Verdrehung der Module
    • 17 Zeichen-Bitmaps definiert (09, Sonderzeichen, Buchstaben)
  • 4.2 Methoden implementiert:

    • showLaserTime(float minutes) obere Zeile (4 Formate je Wertebereich)
    • showCountdown(int seconds) untere Zeile, rechtsbündig (während Gratiszeit)
    • showIdle() -- in unterer Zeile wenn kein Countdown aktiv
    • showStatus(const char* msg) max. 4-Zeichen-Statusmeldung (untere Zeile)
    • setBrightness(), allLedsOn(), allLedsOff(), clear(), printToSerial()
    • update() in loop() aufrufen (no-op, Interface für künftige Animationen)
    • main.cpp integriert: display.begin(), display.showIdle(), display.update()
  • 4.3 Test: test_sketches/test_display_manager.cpp, Environment test-display-mgr

    • Alle LEDs EIN/AUS
    • showLaserTime() alle 12 Grenzwerte (0.0 9999.0 min)
    • showCountdown() 5→0
    • showIdle() --
    • showStatus() mit „Err ", „AP ", „WiFi", „ oF"
    • Realistischer Loop: Laserzeit steigt, Countdown 20→0, dann Idle

Phase 5 Laser-Signaldetektion & Zeit-Tracking (LaserTracker)

  • 5.1 include/laser_tracker.h + src/laser_tracker.cpp erstellt

    • Globale Instanz laserTracker, in main.cpp integriert
  • 5.2 GPIO-Eingang konfiguriert:

    • INPUT_PULLUP auf GPIO 4
    • Software-Debounce 50 ms (LASER_DEBOUNCE_MS aus config.h)
    • Polarität aus settings.get().signalPolarity (LOW_ACTIVE / HIGH_ACTIVE)
  • 5.3 Session-Logik implementiert:

    • onSessionStart() intern → _sessionActive = true, Timer setzen
    • onSessionEnd() intern → Netto-Sekunden berechnen, _totalMinutesBase addieren, NVS via settings.saveTotalMinutes()
  • 5.4 Gratiszeit-Logik:

    • Countdown läuft ab Session-Start
    • getSessionSeconds() gibt erst nach Ablauf der Gratiszeit > 0 zurück
    • getCountdownRemaining() → verbleibende Sekunden (0 wenn abgelaufen / inaktiv)
    • getTotalMinutes() = NVS-Basis + laufende Session-Netto-Minuten (Live-Anzeige)
  • 5.5 Öffentliche Getter:

    • getTotalMinutes() → float (Live)
    • getSessionSeconds() → int (Netto ohne Gratiszeit)
    • getCountdownRemaining() → int (verbleibende Gratiszeit)
    • isActive() → bool
    • getLastSessionSeconds() → int (letzte abgeschlossene Session, für MQTT)
    • resetTotal() → Gesamtzeit auf 0 + NVS-Speicherung
  • 5.6 Test: test_sketches/test_laser_tracker.cpp, Environment test-laser-tracker

    • Button GPIO 4 simuliert Laser-Signal
    • BOOT-Taste (GPIO 0, 3 s) löscht Gesamtzeit
    • Boot-Output: NVS-Werte geladen, Display zeigt Basiswert + Idle
    • Manuelle Verifikation: Button drücken → Countdown, Netto-Zeit, Session-Ende → NVS
  • 5.7 showSessionRing(int seconds) Kreisanzeige auf Modulen 57, 12-Uhr-Start, Uhrzeigersinn (Phase-5-Erweiterung)

    • 60 LEDs auf 3 Modulen (je 8×8, aber nur die äußere Reihe vollständig umlaufend)
    • Jede Sekunde NET_COUNTING: eine weitere LED zugeschaltet
    • Nach 60 Sekunden: Kreis voll, Minutenanzeige (Module 13) inkrementiert
    • Laser-AUS: Anzeige zurück auf --
    • Implementierung in display_manager.h/cpp
    • Aufruf in main.cpp während NET_COUNTING statt showIdle()

Phase 6 MQTT Client (MqttClient)

  • 6.1 include/mqtt_client.h + src/mqtt_client.cpp erstellen
    Wrapper um PubSubClient

  • 6.2 Verbindungsaufbau mit Credentials aus Settings (TLS automatisch bei Port 8883)

  • 6.3 Publish-Methode publishSession(sessionSec, summeSession, gratisSec):

    • Topic: lasercutter/session
    • JSON-Felder (aktualisiert):
      • session_minutes (int, ceiling: 62 s = 2 min)
      • session_seconds (int, Rohwert Netto-Sekunden)
      • freetime_s (int)
      • ip (string)
    • Kein total_min, kein session_start_time (NTP später)
    • Wird von LaserTracker-Logik in main aufgerufen
  • 6.4 Publish-Methode publishHeartbeat(totalMin, ipStr, uptimeSec):

    • Topic: lasercutter/status (retained)
    • Heartbeat alle 30 Sekunden
    • LWT {"online":false} bei Verbindungsverlust
    • JSON-Felder (aktualisiert):
      • online (bool)
      • session_sum (string, Summe Session in Minuten als string mit 2 Dezimalen)
      • machine_running_time_min (string, Maschinenlaufzeit aus NVS)
      • ip (string)
      • uptime_s (int)
  • 6.5 Subscribe auf lasercutter/reset:

    • Payload "1", {"reset":true} oder {"reset":1}LaserTracker::resetTotal() (NVS + RAM)
    • Payload {"reset_session":true}LaserTracker::resetSessionSum() (nur RAM, NVS bleibt)
    • QoS 1
  • 6.6 Reconnect-Logik (Non-blocking, max. alle 10 Sekunden versuchen)

  • 6.7 Session-Publish bei Reset: consumeSessionReset() in main.cpp → ruft publishSession() mit akkumulierten Sekunden auf

    • Identisches JSON-Format wie normales Session-Ende

Phase 7 Webinterface (WebServer)

  • 7.1 include/web_server.h + src/web_server.cpp erstellen
    Basierend auf ESPAsyncWebServer

  • 7.2 Route GET / Statusseite (HTML):

    • Summe Session (Minuten, RAM), Maschinenlaufzeit gesamt (NVS)
    • WLAN-Status (IP), MQTT-Status (verbunden/getrennt)
    • Broker-Info, Gratiszeit
    • Auto-Reload alle 10 s
  • 7.3 Route GET /config Konfigurationsformular (HTML):

    • MQTT Broker IP, Port, User, Passwort
    • Gratiszeit (Slider 0120 s)
    • Signal-Polarität (Radio-Button)
  • 7.4 Route POST /config Konfiguration speichern (via Settings, NVS)
    Redirect + Hinweis „Neustart für MQTT-Änderungen"

  • 7.5 Route POST /reset Session-Summe zurücksetzen (laserTracker.resetSessionSum())

    • Gesamtzeit (NVS) bleibt erhalten; Wartungsreset nur per MQTT {"reset":true} oder resetTotal()
    • Bug fix: resetSessionSum() setzt laufende Session-Timer korrekt zurück (vorher: getAllSessionsSumMinutes() blieb > 0)
  • 7.6 ElegantOTA unter /update einbinden
    Flag ELEGANTOTA_USE_ASYNC_WEBSERVER=1 in platformio.ini

  • 7.7 Button-Layout der Statusseite überarbeitet:

    • Alle 3 Buttons (Session zurücksetzen, Konfiguration, OTA Update) übereinander, gleich breit (width:100%)
    • Alle Buttons einheitlich blau (btn-primary), btn-danger entfernt
    • Flex-Layout mit gap:.6rem für gleichmäßigen Abstand
  • 7.8 Button-Layout der Konfigurationsseite vereinheitlicht:

    • Speichern & Zurück und Abbrechen nun gleich breit (width:100% über .btn-Klasse)
    • Identisches Flex-Spalten-Layout wie Statusseite (display:flex;flex-direction:column;gap:.6rem;margin-top:1.5rem)
    • Farben beibehalten: Blau (#3182ce) = Speichern, Grau (#718096) = Abbrechen

Phase 8 Integration & Hauptprogramm (main.cpp)

  • 8.1 main.cpp aufräumen und alle Module initialisieren:

    • Reihenfolge: SettingsDisplayManagerLaserTrackerWiFiConnector (non-blocking) → Watchdog
    • MQTT + WebServer: Lazy-Init im loop() beim ersten WiFi-Connect
    • Änderung gegenüber ursprünglicher Planung: LaserTracker startet vor WiFi (Priorität 1)
  • 8.2 loop() implementiert (non-blocking, kein delay() außer 20 ms am Ende):

    • Priorität 1: laserTracker.loop() (immer zuerst)
    • Priorität 2: wifiConnector.loop() (non-blocking)
    • Lazy-Init: MQTT + WebServer beim ersten wifiConnector.isConnected()
    • MQTT: mqttClient.loop(), Session-Publish nach consumeSessionEnd/Reset()
    • Display rate-limited: W 500 ms-Blinker, Minuten-Update bei Änderung oder alle 60 s, Sekunden-Update alle 1 s, M nur bei Statuswechsel
  • 8.3 Callback von LaserTrackerMqttClient.publishSession(...) verdrahtet

  • 8.4 WLAN/MQTT-Fehlerzustand auf Display gespiegelt (rate-limited)

  • 8.5 Watchdog Timer aktivieren (ESP32 WDT, 30 s)


Phase 9 OTA & Stabilisierung abgeschlossen

  • 9.1 ElegantOTA via web_server.cpp eingebunden (kein separater loop()-Aufruf nötig)

    • ELEGANTOTA_USE_ASYNC_WEBSERVER=1 → vollständig async, kein ElegantOTA.loop() in main.cpp
    • Bugfix: Include-Konflikt ESPAsyncWebServerWiFiManager (HTTP_GET/POST-Enum-Clash) gelöst via PIMPL-Pattern: web_server.h nur Forward-Declaration AsyncWebServer*, vollständiger #include <ESPAsyncWebServer.h> ausschließlich in web_server.cpp
  • 9.2 Serielles Logging vereinheitlichen: LOG_D-Makro in config.h ergänzt

    • Aktiv wenn CORE_DEBUG_LEVEL >= 3, sonst no-op
  • 9.3 Speicherleck-Monitoring: ESP.getFreeHeap() alle 30 s via LOG_D in loop()

  • 9.4 NTP-Zeitsynchronisation + session_start_time im MQTT-Session-Payload

    • configTime(0, 0, "pool.ntp.org", "time.nist.gov") in wifi_connector.cpp nach WLAN-Connect (auch Reconnect)
    • waitForNtp() blockiert max. 5 s bis Sync bestätigt (getLocalTime()), loggt Ergebnis via LOG_I
    • LaserTracker::onSessionStart() speichert time(nullptr)_sessionStartTime
    • publishSession() formatiert UTC-Zeitstempel als ISO-8601 (strftime, gmtime)
    • Fallback "unknown" wenn NTP beim Session-Start noch nicht synchronisiert
    • Verifiziert: {"session_start_time":"2026-02-23T...Z"} im MQTT-Payload bestätigt
  • 9.5 Edge-Cases manuell getestet (Integrations-Hardware-Test):

    • Laser aktiv, WLAN getrennt → Display läuft weiter
    • MQTT-Broker nicht erreichbar → Reconnect
    • Reset während aktiver Session
    • Neustart nach akkumulierter Zeit → Zeit korrekt aus NVS geladen
  • 9.6 Webinterface mit HTTP-Basic-Auth abgesichert

    • webUser + webPassword in Settings-Struct + NVS-Keys in config.h
    • saveWebCredentials() in SettingsManager
    • WebServerManager::requireAuth() prüft Auth, sendet 401 wenn nicht autorisiert
    • Standard: webUser = "admin", webPassword = "" (leer = kein Schutz, rückwärtskompatibel)
    • Alle Routen (/, /config, /reset) + ElegantOTA (ElegantOTA.setAuth()) geschützt
    • Passwort änderbar über /config-Formular (neuer Abschnitt "Web-Zugang")
    • Forward-Declaration AsyncWebServerRequest in web_server.h ergänzt (Fix Compile-Fehler)
    • Bug fix: requestAuthentication() sendet standardmäßig Digest Auth; überschrieben mit false für Basic Auth
    • Bug fix: webPassword[32] zu webPassword[64] vergrößert (Passwort-Abschneiden bei 31 Zeichen)
    • Bug fix: POST /config überschrieb Passwort immer mit leerem String; leeres Feld = unveraendert lassen; clear_auth-Checkbox für explizites Löschen
  • 9.7 resetSession() umbenannt in resetSessionSum() (klarere Semantik)

    • _sessionResetPending Flag + consumeSessionReset() nach consumeSessionEnd()-Muster
    • Vor Reset: akkumulierte Netto-Sekunden in _lastSessionSec gesichert
    • main.cpp: consumeSessionReset()publishSession() mit gesicherten Sekunden
    • MQTT {"reset_session":true} löst resetSessionSum() aus
    • Display zeigt nach Reset sofort 0 (alle Zeitakkumulatoren zurückgesetzt)
  • 9.8 WiFi-Architektur: vollständig non-blocking (Proposal B+D)

    • Problem: wifiConnector.begin() blockierte bis zu 2 Minuten → LaserTracker startete nie
    • WiFiManager als Pointer (WiFiManager*), lazy new in begin() (verhindert Crash durch globale Konstruktoren)
    • WiFi.mode(WIFI_STA) vor Credentials-Prüfung; _wm->getWiFiSSID(true) statt WiFi.SSID() (erfordert gestarteten Stack)
    • Credentials vorhanden: WiFi.begin() non-blocking, Ergebnis in loop() geprüft
    • Keine/falsche Credentials: setConfigPortalBlocking(false) + setConnectTimeout(1) → Portal sofort, Verarbeitung via _wm->process() in loop()
    • Nach 3 Fehlversuchen (je 15 s): startConfigPortal() → User kann neue Credentials eingeben
    • onConnect(): _wm->stopWebPortal() vor AsyncWebServer-Start (verhindert Port-80-Konflikt)
    • MQTT + WebServer: Lazy-Init erst bei erstem WiFi-Connect
    • Display: rate-limited (W 500 ms-Blinker, Minuten bei Änderung/60 s, Sekunden 1 s, M bei Statuswechsel)
    • Verifiziert: LaserTracker läuft sofort nach Neustart, unabhängig vom WiFi-Status
  • 9.9 ArduinoOTA Integration + platformio.ini Refaktorierung

    • ArduinoOTA in web_server.cpp integriert (ESP32 built-in, kein lib_deps-Eintrag nötig)
    • Hostname: lasercutter-display → erscheint in Arduino IDE als Netzwerk-Port lasercutter-display at <IP>
    • Passwort: ArduinoOTA.setPassword(cfg.webPassword) gleiche Auth wie Webinterface, kein separates OTA-Passwort
    • WebServerManager::loop() hinzugefügt → ArduinoOTA.handle() wird in main.cpp via webServer.loop() aufgerufen
    • platformio.ini auf gemeinsamen [env]-Basisblock umgestellt (lib_deps + build_flags einmalig, alle Environments erben)
    • Neues Environment az-delivery-devkit-v4-ota: upload_protocol = espota, IP konfigurierbar
    • Neues Environment az-delivery-devkit-v4-ota-http: ElegantOTA HTTP-Fallback via upload_ota.py extra_script
    • upload_flags = --auth=...: Kommentar + Anleitung für Passwort-Übergabe an espota (inkl. Umgebungsvariable-Option)
    • OTA-Passwort-Authentifizierung verifiziert: Upload via pio run -e az-delivery-devkit-v4-ota --target upload erfolgreich
    • README.md: Abschnitt "Via WiFi (OTA)" mit vollständiger Anleitung ergänzt

Phase 10 Dokumentation & Abschluss abgeschlossen

  • 10.1 README.md finalisiert

    • Alle Abschnitte vollständig: Hardware, Pinbelegung, Display, Laser-Signaldetektion, Zeit-Tracking, MQTT, Webinterface, Konfiguration, WiFi-Setup, OTA, Fehlerverhalten, Bibliotheken, Build & Flash
    • Screenshots eingefügt (Hauptansicht, Konfiguration, OTA-Seite)
    • Build & Flash: USB und Via WiFi (OTA) dokumentiert inkl. upload_flags --auth Anleitung
    • Projektstruktur, Implementierungsstand-Tabelle, Commit-Konventionen dokumentiert
  • 10.2 platformio.ini mit finalem [env]-Basisblock konsolidiert

    • Alle lib_deps einmalig im gemeinsamen Block, keine Duplizierung über Environments
    • Alle Environments (USB, OTA-espota, OTA-HTTP, Test-Environments) vollständig
  • 10.3 MQTT-Topics verifiziert (realer Hardware-Test)

    • lasercutter/session nach Laser-AUS
    • lasercutter/status Heartbeat alle 60 s
    • lasercutter/reset Payload {"reset":true} und {"reset_session":true}
    • TLS (Port 8883) automatisch aktiv wenn konfiguriert
  • 10.4 Schaltbild in README.md als ASCII-Diagramm dokumentiert

    • SPI-Ketten-Schaltbild (8 Module, 4×2-Anordnung) vollständig in README.md

Abhängigkeitsdiagramm (Reihenfolge)

Phase 1 (Setup)
    └── Phase 2 (NVS/Settings)
            ├── Phase 3 (WiFi)
            ├── Phase 4 (Display)
            └── Phase 5 (LaserTracker)
                    └── Phase 6 (MQTT)
                            └── Phase 7 (Webinterface)
                                    └── Phase 8 (Integration)
                                            └── Phase 9 (OTA/Stabilisierung)
                                                    └── Phase 10 (Doku)

Verwendete MQTT-Topics (Überblick)

Topic Richtung QoS Auslöser
lasercutter/session Publish 1 Session-Ende (Laser inaktiv) oder Session-Reset
lasercutter/status Publish 0 Heartbeat alle 60 s
lasercutter/reset Subscribe 1 {"reset":true} = Gesamt-Reset (NVS+RAM), {"reset_session":true} = nur Session-Summe (RAM)

MQTT JSON-Formate

lasercutter/session

{
  "session_minutes": 2,
  "session_seconds": 125,
  "session_start_time": "2026-02-23T12:34:56Z",
  "freetime_s": 20,
  "ip": "192.168.1.100"
}

lasercutter/status (retained)

{
  "online": true,
  "session_sum": "2.08",
  "machine_running_time_min": "1234.75",
  "ip": "192.168.1.100",
  "uptime_s": 3600
}

Erstellt: 22. Februar 2026