Files
AquaMasterMQTT/docs/superpowers/plans/2026-05-03-rtc-pcf8523-fallback.md
Carsten Graf df95a37ca7 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>
2026-05-03 14:47:37 +02:00

20 KiB
Raw Blame History

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

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:

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
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:

// 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
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:

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
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:

// 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:

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:

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
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:

// 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
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:

// 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
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:

// 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.hWiFi.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
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:

// 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
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:

#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:

  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();:

  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():

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
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:
    "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.