feat(mqtt): session_start_time ISO-8601 UTC; NTP waitForNtp nach WLAN-Connect; docs: README + Plan Phase 9 abgeschlossen

This commit is contained in:
MaPaLo76 2026-02-23 21:23:45 +01:00
parent 2073c3678c
commit 18a1f67f64
6 changed files with 76 additions and 22 deletions

View File

@ -248,11 +248,19 @@
- [x] **9.3** Speicherleck-Monitoring: `ESP.getFreeHeap()` alle 30 s via `LOG_D` in `loop()`
- [ ] **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
- [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 ✅
---

View File

@ -32,10 +32,10 @@ Das Bild zeigt die physische Anordnung der 8 GYMAX7219-Module im 4×2-Format mit
![LaserCutter Display](./doc/screenshot1.png)
[TODO] Ändere den Begriff "Session" in "aktuelle Laserzeit" (das ist die aktuelle Session)
[TODO] Letzer Burst entspricht "aktueller Laserzeit". Dieser Begriff "Burst" kann weg.
[TODO] Ergänze "Summe Laserzeit" (Das ist die aktuelle Summer aller Session bis zu einem manuellen Reset über das Web, MQTT, Button oder Restart)
[TODO] Ersetze "Gesamtzeit (NVS)" durch "Maschinenlaufzeit (gesamt)"
[X] Ändere den Begriff "Session" in "aktuelle Laserzeit" (das ist die aktuelle Session)
[X] Letzer Burst entspricht "aktueller Laserzeit". Dieser Begriff "Burst" kann weg.
[X] Ergänze "Summe Laserzeit" (Das ist die aktuelle Summer aller Session bis zu einem manuellen Reset über das Web, MQTT, Button oder Restart)
[X] Ersetze "Gesamtzeit (NVS)" durch "Maschinenlaufzeit (gesamt)"
### Konfiguration
![Konfiguration](./doc/screenshot2.png)
@ -174,7 +174,7 @@ INACTIVE ──(Laser an)──► GRATIS ──(Gratiszeit abgelaufen)──►
|---|---|---|---|
| `INACTIVE` | `--` | | |
| `GRATIS` | Countdown (z.B. `19`, `18`, ...) | läuft nicht | |
| `NET_COUNTING` | [TODO] Sekundenzähler siehe Session-Sekunden | läuft | |
| `NET_COUNTING` | Sekundenzähler siehe Session-Sekunden | läuft | |
| → Laser aus (aus `NET_COUNTING`) | `--` | wird addiert | gespeichert |
| → Laser aus (aus `GRATIS`) | `--` | 0 addiert | Dauer verwendeter Gratiszeit wird gespeichert |
@ -200,7 +200,7 @@ Wird bei Neustart und `resetSession()` auf 0 zurückgesetzt.
### Session-Sekunden
[TODO] Dies wird in Fomr eines umlaufenden Kreis um die Module 57 angezeigt wie es auf einer Analogen Uhr überlich ist. 3 Module haben umlaufenden 60 Leds. Jede Sekunden wird eine weitere LED dazu geschaltet. Nach 60 Sekunden ist der Kreis voll und die Minutenanzeige (Module 13) wird inkrementiert. Bei Laser-Aus wird diese Anzeige wieder auf "--" zurückgesetzt.
Dies wird in Form eines umlaufenden Kreis um die Module 57 angezeigt wie es auf einer Analogen Uhr überlich ist. 3 Module haben umlaufenden 60 Leds. Jede Sekunden wird eine weitere LED dazu geschaltet. Nach 60 Sekunden ist der Kreis voll und die Minutenanzeige (Module 13) wird inkrementiert. Bei Laser-Aus wird diese Anzeige wieder auf "--" zurückgesetzt.
---
## MQTT Client
@ -218,18 +218,22 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
### JSON-Format `lasercutter/session`
```json
{
"session_minuten": 125,
"session_start_time": "2024-06-01T12:34:56Z",
"session_minuten": 2,
"session_seconds": 95,
"session_start_time": "2026-02-23T12:34:56Z",
"gratiszeit_s": 20,
"ip": "192.168.1.100"
"ip": "192.168.2.62"
}
```
> `session_start_time` ist ein UTC-Zeitstempel (ISO 8601), synchronisiert via NTP (`pool.ntp.org`) unmittelbar nach WLAN-Connect.
> Wert `"unknown"` wenn die NTP-Synchronisation beim Session-Start noch nicht abgeschlossen war.
### JSON-Format `lasercutter/status`
```json
{
"online": true,
"session_sum": "42.50", -
"session_sum": "42.50",
"machine_running_time_min": "1234.75",
"ip": "192.168.1.100",
"uptime_s": 3600

View File

@ -31,6 +31,7 @@
// =============================================================================
#include <Arduino.h>
#include <time.h>
#include "config.h"
#include "settings.h"
@ -76,6 +77,9 @@ public:
// 0 wenn GRATIS oder INACTIVE
int getRunningSessionSeconds() const;
// Unix-Timestamp (UTC) des letzten Session-Starts (0 wenn noch keine Session)
time_t getSessionStartTime() const;
// ---- Reset --------------------------------------------------------------
// Session-Minuten auf 0 (RAM). NVS-Gesamtzeit bleibt unveraendert.
@ -104,6 +108,7 @@ private:
// ---- Session-Zustand ---------------------------------------------------
SessionState _state; // aktueller Session-Zustand
uint32_t _sessionStartMs; // millis() beim Laser-AN (Session-Start)
time_t _sessionStartTime; // Unix-Timestamp (UTC) beim Laser-AN
uint32_t _netStartMs; // millis() beim Uebergang GRATIS -> NET_COUNTING
// ---- Zeitakkumulatoren --------------------------------------------------

View File

@ -15,6 +15,7 @@ LaserTracker::LaserTracker()
, _debounceStartMs(0)
, _state(SessionState::INACTIVE)
, _sessionStartMs(0)
, _sessionStartTime(0)
, _netStartMs(0)
, _sessionNetSec(0)
, _sessionNetMin(0)
@ -76,12 +77,17 @@ void LaserTracker::loop() {
// ---------------------------------------------------------------------------
// Ereignis: Laser AN (nach Debounce)
void LaserTracker::onSessionStart() {
_state = SessionState::GRATIS;
_sessionStartMs = millis();
_netStartMs = 0;
_state = SessionState::GRATIS;
_sessionStartMs = millis();
_sessionStartTime = time(nullptr);
_netStartMs = 0;
Serial.println("[LaserTracker] SessionStart -> GRATIS");
}
time_t LaserTracker::getSessionStartTime() const {
return _sessionStartTime;
}
// ---------------------------------------------------------------------------
// Ereignis: Laser AUS (nach Debounce)
void LaserTracker::onSessionEnd() {

View File

@ -100,11 +100,20 @@ void MqttClient::publishSession(int lastSessionSec, int gratisSec) {
// session_minuten: Decken-Rundung (62 s = 2 min)
int sessionMinuten = (lastSessionSec + 59) / 60;
// session_start_time als ISO-8601 UTC ("2024-06-01T12:34:56Z")
char startTimeBuf[32] = {0};
time_t startTime = laserTracker.getSessionStartTime();
if (startTime > 0) {
struct tm* tmInfo = gmtime(&startTime);
strftime(startTimeBuf, sizeof(startTimeBuf), "%Y-%m-%dT%H:%M:%SZ", tmInfo);
}
JsonDocument doc;
doc["session_minuten"] = sessionMinuten;
doc["session_seconds"] = lastSessionSec;
doc["gratiszeit_s"] = gratisSec;
doc["ip"] = WiFi.localIP().toString();
doc["session_minuten"] = sessionMinuten;
doc["session_seconds"] = lastSessionSec;
doc["session_start_time"] = (startTime > 0) ? startTimeBuf : "unknown";
doc["gratiszeit_s"] = gratisSec;
doc["ip"] = WiFi.localIP().toString();
char buf[128];
serializeJson(doc, buf, sizeof(buf));

View File

@ -7,6 +7,23 @@
// Globale Instanz
WifiConnector wifiConnector;
// ---------------------------------------------------------------------------
// NTP-Sync abwarten (non-restart-blocking, max. timeoutMs)
static void waitForNtp(uint32_t timeoutMs = 5000) {
struct tm ti;
uint32_t t0 = millis();
while (!getLocalTime(&ti) && (millis() - t0) < timeoutMs) {
delay(100);
}
if (getLocalTime(&ti)) {
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &ti);
LOG_I("WIFI", "NTP synchronisiert: %s", buf);
} else {
LOG_E("WIFI", "NTP-Sync Timeout (%u ms) Zeitstempel ungueltig", timeoutMs);
}
}
// Static-Member Initialisierung
WifiConnector* WifiConnector::_instance = nullptr;
@ -82,6 +99,9 @@ void WifiConnector::begin() {
LOG_I("WIFI", "Verbunden mit: %s", WiFi.SSID().c_str());
LOG_I("WIFI", "IP-Adresse : %s", WiFi.localIP().toString().c_str());
LOG_I("WIFI", "RSSI : %d dBm", WiFi.RSSI());
// NTP-Zeitsynchronisation (UTC), auf Sync warten (max. 5 s)
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
waitForNtp();
} else {
LOG_E("WIFI", "Timeout starte ESP32 neu ...");
delay(1000);
@ -108,6 +128,8 @@ void WifiConnector::loop() {
setStatus(WifiStatus::CONNECTED);
LOG_I("WIFI", "Reconnect erfolgreich IP: %s",
WiFi.localIP().toString().c_str());
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
waitForNtp();
} else {
LOG_E("WIFI", "Reconnect fehlgeschlagen starte neu ...");
delay(1000);