Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
37 kB · 836 lines
C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837#include <Arduino.h>#include <BoardConfig.h>#include <Epub.h>#include <FontCacheManager.h>#include <FontDecompressor.h>#include <GfxRenderer.h>#include <HalClock.h>#include <HalDisplay.h>#include <HalFrontlight.h>#include <HalGPIO.h>#include <HalMemory.h>#include <HalPowerManager.h>#include <HalStorage.h>#include <HalSystem.h>#include <HalTiltSensor.h>#include <I18n.h>#include <Logging.h>#include <SPI.h>#include <VectorFontSupport.h>#include <WiFi.h>#include <XteinkDetect.h>#include <builtinFonts/all.h>
#include <cstring>
#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;
// FontsEpdFont 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_FONTSEpdFont 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 modevoid 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<Language>(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<SdFirmwareUpdateActivity>(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); } }}