Implements FR-020: Grace Time (buffer period after free time)
Problem: Fan relay signal continues ~5s after laser stops, causing
false billable time charges when user stops at countdown 1 second.
Solution: New GRACE state between GRATIS and NET_COUNTING with
configurable grace period (default 5s, range 0-30s). During grace
time, display shows blinking 0 (number of blinks = remaining seconds).
If laser stops during GRACE, no billable session is created.
State machine: INACTIVE -> GRATIS -> GRACE -> NET_COUNTING
Changes:
- config.h: Add DEFAULT_GRACE_TIME_S (5), MIN/MAX constants
- settings.h/cpp: Add graceTimeSeconds field, NVS persistence
- laser_tracker.h/cpp: Add GRACE state, transition logic
- display_manager.h/cpp: Add showGraceBlinking() with blink animation
- main.cpp: Add GRACE state handling in display update loop
- mqtt_client.cpp: Add grace_time_s field to session JSON
- web_server.cpp: Add Grace Time input field in config page
- README.md: Update documentation with GRACE state details
- Feature-Requests.md: Mark FR-020 as completed (v1.6.4)
Version: 1.6.4
Tested: Manual testing with logs and MQTT Explorer
FR-015:
- publishHeartbeat() nach Display-Toggle, Session-Reset und Reboot-CMD
- publishHeartbeat() von private nach public (mqtt_client.h)
- Heartbeat-Intervall 60s -> 10s (config.h)
- HA Discovery Switch: state_on/state_off ergaenzt (mqtt_client.cpp)
FR-016:
- Neuer WebSocket-Endpunkt /status-ws (AsyncWebSocket)
- sendStatusWs() am Ende von publishHeartbeat() -> alle Ausloeser abgedeckt
- Statusseite: DOM-Updates via WebSocket, kein location.reload() mehr
- Config-Seite: Reboot-Button live deaktiviert wenn Laser aktiv
- Alle Action-Buttons auf fetch() umgestellt (Reboot, WLAN-Reset, Laufzeit-Reset)
- Display-Button: blau+gelb wenn an, grau+grau wenn aus
- DisplayManager::reinit() setzt nur Kontroll-Register (ohne clear())
- Neues State-Tracking: show*() speichert letzten Zustand intern
- Neues redraw(): zeichnet alle Zonen nach reinit() sofort neu
- LaserTracker::onSessionStart/onSessionEnd rufen display.reinit() auf
- _rebuildClient(): WiFiClientSecure + PubSubClient vor jedem
Reconnect-Versuch sauber delete + new auf Core 0
- mbedTLS startet damit immer mit sauberem Heap-Kontext
- Verhindert CORRUPT HEAP / PANIC wenn Broker die TLS-Session
unerwartet abbricht (MBEDTLS_ERR_NET_CONN_RESET)
- _broker/_port als Members gecacht fuer _rebuildClient()
- web_server.cpp: static String _logBuf -> char[6001] im BSS-Segment
Jeder LOG_I/E/D-Aufruf loeste String-Reallokation aus (~280 Bytes/min Drift)
Fix: memmove-basierter Ring-Puffer, kein malloc/free mehr
- mqtt_client.cpp: JsonDocument in publishHeartbeat() und _doPublishSession()
durch snprintf-JSON ersetzt (kein ArduinoJson-Heap-Pool alle 60 s)
- config.h: LOG-Puffer von 200 auf 320 Bytes erhoeht (Truncation fix)
- platformio.ini: CORE_DEBUG_LEVEL wieder auf 1 (war 3 fuer Heap-Diagnose)
- WiFiClient, WiFiClientSecure, PubSubClient als Pointer deklariert (nullptr init)
- Objekte werden erst in _taskLoop() auf Core 0 per 'new' erstellt
- mbedtls bindet Heap-Allokationen an den erstellenden Core/Task
-> Anlegen auf Core 1 (globaler Konstruktor) + Nutzung auf Core 0 = Heap-Korruption
-> Fix: Anlegen UND Nutzung ausschliesslich auf Core 0
- isConnected(), printToSerial(): Null-Checks ergaenzt
- WiFiClientSecure/mbedtls ausschliesslich auf Core 0 initialisiert und verwendet
(Cross-Core-Heap-Korruption durch mbedtls vermieden)
- xTaskCreatePinnedToCore('mqtt_task', Core 0, 16 KB Stack)
- begin() startet nur Task, kein Netzwerk-Zugriff auf Core 1
- mqttClient.loop() in main.cpp ist No-Op
- publishSession() von Core 1 via volatile-Flags an Core-0-Task uebergeben
- Version: 1.1.0 -> 1.1.1
- WiFiManager als Pointer, lazy new in begin() verhindert Crash durch
globale Konstruktoren vor Arduino-Framework-Init
- WiFi.mode(WIFI_STA) vor Credentials-Pruefung; getWiFiSSID(true) statt
WiFi.SSID() (erfordert gestarteten Stack)
- Credentials vorhanden: WiFi.begin() non-blocking, Ergebnis in loop()
- Keine Credentials: setConfigPortalBlocking(false) + process() in loop()
- Nach 3 Fehlversuchen: startConfigPortal() automatisch
- onConnect(): stopWebPortal() vor AsyncWebServer-Start (Port-80-Konflikt)
- main.cpp: LaserTracker startet vor WiFi (Prioritaet 1)
- MQTT + WebServer: Lazy-Init beim ersten WiFi-Connect
- Display rate-limited: W 500ms-Blinker, Minuten 60s, Sekunden 1s, M bei Aenderung
- Manuell getestet: LaserTracker laeuft sofort, WiFi non-blocking verifiziert
- include/settings.h: Settings struct + SettingsManager class with
begin/load/save/reset and atomic save methods per value
- src/settings.cpp: input validation, clamping, default fallbacks;
password masked in printToSerial()
- src/main.cpp: call settings.begin() and printToSerial() on startup
- test_sketches/test_nvs.cpp: two-phase NVS persistence test;
write known values, reboot, verify all 7 values survive (PASS)
- platformio.ini: add test-nvs environment with settings.cpp in filter
- Implementation-Plan.md: mark tasks 1.4 and 2.1/2.2 complete