feat: add silent NTP time sync on boot via saved WiFi credentials

New "Auto Sync on Boot" toggle in Clock Settings. When enabled, a
background FreeRTOS task scans for saved WiFi networks at boot,
connects, syncs time via NTP, then tears down WiFi — all without
blocking boot or requiring user interaction. If no saved network is
found after two scan attempts (with a 3-second retry gap), it bails
silently.

Conflict guards (BootNtpSync::cancel()) added to all WiFi-using
activities so the background task cleans up before any user-initiated
WiFi flow. Also fixes clock not appearing in the header until a button
press by detecting the invalid→valid time transition after NTP sync.

Made-with: Cursor
This commit is contained in:
cottongin
2026-02-26 18:21:13 -05:00
parent 2eae521b6a
commit 19b6ad047b
22 changed files with 227 additions and 2 deletions

View File

@@ -400,6 +400,7 @@ enum class StrId : uint16_t {
STR_INDEXING_STATUS_ICON, STR_INDEXING_STATUS_ICON,
STR_SYNC_CLOCK, STR_SYNC_CLOCK,
STR_TIME_SYNCED, STR_TIME_SYNCED,
STR_AUTO_NTP_SYNC,
STR_MANAGE_BOOK, STR_MANAGE_BOOK,
STR_ARCHIVE_BOOK, STR_ARCHIVE_BOOK,
STR_UNARCHIVE_BOOK, STR_UNARCHIVE_BOOK,

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Text stavového řádku"
STR_INDEXING_STATUS_ICON: "Ikona stavového řádku" STR_INDEXING_STATUS_ICON: "Ikona stavového řádku"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -364,6 +364,7 @@ STR_INDEXING_STATUS_TEXT: "Status Bar Text"
STR_INDEXING_STATUS_ICON: "Status Bar Icon" STR_INDEXING_STATUS_ICON: "Status Bar Icon"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"
STR_MANAGE_BOOK: "Manage Book" STR_MANAGE_BOOK: "Manage Book"
STR_ARCHIVE_BOOK: "Archive Book" STR_ARCHIVE_BOOK: "Archive Book"
STR_UNARCHIVE_BOOK: "Unarchive Book" STR_UNARCHIVE_BOOK: "Unarchive Book"

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Texte barre d'état"
STR_INDEXING_STATUS_ICON: "Icône barre d'état" STR_INDEXING_STATUS_ICON: "Icône barre d'état"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Statusleistentext"
STR_INDEXING_STATUS_ICON: "Statusleistensymbol" STR_INDEXING_STATUS_ICON: "Statusleistensymbol"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Texto da barra"
STR_INDEXING_STATUS_ICON: "Ícone da barra" STR_INDEXING_STATUS_ICON: "Ícone da barra"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -318,3 +318,4 @@ STR_EMBEDDED_STYLE: "Stil încorporat"
STR_OPDS_SERVER_URL: "URL server OPDS" STR_OPDS_SERVER_URL: "URL server OPDS"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Текст в строке"
STR_INDEXING_STATUS_ICON: "Иконка в строке" STR_INDEXING_STATUS_ICON: "Иконка в строке"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Texto barra estado"
STR_INDEXING_STATUS_ICON: "Icono barra estado" STR_INDEXING_STATUS_ICON: "Icono barra estado"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -343,3 +343,4 @@ STR_INDEXING_STATUS_TEXT: "Statusfältstext"
STR_INDEXING_STATUS_ICON: "Statusfältsikon" STR_INDEXING_STATUS_ICON: "Statusfältsikon"
STR_SYNC_CLOCK: "Sync Clock" STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!" STR_TIME_SYNCED: "Time synced!"
STR_AUTO_NTP_SYNC: "Auto Sync on Boot"

View File

@@ -144,6 +144,7 @@ uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const {
writer.writeItem(file, timezone); writer.writeItem(file, timezone);
writer.writeItem(file, timezoneOffsetHours); writer.writeItem(file, timezoneOffsetHours);
writer.writeItem(file, indexingDisplay); writer.writeItem(file, indexingDisplay);
writer.writeItem(file, autoNtpSync);
return writer.item_count; return writer.item_count;
} }
@@ -288,6 +289,8 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, indexingDisplay, INDEXING_DISPLAY_COUNT); readAndValidate(inputFile, indexingDisplay, INDEXING_DISPLAY_COUNT);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, autoNtpSync);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
if (frontButtonMappingRead) { if (frontButtonMappingRead) {

View File

@@ -227,6 +227,9 @@ class CrossPointSettings {
// Custom timezone offset in hours from UTC (-12 to +14) // Custom timezone offset in hours from UTC (-12 to +14)
int8_t timezoneOffsetHours = 0; int8_t timezoneOffsetHours = 0;
// Automatically sync time via NTP on boot using saved WiFi credentials
uint8_t autoNtpSync = 0;
~CrossPointSettings() = default; ~CrossPointSettings() = default;
// Get singleton instance // Get singleton instance

View File

@@ -91,6 +91,8 @@ inline std::vector<SettingInfo> getSettingsList() {
{StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN, {StrId::STR_TZ_UTC, StrId::STR_TZ_EASTERN, StrId::STR_TZ_CENTRAL, StrId::STR_TZ_MOUNTAIN,
StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM}, StrId::STR_TZ_PACIFIC, StrId::STR_TZ_ALASKA, StrId::STR_TZ_HAWAII, StrId::STR_TZ_CUSTOM},
"timezone", StrId::STR_CAT_CLOCK), "timezone", StrId::STR_CAT_CLOCK),
SettingInfo::Toggle(StrId::STR_AUTO_NTP_SYNC, &CrossPointSettings::autoNtpSync, "autoNtpSync",
StrId::STR_CAT_CLOCK),
// --- Reader --- // --- Reader ---
SettingInfo::DynamicEnum( SettingInfo::DynamicEnum(

View File

@@ -13,6 +13,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "NetworkModeSelectionActivity.h" #include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
#include "util/BootNtpSync.h"
#include "activities/network/CalibreConnectActivity.h" #include "activities/network/CalibreConnectActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@@ -35,6 +36,8 @@ constexpr uint16_t DNS_PORT = 53;
void CrossPointWebServerActivity::onEnter() { void CrossPointWebServerActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
LOG_DBG("WEBACT", "Free heap at onEnter: %d bytes", ESP.getFreeHeap()); LOG_DBG("WEBACT", "Free heap at onEnter: %d bytes", ESP.getFreeHeap());
// Reset state // Reset state

View File

@@ -12,11 +12,14 @@
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h" #include "util/TimeSync.h"
void WifiSelectionActivity::onEnter() { void WifiSelectionActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
BootNtpSync::cancel();
// Load saved WiFi credentials - SD card operations need lock as we use SPI // Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both // for both
{ {

View File

@@ -11,6 +11,7 @@
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h" #include "util/TimeSync.h"
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
@@ -155,6 +156,8 @@ void KOReaderSyncActivity::performUpload() {
void KOReaderSyncActivity::onEnter() { void KOReaderSyncActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
// Check for credentials first // Check for credentials first
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
state = NO_CREDENTIALS; state = NO_CREDENTIALS;

View File

@@ -8,6 +8,7 @@
#include "KOReaderSyncClient.h" #include "KOReaderSyncClient.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "util/BootNtpSync.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@@ -53,6 +54,8 @@ void KOReaderAuthActivity::performAuthentication() {
void KOReaderAuthActivity::onEnter() { void KOReaderAuthActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
// Turn on WiFi // Turn on WiFi
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);

View File

@@ -10,6 +10,7 @@
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h" #include "util/TimeSync.h"
static constexpr unsigned long AUTO_DISMISS_MS = 5000; static constexpr unsigned long AUTO_DISMISS_MS = 5000;
@@ -52,6 +53,7 @@ void NtpSyncActivity::onWifiSelectionComplete(const bool success) {
void NtpSyncActivity::onEnter() { void NtpSyncActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
LOG_DBG("NTP", "Turning on WiFi..."); LOG_DBG("NTP", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);

View File

@@ -9,6 +9,7 @@
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "network/OtaUpdater.h" #include "network/OtaUpdater.h"
#include "util/BootNtpSync.h"
void OtaUpdateActivity::onWifiSelectionComplete(const bool success) { void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
exitActivity(); exitActivity();
@@ -58,6 +59,8 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
void OtaUpdateActivity::onEnter() { void OtaUpdateActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
BootNtpSync::cancel();
// Turn on WiFi immediately // Turn on WiFi immediately
LOG_DBG("OTA", "Turning on WiFi..."); LOG_DBG("OTA", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);

View File

@@ -32,6 +32,7 @@
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
HalDisplay display; HalDisplay display;
@@ -342,6 +343,7 @@ void setup() {
I18N.loadSettings(); I18N.loadSettings();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
BootNtpSync::start();
UITheme::getInstance().reload(); UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager); ButtonNavigator::setMappedInputManager(mappedInputManager);
@@ -459,15 +461,24 @@ void loop() {
// Refresh screen when the displayed minute changes (clock in header) // Refresh screen when the displayed minute changes (clock in header)
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) { if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF && currentActivity) {
static int lastRenderedMinute = -1; static int lastRenderedMinute = -1;
static bool sawInvalidTime = false;
time_t now = time(nullptr); time_t now = time(nullptr);
struct tm* t = localtime(&now); struct tm* t = localtime(&now);
if (t != nullptr && t->tm_year > 100) { if (t != nullptr && t->tm_year > 100) {
const int currentMinute = t->tm_hour * 60 + t->tm_min; const int currentMinute = t->tm_hour * 60 + t->tm_min;
if (lastRenderedMinute >= 0 && currentMinute != lastRenderedMinute) { if (lastRenderedMinute < 0) {
lastRenderedMinute = currentMinute;
if (sawInvalidTime) {
// Time just became valid (e.g. background NTP sync completed)
currentActivity->requestUpdate(); currentActivity->requestUpdate();
} }
} else if (currentMinute != lastRenderedMinute) {
currentActivity->requestUpdate();
lastRenderedMinute = currentMinute; lastRenderedMinute = currentMinute;
} }
} else {
sawInvalidTime = true;
}
} }
const unsigned long activityStartTime = millis(); const unsigned long activityStartTime = millis();

163
src/util/BootNtpSync.cpp Normal file
View File

@@ -0,0 +1,163 @@
#include "BootNtpSync.h"
#include <Logging.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <string>
#include <vector>
#include "CrossPointSettings.h"
#include "WifiCredentialStore.h"
#include "util/TimeSync.h"
namespace BootNtpSync {
static volatile bool running = false;
static TaskHandle_t taskHandle = nullptr;
struct TaskParams {
std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
};
static bool tryConnectToSavedNetwork(const TaskParams& params) {
WiFi.mode(WIFI_STA);
WiFi.disconnect();
vTaskDelay(100 / portTICK_PERIOD_MS);
LOG_DBG("BNTP", "Scanning WiFi networks...");
int16_t count = WiFi.scanNetworks();
if (count <= 0) {
LOG_DBG("BNTP", "Scan returned %d networks", count);
WiFi.scanDelete();
return false;
}
LOG_DBG("BNTP", "Found %d networks, matching against %zu saved credentials", count, params.credentials.size());
// Find best match: prefer lastConnectedSsid, otherwise first saved match
const WifiCredential* bestMatch = nullptr;
for (int i = 0; i < count; i++) {
std::string ssid = WiFi.SSID(i).c_str();
for (const auto& cred : params.credentials) {
if (cred.ssid == ssid) {
if (!bestMatch || cred.ssid == params.lastConnectedSsid) {
bestMatch = &cred;
}
if (cred.ssid == params.lastConnectedSsid) {
break; // Can't do better than lastConnected
}
}
}
if (bestMatch && bestMatch->ssid == params.lastConnectedSsid) {
break;
}
}
WiFi.scanDelete();
if (!bestMatch) {
LOG_DBG("BNTP", "No saved network found in scan results");
return false;
}
LOG_DBG("BNTP", "Connecting to %s", bestMatch->ssid.c_str());
if (!bestMatch->password.empty()) {
WiFi.begin(bestMatch->ssid.c_str(), bestMatch->password.c_str());
} else {
WiFi.begin(bestMatch->ssid.c_str());
}
const unsigned long start = millis();
constexpr unsigned long CONNECT_TIMEOUT_MS = 10000;
while (WiFi.status() != WL_CONNECTED && millis() - start < CONNECT_TIMEOUT_MS) {
if (!running) return false;
vTaskDelay(100 / portTICK_PERIOD_MS);
wl_status_t status = WiFi.status();
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
LOG_DBG("BNTP", "Connection failed (status=%d)", status);
return false;
}
}
if (WiFi.status() == WL_CONNECTED) {
LOG_DBG("BNTP", "Connected to %s", bestMatch->ssid.c_str());
return true;
}
LOG_DBG("BNTP", "Connection timed out");
return false;
}
static void taskFunc(void* param) {
auto* params = static_cast<TaskParams*>(param);
bool connected = tryConnectToSavedNetwork(*params);
if (!connected && running) {
LOG_DBG("BNTP", "First scan failed, retrying in 3s...");
vTaskDelay(3000 / portTICK_PERIOD_MS);
if (running) {
connected = tryConnectToSavedNetwork(*params);
}
}
if (connected && running) {
LOG_DBG("BNTP", "Starting NTP sync...");
bool synced = TimeSync::waitForNtpSync(5000);
TimeSync::stopNtpSync();
if (synced) {
LOG_DBG("BNTP", "NTP sync successful");
} else {
LOG_DBG("BNTP", "NTP sync timed out, continuing without time");
}
}
WiFi.disconnect(false);
vTaskDelay(100 / portTICK_PERIOD_MS);
WiFi.mode(WIFI_OFF);
vTaskDelay(100 / portTICK_PERIOD_MS);
delete params;
running = false;
taskHandle = nullptr;
LOG_DBG("BNTP", "Boot NTP task complete");
vTaskDelete(nullptr);
}
void start() {
if (!SETTINGS.autoNtpSync) {
return;
}
WIFI_STORE.loadFromFile();
const auto& creds = WIFI_STORE.getCredentials();
if (creds.empty()) {
LOG_DBG("BNTP", "No saved WiFi credentials, skipping boot NTP sync");
return;
}
auto* params = new TaskParams{creds, WIFI_STORE.getLastConnectedSsid()};
running = true;
xTaskCreate(taskFunc, "BootNTP", 4096, params, 1, &taskHandle);
LOG_DBG("BNTP", "Boot NTP sync task started");
}
void cancel() {
if (!running) return;
LOG_DBG("BNTP", "Cancelling boot NTP sync...");
running = false;
// Wait for the task to notice and clean up (up to 2s)
for (int i = 0; i < 20 && taskHandle != nullptr; i++) {
delay(100);
}
LOG_DBG("BNTP", "Boot NTP sync cancelled");
}
bool isRunning() { return running; }
} // namespace BootNtpSync

16
src/util/BootNtpSync.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
namespace BootNtpSync {
// Spawn a background FreeRTOS task that scans for saved WiFi networks,
// connects, syncs NTP, then tears down WiFi. Non-blocking; does nothing
// if autoNtpSync is disabled or no credentials are stored.
void start();
// Signal the background task to abort and wait for it to finish.
// Call before starting any other WiFi operation.
void cancel();
bool isRunning();
} // namespace BootNtpSync