From 0bdfc6e816dbc8b4cb181c115a5f9a5e65934e52 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 29 Mar 2026 18:35:36 +0200 Subject: [PATCH] Merge branch 'feat-additional-compensation' of https://github.com/jpirnay/crosspoint-reader into mybuild --- lib/I18n/translations/english.yaml | 37 ++ lib/Logging/Logging.cpp | 11 +- lib/hal/HalClock.cpp | 507 ++++++++++++++++++ lib/hal/HalClock.h | 85 +++ lib/hal/HalPowerManager.cpp | 20 +- lib/hal/HalPowerManager.h | 8 +- src/CrossPointSettings.h | 30 ++ src/SettingsList.h | 11 + src/activities/ActivityManager.cpp | 14 + .../browser/OpdsBookBrowserActivity.cpp | 4 +- .../network/CrossPointWebServerActivity.cpp | 12 +- .../reader/KOReaderSyncActivity.cpp | 47 +- .../settings/ClockSettingsActivity.cpp | 129 +++++ .../settings/ClockSettingsActivity.h | 20 + .../settings/DetectTimezoneActivity.cpp | 308 +++++++++++ .../settings/DetectTimezoneActivity.h | 28 + .../settings/KOReaderAuthActivity.cpp | 7 +- src/activities/settings/OtaUpdateActivity.cpp | 7 +- src/activities/settings/SettingsActivity.cpp | 19 + src/activities/settings/SettingsActivity.h | 3 + .../settings/StatusBarSettingsActivity.cpp | 31 +- src/activities/settings/SyncTimeActivity.cpp | 197 +++++++ src/activities/settings/SyncTimeActivity.h | 25 + src/components/UITheme.cpp | 2 +- src/components/themes/BaseTheme.cpp | 22 +- src/components/themes/lyra/LyraTheme.cpp | 8 + src/main.cpp | 7 +- 27 files changed, 1514 insertions(+), 85 deletions(-) create mode 100644 lib/hal/HalClock.cpp create mode 100644 lib/hal/HalClock.h create mode 100644 src/activities/settings/ClockSettingsActivity.cpp create mode 100644 src/activities/settings/ClockSettingsActivity.h create mode 100644 src/activities/settings/DetectTimezoneActivity.cpp create mode 100644 src/activities/settings/DetectTimezoneActivity.h create mode 100644 src/activities/settings/SyncTimeActivity.cpp create mode 100644 src/activities/settings/SyncTimeActivity.h diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 5ee9e768..43234b93 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -83,6 +83,43 @@ STR_PARA_ALIGNMENT: "Reader Paragraph Alignment" STR_HYPHENATION: "Hyphenation" STR_TIME_TO_SLEEP: "Time to Sleep" STR_SHOW_HIDDEN_FILES: "Show Hidden Files" +STR_USE_CLOCK: "Use Clock" +STR_CLOCK_SETTINGS: "Clock Settings" +STR_CLOCK_SETTINGS_WARNING: "Uses more battery; clock may drift" +STR_CLOCK: "Clock" +STR_CLOCK_FORMAT: "Clock Format" +STR_TIMEZONE: "Timezone" +STR_24H: "24h" +STR_12H: "12h" +STR_TZ_UTC: "UTC (GMT/BST)" +STR_TZ_CET: "Central Europe (CET/CEST)" +STR_TZ_EET: "Eastern Europe (EET/EEST)" +STR_TZ_EST: "US Eastern (EST/EDT)" +STR_TZ_CST: "US Central (CST/CDT)" +STR_TZ_MST: "US Mountain (MST/MDT)" +STR_TZ_PST: "US Pacific (PST/PDT)" +STR_TZ_AEST: "Australia Eastern (AEST/AEDT)" +STR_TZ_NZST: "New Zealand (NZST/NZDT)" +STR_TZ_MSK: "Russia (MSK)" +STR_TZ_UTC_MINUS3: "South America (UTC-3)" +STR_TZ_UTC_PLUS4: "Gulf (UTC+4)" +STR_TZ_IST: "India (UTC+5:30)" +STR_TZ_UTC_PLUS7: "SE Asia (UTC+7)" +STR_TZ_UTC_PLUS8: "China/SE Asia (UTC+8)" +STR_TZ_UTC_PLUS9: "Japan/Korea (UTC+9)" +STR_SYNC_TIME: "Sync Time" +STR_DETECT_TIMEZONE: "Detect Timezone" +STR_SYNCING_CLOCK: "Syncing clock..." +STR_DETECTING_TIMEZONE: "Detecting timezone..." +STR_TIME_SYNCED: "Time synced" +STR_TIME_SYNC_FAILED: "Time sync failed" +STR_CLOCK_DRIFT: "Drift: %s" +STR_LAST_NTP_SYNC: "Last sync: %s" +STR_TIMEZONE_DETECTED: "Timezone detected" +STR_TIMEZONE_DETECT_FAILED: "Timezone detect failed" +STR_DST_ACTIVE: "DST: active" +STR_DST_INACTIVE: "DST: inactive" +STR_DST_UNKNOWN: "DST: unknown" STR_REFRESH_FREQ: "Refresh Frequency" STR_KOREADER_SYNC: "KOReader Sync" STR_CHECK_UPDATES: "Check for updates" diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index d670f5fb..2aec1081 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -1,5 +1,7 @@ #include "Logging.h" +#include + #include #define MAX_ENTRY_LEN 256 @@ -41,7 +43,14 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) { // add the timestamp { unsigned long ms = millis(); - int len = snprintf(c, sizeof(buf), "[%lu] ", ms); + char wallClock[12]; + HalClock::formatLogTime(wallClock, sizeof(wallClock)); + int len; + if (wallClock[0] != '\0') { + len = snprintf(c, sizeof(buf), "[%lu %s] ", ms, wallClock); + } else { + len = snprintf(c, sizeof(buf), "[%lu] ", ms); + } if (len < 0) { return; // encoding error, skip logging } diff --git a/lib/hal/HalClock.cpp b/lib/hal/HalClock.cpp new file mode 100644 index 00000000..a03576af --- /dev/null +++ b/lib/hal/HalClock.cpp @@ -0,0 +1,507 @@ +#include "HalClock.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// ---- RTC-memory state (survives deep sleep, not cold boot) ---------------- + +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 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; +}; + +static constexpr TimeZoneEntry TIMEZONES[] = { + {"GMT0BST,M3.5.0/1,M10.5.0/2"}, + {"CET-1CEST,M3.5.0/2,M10.5.0/3"}, + {"EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"MSK-3"}, + {"UTC-4"}, + {"UTC-5:30"}, + {"UTC-7"}, + {"UTC-8"}, + {"UTC-9"}, + {"AEST-10AEDT,M10.1.0/2,M4.1.0/3"}, + {"NZST-12NZDT,M9.5.0/2,M4.1.0/3"}, + {"UTC+3"}, + {"EST5EDT,M3.2.0/2,M11.1.0/2"}, + {"CST6CDT,M3.2.0/2,M11.1.0/2"}, + {"MST7MDT,M3.2.0/2,M11.1.0/2"}, + {"PST8PDT,M3.2.0/2,M11.1.0/2"}, +}; + +// ---- NVS helpers ---------------------------------------------------------- + +// If the last NTP sync is older than this, treat a cold-boot restore as +// unsynced rather than showing a potentially very wrong time. +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; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putLong64(NVS_KEY, (int64_t)epoch); + prefs.end(); + } +} + +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)) { + prefs.putLong64(NVS_SYNC_KEY, (int64_t)syncEpoch); + prefs.end(); + } +} + +static time_t nvsRead() { + Preferences prefs; + time_t epoch = 0; + if (prefs.begin(NVS_NAMESPACE, true)) { + epoch = (time_t)prefs.getLong64(NVS_KEY, 0); + prefs.end(); + } + return epoch; +} + +static time_t nvsReadSyncTime() { + Preferences prefs; + time_t syncEpoch = 0; + if (prefs.begin(NVS_NAMESPACE, true)) { + syncEpoch = (time_t)prefs.getLong64(NVS_SYNC_KEY, 0); + prefs.end(); + } + return syncEpoch; +} + +// ---- 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; + settimeofday(&tv, nullptr); +} + +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); +} + +// ---- public API ----------------------------------------------------------- + +namespace HalClock { + +void applyTimezone(uint8_t timeZoneSetting) { + const size_t index = timeZoneSetting < (sizeof(TIMEZONES) / sizeof(TIMEZONES[0])) ? timeZoneSetting : 0; + setenv("TZ", TIMEZONES[index].tz, 1); + tzset(); + LOG_DBG("CLK", "Timezone applied: %s", TIMEZONES[index].tz); +} + +bool syncNtp() { + time_t preSyncTime = time(nullptr); + time_t prevSyncTime = nvsReadSyncTime(); + float prevSyncTemp = nvsReadLastSyncTemp(); + + if (esp_sntp_enabled()) { + esp_sntp_stop(); + } + + esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); + esp_sntp_setservername(0, "pool.ntp.org"); + esp_sntp_init(); + + int retry = 0; + constexpr int maxRetries = 50; // 5 seconds + while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) { + vTaskDelay(100 / portTICK_PERIOD_MS); + retry++; + } + + if (retry >= maxRetries) { + LOG_ERR("CLK", "NTP sync timeout"); + return false; + } + + 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; +} + +void saveBeforeSleep(bool keepLpAlive) { + if (!isSynced()) { + return; + } + capture(keepLpAlive); + // 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. + // + // 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) { + float tempNow = readChipTemperatureC(); + double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow); + estimated += (time_t)correctedSec; + } + + setSystemClock(estimated); + // Re-baseline LP timer and temperature for next interval. + rtcEpoch = estimated; + 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; + } + + // Cold boot — try NVS. No elapsed correction possible. + time_t epoch = nvsRead(); + if (epoch > 0) { + time_t lastSync = nvsReadSyncTime(); + if (lastSync > 0 && (epoch - lastSync) > STALE_THRESHOLD_S) { + LOG_ERR("CLK", "NVS epoch %lld is stale (last NTP sync %lld, %lld h ago), discarding", (long long)epoch, + (long long)lastSync, (long long)((epoch - lastSync) / 3600)); + return; + } + setSystemClock(epoch); + 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; + LOG_INF("CLK", "Restored from NVS, epoch %lld (no elapsed correction)", (long long)epoch); + } +} + +time_t now() { + if (!isSynced()) { + return 0; + } + 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 +} + +bool isApproximate() { return clockApproximate; } + +time_t lastSyncTime() { return nvsReadSyncTime(); } + +void formatTime(char* buf, size_t bufSize, bool use24h) { + if (!isSynced()) { + snprintf(buf, bufSize, "--:--"); + return; + } + + time_t t = time(nullptr); + struct tm timeinfo; + localtime_r(&t, &timeinfo); + + const char* prefix = isApproximate() ? "~" : ""; + + if (use24h) { + snprintf(buf, bufSize, "%s%02d:%02d", prefix, timeinfo.tm_hour, timeinfo.tm_min); + } else { + int hour = timeinfo.tm_hour % 12; + if (hour == 0) hour = 12; + const char* ampm = timeinfo.tm_hour < 12 ? "AM" : "PM"; + snprintf(buf, bufSize, "%s%d:%02d%s", prefix, hour, timeinfo.tm_min, ampm); + } +} + +void formatLogTime(char* buf, size_t bufSize) { + if (!isSynced()) { + buf[0] = '\0'; + return; + } + + time_t t = time(nullptr); + struct tm timeinfo; + localtime_r(&t, &timeinfo); + snprintf(buf, bufSize, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); +} + +void wifiOff(bool skipNtpSync) { + if (!skipNtpSync && isApproximate() && WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) { + syncNtp(); + } + if (esp_sntp_enabled()) { + esp_sntp_stop(); + } + WiFi.disconnect(false); + delay(100); + WiFi.mode(WIFI_OFF); + delay(100); +} + +} // namespace HalClock diff --git a/lib/hal/HalClock.h b/lib/hal/HalClock.h new file mode 100644 index 00000000..976003e2 --- /dev/null +++ b/lib/hal/HalClock.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +/// Lightweight wall-clock facade. +/// +/// The ESP32-C3 has no battery-backed RTC, so wall-clock time is lost on every +/// deep-sleep / power cycle. HalClock bridges this gap using three layers: +/// +/// - **LP timer** (`esp_clk_rtc_time()`) — keeps running during deep sleep +/// when `keepClockAlive` is enabled (GPIO13 stays HIGH). Used to compute +/// elapsed time and correct the stored epoch on wake. +/// - **RTC memory** (`RTC_NOINIT_ATTR`) — survives deep sleep, lost on cold +/// boot. Stores the epoch + LP timer value captured before sleep. +/// - **NVS** (flash key-value store) — survives power cycles. Fallback when +/// RTC memory is unavailable (cold boot). +/// +/// Usage: +/// 1. On boot, call `restore()` to seed the system clock from the best +/// available source (RTC memory + LP correction > NVS). +/// 2. After a successful NTP sync, call `syncNtp()`. +/// 3. Before entering deep sleep, call `saveBeforeSleep()`. +/// +/// `now()` returns the best-effort epoch (0 if never synced). +namespace HalClock { + +/// Perform an NTP sync (requires WiFi to be connected). Starts SNTP, +/// waits up to 5 seconds for completion, then captures the result. +/// Returns true if the sync succeeded. +bool syncNtp(); + +/// Apply timezone/DST rules via the POSIX TZ string for the given setting. +void applyTimezone(uint8_t timeZoneSetting); + +/// Call just before deep sleep. Snapshots the current system time to RTC +/// memory and NVS so it can be restored on wake / cold boot. Pass true when +/// the LP timer is kept alive during sleep. +void saveBeforeSleep(bool keepLpAlive); + +/// Call on boot to seed the system clock from the best available stored +/// value. When RTC memory is valid (deep-sleep wake) and the LP timer was +/// running, the restored time includes elapsed-time correction. Falls back +/// to NVS for cold boot (stale, but better than nothing). +void restore(); + +/// Returns the current best-effort wall-clock epoch, or 0 if the clock was +/// never set. +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(); + +/// Returns the epoch of the last successful NTP sync (from NVS), or 0 if +/// no sync has ever been recorded. +time_t lastSyncTime(); + +/// Format the current time for display. Returns "--:--" if the clock was +/// never synced, prefixes with "~" if approximate. +/// When use24h is false, formats as "2:05pm" / "12:30am". +/// Output is written to `buf` (must be at least 16 bytes). +void formatTime(char* buf, size_t bufSize, bool use24h); + +/// Format the current time for log timestamps. Returns "HH:MM:SS" if +/// synced, or an empty string if not. +void formatLogTime(char* buf, size_t bufSize); + +/// Tear down WiFi cleanly. When skipNtpSync is false (default) and the +/// clock is approximate, performs an opportunistic NTP sync before +/// disconnecting — essentially free since we already have a connection. +void wifiOff(bool skipNtpSync = false); + +} // namespace HalClock diff --git a/lib/hal/HalPowerManager.cpp b/lib/hal/HalPowerManager.cpp index c25b1a28..989376ab 100644 --- a/lib/hal/HalPowerManager.cpp +++ b/lib/hal/HalPowerManager.cpp @@ -52,26 +52,30 @@ void HalPowerManager::setPowerSaving(bool enabled) { // Otherwise, no change needed } -void HalPowerManager::startDeepSleep(HalGPIO& gpio) const { +void HalPowerManager::startDeepSleep(HalGPIO& gpio, bool keepClockAlive) const { // Ensure that the power button has been released to avoid immediately turning back on if you're holding it while (gpio.isPressed(HalGPIO::BTN_POWER)) { delay(50); gpio.update(); } - // Pre-sleep routines from the original firmware - // GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep - // Note that this means the MCU will be completely powered off during sleep, including RTC + // GPIO13 is connected to the battery latch MOSFET. + // When keepClockAlive is false (default): GPIO13 goes LOW, the MCU is + // completely powered off during sleep (including the LP timer / RTC memory). + // When keepClockAlive is true: GPIO13 stays HIGH, the MCU remains powered + // at ~3-4 mA so the LP timer keeps running and RTC memory is preserved. + // This allows HalClock to accurately compute elapsed sleep time on wake. constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13; gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT); - gpio_set_level(GPIO_SPIWP, 0); + gpio_set_level(GPIO_SPIWP, keepClockAlive ? 1 : 0); esp_sleep_config_gpio_isolate(); gpio_deep_sleep_hold_en(); gpio_hold_en(GPIO_SPIWP); pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP); // Arm the wakeup trigger *after* the button is released - // Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the - // power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source - // configuration + // Note: when keepClockAlive is false, this is only useful for waking up on USB power. On battery, the MCU will be + // completely powered off, so the power button is hard-wired to briefly provide power to the MCU, waking it up + // regardless of the wakeup source configuration. + // When keepClockAlive is true, this is the actual wakeup mechanism since the MCU stays powered. esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Enter Deep Sleep esp_deep_sleep_start(); diff --git a/lib/hal/HalPowerManager.h b/lib/hal/HalPowerManager.h index 74cff1bf..4fdc1652 100644 --- a/lib/hal/HalPowerManager.h +++ b/lib/hal/HalPowerManager.h @@ -29,9 +29,11 @@ class HalPowerManager { // Control CPU frequency for power saving void setPowerSaving(bool enabled); - // Setup wake up GPIO and enter deep sleep - // Should be called inside main loop() to handle the currentLockMode - void startDeepSleep(HalGPIO& gpio) const; + // Setup wake up GPIO and enter deep sleep. + // When keepClockAlive is true, GPIO13 stays HIGH so the LP timer keeps + // running during sleep (~3-4 mA extra). This allows HalClock to compute + // elapsed sleep time and restore the wall clock accurately on wake. + void startDeepSleep(HalGPIO& gpio, bool keepClockAlive = false) const; // Get battery percentage (range 0-100) uint16_t getBatteryPercentage() const; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index cfcf11ec..9f9290a4 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -138,6 +138,27 @@ class CrossPointSettings { // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; + // Timezone options (POSIX TZ rules for DST support) + enum TIMEZONE { + TZ_UTC = 0, + TZ_CET = 1, + TZ_EET = 2, + TZ_MSK = 3, + TZ_UTC_PLUS4 = 4, + TZ_IST = 5, + TZ_UTC_PLUS7 = 6, + TZ_UTC_PLUS8 = 7, + TZ_UTC_PLUS9 = 8, + TZ_AEST = 9, + TZ_NZST = 10, + TZ_UTC_MINUS3 = 11, + TZ_EST = 12, + TZ_CST = 13, + TZ_MST = 14, + TZ_PST = 15, + TIMEZONE_COUNT + }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -204,6 +225,15 @@ class CrossPointSettings { uint8_t imageRendering = IMAGES_DISPLAY; // Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled) uint8_t syntheticTocFallback = 1; + // Show clock in the reader status bar + uint8_t statusBarClock = 0; + // Clock format: 0 = 24h (14:00), 1 = 12h (2:00pm) + uint8_t clockFormat12h = 0; + // Timezone selection (applies POSIX TZ rules for DST) + uint8_t timeZone = TZ_UTC; + // Use clock and keep the LP timer running during deep sleep (GPIO13 HIGH) + // so time can be accurately restored on wake. Increases sleep current by ~3-4 mA. + uint8_t useClock = 0; ~CrossPointSettings() = default; diff --git a/src/SettingsList.h b/src/SettingsList.h index 1d9bca0a..278a08f3 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -84,6 +84,15 @@ inline const std::vector& getSettingsList() { "sleepTimeout", StrId::STR_CAT_SYSTEM), SettingInfo::Toggle(StrId::STR_SHOW_HIDDEN_FILES, &CrossPointSettings::showHiddenFiles, "showHiddenFiles", StrId::STR_CAT_SYSTEM), + SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat12h, {StrId::STR_24H, StrId::STR_12H}, + "clockFormat12h", StrId::STR_CAT_SYSTEM), + SettingInfo::Enum(StrId::STR_TIMEZONE, &CrossPointSettings::timeZone, + {StrId::STR_TZ_UTC, StrId::STR_TZ_CET, StrId::STR_TZ_EET, StrId::STR_TZ_MSK, + StrId::STR_TZ_UTC_PLUS4, StrId::STR_TZ_IST, StrId::STR_TZ_UTC_PLUS7, StrId::STR_TZ_UTC_PLUS8, + StrId::STR_TZ_UTC_PLUS9, StrId::STR_TZ_AEST, StrId::STR_TZ_NZST, StrId::STR_TZ_UTC_MINUS3, + StrId::STR_TZ_EST, StrId::STR_TZ_CST, StrId::STR_TZ_MST, StrId::STR_TZ_PST}, + "timeZone", StrId::STR_CAT_SYSTEM), + SettingInfo::Toggle(StrId::STR_USE_CLOCK, &CrossPointSettings::useClock, "useClock", StrId::STR_CAT_SYSTEM), // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- SettingInfo::DynamicString( @@ -140,6 +149,8 @@ inline const std::vector& getSettingsList() { StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery", StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock", + StrId::STR_CUSTOMISE_STATUS_BAR), }; return list; } diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 3cf115b1..f99bcdc1 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -1,5 +1,7 @@ #include "ActivityManager.h" +#include +#include #include #include "boot_sleep/BootActivity.h" @@ -56,6 +58,18 @@ void ActivityManager::loop() { currentActivity->loop(); } + if (SETTINGS.useClock && HalClock::isSynced()) { + time_t now = HalClock::now(); + if (now > 0) { + static time_t lastMinute = -1; + time_t minute = now / 60; + if (minute != lastMinute) { + lastMinute = minute; + requestUpdate(); + } + } + } + while (pendingAction != PendingAction::None) { if (pendingAction == PendingAction::Pop) { RenderLock lock; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 0f0895d6..6723b8d5 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -39,8 +40,7 @@ void OpdsBookBrowserActivity::onEnter() { void OpdsBookBrowserActivity::onExit() { Activity::onExit(); - // Turn off WiFi when exiting - WiFi.mode(WIFI_OFF); + HalClock::wifiOff(); entries.clear(); navigationHistory.clear(); diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index f2eaf501..b6601d16 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -86,15 +87,12 @@ void CrossPointWebServerActivity::onExit() { if (isApMode) { LOG_DBG("WEBACT", "Stopping WiFi AP..."); WiFi.softAPdisconnect(true); + delay(30); + WiFi.mode(WIFI_OFF); + delay(30); } else { - LOG_DBG("WEBACT", "Disconnecting WiFi (graceful)..."); - WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + HalClock::wifiOff(); } - delay(30); // Allow disconnect frame to be sent - - LOG_DBG("WEBACT", "Setting WiFi mode OFF..."); - WiFi.mode(WIFI_OFF); - delay(30); // Allow WiFi hardware to power down LOG_DBG("WEBACT", "Free heap at onExit end: %d bytes", ESP.getFreeHeap()); } diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index e8e9c6bd..b06dffd8 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -1,10 +1,10 @@ #include "KOReaderSyncActivity.h" #include +#include #include #include #include -#include #include "KOReaderCredentialStore.h" #include "KOReaderDocumentId.h" @@ -13,43 +13,6 @@ #include "components/UITheme.h" #include "fontIds.h" -namespace { -void syncTimeWithNTP() { - // Stop SNTP if already running (can't reconfigure while running) - if (esp_sntp_enabled()) { - esp_sntp_stop(); - } - - // Configure SNTP - esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); - esp_sntp_setservername(0, "pool.ntp.org"); - esp_sntp_init(); - - // Wait for time to sync (with timeout) - int retry = 0; - const int maxRetries = 50; // 5 seconds max - while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) { - vTaskDelay(100 / portTICK_PERIOD_MS); - retry++; - } - - if (retry < maxRetries) { - LOG_DBG("KOSync", "NTP time synced"); - } else { - LOG_DBG("KOSync", "NTP sync timeout, using fallback"); - } -} -void wifiOff() { - if (esp_sntp_enabled()) { - esp_sntp_stop(); - } - WiFi.disconnect(false); - delay(100); - WiFi.mode(WIFI_OFF); - delay(100); -} -} // namespace - void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { if (!success) { LOG_DBG("KOSync", "WiFi connection failed, exiting"); @@ -70,7 +33,7 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { requestUpdate(true); // Sync time with NTP before making API requests - syncTimeWithNTP(); + HalClock::syncNtp(); { RenderLock lock(*this); @@ -187,7 +150,7 @@ void KOReaderSyncActivity::performUpload() { const auto result = KOReaderSyncClient::updateProgress(progress); if (result != KOReaderSyncClient::OK) { - wifiOff(); + HalClock::wifiOff(true); { RenderLock lock(*this); state = SYNC_FAILED; @@ -197,7 +160,7 @@ void KOReaderSyncActivity::performUpload() { return; } - wifiOff(); + HalClock::wifiOff(true); { RenderLock lock(*this); state = UPLOAD_COMPLETE; @@ -232,7 +195,7 @@ void KOReaderSyncActivity::onEnter() { void KOReaderSyncActivity::onExit() { Activity::onExit(); - wifiOff(); + HalClock::wifiOff(true); } void KOReaderSyncActivity::closeCancelled() { diff --git a/src/activities/settings/ClockSettingsActivity.cpp b/src/activities/settings/ClockSettingsActivity.cpp new file mode 100644 index 00000000..b79a7bc8 --- /dev/null +++ b/src/activities/settings/ClockSettingsActivity.cpp @@ -0,0 +1,129 @@ +#include "ClockSettingsActivity.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "DetectTimezoneActivity.h" +#include "MappedInputManager.h" +#include "SyncTimeActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr int MENU_ITEMS = 5; +const StrId menuNames[MENU_ITEMS] = {StrId::STR_USE_CLOCK, StrId::STR_CLOCK_FORMAT, StrId::STR_TIMEZONE, + StrId::STR_SYNC_TIME, StrId::STR_DETECT_TIMEZONE}; + +const StrId timeZoneNames[CrossPointSettings::TIMEZONE_COUNT] = { + StrId::STR_TZ_UTC, StrId::STR_TZ_CET, StrId::STR_TZ_EET, StrId::STR_TZ_MSK, + StrId::STR_TZ_UTC_PLUS4, StrId::STR_TZ_IST, StrId::STR_TZ_UTC_PLUS7, StrId::STR_TZ_UTC_PLUS8, + StrId::STR_TZ_UTC_PLUS9, StrId::STR_TZ_AEST, StrId::STR_TZ_NZST, StrId::STR_TZ_UTC_MINUS3, + StrId::STR_TZ_EST, StrId::STR_TZ_CST, StrId::STR_TZ_MST, StrId::STR_TZ_PST}; +} // namespace + +void ClockSettingsActivity::onEnter() { + Activity::onEnter(); + selectedIndex = 0; + requestUpdate(); +} + +void ClockSettingsActivity::onExit() { Activity::onExit(); } + +void ClockSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + requestUpdate(); + return; + } + + buttonNavigator.onNextRelease([this] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS); + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS); + requestUpdate(); + }); + + buttonNavigator.onNextContinuous([this] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS); + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS); + requestUpdate(); + }); +} + +void ClockSettingsActivity::handleSelection() { + if (selectedIndex == 0) { + SETTINGS.useClock = (SETTINGS.useClock + 1) % 2; + if (!SETTINGS.useClock) { + SETTINGS.statusBarClock = 0; + } + SETTINGS.saveToFile(); + } else if (selectedIndex == 1) { + SETTINGS.clockFormat12h = (SETTINGS.clockFormat12h + 1) % 2; + SETTINGS.saveToFile(); + } else if (selectedIndex == 2) { + SETTINGS.timeZone = (SETTINGS.timeZone + 1) % CrossPointSettings::TIMEZONE_COUNT; + HalClock::applyTimezone(SETTINGS.timeZone); + SETTINGS.saveToFile(); + } else if (selectedIndex == 3) { + auto resultHandler = [](const ActivityResult&) { SETTINGS.saveToFile(); }; + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + } else if (selectedIndex == 4) { + auto resultHandler = [](const ActivityResult&) { SETTINGS.saveToFile(); }; + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + } +} + +void ClockSettingsActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CLOCK_SETTINGS)); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + tr(STR_CLOCK_SETTINGS_WARNING)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, MENU_ITEMS, selectedIndex, + [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, + [](int index) { + if (index == 0) { + return std::string(SETTINGS.useClock ? tr(STR_STATE_ON) : tr(STR_STATE_OFF)); + } + if (index == 1) { + return std::string(SETTINGS.clockFormat12h ? tr(STR_12H) : tr(STR_24H)); + } + if (index == 2) { + const auto tzIndex = static_cast(SETTINGS.timeZone); + if (tzIndex < (sizeof(timeZoneNames) / sizeof(timeZoneNames[0]))) { + return std::string(I18N.get(timeZoneNames[tzIndex])); + } + return std::string(tr(STR_TZ_UTC)); + } + return std::string(""); + }, + true); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ClockSettingsActivity.h b/src/activities/settings/ClockSettingsActivity.h new file mode 100644 index 00000000..e1f491bc --- /dev/null +++ b/src/activities/settings/ClockSettingsActivity.h @@ -0,0 +1,20 @@ +#pragma once +#include + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +class ClockSettingsActivity final : public Activity { + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + + void handleSelection(); + + public: + explicit ClockSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("ClockSettings", renderer, mappedInput) {} + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/activities/settings/DetectTimezoneActivity.cpp b/src/activities/settings/DetectTimezoneActivity.cpp new file mode 100644 index 00000000..70859b41 --- /dev/null +++ b/src/activities/settings/DetectTimezoneActivity.cpp @@ -0,0 +1,308 @@ +#include "DetectTimezoneActivity.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "network/HttpDownloader.h" + +namespace { +bool mapIanaTimezone(const std::string& tz, uint8_t& outSetting) { + using TZ = CrossPointSettings::TIMEZONE; + if (tz == "UTC" || tz == "Etc/UTC") { + outSetting = TZ::TZ_UTC; + return true; + } + if (tz == "Europe/London" || tz == "Europe/Guernsey" || tz == "Europe/Isle_of_Man" || tz == "Europe/Jersey") { + outSetting = TZ::TZ_UTC; + return true; + } + if (tz == "Europe/Athens" || tz == "Europe/Bucharest" || tz == "Europe/Helsinki" || tz == "Europe/Kiev" || + tz == "Europe/Vilnius" || tz == "Europe/Riga" || tz == "Europe/Tallinn") { + outSetting = TZ::TZ_EET; + return true; + } + if (tz == "Europe/Moscow") { + outSetting = TZ::TZ_MSK; + return true; + } + if (tz.rfind("Europe/", 0) == 0) { + outSetting = TZ::TZ_CET; + return true; + } + if (tz == "America/New_York" || tz == "America/Toronto") { + outSetting = TZ::TZ_EST; + return true; + } + if (tz == "America/Chicago") { + outSetting = TZ::TZ_CST; + return true; + } + if (tz == "America/Denver") { + outSetting = TZ::TZ_MST; + return true; + } + if (tz == "America/Los_Angeles" || tz == "America/Vancouver") { + outSetting = TZ::TZ_PST; + return true; + } + if (tz == "America/Sao_Paulo" || tz == "America/Argentina/Buenos_Aires" || tz == "America/Montevideo") { + outSetting = TZ::TZ_UTC_MINUS3; + return true; + } + if (tz == "Asia/Dubai" || tz == "Asia/Muscat") { + outSetting = TZ::TZ_UTC_PLUS4; + return true; + } + if (tz == "Asia/Kolkata") { + outSetting = TZ::TZ_IST; + return true; + } + if (tz == "Asia/Bangkok" || tz == "Asia/Ho_Chi_Minh" || tz == "Asia/Jakarta" || tz == "Asia/Phnom_Penh" || + tz == "Asia/Vientiane") { + outSetting = TZ::TZ_UTC_PLUS7; + return true; + } + if (tz == "Asia/Shanghai" || tz == "Asia/Hong_Kong" || tz == "Asia/Singapore" || tz == "Asia/Taipei" || + tz == "Asia/Kuala_Lumpur" || tz == "Asia/Manila") { + outSetting = TZ::TZ_UTC_PLUS8; + return true; + } + if (tz == "Asia/Tokyo" || tz == "Asia/Seoul") { + outSetting = TZ::TZ_UTC_PLUS9; + return true; + } + if (tz == "Australia/Sydney" || tz == "Australia/Melbourne" || tz == "Australia/Hobart") { + outSetting = TZ::TZ_AEST; + return true; + } + if (tz == "Pacific/Auckland") { + outSetting = TZ::TZ_NZST; + return true; + } + return false; +} + +bool fetchTimezonePayload(const char* url, std::string& payload) { + // Give DNS a moment after WiFi connect. + delay(500); + for (int attempt = 0; attempt < 2; ++attempt) { + if (HttpDownloader::fetchUrl(url, payload)) { + return true; + } + delay(300); + } + return false; +} + +bool fetchPublicIp(std::string& ip) { + std::string payload; + if (!fetchTimezonePayload("https://api.ipify.org", payload)) { + return false; + } + + // Payload is plain text: IP address + ip = payload; + // Trim any whitespace + while (!ip.empty() && (ip.back() == '\n' || ip.back() == '\r' || ip.back() == ' ' || ip.back() == '\t')) { + ip.pop_back(); + } + if (!ip.empty()) { + LOG_DBG("CLK", "Public IP: %s", ip.c_str()); + return true; + } + return false; +} + +bool detectTimezoneSetting(uint8_t& outSetting, std::string& outIana, bool& outDstKnown, bool& outDstActive) { + std::string payload; + std::string publicIp; + if (fetchPublicIp(publicIp)) { + std::string timeUrl = std::string("https://timeapi.io/api/Time/current/ip?ipAddress=") + publicIp; + LOG_DBG("CLK", "Timezone detect via TimeAPI: %s", timeUrl.c_str()); + if (fetchTimezonePayload(timeUrl.c_str(), payload)) { + // Continue to parse TimeAPI payload below. + } + } + + if (payload.empty() && !fetchTimezonePayload("https://ip-api.com/json/?fields=timezone,dst", payload)) { + LOG_ERR("CLK", "Timezone detect failed: fetch error"); + return false; + } + + JsonDocument doc; + const auto err = deserializeJson(doc, payload); + if (err) { + LOG_ERR("CLK", "Timezone detect failed: %s", err.c_str()); + return false; + } + + const char* tz = doc["timezone"] | ""; + if (!tz || tz[0] == '\0') { + tz = doc["timeZone"] | ""; + } + if (!tz || tz[0] == '\0') { + tz = doc["time_zone"] | ""; + } + if (!tz || tz[0] == '\0') { + LOG_ERR("CLK", "Timezone detect failed: missing timezone (payload: %s)", payload.c_str()); + return false; + } + + outIana = tz; + if (!doc["dst_active"].isNull()) { + outDstKnown = true; + outDstActive = doc["dst_active"].as(); + } else if (!doc["dstActive"].isNull()) { + outDstKnown = true; + outDstActive = doc["dstActive"].as(); + } else if (!doc["dst"].isNull()) { + outDstKnown = true; + outDstActive = doc["dst"].as(); + } else { + outDstKnown = false; + outDstActive = false; + } + + if (!mapIanaTimezone(tz, outSetting)) { + LOG_ERR("CLK", "Timezone detect unsupported: %s", tz); + return false; + } + + if (outDstKnown) { + LOG_DBG("CLK", "Timezone detected: %s (dst=%d)", tz, outDstActive ? 1 : 0); + } else { + LOG_DBG("CLK", "Timezone detected: %s (dst=unknown)", tz); + } + return true; +} + +} // namespace + +void DetectTimezoneActivity::onEnter() { + Activity::onEnter(); + + if (WiFi.status() == WL_CONNECTED) { + onWifiSelectionComplete(true); + return; + } + + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { + if (result.isCancelled) { + onWifiSelectionCancelled(); + return; + } + onWifiSelectionComplete(true); + }); +} + +void DetectTimezoneActivity::onExit() { + Activity::onExit(); + HalClock::wifiOff(); +} + +void DetectTimezoneActivity::onWifiSelectionComplete(bool success) { + if (!success) { + state = FAILED; + requestUpdate(); + return; + } + + { + RenderLock lock(*this); + state = DETECTING; + } + requestUpdateAndWait(); + + performDetect(); +} + +void DetectTimezoneActivity::onWifiSelectionCancelled() { finish(); } + +void DetectTimezoneActivity::performDetect() { + uint8_t detected = SETTINGS.timeZone; + detectedTimezone.clear(); + dstKnown = false; + dstActive = false; + if (detectTimezoneSetting(detected, detectedTimezone, dstKnown, dstActive)) { + SETTINGS.timeZone = detected; + HalClock::applyTimezone(SETTINGS.timeZone); + SETTINGS.saveToFile(); + state = SUCCESS; + } else { + state = FAILED; + } + + HalClock::wifiOff(); + requestUpdate(); +} + +void DetectTimezoneActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_DETECT_TIMEZONE)); + + if (state == CONNECTING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_CONNECTING), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (state == DETECTING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_DETECTING_TIMEZONE), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (state == SUCCESS) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_TIMEZONE_DETECTED), true, EpdFontFamily::BOLD); + + if (!detectedTimezone.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 5, detectedTimezone.c_str()); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 25, dstStatusLabel()); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_TIMEZONE_DETECT_FAILED), true, EpdFontFamily::BOLD); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } +} + +const char* DetectTimezoneActivity::dstStatusLabel() const { + if (!dstKnown) { + return tr(STR_DST_UNKNOWN); + } + return dstActive ? tr(STR_DST_ACTIVE) : tr(STR_DST_INACTIVE); +} + +void DetectTimezoneActivity::loop() { + if (state == SUCCESS || state == FAILED) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + } + } +} diff --git a/src/activities/settings/DetectTimezoneActivity.h b/src/activities/settings/DetectTimezoneActivity.h new file mode 100644 index 00000000..103a0736 --- /dev/null +++ b/src/activities/settings/DetectTimezoneActivity.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include "activities/Activity.h" + +class DetectTimezoneActivity final : public Activity { + public: + explicit DetectTimezoneActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("DetectTimezone", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + enum State { CONNECTING, DETECTING, SUCCESS, FAILED }; + State state = CONNECTING; + std::string detectedTimezone; + bool dstKnown = false; + bool dstActive = false; + + void onWifiSelectionComplete(bool success); + void onWifiSelectionCancelled(); + void performDetect(); + const char* dstStatusLabel() const; +}; diff --git a/src/activities/settings/KOReaderAuthActivity.cpp b/src/activities/settings/KOReaderAuthActivity.cpp index 52938b83..e2733a91 100644 --- a/src/activities/settings/KOReaderAuthActivity.cpp +++ b/src/activities/settings/KOReaderAuthActivity.cpp @@ -1,6 +1,7 @@ #include "KOReaderAuthActivity.h" #include +#include #include #include @@ -93,11 +94,7 @@ void KOReaderAuthActivity::onEnter() { void KOReaderAuthActivity::onExit() { Activity::onExit(); - // Turn off wifi - WiFi.disconnect(false); - delay(100); - WiFi.mode(WIFI_OFF); - delay(100); + HalClock::wifiOff(); } void KOReaderAuthActivity::render(RenderLock&&) { diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index ebbb20f9..c20a7f88 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -1,6 +1,7 @@ #include "OtaUpdateActivity.h" #include +#include #include #include @@ -66,11 +67,7 @@ void OtaUpdateActivity::onEnter() { void OtaUpdateActivity::onExit() { Activity::onExit(); - // Turn off wifi - WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame - delay(100); // Allow disconnect frame to be sent - WiFi.mode(WIFI_OFF); - delay(100); // Allow WiFi hardware to fully power down + HalClock::wifiOff(); } void OtaUpdateActivity::render(RenderLock&&) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 9b6b84cc..6f9cdd76 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,18 +1,22 @@ #include "SettingsActivity.h" #include +#include #include #include "ButtonRemapActivity.h" #include "CalibreSettingsActivity.h" #include "ClearCacheActivity.h" +#include "ClockSettingsActivity.h" #include "CrossPointSettings.h" +#include "DetectTimezoneActivity.h" #include "KOReaderSettingsActivity.h" #include "LanguageSelectActivity.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" #include "SettingsList.h" #include "StatusBarSettingsActivity.h" +#include "SyncTimeActivity.h" #include "SystemInformationActivity.h" #include "activities/network/WifiSelectionActivity.h" #include "components/UITheme.h" @@ -32,6 +36,11 @@ void SettingsActivity::onEnter() { for (const auto& setting : getSettingsList()) { if (setting.category == StrId::STR_NONE_OPT) continue; + if (setting.category == StrId::STR_CAT_SYSTEM && + (setting.nameId == StrId::STR_USE_CLOCK || setting.nameId == StrId::STR_CLOCK_FORMAT || + setting.nameId == StrId::STR_TIMEZONE)) { + continue; + } if (setting.category == StrId::STR_CAT_DISPLAY) { displaySettings.push_back(setting); } else if (setting.category == StrId::STR_CAT_READER) { @@ -47,6 +56,7 @@ void SettingsActivity::onEnter() { // Append device-only ACTION items controlsSettings.insert(controlsSettings.begin(), SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); + systemSettings.push_back(SettingInfo::Action(StrId::STR_CLOCK_SETTINGS, SettingAction::ClockSettings)); systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_BROWSER, SettingAction::OPDSBrowser)); @@ -176,6 +186,9 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::CustomiseStatusBar: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::ClockSettings: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::KOReaderSync: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; @@ -197,6 +210,12 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::SystemInfo: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::SyncTime: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; + case SettingAction::DetectTimezone: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::None: // Do nothing break; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index e09c571a..9ff90794 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -15,6 +15,7 @@ enum class SettingAction { None, RemapFrontButtons, CustomiseStatusBar, + ClockSettings, KOReaderSync, OPDSBrowser, Network, @@ -22,6 +23,8 @@ enum class SettingAction { CheckForUpdates, Language, SystemInfo, + DetectTimezone, + SyncTime, }; struct SettingInfo { diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 0928a57d..09aa7919 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -11,13 +11,14 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 6; +constexpr int MENU_ITEMS = 7; const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, StrId::STR_TITLE, - StrId::STR_BATTERY}; + StrId::STR_BATTERY, + StrId::STR_CLOCK}; constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -36,7 +37,10 @@ const int verticalPreviewTextPadding = 40; void StatusBarSettingsActivity::onEnter() { Activity::onEnter(); - selectedIndex = 0; + const int menuCount = SETTINGS.useClock ? MENU_ITEMS : MENU_ITEMS - 1; + if (selectedIndex >= menuCount) { + selectedIndex = 0; + } // Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { @@ -70,22 +74,26 @@ void StatusBarSettingsActivity::loop() { // Handle navigation buttonNavigator.onNextRelease([this] { - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS); + const int menuCount = SETTINGS.useClock ? MENU_ITEMS : MENU_ITEMS - 1; + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, menuCount); requestUpdate(); }); buttonNavigator.onPreviousRelease([this] { - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS); + const int menuCount = SETTINGS.useClock ? MENU_ITEMS : MENU_ITEMS - 1; + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, menuCount); requestUpdate(); }); buttonNavigator.onNextContinuous([this] { - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS); + const int menuCount = SETTINGS.useClock ? MENU_ITEMS : MENU_ITEMS - 1; + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, menuCount); requestUpdate(); }); buttonNavigator.onPreviousContinuous([this] { - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS); + const int menuCount = SETTINGS.useClock ? MENU_ITEMS : MENU_ITEMS - 1; + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, menuCount); requestUpdate(); }); } @@ -110,6 +118,9 @@ void StatusBarSettingsActivity::handleSelection() { } else if (selectedIndex == 5) { // Show Battery SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; + } else if (selectedIndex == 6 && SETTINGS.useClock) { + // Show Clock + SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % 2; } SETTINGS.saveToFile(); } @@ -124,9 +135,9 @@ void StatusBarSettingsActivity::render(RenderLock&&) { tr(STR_CUSTOMISE_STATUS_BAR)); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing * 2; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; GUI.drawList( - renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, static_cast(MENU_ITEMS), + renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEMS), static_cast(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, [this](int index) { @@ -143,6 +154,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { return I18N.get(titleNames[SETTINGS.statusBarTitle]); } else if (index == 5) { return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE); + } else if (index == 6) { + return SETTINGS.statusBarClock ? tr(STR_SHOW) : tr(STR_HIDE); } else { return tr(STR_HIDE); } diff --git a/src/activities/settings/SyncTimeActivity.cpp b/src/activities/settings/SyncTimeActivity.cpp new file mode 100644 index 00000000..53ae4f28 --- /dev/null +++ b/src/activities/settings/SyncTimeActivity.cpp @@ -0,0 +1,197 @@ +#include "SyncTimeActivity.h" + +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +static void formatDuration(char* buf, size_t bufSize, int32_t totalSeconds) { + const char* sign = totalSeconds < 0 ? "-" : "+"; + int32_t abs = totalSeconds < 0 ? -totalSeconds : totalSeconds; + + int32_t days = abs / 86400; + int32_t hours = (abs % 86400) / 3600; + int32_t mins = (abs % 3600) / 60; + int32_t secs = abs % 60; + + if (days > 0) { + snprintf(buf, bufSize, "%s%ldd %ldh %ldm", sign, (long)days, (long)hours, (long)mins); + } else if (hours > 0) { + snprintf(buf, bufSize, "%s%ldh %ldm %lds", sign, (long)hours, (long)mins, (long)secs); + } else if (mins > 0) { + snprintf(buf, bufSize, "%s%ldm %lds", sign, (long)mins, (long)secs); + } else { + snprintf(buf, bufSize, "%s%lds", sign, (long)secs); + } +} + +static void formatElapsed(char* buf, size_t bufSize, int32_t totalSeconds) { + int32_t days = totalSeconds / 86400; + int32_t hours = (totalSeconds % 86400) / 3600; + int32_t mins = (totalSeconds % 3600) / 60; + + if (days > 0) { + snprintf(buf, bufSize, "%ldd %ldh ago", (long)days, (long)hours); + } else if (hours > 0) { + snprintf(buf, bufSize, "%ldh %ldm ago", (long)hours, (long)mins); + } else { + snprintf(buf, bufSize, "%ldm ago", (long)mins); + } +} + +void SyncTimeActivity::onEnter() { + Activity::onEnter(); + + if (WiFi.status() == WL_CONNECTED) { + onWifiSelectionComplete(true); + return; + } + + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { + if (result.isCancelled) { + onWifiSelectionCancelled(); + return; + } + onWifiSelectionComplete(true); + }); +} + +void SyncTimeActivity::onExit() { + Activity::onExit(); + HalClock::wifiOff(true); +} + +void SyncTimeActivity::onWifiSelectionComplete(bool success) { + if (!success) { + state = FAILED; + requestUpdate(); + return; + } + + { + RenderLock lock(*this); + state = SYNCING; + } + requestUpdateAndWait(); + + performSync(); +} + +void SyncTimeActivity::onWifiSelectionCancelled() { finish(); } + +void SyncTimeActivity::performSync() { + hadTimeBeforeSync = HalClock::isSynced(); + preSyncTime = hadTimeBeforeSync ? time(nullptr) : 0; + prevSyncTime = HalClock::lastSyncTime(); + + bool ok = HalClock::syncNtp(); + + if (ok && hadTimeBeforeSync) { + driftSeconds = (int32_t)(time(nullptr) - preSyncTime); + } + + HalClock::wifiOff(true); + + state = ok ? SUCCESS : FAILED; + requestUpdate(); +} + +void SyncTimeActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SYNC_TIME)); + + if (state == SYNCING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_SYNCING_CLOCK), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (state == SUCCESS) { + int y = pageHeight / 2 - 40; + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_TIME_SYNCED), true, EpdFontFamily::BOLD); + + time_t now = HalClock::now(); + struct tm timeinfo; + localtime_r(&now, &timeinfo); + char timePart[16]; + HalClock::formatTime(timePart, sizeof(timePart), !SETTINGS.clockFormat12h); + char timeStr[32]; + snprintf(timeStr, sizeof(timeStr), "%s %04d-%02d-%02d", timePart, timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, + timeinfo.tm_mday); + y += 30; + renderer.drawCenteredText(UI_10_FONT_ID, y, timeStr); + + int32_t elapsedSinceSync = -1; + if (prevSyncTime > 0) { + elapsedSinceSync = (int32_t)(now - prevSyncTime); + } + + char driftStr[80]; + if (hadTimeBeforeSync) { + char driftFmt[24]; + formatDuration(driftFmt, sizeof(driftFmt), driftSeconds); + + if (elapsedSinceSync > 0) { + double hours = (double)elapsedSinceSync / 3600.0; + double rate = (double)driftSeconds / hours; + char rateFmt[16]; + snprintf(rateFmt, sizeof(rateFmt), "%+.2f", rate); + + char driftWithRate[48]; + snprintf(driftWithRate, sizeof(driftWithRate), "%s (%s s/hr)", driftFmt, rateFmt); + snprintf(driftStr, sizeof(driftStr), tr(STR_CLOCK_DRIFT), driftWithRate); + } else { + snprintf(driftStr, sizeof(driftStr), tr(STR_CLOCK_DRIFT), driftFmt); + } + } else { + snprintf(driftStr, sizeof(driftStr), tr(STR_CLOCK_DRIFT), "N/A"); + } + y += 30; + renderer.drawCenteredText(UI_10_FONT_ID, y, driftStr); + + if (elapsedSinceSync > 0) { + char elapsedFmt[24]; + formatElapsed(elapsedFmt, sizeof(elapsedFmt), elapsedSinceSync); + char lastSyncStr[48]; + snprintf(lastSyncStr, sizeof(lastSyncStr), tr(STR_LAST_NTP_SYNC), elapsedFmt); + y += 25; + renderer.drawCenteredText(UI_10_FONT_ID, y, lastSyncStr); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_TIME_SYNC_FAILED), true, EpdFontFamily::BOLD); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } +} + +void SyncTimeActivity::loop() { + if (state == SUCCESS || state == FAILED) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + } + } +} diff --git a/src/activities/settings/SyncTimeActivity.h b/src/activities/settings/SyncTimeActivity.h new file mode 100644 index 00000000..08f2eecd --- /dev/null +++ b/src/activities/settings/SyncTimeActivity.h @@ -0,0 +1,25 @@ +#pragma once + +#include "activities/Activity.h" + +class SyncTimeActivity final : public Activity { + public: + explicit SyncTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("SyncTime", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + enum State { CONNECTING, SYNCING, SUCCESS, FAILED }; + State state = CONNECTING; + time_t preSyncTime = 0; + time_t prevSyncTime = 0; + int32_t driftSeconds = 0; + bool hadTimeBeforeSync = false; + void onWifiSelectionComplete(bool success); + void onWifiSelectionCancelled(); + void performSync(); +}; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 212e5f0d..1b637757 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -127,7 +127,7 @@ int UITheme::getStatusBarHeight() { // Add status bar margin const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || - SETTINGS.statusBarBattery; + SETTINGS.statusBarBattery || (SETTINGS.useClock && SETTINGS.statusBarClock); const bool showProgressBar = SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) + diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 3c2467d8..d9332df6 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -1,6 +1,7 @@ #include "BaseTheme.h" #include +#include #include #include #include @@ -306,6 +307,13 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight}, showBatteryPercentage); + // Draw clock in header + if (SETTINGS.useClock) { + char clockStr[16]; + HalClock::formatTime(clockStr, sizeof(clockStr), !SETTINGS.clockFormat12h); + renderer.drawText(SMALL_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, rect.y + 5, clockStr); + } + if (title) { int padding = rect.width - batteryX + BaseMetrics::values.batteryWidth; auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, @@ -745,6 +753,17 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c showBatteryPercentage); } + // Draw Clock + int clockTextWidth = 0; + if (SETTINGS.useClock && SETTINGS.statusBarClock) { + char clockStr[16]; + HalClock::formatTime(clockStr, sizeof(clockStr), !SETTINGS.clockFormat12h); + clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, clockStr); + const int batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0; + renderer.drawText(SMALL_FONT_ID, metrics.statusBarHorizontalMargin + orientedMarginLeft + batterySize + 8, textY, + clockStr); + } + // Draw Title if (!title.empty()) { textY -= textYOffset; @@ -754,7 +773,8 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight; const int batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0; - const int titleMarginLeft = batterySize + 30; + const int clockSize = clockTextWidth > 0 ? clockTextWidth + 8 : 0; + const int titleMarginLeft = batterySize + clockSize + 30; const int titleMarginRight = progressTextWidth + 30; // Attempt to center title on the screen, but if title is too wide then later we will center it within the diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 923d7ff5..f1ecc5af 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -1,6 +1,7 @@ #include "LyraTheme.h" #include +#include #include #include #include @@ -150,6 +151,13 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight}, showBatteryPercentage); + // Draw clock in header + if (SETTINGS.useClock) { + char clockStr[16]; + HalClock::formatTime(clockStr, sizeof(clockStr), !SETTINGS.clockFormat12h); + renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, rect.y + 5, clockStr); + } + int maxTitleWidth = rect.width - LyraMetrics::values.contentSidePadding * 2 - (subtitle != nullptr ? maxSubtitleWidth : 0); diff --git a/src/main.cpp b/src/main.cpp index 75bf69c5..e8a00a1a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -184,6 +185,7 @@ void waitForPowerRelease() { void enterDeepSleep() { HalPowerManager::Lock powerLock; // Ensure we are at normal CPU frequency for sleep preparation APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); + HalClock::saveBeforeSleep(SETTINGS.useClock); APP_STATE.saveToFile(); activityManager.goToSleep(); @@ -192,7 +194,7 @@ void enterDeepSleep() { LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1); LOG_DBG("MAIN", "Entering deep sleep"); - powerManager.startDeepSleep(gpio); + powerManager.startDeepSleep(gpio, SETTINGS.useClock); } void setupDisplayAndFonts() { @@ -258,6 +260,7 @@ void setup() { HalSystem::clearPanic(); // TODO: move this to an activity when we have one to display the panic info SETTINGS.loadFromFile(); + HalClock::applyTimezone(SETTINGS.timeZone); I18N.loadSettings(); KOREADER_STORE.loadFromFile(); UITheme::getInstance().reload(); @@ -289,6 +292,7 @@ void setup() { activityManager.goToBoot(); APP_STATE.loadFromFile(); + HalClock::restore(); RECENT_BOOKS.loadFromFile(); // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity @@ -315,6 +319,7 @@ void loop() { static unsigned long lastMemPrint = 0; gpio.update(); + HalClock::updatePeriodic(); renderer.setFadingFix(SETTINGS.fadingFix);