diff --git a/Implementation-Plan.md b/Implementation-Plan.md index cc7b561..45a3954 100644 --- a/Implementation-Plan.md +++ b/Implementation-Plan.md @@ -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 ✅ --- diff --git a/README.md b/README.md index 912396d..97898c1 100644 --- a/README.md +++ b/README.md @@ -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 5–7 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 1–3) wird inkrementiert. Bei Laser-Aus wird diese Anzeige wieder auf "--" zurückgesetzt. +Dies wird in Form eines umlaufenden Kreis um die Module 5–7 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 1–3) 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 diff --git a/include/laser_tracker.h b/include/laser_tracker.h index 4251089..3f0613c 100644 --- a/include/laser_tracker.h +++ b/include/laser_tracker.h @@ -31,6 +31,7 @@ // ============================================================================= #include +#include #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 -------------------------------------------------- diff --git a/src/laser_tracker.cpp b/src/laser_tracker.cpp index 1627d3d..3f142ae 100644 --- a/src/laser_tracker.cpp +++ b/src/laser_tracker.cpp @@ -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() { diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp index 852f4a0..507243d 100644 --- a/src/mqtt_client.cpp +++ b/src/mqtt_client.cpp @@ -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)); diff --git a/src/wifi_connector.cpp b/src/wifi_connector.cpp index b38788f..37517cc 100644 --- a/src/wifi_connector.cpp +++ b/src/wifi_connector.cpp @@ -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);