#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "SdCardFontSystem.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" #include "activities/settings/SdFirmwareUpdateActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "platform/UsbSerialJtagHandoff.h" #include "util/ButtonNavigator.h" #include "util/ScreenshotUtil.h" #include "util/Timezones.h" #if CROSSPOINT_VECTOR_FONTS // Rendering (incl. FreeType TTF rasterization) runs on the Arduino loop task. // The default 8 KB stack overflows inside FreeType's FT_Open_Face / variable-font // parsing. This runtime override applies even with the prebuilt (dio_opi) core, // where CONFIG_ARDUINO_LOOP_STACK_SIZE from sdkconfig is baked in and ignored. // Vector-font boards only: without TTF the stock loop stack has always sufficed, // and non-PSRAM boards need the 16KB back in DRAM. SET_LOOP_TASK_STACK_SIZE(24 * 1024) #endif GfxRenderer renderer(display); MappedInputManager mappedInputManager(gpio, renderer); ActivityManager activityManager(renderer, mappedInputManager); FontDecompressor fontDecompressor; SdCardFontSystem sdFontSystem; FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts(), renderer.getTtfFonts()); static unsigned long allowSleepAt = 0; static unsigned long lastX4ProPowerClickAt = 0; namespace { constexpr unsigned long X4PRO_POWER_DOUBLE_CLICK_MS = 500; constexpr unsigned long X4PRO_POWER_CLICK_MAX_HOLD_MS = 300; } // namespace // A wake hold must never become an in-app power-button action. Boot may continue // while the button is held; swallow the one release that ends that wake gesture. static bool wakePowerReleasePending = false; // Fonts EpdFont notoserif14RegularFont(¬oserif_14_regular); EpdFont notoserif14BoldFont(¬oserif_14_bold); EpdFont notoserif14ItalicFont(¬oserif_14_italic); EpdFont notoserif14BoldItalicFont(¬oserif_14_bolditalic); EpdFontFamily notoserif14FontFamily(¬oserif14RegularFont, ¬oserif14BoldFont, ¬oserif14ItalicFont, ¬oserif14BoldItalicFont); #ifndef OMIT_FONTS EpdFont notoserif12RegularFont(¬oserif_12_regular); EpdFont notoserif12BoldFont(¬oserif_12_bold); EpdFont notoserif12ItalicFont(¬oserif_12_italic); EpdFont notoserif12BoldItalicFont(¬oserif_12_bolditalic); EpdFontFamily notoserif12FontFamily(¬oserif12RegularFont, ¬oserif12BoldFont, ¬oserif12ItalicFont, ¬oserif12BoldItalicFont); EpdFont notoserif16RegularFont(¬oserif_16_regular); EpdFont notoserif16BoldFont(¬oserif_16_bold); EpdFont notoserif16ItalicFont(¬oserif_16_italic); EpdFont notoserif16BoldItalicFont(¬oserif_16_bolditalic); EpdFontFamily notoserif16FontFamily(¬oserif16RegularFont, ¬oserif16BoldFont, ¬oserif16ItalicFont, ¬oserif16BoldItalicFont); EpdFont notoserif18RegularFont(¬oserif_18_regular); EpdFont notoserif18BoldFont(¬oserif_18_bold); EpdFont notoserif18ItalicFont(¬oserif_18_italic); EpdFont notoserif18BoldItalicFont(¬oserif_18_bolditalic); EpdFontFamily notoserif18FontFamily(¬oserif18RegularFont, ¬oserif18BoldFont, ¬oserif18ItalicFont, ¬oserif18BoldItalicFont); EpdFont notosans12RegularFont(¬osans_12_regular); EpdFont notosans12BoldFont(¬osans_12_bold); EpdFont notosans12ItalicFont(¬osans_12_italic); EpdFont notosans12BoldItalicFont(¬osans_12_bolditalic); EpdFontFamily notosans12FontFamily(¬osans12RegularFont, ¬osans12BoldFont, ¬osans12ItalicFont, ¬osans12BoldItalicFont); EpdFont notosans14RegularFont(¬osans_14_regular); EpdFont notosans14BoldFont(¬osans_14_bold); EpdFont notosans14ItalicFont(¬osans_14_italic); EpdFont notosans14BoldItalicFont(¬osans_14_bolditalic); EpdFontFamily notosans14FontFamily(¬osans14RegularFont, ¬osans14BoldFont, ¬osans14ItalicFont, ¬osans14BoldItalicFont); EpdFont notosans16RegularFont(¬osans_16_regular); EpdFont notosans16BoldFont(¬osans_16_bold); EpdFont notosans16ItalicFont(¬osans_16_italic); EpdFont notosans16BoldItalicFont(¬osans_16_bolditalic); EpdFontFamily notosans16FontFamily(¬osans16RegularFont, ¬osans16BoldFont, ¬osans16ItalicFont, ¬osans16BoldItalicFont); EpdFont notosans18RegularFont(¬osans_18_regular); EpdFont notosans18BoldFont(¬osans_18_bold); EpdFont notosans18ItalicFont(¬osans_18_italic); EpdFont notosans18BoldItalicFont(¬osans_18_bolditalic); EpdFontFamily notosans18FontFamily(¬osans18RegularFont, ¬osans18BoldFont, ¬osans18ItalicFont, ¬osans18BoldItalicFont); #endif // OMIT_FONTS EpdFont smallFont(¬osans_8_regular); EpdFontFamily smallFontFamily(&smallFont); EpdFont ui10RegularFont(&ubuntu_10_regular); EpdFont ui10BoldFont(&ubuntu_10_bold); EpdFontFamily ui10FontFamily(&ui10RegularFont, &ui10BoldFont); EpdFont ui12RegularFont(&ubuntu_12_regular); EpdFont ui12BoldFont(&ubuntu_12_bold); EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont); // Definitions for SilentRestart.h. RTC_NOINIT survives ESP.restart() but not power loss. RTC_NOINIT_ATTR uint32_t silentRebootMagic; RTC_NOINIT_ATTR uint32_t silentRebootTarget; RTC_NOINIT_ATTR uint32_t silentRebootPayload; constexpr uint32_t SILENT_REBOOT_MAGIC = 0xC1EAB007; constexpr uint32_t SILENT_REBOOT_TARGET_HOME = 0; constexpr uint32_t SILENT_REBOOT_TARGET_READER = 1; constexpr uint32_t SILENT_REBOOT_TARGET_SETTINGS = 2; constexpr uint32_t SILENT_REBOOT_TARGET_MAX = SILENT_REBOOT_TARGET_SETTINGS; constexpr uint32_t SILENT_REBOOT_LIGHT_ON = 1U << 0; // How the device is coming back to life, resolved once at boot. Both resume // flows suppress the splash and leave the panel holding its pre-boot frame; a // plain boot shows the splash. See setup() for the resolution. enum class BootResume : uint8_t { Splash, // cold boot, flash, panic, or plain reboot Silent, // heap-defrag ESP.restart() (RTC flag; lost on power loss) SplashlessWake, // wake from deep sleep with the splash suppressed by the SD flag }; // Latched true once enterDeepSleep() commits to sleeping, before it tears down // the current activity. WiFi activities call silentRestart() in onExit() to // clear heap fragmentation on the way out, but deep sleep is a full chip reset // on wake and already clears the heap, so rebooting here would just power the // device back up against the user's sleep gesture. Never cleared: // startDeepSleep() does not return, so a set latch only ends at the wakeup reset. static bool deepSleepInProgress = false; // A silent restart is internal maintenance, so the light must come back exactly // as the user left it. SETTINGS.frontlightOn is the saved preference and // legitimately diverges from the live state (a wake with Restore Light on Wake // off leaves the light off while the saved "was on" preference is kept), so // carry the live state across the reboot instead of re-deriving it from // settings. Cleared with the magic in setup(). static void armSilentReboot(const uint32_t target) { silentRebootTarget = target; silentRebootPayload = Frontlight.isOn() ? SILENT_REBOOT_LIGHT_ON : 0; silentRebootMagic = SILENT_REBOOT_MAGIC; } // Returns instead of rebooting when sleep supersedes the reboot; callers keep // running in that case. static void silentRestartTo(const uint32_t target, const char* targetName) { if (deepSleepInProgress) return; // sleeping supersedes the heap-defrag reboot armSilentReboot(target); LOG_DBG("MAIN", "Silent restart (target=%s)", targetName); // E-ink retains the previous frame until the target's first paint lands // (~2-3s). Without an overlay, users don't see the reboot and fire input // through to the new activity. On Home, Select on the default // selectorIndex=0 opens the most-recent book, looking like a trampoline back // to the reader they just exited. GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); delay(50); ESP.restart(); } void silentRestart() { silentRestartTo(SILENT_REBOOT_TARGET_HOME, "home"); } void silentRestartToReader() { silentRestartTo(SILENT_REBOOT_TARGET_READER, "reader"); } void silentRestartToSettings() { silentRestartTo(SILENT_REBOOT_TARGET_SETTINGS, "settings"); } void restartToHomeAfterStorageHandoff() { if (deepSleepInProgress) return; // sleeping supersedes the storage handoff reboot armSilentReboot(SILENT_REBOOT_TARGET_HOME); LOG_DBG("MAIN", "Restart after storage handoff (target=home)"); GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); delay(50); handoffUsbOtgToSerialJtag(); ESP.restart(); } void toggleFrontlight() { if (!Frontlight.present()) return; const bool lightOn = !Frontlight.isOn(); Frontlight.setOn(lightOn); SETTINGS.frontlightOn = lightOn ? 1 : 0; SETTINGS.saveToFile(); LOG_INF("LIGHT", "Frontlight toggled %s", lightOn ? "on" : "off"); } bool handleX4ProFrontlightDoubleClick() { if (!BoardConfig::isX4Pro() || !SETTINGS.doubleClickPwrLight || !gpio.wasReleased(HalGPIO::BTN_POWER)) { return false; } const unsigned long now = millis(); if (gpio.getPowerButtonHeldTime() > X4PRO_POWER_CLICK_MAX_HOLD_MS) { lastX4ProPowerClickAt = 0; return false; } if (lastX4ProPowerClickAt == 0 || now - lastX4ProPowerClickAt > X4PRO_POWER_DOUBLE_CLICK_MS) { lastX4ProPowerClickAt = now; return false; } lastX4ProPowerClickAt = 0; toggleFrontlight(); return true; } constexpr char SLEEP_FRAME_FILE[] = "/.crosspoint/sleep_frame.bin"; static void saveSleepFrameBuffer() { HalFile file; if (!Storage.openFileForWrite("SLP", SLEEP_FRAME_FILE, file)) return; file.write(renderer.getFrameBuffer(), renderer.getBufferSize()); file.close(); } static bool loadSleepFrameBuffer() { HalFile file; if (!Storage.openFileForRead("SLP", SLEEP_FRAME_FILE, file)) return false; const size_t bufferSize = display.getBufferSize(); const size_t bytesRead = file.read(display.getFrameBuffer(), bufferSize); file.close(); if (bytesRead != bufferSize) { Storage.remove(SLEEP_FRAME_FILE); return false; } Storage.remove(SLEEP_FRAME_FILE); return true; } // Enter deep sleep mode void enterDeepSleep(bool fromTimeout = false) { HalPowerManager::Lock powerLock; // Ensure we are at normal CPU frequency for sleep preparation APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); const bool isQuickResumeSleep = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::QUICK_RESUME || (fromTimeout && SETTINGS.quickResumeSleepScreen == CrossPointSettings::QUICK_RESUME_SLEEP_SCREEN::QUICK_RESUME_AFTER_TIMEOUT); // Every sleep mode leaves a complete retained frame on the e-ink panel. Keep // it visible until the first useful reader or home paint replaces it. APP_STATE.showBootScreen = false; APP_STATE.saveToFile(); // Commit to sleeping before goToSleep() runs the outgoing activity's onExit(): // a WiFi activity would otherwise silentRestart() here and reboot instead. deepSleepInProgress = true; activityManager.goToSleep(fromTimeout); if (isQuickResumeSleep) { saveSleepFrameBuffer(); } else if (Storage.exists(SLEEP_FRAME_FILE)) { // A stale Quick Resume frame must not replace the selected sleep screen during wake. Storage.remove(SLEEP_FRAME_FILE); } // Tear down WiFi so the modem power domain isn't held alive across deep sleep. // Wake from deep sleep is effectively a chip reset, so no state needs to survive. if (WiFi.getMode() != WIFI_MODE_NULL) { WiFi.disconnect(true); WiFi.mode(WIFI_OFF); } halTiltSensor.deepSleep(); display.deepSleep(); Storage.prepareForDeepSleep(); LOG_DBG("MAIN", "Entering deep sleep"); powerManager.startDeepSleep(gpio); } void setupDisplayAndFonts(bool seamless = false) { #if !FREEINK_MCU_C3 // C3 resolves its controller in HalGPIO::begin() before SPI claims the // display pins. X4 Pro skips that C3-only path, so probe here before // display.begin() selects and initializes its panel driver. static bool controllerResolved = false; if (!controllerResolved) { controllerResolved = true; if (freeink::applyXteinkDisplayController()) { LOG_DBG("MAIN", "Panel controller: UltraChip UC81xx variant detected"); } } #endif display.begin(seamless); renderer.begin(); activityManager.begin(); LOG_DBG("MAIN", "Display initialized"); // Initialize font decompressor for compressed reader fonts if (!fontDecompressor.init()) { LOG_ERR("MAIN", "Font decompressor init failed"); } fontCacheManager.setFontDecompressor(&fontDecompressor); renderer.setFontCacheManager(&fontCacheManager); renderer.insertFont(NOTOSERIF_14_FONT_ID, notoserif14FontFamily); #ifndef OMIT_FONTS renderer.insertFont(NOTOSERIF_12_FONT_ID, notoserif12FontFamily); renderer.insertFont(NOTOSERIF_16_FONT_ID, notoserif16FontFamily); renderer.insertFont(NOTOSERIF_18_FONT_ID, notoserif18FontFamily); renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily); renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily); renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily); renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily); #endif // OMIT_FONTS renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily); // Discover and load SD card fonts sdFontSystem.begin(renderer); LOG_DBG("MAIN", "Fonts setup"); } void setup() { BoardConfig::holdPowerRails(); #ifdef ENABLE_SERIAL_LOG #ifdef CROSSPOINT_WAIT_FOR_USB_SERIAL // Development builds preserve reliable early CDC logs; release builds let // enumeration proceed asynchronously so users do not pay this startup cost. delay(250); #endif Serial.begin(115200); #if LOG_SERIAL_HAS_TX_TIMEOUT logSerial.setTxTimeoutMs(1); // This is a load-bearing 1. Do not modify. #endif #endif HalSystem::begin(); // checkPanic() clears the watchdog capture marker after a successful SD // dump, so retain the boot classification for the later activity route. const bool rebootedFromPanic = HalSystem::isRebootFromPanic(); // Read-and-clear so a panic later in setup() doesn't loop into silent reboot. // Bound the target range too — RTC_NOINIT memory is uninitialized on cold boot. const bool isSilentReboot = (silentRebootMagic == SILENT_REBOOT_MAGIC); const uint32_t snapshotTarget = (isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_MAX) ? silentRebootTarget : 0; const bool silentRebootLightOn = isSilentReboot && (silentRebootPayload & SILENT_REBOOT_LIGHT_ON) != 0; silentRebootMagic = 0; silentRebootTarget = 0; silentRebootPayload = 0; gpio.begin(); powerManager.begin(); const auto wakeupReason = gpio.getWakeupReason(); // Sample the wake hold now — a click wake is released within milliseconds of // boot — but defer the sleep-or-boot decision until SETTINGS is loaded below: // click-to-wake is a setting, and an X4 battery power-off cuts all power, so // only SD state survives to the next boot. const bool wakeHoldVerified = wakeupReason != HalGPIO::WakeupReason::PowerButton || gpio.verifyPowerButtonWakeup(); // X4 Pro and X4 Classic both map BTN_UP to GPIO0 — an ESP32-S3 boot strap — so // gate recovery on the non-strap Down key (GPIO7) to avoid a stuck-in-recovery loop. const auto recoveryButton = (BoardConfig::isX4Pro() || BoardConfig::isX4Classic()) ? MappedInputManager::Button::Down : MappedInputManager::Button::Up; const bool recoveryFirmwareMode = wakeupReason == HalGPIO::WakeupReason::PowerButton && !BoardConfig::isPaperMono() && mappedInputManager.isPressed(recoveryButton); halTiltSensor.begin(); halClock.begin(); #if FREEINK_DEVICE_X4 || FREEINK_DEVICE_X3 LOG_INF("MAIN", "Hardware detect: %s", gpio.deviceIsX3() ? "X3" : "X4"); #else LOG_INF("MAIN", "Device: %s", BoardConfig::ACTIVE.name); #endif // SD Card Initialization // We need 6 open files concurrently when parsing a new chapter if (!Storage.begin()) { LOG_ERR("MAIN", "SD card initialization failed"); setupDisplayAndFonts(isSilentReboot); activityManager.goToFullScreenMessage("SD card error", EpdFontFamily::BOLD); return; } HalSystem::checkPanic(); APP_STATE.loadFromFile(); const bool isSleepWake = wakeupReason == HalGPIO::WakeupReason::PowerButton; const bool isPersistedSleepWake = isSleepWake && !APP_STATE.showBootScreen; if (recoveryFirmwareMode) { LOG_INF("MAIN", "Recovery firmware mode (%s + POWER held at boot)", (BoardConfig::isX4Pro() || BoardConfig::isX4Classic()) ? "DOWN" : "UP"); } // Touch boards default the reader menu to the toolbar overlay instead of the // full-screen list. Seeded before the load: fromJson() falls back to the // in-memory value only when the file carries no readerMenuStyle key, so a // user's saved choice (either style) still wins. if (gpio.hasTouch()) { SETTINGS.readerMenuStyle = CrossPointSettings::READER_MENU_TOOLBAR; } SETTINGS.loadFromFile(); // Push the saved timezone's POSIX rule into the clock (migrating the legacy // UTC-offset setting on first boot after the update). timezones::applyToClock(); RECENT_BOOKS.loadFromFile(); I18N.setLanguage(static_cast(SETTINGS.language)); KOREADER_STORE.loadFromFile(); OPDS_STORE.loadFromFile(); UITheme::getInstance().reload(); ButtonNavigator::setMappedInputManager(mappedInputManager); // Brightness and warmth are always restored. A normal wake starts with the // light off unless Restore Light on Wake is enabled; silent maintenance // reboots replay the live state captured at restart, so they neither go dark // nor light up against the user's wake preference. const bool restoreLightOn = isSilentReboot ? silentRebootLightOn : (SETTINGS.frontlightOn != 0 && SETTINGS.frontlightRestoreOnWake != 0); Frontlight.begin(SETTINGS.frontlightBrightness, SETTINGS.frontlightWarmth, restoreLightOn); switch (wakeupReason) { case HalGPIO::WakeupReason::PowerButton: // With Short Power Button Press = Sleep, a single click wakes on any // device; otherwise the button must still be held (ghost-wake debounce). if (!wakeHoldVerified && SETTINGS.shortPwrBtn != CrossPointSettings::SHORT_PWRBTN::SLEEP) { LOG_DBG("MAIN", "Power-button wake not held through verification, sleeping"); Storage.prepareForDeepSleep(); powerManager.startDeepSleep(gpio); } wakePowerReleasePending = true; break; case HalGPIO::WakeupReason::AfterUSBPower: // Most devices return to sleep after a USB-powered cold boot. LOG_DBG("MAIN", "Wakeup reason: After USB Power"); #if FREEINK_DEVICE_X4PRO || FREEINK_DEVICE_X4CLASSIC || FREEINK_DEVICE_PAPERMONO || FREEINK_DEVICE_EEGO_A4 // X4 Pro must stay awake so USB Serial/JTAG remains available after leaving // USB Drive and reconnecting the cable. Paper Mono has no armable GPIO wake // (its button is behind the PMIC). EEGO A4's post-flash reset reads as // POWERON (native-USB), so a flash would otherwise be misclassified as a // USB-power cold boot and sleep. Sleeping any of these here would strand // the device in a USB-replug boot loop (or sleep right after a flash). break; #else Storage.prepareForDeepSleep(); powerManager.startDeepSleep(gpio); break; #endif case HalGPIO::WakeupReason::AfterFlash: // After flashing, just proceed to boot case HalGPIO::WakeupReason::Other: default: break; } LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION); // Resolve the single boot-presentation decision. Skipping the splash also // skips the panel-clearing pass and the X3 initial-full-sync arming (see // HalDisplay::begin), so the first paint is FAST_REFRESH (~500ms) over the // retained frame and input dispatches against a visible UI. // Only a verified deep-sleep wake may use the one-shot persisted flag. // Otherwise a stale flag could suppress the splash on a cold boot. const BootResume resume = isSilentReboot ? BootResume::Silent : isPersistedSleepWake ? BootResume::SplashlessWake : BootResume::Splash; bool allowFastInitialReaderRefresh = false; bool needsWakeRefresh = false; setupDisplayAndFonts(resume != BootResume::Splash); switch (resume) { case BootResume::Silent: // Splash skipped: the routing block below picks the target activity; the // panel keeps showing the pre-reboot popup until that first paint lands. break; case BootResume::SplashlessWake: // One-shot flag: re-arm the splash for the next ordinary boot. Save // before any painting so a hang in the blocking paint path can't strand // us in a splashless-with-no-frame loop on the next boot. APP_STATE.showBootScreen = true; APP_STATE.saveToFile(); if (Storage.exists(SLEEP_FRAME_FILE) && loadSleepFrameBuffer()) { if (gpio.deviceIsX3()) { // begin() clears the X3 controller RAM, so restore the saved frame as // the baseline for the first reader paint without refreshing the panel. renderer.cleanupGrayscaleWithFrameBuffer(); allowFastInitialReaderRefresh = true; } } else { // Clean the retained sleep image as part of the first Home paint. needsWakeRefresh = true; } break; case BootResume::Splash: activityManager.goToBoot(); break; } // Output polarity is resolved per render by ActivityManager (night mode // inverts only the reading surfaces), so nothing to restore here. if (recoveryFirmwareMode) { // Skip normal home/reader routing: jump straight into the SD firmware picker. activityManager.replaceActivity( std::make_unique(renderer, mappedInputManager, /*recoveryMode=*/true)); } else if (rebootedFromPanic) { // If we rebooted from a panic, go to crash report screen to show the panic info activityManager.goToCrashReport(); } else if (resume == BootResume::Silent && snapshotTarget == SILENT_REBOOT_TARGET_READER && !APP_STATE.openEpubPath.empty()) { activityManager.goToReader(APP_STATE.openEpubPath); } else if (resume == BootResume::Silent && snapshotTarget == SILENT_REBOOT_TARGET_SETTINGS) { // Back out of the WiFi rows and the user is where they left off, not on Home. activityManager.goToSettings(); } else if (resume == BootResume::Silent) { // target == home (or reader with no open book): land on home — don't fall // through to the sleep-wake "resume reader" logic, which fires on stale // openEpubPath + lastSleepFromReader from a prior session. activityManager.goHome(); } else if (APP_STATE.openEpubPath.empty() || !APP_STATE.lastSleepFromReader || mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity // crashed (indicated by readerActivityLoadCount > 0) activityManager.goHome(HomeMenuItem::NONE, needsWakeRefresh); } else { // Clear app state to avoid getting into a boot loop if the epub doesn't load const auto path = APP_STATE.openEpubPath; APP_STATE.openEpubPath = ""; APP_STATE.readerActivityLoadCount++; APP_STATE.saveToFile(); activityManager.goToReader(path, allowFastInitialReaderRefresh); } if (resume == BootResume::Silent) { // Block until the first paint physically completes. refreshDisplay() // waits on the panel BUSY pin so when this returns the user can see the // new activity. Without the wait, an edge captured by gpio.update() // during boot dispatches against an invisible Home and the default // selectorIndex=0 opens the most-recent book. activityManager.requestUpdateAndWait(); // Absorb any button held at this point into currentState as a non-edge: // two gpio.update() calls separated by > InputManager's 5ms debounce // transition the held bit through lastDebounceTime into currentState // without setting pressedEvents, so the first loop()'s own gpio.update() // sees state == currentState and emits nothing. gpio.update(); delay(10); gpio.update(); } allowSleepAt = millis() + 2000; } void loop() { static unsigned long maxLoopDuration = 0; const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0; gpio.setSharedConfirmPowerShortPressEmitsPower(SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); mappedInputManager.update(); if (activityManager.requiresExclusiveStorageLoop()) { // USB Drive handed the raw SD card to the host. Do not run screenshots, // sleep, shortcuts, or normal navigation while its filesystem is detached. activityManager.loop(); if (activityManager.preventAutoSleep()) { powerManager.setPowerSaving(false); delay(10); } else { // No host is active, so a slower loop is safe. The activity itself times // out the raw-storage handoff rather than entering deep sleep detached. powerManager.setPowerSaving(true); delay(50); } return; } halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity()); renderer.setFadingFix(SETTINGS.fadingFix); // The ROM console does not depend on Arduino USB CDC's connection state. if ((Serial || FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF) && millis() - lastMemPrint >= 10000) { const auto heap = HalMemory::getInternalHeap(); LOG_INF("MEM", "Free: %zu bytes, Total: %zu bytes, Min Free: %zu bytes, MaxAlloc: %zu bytes", heap.freeBytes, heap.totalBytes, heap.minFreeBytes, heap.largestBlockBytes); #ifdef BOARD_HAS_PSRAM const auto psram = HalMemory::getPsramHeap(); LOG_INF("MEM", "PSRAM: Free: %zu bytes, Total: %zu bytes, Min Free: %zu bytes, MaxAlloc: %zu bytes", psram.freeBytes, psram.totalBytes, psram.minFreeBytes, psram.largestBlockBytes); #endif lastMemPrint = millis(); } // Handle incoming serial commands, // nb: we use logSerial from logging to avoid deprecation warnings if (logSerial.available() > 0) { String line = logSerial.readStringUntil('\n'); if (line.startsWith("CMD:")) { String cmd = line.substring(4); cmd.trim(); if (cmd == "SCREENSHOT") { const uint32_t bufferSize = display.getBufferSize(); logSerial.printf("SCREENSHOT_START:%d\n", bufferSize); uint8_t* buf = display.getFrameBuffer(); logSerial.write(buf, bufferSize); logSerial.printf("SCREENSHOT_END\n"); } } } // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || gpio.wasTouchActivity() || halTiltSensor.hadActivity() || activityManager.preventAutoSleep()) { lastActivityTime = millis(); // Reset inactivity timer powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity } // Let wake continue as soon as its hold has been verified. The release can // arrive after setup, so consume that one input frame rather than making it // a page turn, refresh, or other short power-button action. if (wakePowerReleasePending && !gpio.isPressed(HalGPIO::BTN_POWER)) { wakePowerReleasePending = false; return; } static bool screenshotButtonsReleased = true; static bool screenshotComboActive = false; if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) { screenshotComboActive = true; if (screenshotButtonsReleased) { screenshotButtonsReleased = false; { RenderLock lock; ScreenshotUtil::takeScreenshot(renderer); } } return; } if (screenshotComboActive) { if (gpio.isPressed(HalGPIO::BTN_POWER)) return; if (gpio.wasReleased(HalGPIO::BTN_POWER)) { screenshotButtonsReleased = true; screenshotComboActive = false; return; } screenshotButtonsReleased = true; screenshotComboActive = false; } // Consume the second X4 Pro power-button release so it does not also run a // configured short-power action after toggling the frontlight. if (handleX4ProFrontlightDoubleClick()) { return; } const bool x4ProDoubleClickPwrLight = BoardConfig::isX4Pro() && SETTINGS.doubleClickPwrLight; #if FREEINK_CAP_TOUCH // A single X4 Pro power click becomes Confirm only after the frontlight // double-click window expires without a second click. mappedInputManager.setPowerConfirmClickFrame(false); if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PWR_CONFIRM && x4ProDoubleClickPwrLight) { if (lastX4ProPowerClickAt != 0 && millis() - lastX4ProPowerClickAt > X4PRO_POWER_DOUBLE_CLICK_MS) { lastX4ProPowerClickAt = 0; mappedInputManager.setPowerConfirmClickFrame(true); } // A release held too long to be a double-click candidate (but still within // the normal Confirm press duration) never reaches handleX4ProFrontlightDoubleClick's // click tracking above, so it needs its own Confirm check here. if (mappedInputManager.wasReleased(MappedInputManager::Button::Power) && gpio.getPowerButtonHeldTime() > X4PRO_POWER_CLICK_MAX_HOLD_MS && gpio.getPowerButtonHeldTime() <= SETTINGS.getPowerButtonDuration()) { mappedInputManager.setPowerConfirmClickFrame(true); } } #endif // Same deferral for SLEEP: getPowerButtonDuration() drops to 10ms so a quick // tap sleeps the device, which otherwise fires on button-down and never lets // a second click land. Sleep only once the double-click window has passed. if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP && x4ProDoubleClickPwrLight && lastX4ProPowerClickAt != 0 && millis() - lastX4ProPowerClickAt > X4PRO_POWER_DOUBLE_CLICK_MS) { lastX4ProPowerClickAt = 0; enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start return; } const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); if (sleepTimeoutMs > 0 && millis() - lastActivityTime >= sleepTimeoutMs) { LOG_DBG("SLP", "Auto-sleep triggered after %lu ms of inactivity", sleepTimeoutMs); enterDeepSleep(true); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start return; } // A hold that woke the device must be released before it can count as a new // in-app long press. Otherwise a user who keeps holding after wake would put // the device straight back to sleep once allowSleepAt expires. static bool powerReleasedSinceWake = false; if (!gpio.isPressed(HalGPIO::BTN_POWER)) powerReleasedSinceWake = true; // On X4 Pro with SLEEP, a press still within the click window is a // double-click candidate — let it be released and evaluated above instead // of sleeping on button-down. const bool x4ProAwaitingClickWindow = x4ProDoubleClickPwrLight && SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP && gpio.getPowerButtonHeldTime() <= X4PRO_POWER_CLICK_MAX_HOLD_MS; if (!x4ProAwaitingClickWindow && powerReleasedSinceWake && millis() >= allowSleepAt && gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getPowerButtonHeldTime() > SETTINGS.getPowerButtonDuration()) { // If the screenshot combination is potentially being pressed, don't sleep if (gpio.isPressed(HalGPIO::BTN_DOWN)) { return; } LOG_DBG("MAIN", "Power button held %lums, sleeping", gpio.getPowerButtonHeldTime()); enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start return; } #if FREEINK_DEVICE_PAPERMONO // Paper Mono reports the PMIC power button as a one-tick click, so the held // path above cannot fire. With the default Ignore action, retain the normal // power-button meaning and shut down; explicit alternate bindings still win. if ((SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP || SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::IGNORE) && millis() >= allowSleepAt && mappedInputManager.wasReleased(MappedInputManager::Button::Power)) { enterDeepSleep(); return; } #endif // Refresh screen when power button is short-pressed with FORCE_REFRESH setting. if (mappedInputManager.homeButtonAction() == HomeButtonAction::ToggleFrontlight) { toggleFrontlight(); } if (mappedInputManager.homeButtonAction() == HomeButtonAction::Refresh || (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::FORCE_REFRESH && mappedInputManager.wasReleased(MappedInputManager::Button::Power))) { LOG_DBG("MAIN", "Manual screen refresh triggered"); if (!activityManager.handleForcedRefresh()) { RenderLock lock; renderer.displayBuffer(HalDisplay::HALF_REFRESH); } } // Refresh the battery icon when USB is plugged or unplugged. // Placed after sleep guards so we never queue a render that won't be processed. // Not while reading: there a repaint is a full page re-render (visible // flash, the AA pass re-running, and a frontlight dip under the refresh // load); the reader's status bar picks the charging state up on the next // page turn instead. if (gpio.wasUsbStateChanged() && !activityManager.isReaderActivity()) { activityManager.requestUpdate(); } const unsigned long activityStartTime = millis(); activityManager.loop(); const unsigned long activityDuration = millis() - activityStartTime; const unsigned long loopDuration = millis() - loopStartTime; if (loopDuration > maxLoopDuration) { maxLoopDuration = loopDuration; if (maxLoopDuration > 50) { LOG_DBG("LOOP", "New max loop duration: %lu ms (activity: %lu ms)", maxLoopDuration, activityDuration); } } bool skipLoopDelay = false; { RenderLock lock(RenderLock::Mode::Try); if (!lock.ownsLock()) { // Let rendering advance without treating lock contention as idle. delay(10); return; } skipLoopDelay = activityManager.skipLoopDelay(); } // Add delay at the end of the loop to prevent tight spinning // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power if (skipLoopDelay) { powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested yield(); // Give FreeRTOS a chance to run tasks, but return immediately } else { if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) { // If we've been inactive for a while, increase the delay to save power powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity // Sleep in short slices and wake the poll as soon as a button contact closes. // InputManager commits a press only when two consecutive polls agree, so a // press shorter than one 50 ms sleep could land in a single sample and be lost. const unsigned long idleStart = millis(); while (millis() - idleStart < 50) { delay(10); if (gpio.rawInputActive()) break; } } else { // Short delay to prevent tight loop while still being responsive delay(10); } } }