diff --git a/lib/hal/HalClock.cpp b/lib/hal/HalClock.cpp index 4f234f51..a03576af 100644 --- a/lib/hal/HalClock.cpp +++ b/lib/hal/HalClock.cpp @@ -9,6 +9,7 @@ #include #include +#include #include // ---- RTC-memory state (survives deep sleep, not cold boot) ---------------- @@ -16,14 +17,45 @@ static constexpr uint32_t CLOCK_RTC_MAGIC = 0xC10C4B1D; static constexpr uint32_t CLOCK_RTC_FLAG_LP_VALID = 0x00000001u; +// Temperature drift model for ESP32 RTC-based timekeeping. +// +// The chip's low-power (slow) clock frequency depends on temperature. +// ESP32 variants can drift by about 2 minutes per day per °C from the +// initial captured operating temperature. +// +// - dt_drift ≈ 120 seconds/day/°C +// - relative frequency error per second per °C = 120 / 86400 +// +// At restore() we apply a first-order correction over the sleep interval: +// corrected_interval = raw_interval × (1 + ΔT × drift_factor), where +// drift_factor = 120 / 86400. +// +// Experimental source: https://www.reddit.com/r/esp32/comments/11cikkp/the_clock_on_the_esp_is_wrong/ +static constexpr float CLOCK_TEMP_DRIFT_SECONDS_PER_SECOND_PER_DEG = 120.0f / 86400.0f; + RTC_NOINIT_ATTR static uint32_t rtcClockMagic; RTC_NOINIT_ATTR static uint32_t rtcClockFlags; -RTC_NOINIT_ATTR static time_t rtcEpoch; // last-known unix epoch -RTC_NOINIT_ATTR static uint64_t rtcLpTimeUs; // esp_clk_rtc_time() at capture -RTC_NOINIT_ATTR static uint32_t rtcSlowCal; // esp_clk_slowclk_cal_get() at capture +RTC_NOINIT_ATTR static time_t rtcEpoch; // last-known unix epoch +RTC_NOINIT_ATTR static uint64_t rtcLpTimeUs; // esp_clk_rtc_time() at capture +RTC_NOINIT_ATTR static uint32_t rtcSlowCal; // esp_clk_slowclk_cal_get() at capture +RTC_NOINIT_ATTR static float rtcTemperatureC; // captured chip temperature at save static bool clockApproximate = true; +// Drift correction scale factor (learned from NTP sync results). +// +// Raw temp drift model uses 2 min/day/°C -> factor = 120/86400. This is a +// generic base model. The actual board may behave a bit differently. On each +// NTP sync we estimate how the local clock error compares to the model and +// update this scale factor slightly to converge toward real world behavior. +// +// rtcDriftScale = 1.0 means we trust 2 min/day/°C exactly. If the device is +// slower/faster than that, NTP drift calibration adjusts this factor. +static float rtcDriftScale = 1.0f; + +static unsigned long lastPeriodicUpdateMs = 0; +static constexpr unsigned long PERIODIC_UPDATE_INTERVAL_MS = 10UL * 60UL * 1000UL; + struct TimeZoneEntry { const char* tz; }; @@ -56,6 +88,8 @@ static constexpr int64_t STALE_THRESHOLD_S = 72 * 3600; // 72 hours static constexpr char NVS_NAMESPACE[] = "halclock"; static constexpr char NVS_KEY[] = "epoch"; static constexpr char NVS_SYNC_KEY[] = "lastsync"; +static constexpr char NVS_DRIFT_KEY[] = "driftcoef"; +static constexpr char NVS_TEMP_KEY[] = "lasttemp"; static void nvsWrite(time_t epoch) { Preferences prefs; @@ -65,6 +99,46 @@ static void nvsWrite(time_t epoch) { } } +static void nvsWriteDriftScale(float driftScale) { + Preferences prefs; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putFloat(NVS_DRIFT_KEY, driftScale); + prefs.end(); + } +} + +static float nvsReadDriftScale() { + Preferences prefs; + float result = 1.0f; + if (prefs.begin(NVS_NAMESPACE, true)) { + result = prefs.getFloat(NVS_DRIFT_KEY, 1.0f); + prefs.end(); + } + // Guard against NaN, Inf, or out-of-range values from corrupted NVS. + if (!std::isfinite(result) || result < 0.1f || result > 5.0f) { + result = 1.0f; + } + return result; +} + +static void nvsWriteLastSyncTemp(float tempC) { + Preferences prefs; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putFloat(NVS_TEMP_KEY, tempC); + prefs.end(); + } +} + +static float nvsReadLastSyncTemp() { + Preferences prefs; + float result = 0.0f; + if (prefs.begin(NVS_NAMESPACE, true)) { + result = prefs.getFloat(NVS_TEMP_KEY, 0.0f); + prefs.end(); + } + return result; +} + static void nvsWriteSyncTime(time_t syncEpoch) { Preferences prefs; if (prefs.begin(NVS_NAMESPACE, false)) { @@ -95,6 +169,11 @@ static time_t nvsReadSyncTime() { // ---- internal helpers ----------------------------------------------------- +static float readChipTemperatureC() { + // ESP32 and ESP32-C3 use the internal ADC temperature sensor. + return (float)temperatureRead(); +} + static void setSystemClock(time_t epoch) { struct timeval tv = {}; tv.tv_sec = epoch; @@ -103,11 +182,53 @@ static void setSystemClock(time_t epoch) { static bool rtcValid() { return rtcClockMagic == CLOCK_RTC_MAGIC && rtcEpoch > 0; } +/// Compute temperature-corrected elapsed seconds from LP timer delta. +/// Uses the trapezoidal rule (average of start + end temperature) as a +/// first-order approximation of the temperature integral over the interval. +/// Returns the corrected elapsed seconds and updates lpNowOut/calNowOut +/// for the caller to re-baseline. +static double computeCorrectedElapsedSec(uint64_t lpNow, float tempNow) { + uint32_t calNow = esp_clk_slowclk_cal_get(); + uint64_t elapsedUs; + if (rtcSlowCal != 0 && calNow != 0) { + // rtcLpTimeUs was computed with rtcSlowCal; convert it to the + // current calibration basis so the subtraction is consistent. + uint64_t lpThenCorrected = (uint64_t)((double)rtcLpTimeUs * calNow / rtcSlowCal); + elapsedUs = lpNow - lpThenCorrected; + } else { + elapsedUs = lpNow - rtcLpTimeUs; + } + + // Use the full temperature delta between the average over the interval + // and the calibration reference (which is the capture-time temperature). + // avgTemp approximates the mean temperature during the interval. + // The drift model says the RTC runs (1 + deltaT * driftRate) times + // faster/slower than nominal, so the true elapsed wall-clock time + // differs from the raw LP-derived time by that factor. + float avgTemp = (rtcTemperatureC + tempNow) * 0.5f; + float tempDelta = avgTemp - rtcTemperatureC; // = (tempNow - rtcTemperatureC) / 2 + float tempFactor = 1.0f + tempDelta * CLOCK_TEMP_DRIFT_SECONDS_PER_SECOND_PER_DEG * rtcDriftScale; + if (tempFactor < 0.5f) { + tempFactor = 0.5f; + } else if (tempFactor > 1.5f) { + tempFactor = 1.5f; + } + + double elapsedSec = (double)elapsedUs / 1000000.0; + double correctedSec = elapsedSec * (double)tempFactor; + + LOG_DBG("CLK", "Drift calc: startT=%.1fC nowT=%.1fC dT=%.3f factor=%.6f raw=%.3fs corr=%.3fs", rtcTemperatureC, + tempNow, tempDelta, tempFactor, elapsedSec, correctedSec); + + return correctedSec; +} + /// Capture current time + LP timer into RTC memory, and epoch into NVS. static void capture(bool lpValid) { rtcEpoch = time(nullptr); rtcLpTimeUs = esp_clk_rtc_time(); rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = readChipTemperatureC(); rtcClockMagic = CLOCK_RTC_MAGIC; rtcClockFlags = lpValid ? CLOCK_RTC_FLAG_LP_VALID : 0; nvsWrite(rtcEpoch); @@ -125,6 +246,10 @@ void applyTimezone(uint8_t timeZoneSetting) { } bool syncNtp() { + time_t preSyncTime = time(nullptr); + time_t prevSyncTime = nvsReadSyncTime(); + float prevSyncTemp = nvsReadLastSyncTemp(); + if (esp_sntp_enabled()) { esp_sntp_stop(); } @@ -147,6 +272,48 @@ bool syncNtp() { capture(false); nvsWriteSyncTime(rtcEpoch); + + float currentTemp = rtcTemperatureC; + if (currentTemp != 0.0f) { + nvsWriteLastSyncTemp(currentTemp); + } + + if (prevSyncTime > 0 && preSyncTime > 0 && rtcEpoch > prevSyncTime) { + float interval = (float)(rtcEpoch - prevSyncTime); + // error = how far the local clock was off before NTP corrected it. + // Positive means local clock was behind (NTP jumped us forward). + // Negative means local clock was ahead (NTP pulled us back). + float error = (float)(preSyncTime - rtcEpoch); + if (interval >= 60.0f) { + // Convert to seconds-of-drift per day. + float observedDriftPerDay = error * 86400.0f / interval; + + // Adaptive model calibration: + // - Observed drift is derived from the difference between local clock + // reading just before NTP and the true time reported by NTP, scaled + // to a per-day rate over the interval since the previous sync. + // - The baseline model expects 120 sec/day per °C. + // - Measure temp delta since last sync (from stored NVS temp). + // - If large enough, compute an empirical scale to apply to the model + // so future drift corrections are better aligned with actual hardware. + // - The scale is persisted to NVS via saveBeforeSleep(). + float effectiveScale = rtcDriftScale; + float tempDelta = currentTemp - prevSyncTemp; + if (std::fabs(tempDelta) > 0.1f) { + float modelDriftPerDay = 120.0f * tempDelta; + if (std::fabs(modelDriftPerDay) > 0.01f) { + float measuredScale = observedDriftPerDay / modelDriftPerDay; + effectiveScale = 0.9f * rtcDriftScale + 0.1f * measuredScale; + effectiveScale = std::max(0.1f, std::min(5.0f, effectiveScale)); + rtcDriftScale = effectiveScale; + } + } + + LOG_DBG("CLK", "NTP drift: interval=%.0fs error=%.3fs perDay=%.3f scale=%.3f deltaT=%.2f", interval, error, + observedDriftPerDay, rtcDriftScale, tempDelta); + } + } + clockApproximate = false; LOG_INF("CLK", "NTP synced, epoch %lld", (long long)rtcEpoch); return true; @@ -157,38 +324,51 @@ void saveBeforeSleep(bool keepLpAlive) { return; } capture(keepLpAlive); - LOG_DBG("CLK", "Saved epoch %lld before sleep", (long long)rtcEpoch); + // Persist learned drift scale and last temperature to NVS so they survive + // cold boot. We only write here (not periodically) to minimise flash wear. + nvsWriteDriftScale(rtcDriftScale); + nvsWriteLastSyncTemp(rtcTemperatureC); + LOG_DBG("CLK", "Saved epoch %lld before sleep (driftScale=%.3f)", (long long)rtcEpoch, rtcDriftScale); } void restore() { + rtcDriftScale = nvsReadDriftScale(); + const bool lpValid = (rtcClockFlags & CLOCK_RTC_FLAG_LP_VALID) != 0; if (rtcValid() && lpValid) { // RTC memory survived — we woke from deep sleep. - // Use the LP timer to compute how much time elapsed during sleep. - // Apply calibration correction: the slow-clock frequency may have - // drifted (temperature) between when we captured and now. The fresh - // boot-time calibration (calNow) is our best estimate of the actual - // frequency during sleep. + // + // We restore the wall clock by computing elapsed real time from the + // LP timer delta and applying both frequency calibration and temperature + // drift correction. + // + // Steps: + // 1) Read current LP timer and slow-clock calibration. + // 2) Compute raw elapsed LP ticks, on the same calibration basis used + // when capture() was called. + // 3) Convert elapsed ticks to seconds. + // 4) Apply temperature drift correction based on measured RTC memory + // capture temperature and current chip temp. + // 5) Set system time to rtcEpoch + corrected elapsed seconds. + // + // This is an approximation: we use the average of start/end measured + // temperature as a simple integral proxy. More advanced models could + // sample temperature continuously, but this is a good tradeoff for low + // cost and better accuracy vs no temperature compensation. uint64_t lpNow = esp_clk_rtc_time(); time_t estimated = rtcEpoch; if (lpNow > rtcLpTimeUs) { - uint32_t calNow = esp_clk_slowclk_cal_get(); - uint64_t elapsedUs; - if (rtcSlowCal != 0 && calNow != 0) { - // rtcLpTimeUs was computed with rtcSlowCal; convert it to the - // current calibration basis so the subtraction is consistent. - uint64_t lpThenCorrected = (uint64_t)((double)rtcLpTimeUs * calNow / rtcSlowCal); - elapsedUs = lpNow - lpThenCorrected; - } else { - elapsedUs = lpNow - rtcLpTimeUs; - } - estimated += (time_t)(elapsedUs / 1000000LL); + float tempNow = readChipTemperatureC(); + double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow); + estimated += (time_t)correctedSec; } + setSystemClock(estimated); - // Re-capture with current LP baseline + // Re-baseline LP timer and temperature for next interval. rtcEpoch = estimated; - rtcLpTimeUs = lpNow; + rtcLpTimeUs = esp_clk_rtc_time(); rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = readChipTemperatureC(); clockApproximate = true; LOG_INF("CLK", "Restored from RTC + LP timer, epoch %lld", (long long)estimated); return; @@ -207,6 +387,10 @@ void restore() { rtcEpoch = epoch; rtcLpTimeUs = esp_clk_rtc_time(); rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = nvsReadLastSyncTemp(); + if (rtcTemperatureC == 0.0f) { + rtcTemperatureC = readChipTemperatureC(); + } rtcClockMagic = CLOCK_RTC_MAGIC; rtcClockFlags = 0; clockApproximate = true; @@ -221,6 +405,50 @@ time_t now() { return time(nullptr); } +void updatePeriodic() { + if (!isSynced()) { + return; + } + unsigned long nowMs = millis(); + if (nowMs - lastPeriodicUpdateMs < PERIODIC_UPDATE_INTERVAL_MS) { + return; + } + lastPeriodicUpdateMs = nowMs; + + // Compute temperature-corrected elapsed time since last baseline and apply + // only the drift delta (correction - raw) to the system clock. The kernel + // clock already advanced by the raw amount, so we must not re-add it. + uint64_t lpNow = esp_clk_rtc_time(); + if (lpNow <= rtcLpTimeUs) { + return; + } + + float tempNow = readChipTemperatureC(); + double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow); + + // Raw elapsed seconds (what the kernel clock already counted). + uint64_t rawElapsedUs = lpNow - rtcLpTimeUs; + double rawSec = (double)rawElapsedUs / 1000000.0; + + // The drift delta is the difference between what really elapsed + // (temperature-corrected) and what the kernel counted (raw). + double driftDeltaSec = correctedSec - rawSec; + + // Re-baseline LP timer and temperature for the next interval. + rtcLpTimeUs = lpNow; + rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = tempNow; + + // Only nudge the system clock if the drift delta is meaningful (>50 ms). + // This avoids unnecessary settimeofday calls for negligible corrections. + if (std::fabs(driftDeltaSec) > 0.05) { + rtcEpoch = time(nullptr) + (time_t)driftDeltaSec; + setSystemClock(rtcEpoch); + LOG_DBG("CLK", "Periodic drift nudge: raw=%.3fs corr=%.3fs delta=%.3fs scale=%.3f", rawSec, correctedSec, + driftDeltaSec, rtcDriftScale); + } +} + bool isSynced() { return time(nullptr) > 1577836800; // > 2020-01-01 } diff --git a/lib/hal/HalClock.h b/lib/hal/HalClock.h index a96d230f..976003e2 100644 --- a/lib/hal/HalClock.h +++ b/lib/hal/HalClock.h @@ -51,6 +51,14 @@ time_t now(); /// True if the clock has been set at least once (NTP or restore). bool isSynced(); +/// Periodic callback (called from main loop) to compensate temperature-induced +/// RTC drift while the device is awake. Runs at a 10-minute interval. +/// Computes the drift delta since the last baseline using the temperature +/// model and nudges the system clock by only that delta (the kernel clock +/// already advanced the raw amount). Drift state is persisted to NVS only +/// in saveBeforeSleep() to minimise flash wear. +void updatePeriodic(); + /// True if the last restore was from a backup (not NTP) — i.e. the clock /// may have drifted. Cleared on NTP sync. bool isApproximate(); diff --git a/src/main.cpp b/src/main.cpp index 4cc63aaa..e8a00a1a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -319,6 +319,7 @@ void loop() { static unsigned long lastMemPrint = 0; gpio.update(); + HalClock::updatePeriodic(); renderer.setFadingFix(SETTINGS.fadingFix);