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

View File

@@ -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 <Arduino.h>
#include <RTClib.h>
#include <Wire.h>
// 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 <time.h>` 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 2346 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 7284 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 <WiFi.h>` ergänzen.
- [ ] **Step 2: Build CI env**
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`. Bei `'WiFi' was not declared`: `#include <WiFi.h>` 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 <rtcsync.h>` directly after the existing `#include <timesync.h>` line (currently line 23). The new include block tail should look like:
```cpp
#include <rfid.h>
#include <timesync.h>
#include <rtcsync.h>
#include <webserverrouter.h>
#include <wificlass.h>
```
**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 6581), 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://<device-ip>/api/time` in the browser. Expected JSON contains:
```json
"rtc_available": false,
"rtc_synced_from_ntp": <true if STA is online, else false>,
"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: <epoch> (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: <epoch> (UTC)
```
3. `curl http://<device-ip>/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 23; weak-hook decoupling → Tasks 45; 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 20252099" range.