diff --git a/README.md b/README.md index d59df835..cfbb0453 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec - [ ] EPUB picker with cover art - [x] Custom sleep screen - [x] Cover sleep screen +- [x] Calendar Mode (automated image display from URL) - [x] Wifi book upload - [x] Wifi OTA updates - [x] Configurable font, layout, and display options @@ -43,6 +44,61 @@ This project is **not affiliated with Xteink**; it's built as a community projec See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint. +## Calendar Mode + +Calendar Mode transforms your CrossPoint Reader into an automated display that periodically fetches and displays a BMP image from a URL. This is useful for: + +- Calendar/schedule displays +- Weather dashboards +- Information displays +- Digital signage + +### How It Works + +1. When enabled, the device wakes from deep sleep on a timer (1-24 hours configurable) +2. Connects to saved WiFi network +3. Fetches a BMP image from the configured URL +4. Displays the image as the sleep screen +5. Returns to deep sleep until the next refresh + +### Configuration + +In **Settings**, you'll find: + +| Setting | Description | +|---------|-------------| +| **Calendar Mode** | Enable/disable the feature (ON/OFF) | +| **Calendar Refresh (hours)** | How often to refresh (1-24 hours) | +| **Calendar Server URL** | URL that returns a BMP image | +| **Test Calendar Now** | Immediately test the fetch and display | + +### Image Requirements + +The server URL must return a valid BMP image: + +- **Format**: BMP (Windows bitmap) +- **Bit depth**: 1, 2, 8, 24, or 32 bpp (8-bit grayscale recommended) +- **Dimensions**: 480 x 800 pixels (device screen size) +- **Orientation**: Top-down (negative height in BMP header) recommended + +### Setting Up a Server + +You'll need a server that generates and serves BMP images. Example options: + +- **Cloudflare Worker**: Serverless function that generates images on-demand +- **Local server**: Python/Node.js script serving static or dynamic images +- **Static hosting**: Pre-generated images on any web server + +The device makes a simple HTTP GET request to the URL and expects raw BMP data in response. + +### Power Consumption + +Calendar Mode uses ESP32-C3 deep sleep between refreshes: +- Deep sleep: ~10-20µA +- Active (WiFi + fetch): ~100-150mA for ~10-30 seconds + +With a 4-hour refresh interval, expect several months of battery life. + ## Installing ### Web (latest firmware) 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..3fc46607 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..46504b2b --- /dev/null +++ b/src/activities/calendar/CalendarActivity.cpp @@ -0,0 +1,251 @@ +#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()); + renderStatus("Starting..."); + + // 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()); + + char statusMsg[64]; + snprintf(statusMsg, sizeof(statusMsg), "Connecting to %s...", cred.ssid.c_str()); + renderStatus(statusMsg); + + 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); + + // Check if URL is set + if (strlen(SETTINGS.calendarServerUrl) == 0) { + Serial.printf("[%lu] [CAL] ERROR: Calendar Server URL is empty!\n", millis()); + return false; + } + + 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(); + + // Show error on screen + char errorMsg[128]; + snprintf(errorMsg, sizeof(errorMsg), "Error: %s", message); + renderStatus(errorMsg); +} + +void CalendarActivity::renderStatus(const char* status) { + Serial.printf("[%lu] [CAL] Status: %s\n", millis(), status); + + // Show status on screen + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 50, "Calendar Mode", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, 100, status, true); + renderer.displayBuffer(); +} + +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()); + renderStatus("WiFi connected, fetching..."); + 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()); + renderStatus("Image saved!"); + 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..4114207c --- /dev/null +++ b/src/activities/calendar/CalendarActivity.h @@ -0,0 +1,49 @@ +#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/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efa0b9e1..cec7d892 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,14 +9,20 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "activities/calendar/CalendarActivity.h" +#include "activities/util/KeyboardEntryActivity.h" #include "fontIds.h" // Define the static settings list namespace { -constexpr int settingsCount = 20; +constexpr int settingsCount = 24; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), + // Calendar mode settings + SettingInfo::Toggle("Calendar Mode", &CrossPointSettings::calendarModeEnabled), + SettingInfo::Value("Calendar Refresh (hours)", &CrossPointSettings::calendarRefreshHours, {1, 24, 1}), + SettingInfo::Action("Calendar Server URL"), SettingInfo::Action("Test Calendar Now"), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), @@ -41,8 +47,7 @@ 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"}), - SettingInfo::Action("Calibre Settings"), - SettingInfo::Action("Check for updates")}; + SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -155,6 +160,30 @@ void SettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Calendar Server URL") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Calendar Server URL", SETTINGS.calendarServerUrl, 10, + 255, // maxLength + false, // not password + [this](const std::string& url) { + strncpy(SETTINGS.calendarServerUrl, url.c_str(), sizeof(SETTINGS.calendarServerUrl) - 1); + SETTINGS.calendarServerUrl[sizeof(SETTINGS.calendarServerUrl) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + 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 @@ -206,6 +235,11 @@ void SettingsActivity::render() const { valueText = settingsList[i].enumValues[value]; } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } else if (settingsList[i].type == SettingType::ACTION) { + // Show [Set]/[Not Set] for URL-based action settings + if (strcmp(settingsList[i].name, "Calendar Server URL") == 0) { + valueText = (strlen(SETTINGS.calendarServerUrl) > 0) ? "[Set]" : "[Not Set]"; + } } const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); diff --git a/src/main.cpp b/src/main.cpp index 8a7c3b91..86c7324e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include @@ -16,6 +17,7 @@ #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" @@ -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();