From 96fcb74c80ec3b451f75067b6486050327d1930f Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 14:44:40 +0200 Subject: [PATCH 01/10] docs: add design spec for PCF8523 RTC fallback New header rtcsync.h providing persistent time storage and offline fallback when NTP is unavailable. Soft-fails when hardware is absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-03-rtc-pcf8523-fallback-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-rtc-pcf8523-fallback-design.md diff --git a/docs/superpowers/specs/2026-05-03-rtc-pcf8523-fallback-design.md b/docs/superpowers/specs/2026-05-03-rtc-pcf8523-fallback-design.md new file mode 100644 index 0000000..449aa39 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-rtc-pcf8523-fallback-design.md @@ -0,0 +1,152 @@ +# RTC-Fallback (PCF8523) — Design + +**Datum:** 2026-05-03 +**Status:** Entwurf +**Scope:** Neuer Header `src/rtcsync.h` für Adafruit Adalogger FeatherWing (PCF8523) als persistente Zeitquelle mit NTP-Sync und Fallback bei fehlendem Internet. + +## Ziel + +Der AquaMaster soll nach jedem Boot eine plausible Uhrzeit haben, auch ohne WiFi-STA-Verbindung. NTP bleibt die primäre Quelle; die RTC dient als persistenter Speicher (überlebt Power-Off) und als Fallback im AP-only-Betrieb. + +Hardware ist optional — Geräte ohne FeatherWing müssen unverändert funktionieren. + +## Nicht-Ziele + +- Keine Drift-Kompensation in Software (PCF8523-Trim-Register werden nicht angefasst) +- Kein RTC-Alarm-/Interrupt-Handling +- Keine Persistenz anderer Daten als der Uhrzeit (SD-Slot des FeatherWing wird ignoriert) +- Kein Migrations-Pfad für andere RTC-Chips (DS3231 etc.) — explizit auf PCF8523 zugeschnitten + +## Architektur + +### Neuer Header: `src/rtcsync.h` + +Header-only nach bestehendem Pattern, wird **nur** in `master.cpp` inkludiert. Definiert eigene globale Objekte: + +- `RTC_PCF8523 rtc` +- `bool rtcAvailable` — wurde Chip beim Boot via I²C gefunden +- `bool ntpEverSynced` — gab es seit Boot mind. einen erfolgreichen NTP-Sync +- `time_t lastNtpSyncEpoch` — Zeitpunkt des letzten NTP-Erfolgs (UTC, für 24h-Timer und Status-Anzeige) +- `bool lastStaConnected` — Edge-Detection für STA-Reconnect + +### Library + +`adafruit/RTClib` (zieht `adafruit/Adafruit BusIO` transitiv mit) — ergänzt in `platformio.ini` unter `lib_deps` aller relevanten Envs. + +### Schnittstelle (Header-Funktionen) + +| Funktion | Zweck | +|----------|-------| +| `void setupRTC()` | I²C-Init, PCF8523 detektieren (`rtc.begin()` + `rtc.initialized()`-Check), bei Erfolg RTC-Zeit als initialen Fallback in Systemzeit übernehmen | +| `bool syncFromNTP()` | Ruft existierendes `syncTimeWithNTP()` aus `timesync.h` auf; bei Erfolg → schreibt UTC in RTC, setzt `ntpEverSynced=true`, aktualisiert `lastNtpSyncEpoch`. Return: ob NTP erfolgreich war. | +| `void loopRTC()` | Aus `loop()` aufgerufen: erkennt STA-Reconnect-Edge (Übergang `lastStaConnected: false → true`) und 24h-Periodik → ruft `syncFromNTP()`. Reine Vergleichsoperationen, nicht-blockierend bis NTP tatsächlich getriggert wird. | +| `void persistSystemTimeToRTC(time_t t)` | Schreibt Systemzeit in RTC, **nur wenn** `rtcAvailable && t >= 1735689600 && t < 4102444800` (Jahr 2025–2099). Wird via Weak-Hook von `setSystemTime()` aufgerufen. | +| `void appendTimeStatus(JsonDocument &doc)` | Hängt Felder `rtc_available`, `rtc_synced_from_ntp`, `last_ntp_sync_ago_s`, `rtc_time_utc` an die Antwort von `/api/time` an (Implementierung des Weak-Hooks aus `timesync.h`). | + +### Kopplung mit `timesync.h` via Weak-Symbol + +`timesync.h` darf `rtcsync.h` **nicht** kennen (Geräte ohne RTC sollen ohne Code-Änderung funktionieren). Lösung: + +```cpp +// in timesync.h, am Ende von setSystemTime() vor return true: +extern "C" void onSystemTimeSet(time_t t) __attribute__((weak)); +if (onSystemTimeSet) onSystemTimeSet(timestamp); + +// in rtcsync.h: +extern "C" void onSystemTimeSet(time_t t) { + persistSystemTimeToRTC(t); +} +``` + +Wenn `rtcsync.h` aus `master.cpp` weggelassen wird, ist `onSystemTimeSet` unaufgelöst → Weak-Default ist Nullpointer → if-Check verhindert Aufruf. Geräte ohne RTC kompilieren und laufen unverändert. + +Analog für `getCurrentTimeJSON()`: Weak-Hook `void appendTimeStatus(JsonDocument&)` der von `rtcsync.h` überschrieben wird. + +## Boot-Flow (`master.cpp::setup()`) + +Reihenfolge — `setupRTC()` kommt **vor** WiFi, damit Systemzeit so früh wie möglich plausibel ist: + +``` +SPIFFS +→ API-Setups +→ load*() aus Preferences +→ setupRTC() ← NEU + └─ wenn rtcAvailable: settimeofday(rtc.now().unixtime()) +→ WiFi (AP/STA) +→ wenn STA online: syncFromNTP() ← überschreibt RTC mit NTP-Wert +→ ...rest unverändert +``` + +Im AP-only-Betrieb bleibt es bei der RTC-Zeit. Im STA-Modus wird sie sofort durch NTP übersteuert. + +## Loop-Flow (`master.cpp::loop()`) + +`loopRTC()` als erste Zeile in `loop()` — billig (zwei Vergleiche), nicht-blockierend bis tatsächlich ein Sync ausgelöst wird. Der NTP-Sync selbst ist blockierend (max. 5 s wie heute), passiert aber selten: + +- bei STA-Reconnect (Übergang false→true von `WiFi.isConnected()`) +- alle 24 h (`time(NULL) - lastNtpSyncEpoch >= 86400`) + +## Zeitzone + +UTC in der RTC. Schreiben mit `rtc.adjust(DateTime((uint32_t)t))`, lesen mit `rtc.now().unixtime()`. Systemzeit ist POSIX-intern auch UTC; der TZ-Offset (`configTime(3600, 0, ...)`) wirkt nur auf `localtime()`-Konversion. Daher kein +/-3600 nötig. + +## Plausibilitäts-Check + +`persistSystemTimeToRTC()` schreibt nur, wenn der Timestamp im Bereich [2025-01-01, 2099-01-01) liegt. Verhindert Bugs mit Timestamp=0 oder negativen Werten, ist aber lose genug, dass jede vom Browser gesetzte Zeit durchkommt (Browser-Sync-Workflow soll erhalten bleiben). + +Konkret: untere Grenze `1735689600` (= 2025-01-01 UTC), obere Grenze `4102444800` (= 2100-01-01 UTC). + +## Soft-Fail-Verhalten + +Wenn `rtc.begin()` fehlschlägt: +- `rtcAvailable = false`, Warnung in Serial +- Alle RTC-Schreib-/Lese-Operationen werden no-op (geprüft via `rtcAvailable`) +- NTP-Sync funktioniert weiter wie bisher +- `/api/time` zeigt `rtc_available: false` + +Es gibt **keine** automatische Re-Detection. Wenn die RTC nachträglich angesteckt wird, ist ein Reboot nötig. + +## API-Erweiterung + +`/api/time` (GET) bekommt zusätzliche Felder: + +```json +{ + "timestamp": 1746288000, + "success": true, + "formatted": "2026-05-03 14:00:00", + ..., + "rtc_available": true, + "rtc_synced_from_ntp": true, + "last_ntp_sync_ago_s": 1234, + "rtc_time_utc": 1746288001 +} +``` + +`rtc_time_utc` ist der direkt aus dem Chip gelesene Wert (zur Diagnose, falls Systemzeit und RTC divergieren). + +Keine neuen Endpunkte. Bewusste Entscheidung gegen `/api/rtc/set`, weil der bestehende `set browser time`-Workflow über `setSystemTime()` jetzt automatisch in die RTC durchschlägt. + +## Geänderte Dateien + +| Datei | Änderung | +|-------|----------| +| `src/rtcsync.h` | **Neu** — komplette RTC-Logik | +| `src/master.cpp` | Include `rtcsync.h`; `setupRTC()` in `setup()` (vor WiFi); `loopRTC()` in `loop()` (erste Zeile) | +| `src/timesync.h` | Weak-Hook `onSystemTimeSet()` am Ende von `setSystemTime()`; Weak-Hook `appendTimeStatus()` in `getCurrentTimeJSON()` | +| `platformio.ini` | `adafruit/RTClib` in `lib_deps` aller Envs ergänzen | + +## Risiken & Trade-offs + +- **PCF8523-Drift (~±20 ppm, ≈1.7 s/Tag):** Akzeptabel, weil bei jedem STA-Reconnect und mind. alle 24 h nachsyncronisiert wird. +- **I²C-Bus geteilt mit PN532:** Keine Adresskollision (PN532=`0x24`, PCF8523=`0x68`). Bus-Init muss vor *beiden* Devices passieren — `Wire.begin()` wird in `setupRTC()` einmal aufgerufen und ist idempotent. +- **`rtc.begin()`-Verhalten:** Returnt auf manchen Library-Versionen `true` auch ohne Hardware. Zusätzlich `rtc.initialized()` und ein Test-Read prüfen. +- **Weak-Symbol-Pattern:** Funktioniert mit GCC (ESP32-Toolchain ist GCC) — keine Portabilitätsbedenken in diesem Projekt. + +## Was explizit *nicht* getestet wird + +Es gibt im Projekt keine Test-Suite (`test/` ist leer). Verifikation erfolgt durch: +- Build über alle Envs (`pio run`) +- Manueller Test auf Hardware mit/ohne FeatherWing +- Serial-Log-Checks für Boot-Flow +- `/api/time` im Frontend prüfen From df95a37ca76991b911735cea069a3991f6945451 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 14:47:37 +0200 Subject: [PATCH 02/10] docs: add implementation plan for PCF8523 RTC fallback Ten tasks covering dependency setup, header creation, weak-hook decoupling from timesync.h, master.cpp wiring, and hardware verification on real device. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-03-rtc-pcf8523-fallback.md | 607 ++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-rtc-pcf8523-fallback.md diff --git a/docs/superpowers/plans/2026-05-03-rtc-pcf8523-fallback.md b/docs/superpowers/plans/2026-05-03-rtc-pcf8523-fallback.md new file mode 100644 index 0000000..0059ee1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-rtc-pcf8523-fallback.md @@ -0,0 +1,607 @@ +# RTC PCF8523 Fallback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new header `src/rtcsync.h` that uses an Adafruit Adalogger FeatherWing (PCF8523) as persistent time storage and as a fallback time source when NTP is unavailable, while remaining fully optional (soft-fail without hardware). + +**Architecture:** Header-only module following the existing AquaMaster pattern. Decoupled from `timesync.h` via GCC weak symbols, so devices without RTC compile and run unchanged. Time is stored in the RTC as UTC (POSIX epoch). NTP remains the authoritative source; RTC is consulted only at boot before WiFi comes up. Manual time sets (e.g. browser-time button) are persisted to RTC with a plausibility check. + +**Tech Stack:** ESP32 / Arduino-Framework / PlatformIO, `adafruit/RTClib`, `ArduinoJson@^7.4.1`, existing `timesync.h` API. + +**Spec:** [docs/superpowers/specs/2026-05-03-rtc-pcf8523-fallback-design.md](../specs/2026-05-03-rtc-pcf8523-fallback-design.md) + +**Verification model:** This project has no unit-test suite (`test/` is empty). "Tests" in this plan mean: (1) build success across all PlatformIO envs, (2) serial-log behavior on real hardware, (3) HTTP-API checks via `curl`/browser. Each task ends with a verification step appropriate for the change. + +--- + +## File Structure + +| File | Role | +|------|------| +| `src/rtcsync.h` | **NEW** — header-only RTC module (globals + `setupRTC`, `loopRTC`, `syncFromNTP`, `persistSystemTimeToRTC`, weak-hook overrides) | +| `src/timesync.h` | Add two weak-symbol declarations + invocations (no behavioral change without `rtcsync.h`) | +| `src/master.cpp` | Include `rtcsync.h`; call `setupRTC()` before `setupWifi()`; call `loopRTC()` at start of `loop()` | +| `platformio.ini` | Add `adafruit/RTClib` to `lib_deps` of every env | + +--- + +## Task 1: Add RTClib dependency to all PlatformIO envs + +**Files:** +- Modify: `platformio.ini` (lines 30, 50, 70, 87, 105, 129 — `lib_deps` blocks of all six envs) + +- [ ] **Step 1: Add `adafruit/RTClib@^2.1.4` to every `lib_deps` block** + +In `platformio.ini`, append the line ` adafruit/RTClib@^2.1.4` (tab-indented like the other entries) to each of the six `lib_deps` sections. Concretely, each block should become: + +```ini +lib_deps = + bblanchon/ArduinoJson@^7.4.1 + esp32async/ESPAsyncWebServer@^3.7.7 + esp32async/AsyncTCP@^3.4.2 + mlesniew/PicoMQTT@^1.3.0 + adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 +``` + +Apply this edit to envs: `wemos_d1_mini32`, `esp32thing_OTA`, `esp32thing`, `esp32thing_CI`, `um_feathers3`, `um_feathers3_debug`. + +- [ ] **Step 2: Verify CI env builds with new dependency** + +Run: `pio run -e esp32thing_CI` +Expected: Library is downloaded (look for `Library Manager: Installing adafruit/RTClib`), build succeeds with `SUCCESS` line. No code change yet, so binary size should be essentially unchanged. + +- [ ] **Step 3: Commit** + +```bash +git add platformio.ini +git commit -m "build: add RTClib dependency for PCF8523 RTC support" +``` + +--- + +## Task 2: Create `src/rtcsync.h` skeleton with RTC detection (soft-fail) + +**Files:** +- Create: `src/rtcsync.h` + +- [ ] **Step 1: Create the skeleton file** + +Create `src/rtcsync.h` with the following content. This is the smallest possible vertical slice — only detection, no time-handling yet: + +```cpp +// PCF8523-RTC-Modul mit NTP-Sync und Fallback. +// Header-only nach Projekt-Pattern: Globale Objekte werden hier definiert. +// Dieser Header darf NUR in master.cpp inkludiert werden. +#pragma once +#include +#include +#include + +// Globale RTC-Instanz und Status-Flags +RTC_PCF8523 rtc; +bool rtcAvailable = false; +bool ntpEverSynced = false; +time_t lastNtpSyncEpoch = 0; +bool lastStaConnected = false; + +// I2C-Init und PCF8523-Detektion. Soft-Fail: setzt nur rtcAvailable. +void setupRTC() { + Wire.begin(); // idempotent — falls bereits durch andere Module aufgerufen + if (!rtc.begin()) { + Serial.println("[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert"); + rtcAvailable = false; + return; + } + if (!rtc.initialized() || rtc.lostPower()) { + Serial.println("[RTC] PCF8523 erkannt, aber Akku-Backup ungültig (lostPower)"); + // Wir betrachten den Chip trotzdem als verfügbar — NTP wird ihn gleich neu setzen. + } + rtc.start(); // STOP-Bit löschen, falls gesetzt + rtcAvailable = true; + Serial.println("[RTC] PCF8523 initialisiert"); +} +``` + +- [ ] **Step 2: Build CI env to verify the header compiles standalone** + +The header is not yet included anywhere, so this only checks that the includes resolve. Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`, no warnings about `rtcsync.h`. + +- [ ] **Step 3: Commit** + +```bash +git add src/rtcsync.h +git commit -m "feat(rtc): add rtcsync.h skeleton with PCF8523 detection" +``` + +--- + +## Task 3: Add RTC → system-time fallback inside `setupRTC()` + +**Files:** +- Modify: `src/rtcsync.h` (the `setupRTC()` function from Task 2) + +- [ ] **Step 1: Extend `setupRTC()` to seed the system clock from RTC if available** + +Replace the body of `setupRTC()` with: + +```cpp +void setupRTC() { + Wire.begin(); + if (!rtc.begin()) { + Serial.println("[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert"); + rtcAvailable = false; + return; + } + if (!rtc.initialized() || rtc.lostPower()) { + Serial.println("[RTC] PCF8523 erkannt, aber Akku-Backup ungültig (lostPower)"); + } + rtc.start(); + rtcAvailable = true; + Serial.println("[RTC] PCF8523 initialisiert"); + + // Systemzeit aus RTC vorbelegen — wird ggf. später durch NTP übersteuert. + // RTC speichert UTC, settimeofday erwartet UTC. Kein Offset nötig. + DateTime nowRtc = rtc.now(); + uint32_t rtcEpoch = nowRtc.unixtime(); + // Plausibilität: RTC sollte mindestens 2025 zeigen, sonst ist sie ungestellt. + if (rtcEpoch >= 1735689600UL) { + struct timeval tv; + tv.tv_sec = (time_t)rtcEpoch; + tv.tv_usec = 0; + settimeofday(&tv, NULL); + Serial.printf("[RTC] Systemzeit aus RTC gesetzt: %lu (UTC)\n", + (unsigned long)rtcEpoch); + } else { + Serial.printf("[RTC] RTC-Zeit unplausibel (%lu) — nicht übernommen\n", + (unsigned long)rtcEpoch); + } +} +``` + +- [ ] **Step 2: Build CI env** + +Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`. The header still isn't wired into `master.cpp`, so no runtime effect yet. + +- [ ] **Step 3: Commit** + +```bash +git add src/rtcsync.h +git commit -m "feat(rtc): seed system time from RTC at boot" +``` + +--- + +## Task 4: Add weak-symbol hooks to `timesync.h` + +These hooks let `rtcsync.h` extend `timesync.h` without `timesync.h` knowing about it. Without `rtcsync.h` linked in, the weak default is a null pointer and the call sites short-circuit. + +**Files:** +- Modify: `src/timesync.h:23-46` (`getCurrentTimeJSON`) +- Modify: `src/timesync.h:72-84` (`setSystemTime`) + +- [ ] **Step 1: Add weak hook declarations near the top of `timesync.h`** + +Insert these declarations directly after the existing `bool isValidDateTime(...)` line is declared (i.e. after the prototypes block, before `getCurrentTimeJSON`'s implementation around line 22). If no clean location exists, place them right after the `#include ` line: + +```cpp +// Weak hooks — falls rtcsync.h kompiliert/gelinkt wird, überschreibt es diese. +// Ohne rtcsync.h sind beide Symbole nullptr und werden nicht aufgerufen. +extern "C" void onSystemTimeSet(time_t t) __attribute__((weak)); +extern "C" void appendTimeStatus(JsonDocument &doc) __attribute__((weak)); +``` + +- [ ] **Step 2: Convert `getCurrentTimeJSON()` doc type and invoke `appendTimeStatus`** + +The existing function uses the deprecated `StaticJsonDocument<200>`. ArduinoJson v7 prefers plain `JsonDocument`. Replace lines 23–46 of `src/timesync.h` (the entire `getCurrentTimeJSON()` body) with: + +```cpp +String getCurrentTimeJSON() { + gettimeofday(&tv, &tz); + now = tv.tv_sec; + + JsonDocument doc; + doc["timestamp"] = (long)now; + doc["success"] = true; + + // Zusätzliche Zeitinformationen + gmtime_r(&now, &timeinfo); + char timeStr[64]; + strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); + doc["formatted"] = String(timeStr); + doc["year"] = timeinfo.tm_year + 1900; + doc["month"] = timeinfo.tm_mon + 1; + doc["day"] = timeinfo.tm_mday; + doc["hour"] = timeinfo.tm_hour; + doc["minute"] = timeinfo.tm_min; + doc["second"] = timeinfo.tm_sec; + + // Optionale RTC/Sync-Status-Felder, falls rtcsync.h gelinkt ist + if (appendTimeStatus) { + appendTimeStatus(doc); + } + + String response; + serializeJson(doc, response); + return response; +} +``` + +- [ ] **Step 3: Invoke `onSystemTimeSet` from `setSystemTime()`** + +Replace lines 72–84 of `src/timesync.h` (the `setSystemTime` function) with: + +```cpp +bool setSystemTime(long timestamp) { + struct timeval tv; + tv.tv_sec = timestamp; + tv.tv_usec = 0; + + if (settimeofday(&tv, NULL) == 0) { + Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp)); + if (onSystemTimeSet) { + onSystemTimeSet((time_t)timestamp); + } + return true; + } else { + Serial.println("Fehler beim Setzen der Zeit"); + return false; + } +} +``` + +- [ ] **Step 4: Build CI env to verify backwards compatibility** + +`rtcsync.h` is still not included in `master.cpp`, so both weak symbols must resolve to nullptr and the `if (...)` guards must skip them. +Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`. No new warnings. Behavior is identical to before this commit (the hooks are no-ops). + +- [ ] **Step 5: Commit** + +```bash +git add src/timesync.h +git commit -m "feat(timesync): add weak hooks for RTC integration" +``` + +--- + +## Task 5: Implement `persistSystemTimeToRTC()` and the `onSystemTimeSet` override + +**Files:** +- Modify: `src/rtcsync.h` (append below `setupRTC`) + +- [ ] **Step 1: Append the persistence function and the weak-hook override** + +Add the following at the end of `src/rtcsync.h`: + +```cpp +// Plausibilitäts-Grenzen: 2025-01-01 .. 2100-01-01 (UTC). +// Verhindert, dass kaputte Timestamps (0, negativ, 1970) die RTC korrumpieren. +static constexpr uint32_t RTC_MIN_EPOCH = 1735689600UL; // 2025-01-01 00:00:00 UTC +static constexpr uint32_t RTC_MAX_EPOCH = 4102444800UL; // 2100-01-01 00:00:00 UTC + +void persistSystemTimeToRTC(time_t t) { + if (!rtcAvailable) return; + if ((uint32_t)t < RTC_MIN_EPOCH || (uint32_t)t >= RTC_MAX_EPOCH) { + Serial.printf("[RTC] persist abgelehnt — Timestamp unplausibel: %ld\n", + (long)t); + return; + } + rtc.adjust(DateTime((uint32_t)t)); + Serial.printf("[RTC] in RTC geschrieben: %ld (UTC)\n", (long)t); +} + +// Weak-Hook-Override aus timesync.h — wird automatisch aufgerufen, +// sobald irgendwo setSystemTime() Erfolg meldet. +extern "C" void onSystemTimeSet(time_t t) { + persistSystemTimeToRTC(t); +} +``` + +- [ ] **Step 2: Build CI env** + +Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`. Still no behavioral change because `rtcsync.h` is not yet included by `master.cpp`. + +- [ ] **Step 3: Commit** + +```bash +git add src/rtcsync.h +git commit -m "feat(rtc): persist system time to RTC with plausibility check" +``` + +--- + +## Task 6: Implement `syncFromNTP()` wrapper + +**Files:** +- Modify: `src/rtcsync.h` (append at end) + +- [ ] **Step 1: Add a thin wrapper around `syncTimeWithNTP()` from `timesync.h`** + +`syncTimeWithNTP()` in `timesync.h:48` already does the NTP heavy lifting (configTime + 5 s polling loop) and prints success/failure. We just need to detect success after the fact (system clock is now > 2025) and persist + bookkeep. + +Append to `src/rtcsync.h`: + +```cpp +// Versucht NTP-Sync via timesync.h. Bei Erfolg: schreibt UTC in RTC, +// setzt ntpEverSynced=true, aktualisiert lastNtpSyncEpoch. +// Returns true bei Erfolg. +bool syncFromNTP() { + // Snapshot vor dem Sync — wenn die Zeit hinterher >= 2025 und neuer als vorher, + // werten wir den Sync als erfolgreich. + time_t before = time(NULL); + syncTimeWithNTP(); // benutzt Defaults aus timesync.h: pool.ntp.org, +1h, kein DST + time_t after = time(NULL); + + bool ok = ((uint32_t)after >= RTC_MIN_EPOCH) && (after >= before); + if (!ok) { + Serial.println("[RTC] NTP-Sync fehlgeschlagen — RTC unverändert"); + return false; + } + + // setSystemTime() wird intern von syncTimeWithNTP NICHT aufgerufen, + // also den Weak-Hook-Pfad hier umgehen und direkt schreiben. + persistSystemTimeToRTC(after); + ntpEverSynced = true; + lastNtpSyncEpoch = after; + return true; +} +``` + +- [ ] **Step 2: Build CI env** + +Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`. + +- [ ] **Step 3: Commit** + +```bash +git add src/rtcsync.h +git commit -m "feat(rtc): add syncFromNTP wrapper that persists to RTC" +``` + +--- + +## Task 7: Implement `loopRTC()` (STA reconnect edge + 24 h periodic) + +**Files:** +- Modify: `src/rtcsync.h` (append at end) + +- [ ] **Step 1: Add the loop-tick function** + +Append to `src/rtcsync.h`: + +```cpp +// Aus loop() aufgerufen. Triggert syncFromNTP(): +// - bei steigender Flanke von WiFi.isConnected() (STA-Reconnect) +// - alle 24h, sobald STA verbunden ist +// Reine Vergleichsoperationen, NTP-Roundtrip selbst ist blockierend (~ms..5s). +void loopRTC() { + bool sta = (WiFi.status() == WL_CONNECTED); + + // Edge: false -> true (frischer STA-Connect) + if (sta && !lastStaConnected) { + Serial.println("[RTC] STA-Reconnect erkannt — NTP-Sync"); + syncFromNTP(); + } + lastStaConnected = sta; + + // 24h-Periodik (nur wenn STA online) + if (sta && ntpEverSynced) { + time_t nowEpoch = time(NULL); + if (nowEpoch - lastNtpSyncEpoch >= 86400) { + Serial.println("[RTC] 24h-Periodik — NTP-Sync"); + syncFromNTP(); + } + } +} +``` + +`WiFi.h` ist bereits indirekt durch andere Header (`wificlass.h` → `WiFi.h`) verfügbar; falls der Build hier ein Symbol nicht findet, oben im Header `#include ` ergänzen. + +- [ ] **Step 2: Build CI env** + +Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`. Bei `'WiFi' was not declared`: `#include ` ganz oben in `rtcsync.h` ergänzen und neu bauen. + +- [ ] **Step 3: Commit** + +```bash +git add src/rtcsync.h +git commit -m "feat(rtc): add loopRTC with STA-reconnect and 24h re-sync" +``` + +--- + +## Task 8: Implement `appendTimeStatus()` override + +**Files:** +- Modify: `src/rtcsync.h` (append at end) + +- [ ] **Step 1: Add the JSON-extension override** + +Append to `src/rtcsync.h`: + +```cpp +// Weak-Hook-Override aus timesync.h — erweitert /api/time um RTC-Status. +extern "C" void appendTimeStatus(JsonDocument &doc) { + doc["rtc_available"] = rtcAvailable; + doc["rtc_synced_from_ntp"] = ntpEverSynced; + doc["last_ntp_sync_ago_s"] = + ntpEverSynced ? (long)(time(NULL) - lastNtpSyncEpoch) : (long)-1; + if (rtcAvailable) { + doc["rtc_time_utc"] = (long)rtc.now().unixtime(); + } else { + doc["rtc_time_utc"] = (long)0; + } +} +``` + +- [ ] **Step 2: Build CI env** + +Run: `pio run -e esp32thing_CI` +Expected: `SUCCESS`. + +- [ ] **Step 3: Commit** + +```bash +git add src/rtcsync.h +git commit -m "feat(rtc): expose RTC status fields via /api/time" +``` + +--- + +## Task 9: Wire `rtcsync.h` into `master.cpp` + +This is the activation step. Until now, `rtcsync.h` exists but is unused. After this commit, devices with the FeatherWing get RTC behavior; devices without it print one warning line and run normally. + +**Files:** +- Modify: `src/master.cpp:3-25` (include block) and `src/master.cpp:31-81` (setup/loop) + +- [ ] **Step 1: Add the include** + +In `src/master.cpp`, add `#include ` directly after the existing `#include ` line (currently line 23). The new include block tail should look like: + +```cpp +#include +#include +#include +#include +#include +``` + +**Reihenfolge:** `rtcsync.h` MUSS nach `timesync.h` kommen — die Weak-Hook-Overrides in `rtcsync.h` brauchen die Deklarationen aus `timesync.h`. + +- [ ] **Step 2: Call `setupRTC()` before `setupWifi()`** + +In `setup()`, insert `setupRTC();` directly before the existing `setupWifi();` call (currently line 53). The relevant block becomes: + +```cpp + loadWifiSettings(); + loadLocationSettings(); + + setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist + setupWifi(); // WiFi initialisieren + setupOTA(&server); +``` + +- [ ] **Step 3: Add an explicit NTP-sync attempt after WiFi is up** + +After `setupWifi()`, the STA may already be connected. The first `loopRTC()` call would catch the edge eventually, but doing one explicit boot-time sync makes the boot log cleaner. Insert directly after `setupWifi();`: + +```cpp + setupWifi(); // WiFi initialisieren + if (WiFi.status() == WL_CONNECTED) { + syncFromNTP(); + lastStaConnected = true; // Edge bereits "konsumiert" + } + setupOTA(&server); +``` + +- [ ] **Step 4: Call `loopRTC()` at the start of `loop()`** + +In `loop()` (currently lines 65–81), make `loopRTC()` the first call after `checkAutoReset()`: + +```cpp +void loop() { + checkAutoReset(); + loopRTC(); + + // MQTT hat höchste Priorität (wird zuerst verarbeitet) + loopMqttServer(); + ... +``` + +- [ ] **Step 5: Build all PlatformIO envs** + +Run them sequentially (or in one go if your machine handles it): + +``` +pio run -e esp32thing_CI +pio run -e esp32thing +pio run -e wemos_d1_mini32 +pio run -e um_feathers3 +``` + +Expected: every env reports `SUCCESS`. Note the increase in flash usage (RTClib is small, ~5 KB). + +- [ ] **Step 6: Commit** + +```bash +git add src/master.cpp +git commit -m "feat(rtc): wire rtcsync into setup/loop" +``` + +--- + +## Task 10: Hardware verification on real device + +This task is mandatory — automated tests cannot validate the I²C interaction or boot ordering. If no FeatherWing is currently attached, do at least Sub-step A (no-hardware soft-fail check). + +**Files:** none (manual) + +- [ ] **Step A — Without RTC attached: confirm soft-fail path** + +1. Ensure no FeatherWing is plugged in. +2. `pio run -e esp32thing -t upload` +3. `pio device monitor -b 115200` +4. Expected serial output during boot: + ``` + [RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert + ``` + followed by the rest of the boot sequence reaching the WiFi/MQTT init **without crash or reboot loop**. +5. Open `http:///api/time` in the browser. Expected JSON contains: + ```json + "rtc_available": false, + "rtc_synced_from_ntp": , + "rtc_time_utc": 0 + ``` + +- [ ] **Step B — With RTC attached, no NTP (AP-only mode)** + +1. Plug in the Adalogger FeatherWing. +2. Disconnect the device from any STA network (factory-reset WiFi or boot in AP-only). +3. Boot, observe serial: + ``` + [RTC] PCF8523 initialisiert + [RTC] Systemzeit aus RTC gesetzt: (UTC) + ``` + (If the RTC was never set, the line says "unplausibel" instead — acceptable for a fresh chip.) +4. Connect to the AP and load `http://192.168.10.1/api/time`. Expected: `rtc_available: true`, formatted time matches the RTC's last known value. + +- [ ] **Step C — With RTC attached, with NTP** + +1. Configure WiFi-STA credentials so the device joins the home network. +2. Boot, observe serial: + ``` + [RTC] PCF8523 initialisiert + [RTC] Systemzeit aus RTC gesetzt: ... + ... (WiFi connect) + Warte auf NTP-Zeit (max 5s)... + NTP-Zeit synchronisiert! + [RTC] in RTC geschrieben: (UTC) + ``` +3. `curl http:///api/time` — expected `rtc_synced_from_ntp: true`, `last_ntp_sync_ago_s` close to 0, `rtc_time_utc` matches `timestamp`. + +- [ ] **Step D — Browser-time persistence** + +1. With device booted (Step C), use the existing "set browser time" button in the settings UI. +2. Power-cycle the device with WiFi disabled (pull antenna or block STA). +3. Boot. Expected: serial shows the time from the browser-set, not 1970. + +- [ ] **Step E — Document outcome** + +If all steps pass: the task is done. +If any step fails: do **not** mark this task complete. Open a follow-up note describing what failed and which task's assumptions were wrong. + +--- + +## Self-Review (already performed) + +- **Spec coverage:** Each spec section maps to a task — Architecture/library → Task 1; new header + soft-fail → Tasks 2–3; weak-hook decoupling → Tasks 4–5; NTP-sync wrapper → Task 6; loop integration → Task 7; API extension → Task 8; setup/loop wiring → Task 9; hardware verification → Task 10. +- **Placeholder scan:** No TBD/TODO; all code blocks are concrete and copy-pasteable. +- **Type/name consistency:** `appendTimeStatus`, `onSystemTimeSet`, `persistSystemTimeToRTC`, `syncFromNTP`, `setupRTC`, `loopRTC`, `rtcAvailable`, `ntpEverSynced`, `lastNtpSyncEpoch`, `lastStaConnected`, `RTC_MIN_EPOCH`, `RTC_MAX_EPOCH` all used consistently across tasks. +- **Bounds check:** `RTC_MIN_EPOCH = 1735689600` (2025-01-01) and `RTC_MAX_EPOCH = 4102444800` (2100-01-01) — match the spec's "Year 2025–2099" range. From f6b2dceedc61fd2c4f31b0ee85c62a8c03c467be Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 14:51:51 +0200 Subject: [PATCH 03/10] build: add RTClib dependency for PCF8523 RTC support --- platformio.ini | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/platformio.ini b/platformio.ini index d73448a..a6eb317 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,7 +19,7 @@ lib_compat_mode = strict [env:wemos_d1_mini32] board = wemos_d1_mini32 monitor_speed = 115200 -build_flags = +build_flags = -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue -DBATTERY_PIN=16 @@ -27,19 +27,20 @@ board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv targets = uploadfs board_build.psram = disabled -lib_deps = +lib_deps = bblanchon/ArduinoJson@^7.4.1 esp32async/ESPAsyncWebServer@^3.7.7 esp32async/AsyncTCP@^3.4.2 mlesniew/PicoMQTT@^1.3.0 adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 [env:esp32thing_OTA] board = esp32thing monitor_speed = 115200 upload_protocol = espota upload_port = 192.168.1.96 -build_flags = +build_flags = -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue -DBATTERY_PIN=36 @@ -47,19 +48,20 @@ board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv targets = uploadfs board_build.psram = disabled -lib_deps = +lib_deps = bblanchon/ArduinoJson@^7.4.1 esp32async/ESPAsyncWebServer@^3.7.7 esp32async/AsyncTCP@^3.4.2 mlesniew/PicoMQTT@^1.3.0 adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 [env:esp32thing] board = esp32thing_plus monitor_speed = 115200 -build_flags = +build_flags = -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue -DBATTERY_PIN=36 @@ -67,54 +69,57 @@ board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv targets = uploadfs board_build.psram = disabled -lib_deps = +lib_deps = bblanchon/ArduinoJson@^7.4.1 esp32async/ESPAsyncWebServer@^3.7.7 esp32async/AsyncTCP@^3.4.2 mlesniew/PicoMQTT@^1.3.0 adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 [env:esp32thing_CI] platform = espressif32 board = esp32dev framework = arduino -build_flags = +build_flags = -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue -DBATTERY_PIN=36 board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv -lib_deps = +lib_deps = bblanchon/ArduinoJson@^7.4.1 esp32async/ESPAsyncWebServer@^3.7.7 esp32async/AsyncTCP@^3.4.2 mlesniew/PicoMQTT@^1.3.0 adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 [env:um_feathers3] board = um_feathers3 monitor_speed = 115200 board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv -board_upload.wait_for_upload_port = false -build_flags = +board_upload.wait_for_upload_port = false +build_flags = -D ARDUINO_USB_CDC_ON_BOOT=1 -D BATTERY_PIN=35 -D ARDUINO_USB_MODE=1 - -lib_deps = + +lib_deps = bblanchon/ArduinoJson@^7.4.1 esp32async/ESPAsyncWebServer@^3.7.7 esp32async/AsyncTCP@^3.4.2 mlesniew/PicoMQTT@^1.3.0 adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 [env:um_feathers3_debug] board = um_feathers3 board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv -board_upload.wait_for_upload_port = false -build_flags = +board_upload.wait_for_upload_port = false +build_flags = -D ARDUINO_USB_CDC_ON_BOOT=1 -D BATTERY_PIN=35 -D ARDUINO_USB_MODE=0 @@ -126,10 +131,11 @@ upload_port = COM5 monitor_speed = 115200 monitor_port = COM7 -lib_deps = +lib_deps = bblanchon/ArduinoJson@^7.4.1 esp32async/ESPAsyncWebServer@^3.7.7 esp32async/AsyncTCP@^3.4.2 mlesniew/PicoMQTT@^1.3.0 adafruit/Adafruit PN532@^1.3.4 + adafruit/RTClib@^2.1.4 From a875b20ba210a272606338ad136f9123a59513ce Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 14:55:05 +0200 Subject: [PATCH 04/10] feat(rtc): add rtcsync.h with PCF8523 detection and time fallback --- src/rtcsync.h | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/rtcsync.h diff --git a/src/rtcsync.h b/src/rtcsync.h new file mode 100644 index 0000000..8607f43 --- /dev/null +++ b/src/rtcsync.h @@ -0,0 +1,48 @@ +// PCF8523-RTC-Modul mit NTP-Sync und Fallback. +// Header-only nach Projekt-Pattern: Globale Objekte werden hier definiert. +// Dieser Header darf NUR in master.cpp inkludiert werden. +#pragma once +#include +#include +#include + +// Globale RTC-Instanz und Status-Flags +RTC_PCF8523 rtc; +bool rtcAvailable = false; +bool ntpEverSynced = false; +time_t lastNtpSyncEpoch = 0; +bool lastStaConnected = false; + +// I2C-Init, PCF8523-Detektion, und Systemzeit-Fallback aus RTC. +// Soft-Fail: bei nicht gefundener Hardware bleibt rtcAvailable=false. +void setupRTC() { + Wire.begin(); + if (!rtc.begin()) { + Serial.println("[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert"); + rtcAvailable = false; + return; + } + if (!rtc.initialized() || rtc.lostPower()) { + Serial.println("[RTC] PCF8523 erkannt, aber Akku-Backup ungültig (lostPower)"); + } + rtc.start(); + rtcAvailable = true; + Serial.println("[RTC] PCF8523 initialisiert"); + + // Systemzeit aus RTC vorbelegen — wird ggf. später durch NTP übersteuert. + // RTC speichert UTC, settimeofday erwartet UTC. Kein Offset nötig. + DateTime nowRtc = rtc.now(); + uint32_t rtcEpoch = nowRtc.unixtime(); + // Plausibilität: RTC sollte mindestens 2025 zeigen, sonst ist sie ungestellt. + if (rtcEpoch >= 1735689600UL) { + struct timeval tv; + tv.tv_sec = (time_t)rtcEpoch; + tv.tv_usec = 0; + settimeofday(&tv, NULL); + Serial.printf("[RTC] Systemzeit aus RTC gesetzt: %lu (UTC)\n", + (unsigned long)rtcEpoch); + } else { + Serial.printf("[RTC] RTC-Zeit unplausibel (%lu) — nicht übernommen\n", + (unsigned long)rtcEpoch); + } +} From 781ad18c6a3eeff4536c866d8f1d933eec0157ea Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 14:58:48 +0200 Subject: [PATCH 05/10] feat(rtc): wire timesync hooks and add persistSystemTimeToRTC --- src/rtcsync.h | 22 ++++++++++++++++++++++ src/timesync.h | 23 ++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/rtcsync.h b/src/rtcsync.h index 8607f43..74172d1 100644 --- a/src/rtcsync.h +++ b/src/rtcsync.h @@ -46,3 +46,25 @@ void setupRTC() { (unsigned long)rtcEpoch); } } + +// Plausibilitäts-Grenzen: 2025-01-01 .. 2100-01-01 (UTC). +// Verhindert, dass kaputte Timestamps (0, negativ, 1970) die RTC korrumpieren. +static constexpr uint32_t RTC_MIN_EPOCH = 1735689600UL; // 2025-01-01 00:00:00 UTC +static constexpr uint32_t RTC_MAX_EPOCH = 4102444800UL; // 2100-01-01 00:00:00 UTC + +void persistSystemTimeToRTC(time_t t) { + if (!rtcAvailable) return; + if ((uint32_t)t < RTC_MIN_EPOCH || (uint32_t)t >= RTC_MAX_EPOCH) { + Serial.printf("[RTC] persist abgelehnt — Timestamp unplausibel: %ld\n", + (long)t); + return; + } + rtc.adjust(DateTime((uint32_t)t)); + Serial.printf("[RTC] in RTC geschrieben: %ld (UTC)\n", (long)t); +} + +// Weak-Hook-Override aus timesync.h — wird automatisch aufgerufen, +// sobald irgendwo setSystemTime() Erfolg meldet. +extern "C" void onSystemTimeSet(time_t t) { + persistSystemTimeToRTC(t); +} diff --git a/src/timesync.h b/src/timesync.h index 8656b89..832b879 100644 --- a/src/timesync.h +++ b/src/timesync.h @@ -6,6 +6,11 @@ #include #include +// Weak hooks — falls rtcsync.h kompiliert/gelinkt wird, überschreibt es diese. +// Ohne rtcsync.h sind beide Symbole nullptr und werden nicht aufgerufen. +extern "C" void onSystemTimeSet(time_t t) __attribute__((weak)); +extern "C" void appendTimeStatus(JsonDocument &doc) __attribute__((weak)); + // Globale Zeitvariablen struct timeval tv; struct timezone tz; @@ -24,7 +29,7 @@ String getCurrentTimeJSON() { gettimeofday(&tv, &tz); now = tv.tv_sec; - StaticJsonDocument<200> doc; + JsonDocument doc; doc["timestamp"] = (long)now; doc["success"] = true; @@ -33,13 +38,18 @@ String getCurrentTimeJSON() { char timeStr[64]; strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); doc["formatted"] = String(timeStr); - doc["year"] = timeinfo.tm_year + 1900; - doc["month"] = timeinfo.tm_mon + 1; - doc["day"] = timeinfo.tm_mday; - doc["hour"] = timeinfo.tm_hour; + doc["year"] = timeinfo.tm_year + 1900; + doc["month"] = timeinfo.tm_mon + 1; + doc["day"] = timeinfo.tm_mday; + doc["hour"] = timeinfo.tm_hour; doc["minute"] = timeinfo.tm_min; doc["second"] = timeinfo.tm_sec; + // Optionale RTC/Sync-Status-Felder, falls rtcsync.h gelinkt ist + if (appendTimeStatus) { + appendTimeStatus(doc); + } + String response; serializeJson(doc, response); return response; @@ -76,6 +86,9 @@ bool setSystemTime(long timestamp) { if (settimeofday(&tv, NULL) == 0) { Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp)); + if (onSystemTimeSet) { + onSystemTimeSet((time_t)timestamp); + } return true; } else { Serial.println("Fehler beim Setzen der Zeit"); From 68483c8127cae431203b9545d6091f7e548938b2 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 15:00:08 +0200 Subject: [PATCH 06/10] feat(rtc): add syncFromNTP wrapper that persists to RTC --- src/rtcsync.h | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/rtcsync.h b/src/rtcsync.h index 74172d1..1023d71 100644 --- a/src/rtcsync.h +++ b/src/rtcsync.h @@ -68,3 +68,27 @@ void persistSystemTimeToRTC(time_t t) { extern "C" void onSystemTimeSet(time_t t) { persistSystemTimeToRTC(t); } + +// Versucht NTP-Sync via timesync.h. Bei Erfolg: schreibt UTC in RTC, +// setzt ntpEverSynced=true, aktualisiert lastNtpSyncEpoch. +// Returns true bei Erfolg. +bool syncFromNTP() { + // Snapshot vor dem Sync — wenn die Zeit hinterher >= 2025 und neuer als vorher, + // werten wir den Sync als erfolgreich. + time_t before = time(NULL); + syncTimeWithNTP(); // benutzt Defaults aus timesync.h: pool.ntp.org, +1h, kein DST + time_t after = time(NULL); + + bool ok = ((uint32_t)after >= RTC_MIN_EPOCH) && (after >= before); + if (!ok) { + Serial.println("[RTC] NTP-Sync fehlgeschlagen — RTC unverändert"); + return false; + } + + // setSystemTime() wird intern von syncTimeWithNTP NICHT aufgerufen, + // also den Weak-Hook-Pfad hier umgehen und direkt schreiben. + persistSystemTimeToRTC(after); + ntpEverSynced = true; + lastNtpSyncEpoch = after; + return true; +} From 8acb611b9b625c135ce7e78d808696ac4d899dbd Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 15:01:31 +0200 Subject: [PATCH 07/10] feat(rtc): add loopRTC and appendTimeStatus hook --- src/rtcsync.h | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/rtcsync.h b/src/rtcsync.h index 1023d71..7b4a289 100644 --- a/src/rtcsync.h +++ b/src/rtcsync.h @@ -92,3 +92,40 @@ bool syncFromNTP() { lastNtpSyncEpoch = after; return true; } + +// Aus loop() aufgerufen. Triggert syncFromNTP(): +// - bei steigender Flanke von WiFi.isConnected() (STA-Reconnect) +// - alle 24h, sobald STA verbunden ist +// Reine Vergleichsoperationen, NTP-Roundtrip selbst ist blockierend (~ms..5s). +void loopRTC() { + bool sta = (WiFi.status() == WL_CONNECTED); + + // Edge: false -> true (frischer STA-Connect) + if (sta && !lastStaConnected) { + Serial.println("[RTC] STA-Reconnect erkannt — NTP-Sync"); + syncFromNTP(); + } + lastStaConnected = sta; + + // 24h-Periodik (nur wenn STA online) + if (sta && ntpEverSynced) { + time_t nowEpoch = time(NULL); + if (nowEpoch - lastNtpSyncEpoch >= 86400) { + Serial.println("[RTC] 24h-Periodik — NTP-Sync"); + syncFromNTP(); + } + } +} + +// Weak-Hook-Override aus timesync.h — erweitert /api/time um RTC-Status. +extern "C" void appendTimeStatus(JsonDocument &doc) { + doc["rtc_available"] = rtcAvailable; + doc["rtc_synced_from_ntp"] = ntpEverSynced; + doc["last_ntp_sync_ago_s"] = + ntpEverSynced ? (long)(time(NULL) - lastNtpSyncEpoch) : (long)-1; + if (rtcAvailable) { + doc["rtc_time_utc"] = (long)rtc.now().unixtime(); + } else { + doc["rtc_time_utc"] = (long)0; + } +} From a6c885ee33649a4edbef712651fa74a34c4473f4 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 15:06:07 +0200 Subject: [PATCH 08/10] feat(rtc): wire rtcsync into setup and loop --- src/master.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/master.cpp b/src/master.cpp index 979d931..449bc73 100644 --- a/src/master.cpp +++ b/src/master.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -50,7 +51,12 @@ void setup() { loadWifiSettings(); loadLocationSettings(); + setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist setupWifi(); // WiFi initialisieren + if (WiFi.status() == WL_CONNECTED) { + syncFromNTP(); + lastStaConnected = true; // Edge bereits "konsumiert" + } setupOTA(&server); setupRoutes(); @@ -64,6 +70,7 @@ void setup() { void loop() { checkAutoReset(); + loopRTC(); // MQTT hat höchste Priorität (wird zuerst verarbeitet) loopMqttServer(); From fa87fd0222828be312bea7ac4d31e9388971df0d Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 15:17:19 +0200 Subject: [PATCH 09/10] fix(rtc): address code-review findings (5 fixes) - Drop extern "C" from weak hooks (UB with C++ reference param) - syncTimeWithNTP returns bool; syncFromNTP uses it (robust success check) - Avoid duplicate NTP sync at boot (wificlass already syncs) - Clamp negative time deltas in 24h timer and JSON status - Cache rtc.now() in loopRTC to avoid I2C race with PN532 --- src/master.cpp | 10 ++++++++-- src/rtcsync.h | 42 ++++++++++++++++++++++-------------------- src/timesync.h | 7 ++++--- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/master.cpp b/src/master.cpp index 449bc73..49289ea 100644 --- a/src/master.cpp +++ b/src/master.cpp @@ -53,8 +53,14 @@ void setup() { setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist setupWifi(); // WiFi initialisieren - if (WiFi.status() == WL_CONNECTED) { - syncFromNTP(); + // wificlass.h hat intern bereits syncTimeWithNTP() versucht. + // Falls die Systemzeit jetzt plausibel ist, in RTC persistieren und Bookkeeping setzen — + // ohne einen zweiten NTP-Roundtrip zu provozieren. + if (WiFi.status() == WL_CONNECTED && time(NULL) >= 1735689600L) { + time_t nowEpoch = time(NULL); + persistSystemTimeToRTC(nowEpoch); + ntpEverSynced = true; + lastNtpSyncEpoch = nowEpoch; lastStaConnected = true; // Edge bereits "konsumiert" } setupOTA(&server); diff --git a/src/rtcsync.h b/src/rtcsync.h index 7b4a289..ade0fc9 100644 --- a/src/rtcsync.h +++ b/src/rtcsync.h @@ -12,6 +12,7 @@ bool rtcAvailable = false; bool ntpEverSynced = false; time_t lastNtpSyncEpoch = 0; bool lastStaConnected = false; +time_t cachedRtcEpoch = 0; // letzter rtc.now()-Wert, gelesen aus loopRTC (I2C-Race-Guard) // I2C-Init, PCF8523-Detektion, und Systemzeit-Fallback aus RTC. // Soft-Fail: bei nicht gefundener Hardware bleibt rtcAvailable=false. @@ -39,6 +40,7 @@ void setupRTC() { tv.tv_sec = (time_t)rtcEpoch; tv.tv_usec = 0; settimeofday(&tv, NULL); + cachedRtcEpoch = (time_t)rtcEpoch; Serial.printf("[RTC] Systemzeit aus RTC gesetzt: %lu (UTC)\n", (unsigned long)rtcEpoch); } else { @@ -65,7 +67,7 @@ void persistSystemTimeToRTC(time_t t) { // Weak-Hook-Override aus timesync.h — wird automatisch aufgerufen, // sobald irgendwo setSystemTime() Erfolg meldet. -extern "C" void onSystemTimeSet(time_t t) { +void onSystemTimeSet(time_t t) { persistSystemTimeToRTC(t); } @@ -73,20 +75,15 @@ extern "C" void onSystemTimeSet(time_t t) { // setzt ntpEverSynced=true, aktualisiert lastNtpSyncEpoch. // Returns true bei Erfolg. bool syncFromNTP() { - // Snapshot vor dem Sync — wenn die Zeit hinterher >= 2025 und neuer als vorher, - // werten wir den Sync als erfolgreich. - time_t before = time(NULL); - syncTimeWithNTP(); // benutzt Defaults aus timesync.h: pool.ntp.org, +1h, kein DST - time_t after = time(NULL); - - bool ok = ((uint32_t)after >= RTC_MIN_EPOCH) && (after >= before); - if (!ok) { + if (!syncTimeWithNTP()) { Serial.println("[RTC] NTP-Sync fehlgeschlagen — RTC unverändert"); return false; } - - // setSystemTime() wird intern von syncTimeWithNTP NICHT aufgerufen, - // also den Weak-Hook-Pfad hier umgehen und direkt schreiben. + time_t after = time(NULL); + if ((uint32_t)after < RTC_MIN_EPOCH) { + Serial.println("[RTC] NTP-Sync lieferte unplausible Zeit — RTC unverändert"); + return false; + } persistSystemTimeToRTC(after); ntpEverSynced = true; lastNtpSyncEpoch = after; @@ -98,6 +95,11 @@ bool syncFromNTP() { // - alle 24h, sobald STA verbunden ist // Reine Vergleichsoperationen, NTP-Roundtrip selbst ist blockierend (~ms..5s). void loopRTC() { + // RTC-Lesen passiert NUR aus der main-loop (verhindert I2C-Race mit PN532). + // appendTimeStatus() liest danach nur den gecachten Wert. + if (rtcAvailable) { + cachedRtcEpoch = (time_t)rtc.now().unixtime(); + } bool sta = (WiFi.status() == WL_CONNECTED); // Edge: false -> true (frischer STA-Connect) @@ -110,6 +112,9 @@ void loopRTC() { // 24h-Periodik (nur wenn STA online) if (sta && ntpEverSynced) { time_t nowEpoch = time(NULL); + // Defensive: Clock-Jump rückwärts (z.B. via Browser-Time auf altes Datum) + // würde das Delta negativ machen. Auf "jetzt" zurücksetzen. + if (nowEpoch < lastNtpSyncEpoch) lastNtpSyncEpoch = nowEpoch; if (nowEpoch - lastNtpSyncEpoch >= 86400) { Serial.println("[RTC] 24h-Periodik — NTP-Sync"); syncFromNTP(); @@ -118,14 +123,11 @@ void loopRTC() { } // Weak-Hook-Override aus timesync.h — erweitert /api/time um RTC-Status. -extern "C" void appendTimeStatus(JsonDocument &doc) { +void appendTimeStatus(JsonDocument &doc) { doc["rtc_available"] = rtcAvailable; doc["rtc_synced_from_ntp"] = ntpEverSynced; - doc["last_ntp_sync_ago_s"] = - ntpEverSynced ? (long)(time(NULL) - lastNtpSyncEpoch) : (long)-1; - if (rtcAvailable) { - doc["rtc_time_utc"] = (long)rtc.now().unixtime(); - } else { - doc["rtc_time_utc"] = (long)0; - } + long ago = ntpEverSynced ? (long)(time(NULL) - lastNtpSyncEpoch) : (long)-1; + if (ago < 0 && ntpEverSynced) ago = 0; // Clock-Jump rückwärts → 0 statt negativ + doc["last_ntp_sync_ago_s"] = ago; + doc["rtc_time_utc"] = rtcAvailable ? (long)cachedRtcEpoch : (long)0; } diff --git a/src/timesync.h b/src/timesync.h index 832b879..0461872 100644 --- a/src/timesync.h +++ b/src/timesync.h @@ -8,8 +8,8 @@ // Weak hooks — falls rtcsync.h kompiliert/gelinkt wird, überschreibt es diese. // Ohne rtcsync.h sind beide Symbole nullptr und werden nicht aufgerufen. -extern "C" void onSystemTimeSet(time_t t) __attribute__((weak)); -extern "C" void appendTimeStatus(JsonDocument &doc) __attribute__((weak)); +void onSystemTimeSet(time_t t) __attribute__((weak)); +void appendTimeStatus(JsonDocument &doc) __attribute__((weak)); // Globale Zeitvariablen struct timeval tv; @@ -55,7 +55,7 @@ String getCurrentTimeJSON() { return response; } -void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org", +bool syncTimeWithNTP(const char *ntpServer = "pool.ntp.org", long gmtOffset_sec = 3600, int daylightOffset_sec = 0) { configTime(gmtOffset_sec, daylightOffset_sec, ntpServer); Serial.println("Warte auf NTP-Zeit (max 5s)..."); @@ -76,6 +76,7 @@ void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org", } else { Serial.println("\nNTP-Sync fehlgeschlagen (Timeout nach 5s)"); } + return synced; } // Hilfsfunktion: Setzt die Systemzeit auf den angegebenen Zeitstempel. From 3400b9cc6a2a151bfba9402ab343c1712be4935d Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 16:27:27 +0200 Subject: [PATCH 10/10] =?UTF-8?q?glow=20f=C3=BCr=20neue=20Zeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/index.css | 82 +++++++++++++++++++++++++++++++++++++++++++ data/index.html | 37 ++++++++++--------- src/databasebackend.h | 10 ++++-- 3 files changed, 107 insertions(+), 22 deletions(-) diff --git a/data/index.css b/data/index.css index 373b547..7785183 100644 --- a/data/index.css +++ b/data/index.css @@ -674,6 +674,88 @@ body { box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); } +.leaderboard-entry.latest { + border: 2px solid #00ff88; + animation: latest-pulse 1.6s ease-in-out infinite; + position: relative; + z-index: 1; +} + +.leaderboard-entry.latest .name { + color: #ffffff; + font-weight: 800; + text-shadow: 0 0 8px rgba(0, 255, 136, 0.7); +} + +.leaderboard-entry.latest .time { + color: #ffffff; + animation: latest-time-flash 1.6s ease-in-out infinite; +} + +.latest-badge { + display: inline-block; + background: #00ff88; + color: #0d1733; + font-weight: 900; + font-size: clamp(0.6rem, 1vw, 0.85rem); + letter-spacing: 1px; + padding: 3px 8px; + border-radius: 5px; + flex-shrink: 0; + text-transform: uppercase; + animation: latest-badge-pulse 1.6s ease-in-out infinite; +} + +@keyframes latest-pulse { + 0%, + 100% { + background: linear-gradient( + 135deg, + rgba(0, 255, 136, 0.28) 0%, + rgba(0, 200, 110, 0.18) 100% + ); + box-shadow: 0 0 8px rgba(0, 255, 136, 0.35), + inset 0 0 6px rgba(0, 255, 136, 0.18); + border-color: #00ff88; + } + 50% { + background: linear-gradient( + 135deg, + rgba(0, 255, 136, 0.5) 0%, + rgba(0, 230, 120, 0.32) 100% + ); + box-shadow: 0 0 16px rgba(0, 255, 136, 0.6), + 0 0 32px rgba(0, 255, 136, 0.3), + inset 0 0 10px rgba(255, 255, 255, 0.25); + border-color: #ffffff; + } +} + +@keyframes latest-badge-pulse { + 0%, + 100% { + background: #00ff88; + color: #0d1733; + box-shadow: 0 0 5px rgba(0, 255, 136, 0.5); + } + 50% { + background: #ffffff; + color: #006a3a; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.7), + 0 0 16px rgba(0, 255, 136, 0.55); + } +} + +@keyframes latest-time-flash { + 0%, + 100% { + text-shadow: 0 0 6px rgba(0, 255, 136, 0.55); + } + 50% { + text-shadow: 0 0 8px #ffffff, 0 0 14px rgba(0, 255, 136, 0.7); + } +} + .leaderboard-entry .rank { color: #ffd700; font-weight: bold; diff --git a/data/index.html b/data/index.html index b87e189..874f634 100644 --- a/data/index.html +++ b/data/index.html @@ -168,23 +168,12 @@ document.getElementById(indicatorId).classList.remove("active"); } } - // Start-/Stopp-Event → Status kann jetzt wechseln (ready↔running). - // Bei Stop-Events den Display-Wert lokal einfrieren, damit der - // Timer nicht bis zum nächsten Sync weiterzählt und dann sichtbar - // zurückspringt. Anschließend sofort syncen, damit der Scheduler - // zwischen schneller (50 ms) und langsamer (500 ms) Taktung wechselt. - if (data.active === true) { - const now = Date.now(); - if (data.button === "stop1" && status1 === "running") { - timer1 += (now - lastSync) / 1000; - status1 = "finished"; - } else if (data.button === "stop2" && status2 === "running") { - timer2 += (now - lastSync) / 1000; - status2 = "finished"; - } - kickDisplayScheduler(); - syncFromBackend(); - } + // Hinweis: Heartbeats und echte Tastendrücke kommen im WebSocket + // identisch als {button, mac, active: true} an. Eine optimistische + // Status-Übernahme (z. B. running→finished bei stop1) führte daher + // zu kurzem „Geschafft!"-Aufblitzen während des Laufs, sobald der + // Stop-Button einen periodischen Heartbeat sendete. Der Status + // kommt jetzt ausschließlich über syncFromBackend (1 s-Polling). } try { @@ -495,9 +484,17 @@ return `${hh}:${mm}:${ss}`; } - function createEntryElement(entry) { + function createEntryElement(entry, isLatest) { const div = document.createElement("div"); div.className = "leaderboard-entry"; + if (isLatest) div.classList.add("latest"); + + if (isLatest) { + const badge = document.createElement("span"); + badge.className = "latest-badge"; + badge.textContent = "NEU"; + div.appendChild(badge); + } const nameSpan = document.createElement("span"); nameSpan.className = "name"; @@ -527,7 +524,9 @@ container.appendChild(empty); return; } - entries.forEach((e) => container.appendChild(createEntryElement(e))); + entries.forEach((e, i) => + container.appendChild(createEntryElement(e, i === 0)) + ); } function updateLeaderboardDisplay() { diff --git a/src/databasebackend.h b/src/databasebackend.h index 3f1019e..238bf2a 100644 --- a/src/databasebackend.h +++ b/src/databasebackend.h @@ -425,8 +425,12 @@ void setupBackendRoutes(AsyncWebServer &server) { // Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge) server.on( "/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) { - // Sortiere nach Zeit (beste zuerst) - std::sort(localTimes.begin(), localTimes.end(), + // Sortiere eine Kopie nach Zeit (beste zuerst). Niemals die globale + // localTimes-Liste sortieren - sonst geht die chronologische + // Reihenfolge verloren, die /api/leaderboard für "letzte Zeiten" + // braucht (und die per saveBestTimes auch persistiert wird). + std::vector sortedTimes(localTimes); + std::sort(sortedTimes.begin(), sortedTimes.end(), [](const LocalTime &a, const LocalTime &b) { return a.timeMs < b.timeMs; }); @@ -436,7 +440,7 @@ void setupBackendRoutes(AsyncWebServer &server) { // Nimm die besten 10 int count = 0; - for (const auto &time : localTimes) { + for (const auto &time : sortedTimes) { if (count >= 10) break;