diff --git a/Implementation-Plan.md b/Implementation-Plan.md new file mode 100644 index 0000000..696a778 --- /dev/null +++ b/Implementation-Plan.md @@ -0,0 +1,229 @@ +# 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) + +--- + +## 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 (0–120 s) + - Signal-Polarität (`LOW_ACTIVE` / `HIGH_ACTIVE`) + - Akkumulierte Laserzeit (float, Minuten) + +- [ ] **2.2** Unit-Test (Serial-Output): Werte schreiben, ESP32 neu starten, Werte lesen und verifizieren + +--- + +## Phase 3 – WiFi & WiFiManager + +- [ ] **3.1** WiFiManager einbinden + - AP-Name: `LaserCutter-Setup` + - Captive-Portal-Seite für WLAN-Credentials + - Timeout: 120 s, danach Neustart + +- [ ] **3.2** Verbindungsstatus-Callback implementieren (für spätere Fehleranzeige) + +- [ ] **3.3** Test: Erstverbindung mit neuem AP, gespeichertes WLAN nach Neustart automatisch verbinden + +--- + +## Phase 4 – Dot-Matrix-Display (`DisplayManager`) + +- [ ] **4.1** `include/display_manager.h` + `src/display_manager.cpp` erstellen + Wrapper um `MD_Parola` mit zwei Zonen: + - Zone 0 (Module 0–3, obere Reihe): Laserzeit in Minuten + - Zone 1 (Module 4–7, untere Reihe): Countdown / Statusmeldungen + +- [ ] **4.2** Methoden implementieren: + - `showLaserTime(float minutes)` – obere Zeile + - `showCountdown(int seconds)` – untere Zeile (während Gratiszeit) + - `showIdle()` – `---` in unterer Zeile wenn kein Countdown + - `showError(const char* msg)` – Fehlermeldung in unterer Zeile, Modul 1 + - `update()` – in `loop()` aufrufen (MD_Parola benötigt regelmäßigen Aufruf) + +- [ ] **4.3** Test: Statische Zahlen und Scrolltext auf beiden Zonen anzeigen + +--- + +## Phase 5 – Laser-Signaldetektion & Zeit-Tracking (`LaserTracker`) + +- [ ] **5.1** `include/laser_tracker.h` + `src/laser_tracker.cpp` erstellen + +- [ ] **5.2** GPIO-Eingang konfigurieren: + - `INPUT_PULLUP` auf GPIO 4 + - Software-Debounce (min. 50 ms Entprellzeit) + - Polarität über `Settings` auslesbar + +- [ ] **5.3** Session-Logik implementieren: + - `onLaserActive()` → Session-Start, Timer starten + - `onLaserInactive()` → Session-Ende, akkumulierte Zeit aktualisieren, NVS speichern + +- [ ] **5.4** Gratiszeit-Logik: + - Countdown läuft ab Session-Start + - Zeit wird erst nach Ablauf der Gratiszeit zur Gesamtzeit addiert + - `getCountdownRemaining()` → Rückgabe verbleibende Sekunden (0 wenn abgelaufen) + +- [ ] **5.5** Öffentliche Getter: + - `getTotalMinutes()` → float + - `getSessionSeconds()` → int (aktuelle Session ohne Gratiszeit) + - `isActive()` → bool + - `getLastSessionSeconds()` → int (für MQTT Publish) + +- [ ] **5.6** Test: Laser-Signal simulieren (GPIO auf GND ziehen), Zeitmessung verifizieren + +--- + +## 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` + +- [ ] **6.3** Publish-Methode `publishSession(sessionSec, totalMin, gratisSec)`: + - Topic: `lasercutter/session` + - JSON mit `session_s`, `total_min`, `gratiszeit_s`, `ts` + - Wird von `LaserTracker` beim Session-Ende aufgerufen + +- [ ] **6.4** Publish-Methode `publishStatus(totalMin, ipStr, uptimeSec)`: + - Topic: `lasercutter/status` + - Heartbeat alle 60 Sekunden + - QoS 0 + +- [ ] **6.5** Subscribe auf `lasercutter/reset`: + - Payload `"1"` oder `{"action":"reset"}` → `LaserTracker::resetTotal()` + - QoS 1 + +- [ ] **6.6** Reconnect-Logik (Non-blocking, max. alle 10 Sekunden versuchen) + +- [ ] **6.7** Test: Session-Daten mit MQTT Explorer empfangen, Reset auslösen und verifizieren + +--- + +## Phase 7 – Webinterface (`WebServer`) + +- [ ] **7.1** `include/web_server.h` + `src/web_server.cpp` erstellen + Basierend auf `ESPAsyncWebServer` + +- [ ] **7.2** Route `GET /` – Statusseite (HTML): + - Aktuelle Laserzeit (Minuten) + - Letzter Session-Wert (Sekunden) + - WLAN-Status, MQTT-Status + - IP-Adresse + +- [ ] **7.3** Route `GET /config` – Konfigurationsformular (HTML): + - MQTT Broker IP, Port, User, Passwort + - Gratiszeit (Slider 0–120 s) + - Signal-Polarität (Radio-Button) + +- [ ] **7.4** Route `POST /config` – Konfiguration speichern (via `Settings`, NVS) + +- [ ] **7.5** Route `POST /reset` – Laserzeit zurücksetzen (Button auf Statusseite) + +- [ ] **7.6** ElegantOTA unter `/update` einbinden + +- [ ] **7.7** Test: Alle Seiten im Browser aufrufen, Konfiguration ändern und nach Neustart prüfen + +--- + +## Phase 8 – Integration & Hauptprogramm (`main.cpp`) + +- [ ] **8.1** `main.cpp` aufräumen und alle Module initialisieren: + - Reihenfolge: `Settings` → `DisplayManager` → `WiFiManager` → `MqttClient` → `WebServer` → `LaserTracker` + +- [ ] **8.2** `loop()` implementieren: + - `displayManager.update()` + - `laserTracker.update()` (GPIO-Polling + Session-Logik) + - `mqttClient.loop()` (Reconnect + Heartbeat) + - `displayManager.showLaserTime(laserTracker.getTotalMinutes())` + - `displayManager.showCountdown(...)` oder `showIdle()` oder `showError(...)` + +- [ ] **8.3** Callback von `LaserTracker` → `MqttClient.publishSession(...)` verdrahten + +- [ ] **8.4** WLAN/MQTT-Fehlerzustand auf Display spiegeln + +- [ ] **8.5** Watchdog Timer aktivieren (ESP32 WDT, 30 s) + +--- + +## Phase 9 – OTA & Stabilisierung + +- [ ] **9.1** ElegantOTA in `main.cpp` initialisieren und in `loop()` einbinden + +- [ ] **9.2** Serielles Logging vereinheitlichen (Log-Level: INFO / DEBUG per `#define`) + +- [ ] **9.3** Langzeit-Test: 24h Betrieb, Speicherleck-Check via Serial Monitor (`ESP.getFreeHeap()`) + +- [ ] **9.4** Edge-Cases testen: + - Laser aktiv, WLAN wird getrennt → Display läuft weiter + - MQTT-Broker nicht erreichbar → Reconnect + - Reset während aktiver Session + - Neustart nach akkumulierter Zeit → Zeit korrekt aus NVS geladen + +--- + +## Phase 10 – Dokumentation & Abschluss + +- [ ] **10.1** README.md finalisieren (Screenshots, Schaltplan-Skizze ggf. ergänzen) + +- [ ] **10.2** `platformio.ini` mit finalen Library-Versionen dokumentieren + +- [ ] **10.3** MQTT-Topics in README.md validieren (mit realem Broker testen) + +- [ ] **10.4** Optionaler Schaltplan (Fritzing / ASCII) für Laser-Signal-Anschluss (Optokoppler) + +--- + +## 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) | +| `lasercutter/status` | Publish | 0 | Heartbeat alle 60 s | +| `lasercutter/reset` | Subscribe | 1 | Externer Reset-Befehl | + +--- + +*Erstellt: 22. Februar 2026* diff --git a/README.md b/README.md index 738e78e..4b6288b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,230 @@ # Laser Cutter Dot Matrix Display und MQTT Client -Dieses Projekt ist ein Dotmatrix-Display und MQTT-Client für einen Laser Cutter. Es ist darauf ausgelegt, die aktive Lasercutter Zeit auf einem Dotmatrix-Display anzuzeigen und Nachrichten über die aktive Laserzeit an einen MQTT-Broker zu senden und Nachrichten zum Zurücksetzen der Lasercutter-Zeit zu empfangen. +Dieses Projekt implementiert einen ESP32-basierten MQTT-Client mit Dot-Matrix-Display für einen Laser Cutter. Es misst und visualisiert die aktive Laserzeit, sendet Session-Daten an einen MQTT-Broker und bietet ein Browser-Webinterface zur Konfiguration und Steuerung. +--- + +## Inhaltsverzeichnis + +1. [Hardware](#hardware) +2. [Pinbelegung](#pinbelegung) +3. [Display](#display) +4. [Laser-Signaldetektion](#laser-signaldetektion) +5. [Zeit-Tracking & Gratiszeit](#zeit-tracking--gratiszeit) +6. [MQTT Client](#mqtt-client) +7. [Webinterface](#webinterface) +8. [Konfiguration & Persistenz](#konfiguration--persistenz) +9. [WiFi-Setup (WiFiManager)](#wifi-setup-wifimanager) +10. [OTA Firmware-Update](#ota-firmware-update) +11. [Fehlerverhalten](#fehlerverhalten) +12. [Bibliotheken](#bibliotheken) +13. [Build & Flash](#build--flash) + +--- + +## Hardware + +| Komponente | Modell / Beschreibung | +|-------------------------|----------------------------------------------------| +| Mikrocontroller | AZ-Delivery ESP32 DevKit V4 | +| Dot-Matrix-Display | 8× GYMAX7219 Module (kompatibel zu MAX7219), 8×8 LEDs je Modul | +| Display-Anordnung | 4 Module nebeneinander × 2 Reihen = 32×16 LEDs | +| Display-Kaskadierung | Alle 8 Module in einer einzigen SPI-Kette | +| Laser-Aktivsignal | Potentialfreier Ausgang des Laser Cutters (Optokoppler empfohlen) | +| Optional | Shelly PM Mini G3 als externer Leistungsmesser (separater MQTT-Service) | + +--- + +## Pinbelegung + +| Signal | ESP32 GPIO | Beschreibung | +|---------------|-----------|------------------------------------------------| +| MAX7219 MOSI | GPIO 23 | SPI Data (VSPI) | +| MAX7219 CLK | GPIO 18 | SPI Clock (VSPI) | +| MAX7219 CS | GPIO 5 | SPI Chip Select | +| Laser-Signal | GPIO 4 | Potentialfreier Eingang (INPUT_PULLUP, konfigurierbar invertierbar) | + +> Die Polarität des Laser-Signals (HIGH = aktiv oder LOW = aktiv) ist über das Webinterface konfigurierbar. + +--- + +## Display + +Das Display besteht aus 8 GYMAX7219-Modulen in einer 4×2-Anordnung (32×16 LEDs gesamt). +Es ist in **zwei Zonen** aufgeteilt, die unabhängig voneinander beschrieben werden: + +| Zone | Module (Kette) | Anzeige-Inhalt | +|------|----------------|----------------------------------------| +| Oben | Module 0–3 | Akkumulierte aktive Laserzeit in **Minuten** (z.B. `42.5 min`) | +| Unten | Module 4–7 | Laufender **Countdown** in Sekunden der Gratiszeit, danach `---` | + +- Die Anzeige aktualisiert sich sekündlich, solange der Laser aktiv ist. +- Bei inaktivem Laser bleibt die letzte gemessene Zeit dauerhaft sichtbar. +- Fehlerzustände (WLAN, MQTT) werden in der **unteren Zeile, erstes Modul** angezeigt. +- Die Bibliotheken `MD_Parola` + `MD_MAX72XX` werden für die Ansteuerung verwendet. + +--- + +## Laser-Signaldetektion + +Der ESP32 überwacht einen **digitalen GPIO-Eingang** mit internem Pull-Up-Widerstand (`INPUT_PULLUP`). +Der potentialfreie Ausgang des Laser Cutters (z.B. Schließer-Kontakt über Optokoppler) wird an diesen Pin angeschlossen. + +- **Polarität konfigurierbar**: Im Webinterface einstellbar, ob `LOW` oder `HIGH` den aktiven Zustand bedeutet. +- **Debounce**: Software-Entprellung, um Fehlmessungen bei Schaltflanken zu vermeiden. +- Eine **Session** beginnt, wenn der Laser aktiv wird, und endet, wenn er inaktiv wird. + +--- + +## Zeit-Tracking & Gratiszeit + +- Die Laserzeit wird **akkumulativ** in Minuten gezählt und über Neustarts hinaus im **NVS (Non-Volatile Storage)** des ESP32 gespeichert. +- **Gratiszeit**: Eine konfigurierbare Zeitspanne (0–120 Sekunden) am Anfang jeder Session wird **nicht** zur akkumulierten Zeit gezählt. Dies erlaubt kurze Testläufe zum Einstellen des Laser Cutters ohne Kosten. + - Standard: 20 Sekunden + - Einstellbar über das Webinterface +- Die untere Display-Zeile zeigt während der Gratiszeit einen Countdown an. + +--- + +## MQTT Client + +Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterface). + +### Topics + +| Richtung | Topic | Format | Beschreibung | +|------------|---------------------------|---------|-------------------------------------------| +| Publish | `lasercutter/session` | JSON | Wird beim **Ende einer Session** gesendet | +| Subscribe | `lasercutter/reset` | `"1"` | Setzt die akkumulierte Laserzeit auf 0 | +| Publish | `lasercutter/status` | JSON | Heartbeat alle 60 Sekunden (online/offline)| + +### JSON-Format `lasercutter/session` +```json +{ + "session_s": 125, + "total_min": 42.5, + "gratiszeit_s": 20, + "ts": "2026-02-22T10:30:00Z" +} +``` + +### JSON-Format `lasercutter/status` +```json +{ + "online": true, + "total_min": 42.5, + "ip": "192.168.1.100", + "uptime_s": 3600 +} +``` + +### Verhalten +- Reconnect im Hintergrund, falls MQTT-Broker nicht erreichbar. +- Bei aktivem Laser läuft die Zeitmessung unabhängig vom MQTT-Status weiter. +- QoS 1 für Session- und Reset-Topics. + +--- + +## Webinterface + +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 | +| Reset | `/reset` | Setzt akkumulierte Laserzeit zurück | +| OTA Update | `/update` | Firmware-Update über Browser | + +--- + +## Konfiguration & Persistenz + +Alle Einstellungen werden im **NVS (Non-Volatile Storage)** des ESP32 gespeichert und sind über das Webinterface änderbar: + +| Einstellung | Standard | Beschreibung | +|-----------------------|---------------|-----------------------------------------| +| MQTT Broker IP | `192.168.1.1` | IP-Adresse des MQTT-Brokers | +| MQTT Port | `1883` | Port des MQTT-Brokers | +| MQTT User | *(leer)* | MQTT Benutzername (optional) | +| MQTT Passwort | *(leer)* | MQTT Passwort (optional) | +| Gratiszeit | `20` | Sekunden, 0–120 | +| Signal-Polarität | `LOW_ACTIVE` | `LOW_ACTIVE` oder `HIGH_ACTIVE` | +| Akkumulierte Zeit | `0.0` | Gespeicherte Laserzeit in Minuten (NVS) | + +--- + +## WiFi-Setup (WiFiManager) + +Beim ersten Start (oder wenn keine gespeicherten WLAN-Daten vorhanden) öffnet das Gerät einen **Access Point** mit Captive Portal: + +- SSID: `LaserCutter-Setup` +- Im Browser öffnet sich automatisch die Konfigurationsseite. +- WLAN-Credentials werden nach erfolgreicher Verbindung im NVS gespeichert. + +--- + +## OTA Firmware-Update + +Firmware-Updates können kabellos über die Weboberfläche unter `/update` eingespielt werden (ElegantOTA). + +--- + +## Fehlerverhalten + +| Zustand | Display-Anzeige (untere Zeile, Modul 1) | Verhalten | +|----------------------|----------------------------------------|----------------------------------------| +| WLAN getrennt | `WiFi ERR` | Zeiterfassung läuft weiter, Reconnect | +| MQTT nicht erreichbar| `MQTT ERR` | Zeiterfassung läuft weiter, Reconnect | +| WLAN + MQTT OK | Normaler Betrieb (Countdown/`---`) | – | + +--- + +## Bibliotheken + +| Bibliothek | Zweck | +|-----------------------|--------------------------------------| +| `MD_Parola` | Dot-Matrix-Display Textausgabe | +| `MD_MAX72XX` | Treiber für MAX7219/GYMAX7219 | +| `PubSubClient` | MQTT Client | +| `WiFiManager` | WiFi Captive Portal | +| `ESPAsyncWebServer` | Asynchroner Webserver | +| `AsyncTCP` | TCP-Basis für ESPAsyncWebServer | +| `ArduinoJson` | JSON Serialisierung/Deserialisierung | +| `Preferences` | NVS-Zugriff (built-in ESP32 Arduino) | +| `ElegantOTA` | OTA-Update über Webinterface | + +--- + +## Build & Flash + +```bash +# PlatformIO CLI +pio run --target upload + +# Serieller Monitor +pio device monitor +``` + +Ziel-Board: `az-delivery-devkit-v4` (ESP32), Upload-Port: `COM3` + +--- + +## Projektstruktur + +``` +MQTT-Display-LaserCutter/ +├── src/ +│ └── main.cpp # Hauptprogramm +├── include/ +│ ├── config.h # Pin-Definitionen, Konstanten +│ ├── display_manager.h # Display-Logik (MD_Parola) +│ ├── laser_tracker.h # Signal-Detektion & Zeiterfassung +│ ├── mqtt_client.h # MQTT-Wrapper (PubSubClient) +│ ├── web_server.h # Webinterface (ESPAsyncWebServer) +│ └── settings.h # NVS-Persistenz (Preferences) +├── parser/ +│ └── shelly_parser.py # Separater Python-Parser für Shelly PM G3 +├── platformio.ini +└── README.md +``` diff --git a/parser/shelly_parser.py b/parser/shelly_parser.py new file mode 100644 index 0000000..1f45db9 --- /dev/null +++ b/parser/shelly_parser.py @@ -0,0 +1,81 @@ +"""Shelly PM Mini G3 Parser - extracts power data from MQTT messages.""" + +from datetime import datetime + +import structlog + +from exceptions import ParserError + +logger = structlog.get_logger() + + +class ShellyParser: + """Parser for Shelly PM Mini G3 MQTT Messages.""" + + def parse_message(self, topic: str, payload: dict) -> dict | None: + """ + Parse Shelly MQTT message. + + Args: + topic: MQTT topic + payload: Message payload (already JSON parsed) + + Returns: + Dict with parsed data or None + """ + try: + # Only parse status messages + if "/status/pm1:0" in topic: + return self._parse_status_message(topic, payload) + + return None + + except Exception as e: + logger.debug("shelly_parse_error", topic=topic, error=str(e)) + return None + + def _parse_status_message(self, topic: str, data: dict) -> dict | None: + """ + Parse Shelly PM status message. + Topic: shaperorigin/status/pm1:0 + + Payload format: + { + "id": 0, + "voltage": 230.0, + "current": 0.217, + "apower": 50.0, + "freq": 50.0, + "aenergy": {"total": 12345.6}, + "temperature": {"tC": 35.2} + } + """ + try: + device_id = self._extract_device_id(topic) + + result = { + "message_type": "status", + "device_id": device_id, + "timestamp": datetime.utcnow().isoformat() + "Z", + "voltage": data.get("voltage"), + "current": data.get("current"), + "apower": data.get("apower", 0), # Active Power in Watts + "frequency": data.get("freq"), + "total_energy": data.get("aenergy", {}).get("total"), + "temperature": data.get("temperature", {}).get("tC"), + } + + logger.debug("shelly_parsed_status", device_id=device_id, apower=result["apower"]) + return result + + except Exception as e: + logger.error("shelly_status_parse_error", error=str(e)) + return None + + def _extract_device_id(self, topic: str) -> str: + """Extract device ID from topic path.""" + # Example: shaperorigin/status/pm1:0 -> shaperorigin + parts = topic.split("/") + if len(parts) > 0: + return parts[0] + return "unknown"