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

397 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
- [x] **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)
- [x] **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
- [x] **1.3** Build prüfen (leeres Projekt kompiliert fehlerfrei)
- [x] **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
- [x] **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`)
- [x] **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`
- [x] **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
- [x] **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()`
- [x] **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`
- [x] **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`)
- [x] **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)
- [x] **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()`
- [x] **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`)
- [x] **5.1** `include/laser_tracker.h` + `src/laser_tracker.cpp` erstellt
- Globale Instanz `laserTracker`, in `main.cpp` integriert
- [x] **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)
- [x] **5.3** Session-Logik implementiert:
- `onSessionStart()` intern → `_sessionActive = true`, Timer setzen
- `onSessionEnd()` intern → Netto-Sekunden berechnen, `_totalMinutesBase` addieren, NVS via `settings.saveTotalMinutes()`
- [x] **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)
- [x] **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
- [x] **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
- [x] **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`)
- [x] **6.1** `include/mqtt_client.h` + `src/mqtt_client.cpp` erstellen
Wrapper um `PubSubClient`
- [x] **6.2** Verbindungsaufbau mit Credentials aus `Settings` (TLS automatisch bei Port 8883)
- [x] **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
- [x] **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)
- [x] **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
- [x] **6.6** Reconnect-Logik (Non-blocking, max. alle 10 Sekunden versuchen)
- [x] **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`)
- [x] **7.1** `include/web_server.h` + `src/web_server.cpp` erstellen
Basierend auf `ESPAsyncWebServer`
- [x] **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
- [x] **7.3** Route `GET /config` Konfigurationsformular (HTML):
- MQTT Broker IP, Port, User, Passwort
- Gratiszeit (Slider 0120 s)
- Signal-Polarität (Radio-Button)
- [x] **7.4** Route `POST /config` Konfiguration speichern (via `Settings`, NVS)
Redirect + Hinweis „Neustart für MQTT-Änderungen"
- [x] **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)
- [x] **7.6** ElegantOTA unter `/update` einbinden
Flag `ELEGANTOTA_USE_ASYNC_WEBSERVER=1` in `platformio.ini`
- [x] **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
- [x] **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`)
- [x] **8.1** `main.cpp` aufräumen und alle Module initialisieren:
- Reihenfolge: `Settings``DisplayManager``LaserTracker``WiFiConnector` (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)
- [x] **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
- [x] **8.3** Callback von `LaserTracker``MqttClient.publishSession(...)` verdrahtet
- [x] **8.4** WLAN/MQTT-Fehlerzustand auf Display gespiegelt (rate-limited)
- [x] **8.5** Watchdog Timer aktivieren (ESP32 WDT, 30 s)
---
## Phase 9 OTA & Stabilisierung ✅ abgeschlossen
- [x] **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 `ESPAsyncWebServer``WiFiManager` (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`
- [x] **9.2** Serielles Logging vereinheitlichen: `LOG_D`-Makro in `config.h` ergänzt
- Aktiv wenn `CORE_DEBUG_LEVEL >= 3`, sonst no-op
- [x] **9.3** Speicherleck-Monitoring: `ESP.getFreeHeap()` alle 30 s via `LOG_D` in `loop()`
- [x] **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
- [x] **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 ✅
- [x] **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
- [x] **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)
- [x] **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 ✅
- [x] **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
- [x] **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
- [x] **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
- [x] **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 ✅
- [x] **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`**
```json
{
"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)
```json
{
"online": true,
"session_sum": "2.08",
"machine_running_time_min": "1234.75",
"ip": "192.168.1.100",
"uptime_s": 3600
}
```
---
*Erstellt: 22. Februar 2026*