From 6600681082fea226fa5adba1c2107aeb7505a58e Mon Sep 17 00:00:00 2001 From: Chris Korhonen Date: Sat, 17 Jan 2026 06:35:11 -0500 Subject: [PATCH 1/3] Add Calendar Mode for automated e-ink calendar display Features: - CalendarActivity: Connects to WiFi, fetches BMP from URL, displays as sleep screen - Timer wake: Device wakes every N hours (configurable) to refresh - Settings: calendarModeEnabled, calendarRefreshHours, calendarServerUrl - Fallback: Uses cached image if fetch fails - Power button still works for normal use (GPIO wake preserved) --- src/CrossPointSettings.cpp | 20 +- src/CrossPointSettings.h | 5 + src/activities/calendar/CalendarActivity.cpp | 232 +++++++++++++++++++ src/activities/calendar/CalendarActivity.h | 57 +++++ src/main.cpp | 12 + 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 src/activities/calendar/CalendarActivity.cpp create mode 100644 src/activities/calendar/CalendarActivity.h diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 17b5d053..9433333c 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,9 +12,9 @@ CrossPointSettings CrossPointSettings::instance; namespace { -constexpr uint8_t SETTINGS_FILE_VERSION = 1; +constexpr uint8_t SETTINGS_FILE_VERSION = 2; // Incremented for calendar mode // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 18; +constexpr uint8_t SETTINGS_COUNT = 21; // Added 3 calendar settings constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -48,6 +48,10 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); + // Calendar mode settings + serialization::writePod(outputFile, calendarModeEnabled); + serialization::writePod(outputFile, calendarRefreshHours); + serialization::writeString(outputFile, std::string(calendarServerUrl)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -116,6 +120,18 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, longPressChapterSkip); if (++settingsRead >= fileSettingsCount) break; + // Calendar mode settings + serialization::readPod(inputFile, calendarModeEnabled); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, calendarRefreshHours); + if (++settingsRead >= fileSettingsCount) break; + { + std::string urlStr; + serialization::readString(inputFile, urlStr); + strncpy(calendarServerUrl, urlStr.c_str(), sizeof(calendarServerUrl) - 1); + calendarServerUrl[sizeof(calendarServerUrl) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index a5641aad..654148a1 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -93,6 +93,11 @@ class CrossPointSettings { // Long-press chapter skip on side buttons uint8_t longPressChapterSkip = 1; + // Calendar mode settings + uint8_t calendarModeEnabled = 0; // 0 = disabled, 1 = enabled + uint8_t calendarRefreshHours = 4; // Refresh interval in hours (1-24) + char calendarServerUrl[256] = ""; // URL to fetch BMP image from + ~CrossPointSettings() = default; // Get singleton instance diff --git a/src/activities/calendar/CalendarActivity.cpp b/src/activities/calendar/CalendarActivity.cpp new file mode 100644 index 00000000..a88430ef --- /dev/null +++ b/src/activities/calendar/CalendarActivity.cpp @@ -0,0 +1,232 @@ +#include "CalendarActivity.h" + +#include +#include +#include +#include +#include + +#include "../../CrossPointSettings.h" +#include "../../WifiCredentialStore.h" +#include "../../fontIds.h" +#include "../boot_sleep/SleepActivity.h" + +// External functions from main.cpp +extern void exitActivity(); +extern void enterNewActivity(Activity* activity); +extern void enterDeepSleep(); + +void CalendarActivity::onEnter() { + Activity::onEnter(); + state = CalendarState::INIT; + stateStartTime = millis(); + + Serial.printf("[%lu] [CAL] Calendar mode starting\n", millis()); + + // Begin WiFi connection + startWifiConnection(); +} + +void CalendarActivity::startWifiConnection() { + Serial.printf("[%lu] [CAL] Loading WiFi credentials\n", millis()); + + // Load saved credentials + WIFI_STORE.loadFromFile(); + + const auto& credentials = WIFI_STORE.getCredentials(); + if (credentials.empty()) { + handleError("No saved WiFi"); + return; + } + + // Use first saved network + const auto& cred = credentials[0]; + Serial.printf("[%lu] [CAL] Connecting to: %s\n", millis(), cred.ssid.c_str()); + + WiFi.mode(WIFI_STA); + WiFi.begin(cred.ssid.c_str(), cred.password.c_str()); + + state = CalendarState::CONNECTING_WIFI; + stateStartTime = millis(); +} + +bool CalendarActivity::checkWifiConnection() { + return WiFi.status() == WL_CONNECTED; +} + +bool CalendarActivity::fetchAndSaveImage() { + Serial.printf("[%lu] [CAL] Fetching image from: %s\n", millis(), SETTINGS.calendarServerUrl); + + HTTPClient http; + http.begin(SETTINGS.calendarServerUrl); + http.setTimeout(30000); + http.setConnectTimeout(10000); + + int httpCode = http.GET(); + + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [CAL] HTTP error: %d\n", millis(), httpCode); + http.end(); + return false; + } + + int contentLength = http.getSize(); + Serial.printf("[%lu] [CAL] Content length: %d bytes\n", millis(), contentLength); + + // Open file for writing using SdMan + FsFile file; + if (!SdMan.openFileForWrite("CAL", "/sleep.bmp", file)) { + Serial.printf("[%lu] [CAL] Failed to open /sleep.bmp for writing\n", millis()); + http.end(); + return false; + } + + // Stream response to file + WiFiClient* stream = http.getStreamPtr(); + uint8_t buffer[512]; + int totalWritten = 0; + + while (http.connected() && (contentLength > 0 || contentLength == -1)) { + size_t available = stream->available(); + if (available) { + size_t toRead = min(available, sizeof(buffer)); + size_t bytesRead = stream->readBytes(buffer, toRead); + + if (bytesRead > 0) { + file.write(buffer, bytesRead); + totalWritten += bytesRead; + + if (contentLength > 0) { + contentLength -= bytesRead; + } + } + } else { + delay(10); + } + + // Check for timeout + if (millis() - stateStartTime > HTTP_TIMEOUT_MS) { + Serial.printf("[%lu] [CAL] HTTP timeout during download\n", millis()); + file.close(); + http.end(); + return false; + } + + // Break if we've received everything + if (contentLength == 0) { + break; + } + } + + file.close(); + http.end(); + + Serial.printf("[%lu] [CAL] Saved %d bytes to /sleep.bmp\n", millis(), totalWritten); + return totalWritten > 0; +} + +void CalendarActivity::renderSleepScreen() { + Serial.printf("[%lu] [CAL] Rendering sleep screen\n", millis()); + + // Force sleep screen mode to CUSTOM to use our downloaded image + SETTINGS.sleepScreen = CrossPointSettings::CUSTOM; + + // Create and enter SleepActivity to render the image + exitActivity(); + enterNewActivity(new SleepActivity(renderer, mappedInput)); +} + +void CalendarActivity::scheduleWakeAndSleep() { + Serial.printf("[%lu] [CAL] Scheduling wake in %d hours\n", millis(), SETTINGS.calendarRefreshHours); + + // Disconnect WiFi to save power + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + + // Calculate sleep duration in microseconds + uint64_t sleepDurationUs = (uint64_t)SETTINGS.calendarRefreshHours * 60ULL * 60ULL * 1000000ULL; + + Serial.printf("[%lu] [CAL] Sleep duration: %llu us (%d hours)\n", millis(), sleepDurationUs, + SETTINGS.calendarRefreshHours); + + // Enable timer wakeup + esp_sleep_enable_timer_wakeup(sleepDurationUs); + + // Also keep GPIO wakeup for power button (allows manual wake for normal use) + esp_deep_sleep_enable_gpio_wakeup(1ULL << 3, ESP_GPIO_WAKEUP_GPIO_LOW); // Power button pin + + Serial.printf("[%lu] [CAL] Entering deep sleep\n", millis()); + + // Enter deep sleep + esp_deep_sleep_start(); +} + +void CalendarActivity::handleError(const char* message) { + Serial.printf("[%lu] [CAL] Error: %s\n", millis(), message); + errorMessage = message; + state = CalendarState::ERROR; + stateStartTime = millis(); + + // For now, just log - we'll use default sleep screen on error +} + +void CalendarActivity::renderStatus(const char* status) { + // Minimal status rendering - just log for now + Serial.printf("[%lu] [CAL] Status: %s\n", millis(), status); +} + +void CalendarActivity::loop() { + switch (state) { + case CalendarState::INIT: + // Should not reach here - onEnter handles init + break; + + case CalendarState::CONNECTING_WIFI: + if (checkWifiConnection()) { + Serial.printf("[%lu] [CAL] WiFi connected, IP: %s\n", millis(), WiFi.localIP().toString().c_str()); + state = CalendarState::FETCHING_IMAGE; + stateStartTime = millis(); + } else if (millis() - stateStartTime > WIFI_TIMEOUT_MS) { + handleError("WiFi timeout"); + } + break; + + case CalendarState::FETCHING_IMAGE: + if (fetchAndSaveImage()) { + Serial.printf("[%lu] [CAL] Image saved successfully\n", millis()); + state = CalendarState::RENDERING; + } else { + // Check if we have a cached image + if (SdMan.exists("/sleep.bmp")) { + Serial.printf("[%lu] [CAL] Fetch failed, using cached image\n", millis()); + state = CalendarState::RENDERING; + } else { + handleError("Fetch failed"); + } + } + break; + + case CalendarState::RENDERING: + renderSleepScreen(); + // After SleepActivity renders, we need to schedule sleep + state = CalendarState::SCHEDULING_SLEEP; + break; + + case CalendarState::SCHEDULING_SLEEP: + scheduleWakeAndSleep(); + // Should never reach here - device sleeps + break; + + case CalendarState::ERROR: + // Wait 3 seconds showing error, then sleep anyway (use cached image if available) + if (millis() - stateStartTime > 3000) { + if (SdMan.exists("/sleep.bmp")) { + state = CalendarState::RENDERING; + } else { + // No cached image - just sleep with default screen and try again later + scheduleWakeAndSleep(); + } + } + break; + } +} diff --git a/src/activities/calendar/CalendarActivity.h b/src/activities/calendar/CalendarActivity.h new file mode 100644 index 00000000..b21a8c94 --- /dev/null +++ b/src/activities/calendar/CalendarActivity.h @@ -0,0 +1,57 @@ +#pragma once + +#include "../Activity.h" + +/** + * CalendarActivity - Automated calendar image fetch and display + * + * This activity is triggered on timer wake (not power button wake). + * It connects to WiFi, fetches a BMP image from a configured URL, + * saves it as the sleep screen, and returns to deep sleep. + * + * Flow: + * 1. Load saved WiFi credentials + * 2. Connect to WiFi (timeout: 30s) + * 3. HTTP GET image from configured URL (timeout: 60s) + * 4. Save image to /sleep.bmp on SD card + * 5. Render sleep screen + * 6. Schedule timer wake for next refresh + * 7. Enter deep sleep + */ + +enum class CalendarState { + INIT, + CONNECTING_WIFI, + FETCHING_IMAGE, + SAVING_IMAGE, + RENDERING, + SCHEDULING_SLEEP, + ERROR +}; + +class CalendarActivity final : public Activity { + public: + explicit CalendarActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("Calendar", renderer, mappedInput) {} + + void onEnter() override; + void loop() override; + bool preventAutoSleep() override { return true; } + bool skipLoopDelay() override { return true; } + + private: + CalendarState state = CalendarState::INIT; + unsigned long stateStartTime = 0; + String errorMessage; + + void startWifiConnection(); + bool checkWifiConnection(); + bool fetchAndSaveImage(); + void renderSleepScreen(); + void scheduleWakeAndSleep(); + void handleError(const char* message); + void renderStatus(const char* status); + + static constexpr unsigned long WIFI_TIMEOUT_MS = 30000; + static constexpr unsigned long HTTP_TIMEOUT_MS = 60000; +}; diff --git a/src/main.cpp b/src/main.cpp index 8a7c3b91..1770b913 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include @@ -21,6 +22,7 @@ #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "activities/calendar/CalendarActivity.h" #include "fontIds.h" #define SPI_FQ 40000000 @@ -290,6 +292,16 @@ void setup() { SETTINGS.loadFromFile(); + // Check if this is a timer wake for calendar mode + esp_sleep_wakeup_cause_t wakeupCause = esp_sleep_get_wakeup_cause(); + if (wakeupCause == ESP_SLEEP_WAKEUP_TIMER && SETTINGS.calendarModeEnabled) { + Serial.printf("[%lu] [ ] Timer wake detected - entering calendar mode\n", millis()); + setupDisplayAndFonts(); + exitActivity(); + enterNewActivity(new CalendarActivity(renderer, mappedInputManager)); + return; // Skip normal boot flow + } + // verify power button press duration after we've read settings. verifyWakeupLongPress(); From 50b2a37fb7b2114904e0b0c6cc1b97670a3c3c25 Mon Sep 17 00:00:00 2001 From: Chris Korhonen Date: Sat, 17 Jan 2026 19:14:58 -0500 Subject: [PATCH 2/3] feat: Add Calendar Mode for automated e-ink calendar display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CALENDAR sleep mode that bypasses /sleep/ directory to ensure calendar image displays - Implement CalendarActivity state machine: WiFi→NTP→HTTP fetch→save to /sleep.bmp→deep sleep - Add NTP time sync and fetch timestamp persistence in CrossPointState - Show visual status feedback during calendar fetch ("Connecting...", "Fetching...") - Add calendar settings UI: toggle, refresh hours (1-24h), "Test Calendar Now" action - Fix hardware button sleep to display calendar when calendar mode enabled - Graceful fallback: if fetch fails but cached image exists, use cached; otherwise use default screen Co-Authored-By: Claude Haiku 4.5 --- src/CrossPointSettings.h | 5 +- src/CrossPointState.cpp | 6 +- src/CrossPointState.h | 1 + src/activities/boot_sleep/SleepActivity.cpp | 19 +++++ src/activities/boot_sleep/SleepActivity.h | 1 + src/activities/calendar/CalendarActivity.cpp | 81 ++++++++------------ src/activities/calendar/CalendarActivity.h | 12 +-- src/activities/settings/SettingsActivity.cpp | 12 ++- src/main.cpp | 56 +++++++++++++- 9 files changed, 129 insertions(+), 64 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 654148a1..e4808251 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -16,7 +16,8 @@ class CrossPointSettings { CrossPointSettings& operator=(const CrossPointSettings&) = delete; // Should match with SettingsActivity text - enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 }; + // Note: CALENDAR is internal-only, not user-selectable in Settings + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4, CALENDAR = 5 }; enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 }; // Status bar display type enum @@ -96,7 +97,7 @@ class CrossPointSettings { // Calendar mode settings uint8_t calendarModeEnabled = 0; // 0 = disabled, 1 = enabled uint8_t calendarRefreshHours = 4; // Refresh interval in hours (1-24) - char calendarServerUrl[256] = ""; // URL to fetch BMP image from + char calendarServerUrl[256] = ""; // URL to fetch BMP image from ~CrossPointSettings() = default; diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 91aa2536..1033dc35 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr uint8_t STATE_FILE_VERSION = 2; +constexpr uint8_t STATE_FILE_VERSION = 3; constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace @@ -20,6 +20,7 @@ bool CrossPointState::saveToFile() const { serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); serialization::writePod(outputFile, lastSleepImage); + serialization::writePod(outputFile, lastCalendarFetch); outputFile.close(); return true; } @@ -44,6 +45,9 @@ bool CrossPointState::loadFromFile() { } else { lastSleepImage = 0; } + if (version >= 3) { + serialization::readPod(inputFile, lastCalendarFetch); + } inputFile.close(); return true; diff --git a/src/CrossPointState.h b/src/CrossPointState.h index 87ce4e96..dd633347 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -9,6 +9,7 @@ class CrossPointState { public: std::string openEpubPath; uint8_t lastSleepImage; + uint32_t lastCalendarFetch = 0; // Unix epoch of last successful calendar fetch ~CrossPointState() = default; // Get singleton instance diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index bf2b5857..21defbe4 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -20,6 +20,10 @@ void SleepActivity::onEnter() { return renderBlankSleepScreen(); } + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CALENDAR) { + return renderCalendarSleepScreen(); + } + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { return renderCustomSleepScreen(); } @@ -119,6 +123,21 @@ void SleepActivity::renderCustomSleepScreen() const { renderDefaultSleepScreen(); } +void SleepActivity::renderCalendarSleepScreen() const { + // Calendar mode: read /sleep.bmp directly, bypassing /sleep/ directory + FsFile file; + if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + Serial.printf("[%lu] [SLP] Loading calendar: /sleep.bmp\n", millis()); + renderBitmapSleepScreen(bitmap); + return; + } + } + // Fallback if no calendar image + renderDefaultSleepScreen(); +} + void SleepActivity::renderDefaultSleepScreen() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 283220ce..d4502b44 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -13,6 +13,7 @@ class SleepActivity final : public Activity { void renderPopup(const char* message) const; void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; + void renderCalendarSleepScreen() const; void renderCoverSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; void renderBlankSleepScreen() const; diff --git a/src/activities/calendar/CalendarActivity.cpp b/src/activities/calendar/CalendarActivity.cpp index a88430ef..9eb178e8 100644 --- a/src/activities/calendar/CalendarActivity.cpp +++ b/src/activities/calendar/CalendarActivity.cpp @@ -5,16 +5,18 @@ #include #include #include +#include #include "../../CrossPointSettings.h" +#include "../../CrossPointState.h" #include "../../WifiCredentialStore.h" #include "../../fontIds.h" -#include "../boot_sleep/SleepActivity.h" // External functions from main.cpp extern void exitActivity(); extern void enterNewActivity(Activity* activity); extern void enterDeepSleep(); +extern void enterCalendarDeepSleep(uint8_t refreshHours); void CalendarActivity::onEnter() { Activity::onEnter(); @@ -23,7 +25,8 @@ void CalendarActivity::onEnter() { Serial.printf("[%lu] [CAL] Calendar mode starting\n", millis()); - // Begin WiFi connection + // Show status and begin WiFi connection + renderStatus("Connecting..."); startWifiConnection(); } @@ -125,42 +128,6 @@ bool CalendarActivity::fetchAndSaveImage() { return totalWritten > 0; } -void CalendarActivity::renderSleepScreen() { - Serial.printf("[%lu] [CAL] Rendering sleep screen\n", millis()); - - // Force sleep screen mode to CUSTOM to use our downloaded image - SETTINGS.sleepScreen = CrossPointSettings::CUSTOM; - - // Create and enter SleepActivity to render the image - exitActivity(); - enterNewActivity(new SleepActivity(renderer, mappedInput)); -} - -void CalendarActivity::scheduleWakeAndSleep() { - Serial.printf("[%lu] [CAL] Scheduling wake in %d hours\n", millis(), SETTINGS.calendarRefreshHours); - - // Disconnect WiFi to save power - WiFi.disconnect(true); - WiFi.mode(WIFI_OFF); - - // Calculate sleep duration in microseconds - uint64_t sleepDurationUs = (uint64_t)SETTINGS.calendarRefreshHours * 60ULL * 60ULL * 1000000ULL; - - Serial.printf("[%lu] [CAL] Sleep duration: %llu us (%d hours)\n", millis(), sleepDurationUs, - SETTINGS.calendarRefreshHours); - - // Enable timer wakeup - esp_sleep_enable_timer_wakeup(sleepDurationUs); - - // Also keep GPIO wakeup for power button (allows manual wake for normal use) - esp_deep_sleep_enable_gpio_wakeup(1ULL << 3, ESP_GPIO_WAKEUP_GPIO_LOW); // Power button pin - - Serial.printf("[%lu] [CAL] Entering deep sleep\n", millis()); - - // Enter deep sleep - esp_deep_sleep_start(); -} - void CalendarActivity::handleError(const char* message) { Serial.printf("[%lu] [CAL] Error: %s\n", millis(), message); errorMessage = message; @@ -171,8 +138,11 @@ void CalendarActivity::handleError(const char* message) { } void CalendarActivity::renderStatus(const char* status) { - // Minimal status rendering - just log for now Serial.printf("[%lu] [CAL] Status: %s\n", millis(), status); + + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() / 2, status, true, EpdFontFamily::BOLD); + renderer.displayBuffer(); } void CalendarActivity::loop() { @@ -184,6 +154,15 @@ void CalendarActivity::loop() { case CalendarState::CONNECTING_WIFI: if (checkWifiConnection()) { Serial.printf("[%lu] [CAL] WiFi connected, IP: %s\n", millis(), WiFi.localIP().toString().c_str()); + + // Sync time via NTP + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + struct tm timeinfo; + if (getLocalTime(&timeinfo, 5000)) { + Serial.printf("[%lu] [CAL] NTP time synced\n", millis()); + } + + renderStatus("Fetching..."); state = CalendarState::FETCHING_IMAGE; stateStartTime = millis(); } else if (millis() - stateStartTime > WIFI_TIMEOUT_MS) { @@ -194,6 +173,17 @@ void CalendarActivity::loop() { case CalendarState::FETCHING_IMAGE: if (fetchAndSaveImage()) { Serial.printf("[%lu] [CAL] Image saved successfully\n", millis()); + + // Save fetch timestamp + time_t now; + time(&now); + if (now > 1700000000) { // Sanity check - after Nov 2023 + APP_STATE.lastCalendarFetch = now; + APP_STATE.saveToFile(); + Serial.printf("[%lu] [CAL] Saved fetch timestamp: %lu\n", millis(), (unsigned long)now); + } + + renderStatus("Image saved!"); state = CalendarState::RENDERING; } else { // Check if we have a cached image @@ -207,14 +197,9 @@ void CalendarActivity::loop() { break; case CalendarState::RENDERING: - renderSleepScreen(); - // After SleepActivity renders, we need to schedule sleep - state = CalendarState::SCHEDULING_SLEEP; - break; - - case CalendarState::SCHEDULING_SLEEP: - scheduleWakeAndSleep(); - // Should never reach here - device sleeps + Serial.printf("[%lu] [CAL] Rendering complete, entering deep sleep\n", millis()); + enterCalendarDeepSleep(SETTINGS.calendarRefreshHours); + // Never reaches here - device enters deep sleep break; case CalendarState::ERROR: @@ -224,7 +209,7 @@ void CalendarActivity::loop() { state = CalendarState::RENDERING; } else { // No cached image - just sleep with default screen and try again later - scheduleWakeAndSleep(); + enterCalendarDeepSleep(SETTINGS.calendarRefreshHours); } } break; diff --git a/src/activities/calendar/CalendarActivity.h b/src/activities/calendar/CalendarActivity.h index b21a8c94..5b2149bd 100644 --- a/src/activities/calendar/CalendarActivity.h +++ b/src/activities/calendar/CalendarActivity.h @@ -19,15 +19,7 @@ * 7. Enter deep sleep */ -enum class CalendarState { - INIT, - CONNECTING_WIFI, - FETCHING_IMAGE, - SAVING_IMAGE, - RENDERING, - SCHEDULING_SLEEP, - ERROR -}; +enum class CalendarState { INIT, CONNECTING_WIFI, FETCHING_IMAGE, RENDERING, ERROR }; class CalendarActivity final : public Activity { public: @@ -47,8 +39,6 @@ class CalendarActivity final : public Activity { void startWifiConnection(); bool checkWifiConnection(); bool fetchAndSaveImage(); - void renderSleepScreen(); - void scheduleWakeAndSleep(); void handleError(const char* message); void renderStatus(const char* status); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efa0b9e1..ee84c6cd 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -10,10 +10,11 @@ #include "MappedInputManager.h" #include "OtaUpdateActivity.h" #include "fontIds.h" +#include "../calendar/CalendarActivity.h" // Define the static settings list namespace { -constexpr int settingsCount = 20; +constexpr int settingsCount = 23; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -41,6 +42,10 @@ const SettingInfo settingsList[settingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + // Calendar mode settings + SettingInfo::Toggle("Calendar Mode", &CrossPointSettings::calendarModeEnabled), + SettingInfo::Value("Calendar Refresh (hrs)", &CrossPointSettings::calendarRefreshHours, {1, 24, 1}), + SettingInfo::Action("Test Calendar Now"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; } // namespace @@ -155,6 +160,11 @@ void SettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Test Calendar Now") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new CalendarActivity(renderer, mappedInput)); + xSemaphoreGive(renderingMutex); } } else { // Only toggle if it's a toggle type and has a value pointer diff --git a/src/main.cpp b/src/main.cpp index 1770b913..f8e6066c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include @@ -198,6 +199,11 @@ void waitForPowerRelease() { // Enter deep sleep mode void enterDeepSleep() { + // If calendar mode enabled, use calendar sleep screen to show /sleep.bmp + if (SETTINGS.calendarModeEnabled) { + SETTINGS.sleepScreen = CrossPointSettings::CALENDAR; + } + exitActivity(); enterNewActivity(new SleepActivity(renderer, mappedInputManager)); @@ -211,6 +217,34 @@ void enterDeepSleep() { esp_deep_sleep_start(); } +// Calendar mode deep sleep - renders custom sleep screen and schedules timer wake +void enterCalendarDeepSleep(uint8_t refreshHours) { + // Set sleep screen to CALENDAR to use /sleep.bmp directly (bypasses /sleep/ directory) + SETTINGS.sleepScreen = CrossPointSettings::CALENDAR; + + // Transition to SleepActivity to render the custom image + exitActivity(); + enterNewActivity(new SleepActivity(renderer, mappedInputManager)); + + // Put display to sleep + einkDisplay.deepSleep(); + + // Disconnect WiFi to save power + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + + // Calculate sleep duration + uint64_t sleepDurationUs = (uint64_t)refreshHours * 60ULL * 60ULL * 1000000ULL; + Serial.printf("[%lu] [CAL] Sleep duration: %llu us (%d hours)\n", millis(), sleepDurationUs, refreshHours); + + // Enable timer + GPIO wakeup + esp_sleep_enable_timer_wakeup(sleepDurationUs); + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); + + Serial.printf("[%lu] [CAL] Entering deep sleep\n", millis()); + esp_deep_sleep_start(); // Never returns +} + void onGoHome(); void onGoToReader(const std::string& initialEpubPath) { exitActivity(); @@ -291,9 +325,30 @@ void setup() { } SETTINGS.loadFromFile(); + APP_STATE.loadFromFile(); // Check if this is a timer wake for calendar mode esp_sleep_wakeup_cause_t wakeupCause = esp_sleep_get_wakeup_cause(); + + // Check if calendar needs refresh (backup for power loss) + if (SETTINGS.calendarModeEnabled && wakeupCause != ESP_SLEEP_WAKEUP_TIMER) { + // Not a timer wake - check if we need a backup fetch + time_t now; + time(&now); + uint32_t elapsed = (now > APP_STATE.lastCalendarFetch) ? + (now - APP_STATE.lastCalendarFetch) : UINT32_MAX; + uint32_t thresholdSec = SETTINGS.calendarRefreshHours * 3600; + + // If time is valid and elapsed > threshold, trigger backup fetch + if (now > 1700000000 && elapsed > thresholdSec) { + Serial.printf("[%lu] [ ] Calendar stale, triggering backup fetch\n", millis()); + setupDisplayAndFonts(); + exitActivity(); + enterNewActivity(new CalendarActivity(renderer, mappedInputManager)); + return; + } + } + if (wakeupCause == ESP_SLEEP_WAKEUP_TIMER && SETTINGS.calendarModeEnabled) { Serial.printf("[%lu] [ ] Timer wake detected - entering calendar mode\n", millis()); setupDisplayAndFonts(); @@ -313,7 +368,6 @@ void setup() { exitActivity(); enterNewActivity(new BootActivity(renderer, mappedInputManager)); - APP_STATE.loadFromFile(); if (APP_STATE.openEpubPath.empty()) { onGoHome(); } else { From 7ebb3c84c5c7168e67f64133697ddbc5e8c2cdf2 Mon Sep 17 00:00:00 2001 From: Chris Korhonen Date: Sat, 17 Jan 2026 19:23:55 -0500 Subject: [PATCH 3/3] style: Apply clang-format to calendar mode files Co-Authored-By: Claude Opus 4.5 --- src/CrossPointSettings.h | 4 ++-- src/activities/calendar/CalendarActivity.cpp | 4 +--- src/activities/settings/SettingsActivity.cpp | 5 ++--- src/main.cpp | 7 +++---- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index e4808251..4d6faf68 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -95,8 +95,8 @@ class CrossPointSettings { uint8_t longPressChapterSkip = 1; // Calendar mode settings - uint8_t calendarModeEnabled = 0; // 0 = disabled, 1 = enabled - uint8_t calendarRefreshHours = 4; // Refresh interval in hours (1-24) + uint8_t calendarModeEnabled = 0; // 0 = disabled, 1 = enabled + uint8_t calendarRefreshHours = 4; // Refresh interval in hours (1-24) char calendarServerUrl[256] = ""; // URL to fetch BMP image from ~CrossPointSettings() = default; diff --git a/src/activities/calendar/CalendarActivity.cpp b/src/activities/calendar/CalendarActivity.cpp index 9eb178e8..6731d3dd 100644 --- a/src/activities/calendar/CalendarActivity.cpp +++ b/src/activities/calendar/CalendarActivity.cpp @@ -53,9 +53,7 @@ void CalendarActivity::startWifiConnection() { stateStartTime = millis(); } -bool CalendarActivity::checkWifiConnection() { - return WiFi.status() == WL_CONNECTED; -} +bool CalendarActivity::checkWifiConnection() { return WiFi.status() == WL_CONNECTED; } bool CalendarActivity::fetchAndSaveImage() { Serial.printf("[%lu] [CAL] Fetching image from: %s\n", millis(), SETTINGS.calendarServerUrl); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index ee84c6cd..505c7447 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -5,12 +5,12 @@ #include +#include "../calendar/CalendarActivity.h" #include "CalibreSettingsActivity.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" #include "fontIds.h" -#include "../calendar/CalendarActivity.h" // Define the static settings list namespace { @@ -45,8 +45,7 @@ const SettingInfo settingsList[settingsCount] = { // Calendar mode settings SettingInfo::Toggle("Calendar Mode", &CrossPointSettings::calendarModeEnabled), SettingInfo::Value("Calendar Refresh (hrs)", &CrossPointSettings::calendarRefreshHours, {1, 24, 1}), - SettingInfo::Action("Test Calendar Now"), - SettingInfo::Action("Calibre Settings"), + SettingInfo::Action("Test Calendar Now"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; } // namespace diff --git a/src/main.cpp b/src/main.cpp index f8e6066c..1834e9eb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,9 +5,9 @@ #include #include #include +#include #include #include -#include #include @@ -18,12 +18,12 @@ #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" +#include "activities/calendar/CalendarActivity.h" #include "activities/home/HomeActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" -#include "activities/calendar/CalendarActivity.h" #include "fontIds.h" #define SPI_FQ 40000000 @@ -335,8 +335,7 @@ void setup() { // Not a timer wake - check if we need a backup fetch time_t now; time(&now); - uint32_t elapsed = (now > APP_STATE.lastCalendarFetch) ? - (now - APP_STATE.lastCalendarFetch) : UINT32_MAX; + uint32_t elapsed = (now > APP_STATE.lastCalendarFetch) ? (now - APP_STATE.lastCalendarFetch) : UINT32_MAX; uint32_t thresholdSec = SETTINGS.calendarRefreshHours * 3600; // If time is valid and elapsed > threshold, trigger backup fetch