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) <noreply@anthropic.com>
This commit is contained in:
Carsten Graf
2026-05-03 14:44:40 +02:00
parent 48ae556949
commit 96fcb74c80

View File

@@ -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 20252099). 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