#include "SleepActivity.h" #include #include #include #include #include #include "BookManager.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "fontIds.h" #include "images/CrossLarge.h" #include "util/StringUtils.h" namespace { // Edge luminance cache file format (per-BMP): // - 4 bytes: uint32_t file size (for cache invalidation) // - 4 bytes: EdgeLuminance (top, bottom, left, right) constexpr size_t EDGE_CACHE_SIZE = 8; // Book-level edge cache file format (stored in book's cache directory): // - 4 bytes: uint32_t cover BMP file size (for cache invalidation) // - 4 bytes: EdgeLuminance (top, bottom, left, right) // - 1 byte: cover mode (0=FIT, 1=CROP) - for EPUB mode invalidation constexpr size_t BOOK_EDGE_CACHE_SIZE = 9; } // namespace void SleepActivity::onEnter() { Activity::onEnter(); renderPopup("Entering Sleep..."); if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { return renderBlankSleepScreen(); } if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { return renderCustomSleepScreen(); } if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) { return renderCoverSleepScreen(); } renderDefaultSleepScreen(); } void SleepActivity::renderPopup(const char* message) const { const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); constexpr int margin = 20; const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; constexpr int y = 117; const int w = textWidth + margin * 2; const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2; // renderer.clearScreen(); renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true); renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD); renderer.displayBuffer(); } void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /sleep directory auto dir = SdMan.open("/sleep"); if (dir && dir.isDirectory()) { std::vector files; char name[500]; // collect all valid BMP files for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { if (file.isDirectory()) { file.close(); continue; } file.getName(name, sizeof(name)); auto filename = std::string(name); if (filename[0] == '.') { file.close(); continue; } if (filename.substr(filename.length() - 4) != ".bmp") { Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name); file.close(); continue; } Bitmap bitmap(file); if (bitmap.parseHeaders() != BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name); file.close(); continue; } files.emplace_back(filename); file.close(); } const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles auto randomFileIndex = random(numFiles); // If we picked the same image as last time, reroll while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) { randomFileIndex = random(numFiles); } APP_STATE.lastSleepImage = randomFileIndex; APP_STATE.saveToFile(); const auto bmpPath = "/sleep/" + files[randomFileIndex]; FsFile file; if (SdMan.openFileForRead("SLP", bmpPath, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap, bmpPath); dir.close(); return; } } } } if (dir) dir.close(); // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. FsFile file; const std::string rootSleepPath = "/sleep.bmp"; if (SdMan.openFileForRead("SLP", rootSleepPath, file)) { Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); renderBitmapSleepScreen(bitmap, rootSleepPath); return; } } renderDefaultSleepScreen(); } void SleepActivity::renderDefaultSleepScreen() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); // Bezel compensation const int bezelTop = renderer.getBezelOffsetTop(); const int bezelBottom = renderer.getBezelOffsetBottom(); const int centerY = (pageHeight - bezelTop - bezelBottom) / 2 + bezelTop; renderer.clearScreen(); renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, centerY - 64, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, centerY + 70, "CrossPoint", true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, centerY + 95, "SLEEPING"); // Make sleep screen dark unless light is selected in settings if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) { renderer.invertScreen(); } renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); float cropX = 0, cropY = 0; int drawWidth = pageWidth; int drawHeight = pageHeight; int fillWidth, fillHeight; // Actual area the image will occupy (set per mode) Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight); const float bitmapRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), bitmapRatio, screenRatio); const auto coverMode = SETTINGS.sleepScreenCoverMode; if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::ACTUAL) { // ACTUAL mode: Show image at actual size, centered (no scaling) x = (pageWidth - bitmap.getWidth()) / 2; y = (pageHeight - bitmap.getHeight()) / 2; // Don't constrain to screen dimensions - drawBitmap will clip drawWidth = 0; drawHeight = 0; fillWidth = static_cast(bitmap.getWidth()); fillHeight = static_cast(bitmap.getHeight()); Serial.printf("[%lu] [SLP] ACTUAL mode: centering at %d, %d (fill: %dx%d)\n", millis(), x, y, fillWidth, fillHeight); } else if (coverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { // CROP mode: Scale to fill screen completely (may crop edges) // Calculate crop values to fill the screen while maintaining aspect ratio if (bitmapRatio > screenRatio) { // Image is wider than screen ratio - crop horizontally cropX = 1.0f - (screenRatio / bitmapRatio); Serial.printf("[%lu] [SLP] CROP mode: cropping x by %f\n", millis(), cropX); } else if (bitmapRatio < screenRatio) { // Image is taller than screen ratio - crop vertically cropY = 1.0f - (bitmapRatio / screenRatio); Serial.printf("[%lu] [SLP] CROP mode: cropping y by %f\n", millis(), cropY); } // After cropping, the image should fill the screen exactly x = 0; y = 0; fillWidth = pageWidth; fillHeight = pageHeight; Serial.printf("[%lu] [SLP] CROP mode: drawing at 0, 0 with crop %f, %f\n", millis(), cropX, cropY); } else { // FIT mode (default): Scale to fit entire image within screen (may have letterboxing) // Calculate the scaled dimensions float scale; if (bitmapRatio > screenRatio) { // Image is wider than screen ratio - fit to width scale = static_cast(pageWidth) / static_cast(bitmap.getWidth()); } else { // Image is taller than screen ratio - fit to height scale = static_cast(pageHeight) / static_cast(bitmap.getHeight()); } // Use ceil to ensure fill area covers all drawn pixels fillWidth = static_cast(std::ceil(bitmap.getWidth() * scale)); fillHeight = static_cast(std::ceil(bitmap.getHeight() * scale)); // Center the scaled image x = (pageWidth - fillWidth) / 2; y = (pageHeight - fillHeight) / 2; Serial.printf("[%lu] [SLP] FIT mode: scale %f, scaled size %d x %d, position %d, %d\n", millis(), scale, fillWidth, fillHeight, x, y); } // Get edge luminance values (from cache or calculate) const EdgeLuminance edges = getEdgeLuminance(bitmap, bmpPath); const uint8_t topGray = quantizeGray(edges.top); const uint8_t bottomGray = quantizeGray(edges.bottom); const uint8_t leftGray = quantizeGray(edges.left); const uint8_t rightGray = quantizeGray(edges.right); Serial.printf("[%lu] [SLP] Edge luminance: T=%d B=%d L=%d R=%d -> gray levels T=%d B=%d L=%d R=%d\n", millis(), edges.top, edges.bottom, edges.left, edges.right, topGray, bottomGray, leftGray, rightGray); // Check if greyscale pass should be used (PR #476: skip if filter is applied) const bool hasGreyscale = bitmap.hasGreyscale() && SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; // Clear screen to white first (default background) renderer.clearScreen(0xFF); // Fill letterbox regions with edge colors (BW pass) // Top letterbox if (y > 0) { renderer.fillRectGray(0, 0, pageWidth, y, topGray); } // Bottom letterbox if (y + fillHeight < pageHeight) { renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray); } // Left letterbox if (x > 0) { renderer.fillRectGray(0, y, x, fillHeight, leftGray); } // Right letterbox if (x + fillWidth < pageWidth) { renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray); } Serial.printf("[%lu] [SLP] drawing bitmap at %d, %d\n", millis(), x, y); renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); // PR #476: Apply inverted B&W filter if selected if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { renderer.invertScreen(); } renderer.displayBuffer(EInkDisplay::HALF_REFRESH); if (hasGreyscale) { // Grayscale LSB pass bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); // Fill letterbox regions for LSB pass if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray); if (y + fillHeight < pageHeight) renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray); if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray); if (x + fillWidth < pageWidth) renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray); renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); // Grayscale MSB pass bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); // Fill letterbox regions for MSB pass if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray); if (y + fillHeight < pageHeight) renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray); if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray); if (x + fillWidth < pageWidth) renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray); renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); renderer.setRenderMode(GfxRenderer::BW); } } void SleepActivity::renderCoverSleepScreen() const { if (APP_STATE.openEpubPath.empty()) { return renderDefaultSleepScreen(); } const bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; // Try to use cached edge data to skip book metadata loading if (tryRenderCachedCoverSleep(APP_STATE.openEpubPath, cropped)) { return; } // Cache miss - need to load book metadata and generate cover Serial.printf("[%lu] [SLP] Cache miss, loading book metadata\n", millis()); std::string coverBmpPath; // Check if the current book is TXT or EPUB if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { // Handle TXT file - looks for cover image in the same folder Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastTxt.load()) { Serial.println("[SLP] Failed to load last TXT"); return renderDefaultSleepScreen(); } if (!lastTxt.generateCoverBmp()) { Serial.println("[SLP] No cover image found for TXT file"); return renderDefaultSleepScreen(); } coverBmpPath = lastTxt.getCoverBmpPath(); } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastEpub.load()) { Serial.println("[SLP] Failed to load last epub"); return renderDefaultSleepScreen(); } if (!lastEpub.generateCoverBmp(cropped)) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); } coverBmpPath = lastEpub.getCoverBmpPath(cropped); } else { return renderDefaultSleepScreen(); } FsFile file; if (SdMan.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { // Render the bitmap - this will calculate and cache edge luminance per-BMP renderBitmapSleepScreen(bitmap, coverBmpPath); // Also save to book-level edge cache for faster subsequent sleeps const std::string edgeCachePath = getBookEdgeCachePath(APP_STATE.openEpubPath); if (!edgeCachePath.empty()) { // Get the edge luminance that was just calculated (it's now cached per-BMP) const EdgeLuminance edges = getEdgeLuminance(bitmap, coverBmpPath); const uint32_t bmpFileSize = bitmap.getFileSize(); const uint8_t coverMode = cropped ? 1 : 0; FsFile cacheFile; if (SdMan.openFileForWrite("SLP", edgeCachePath, cacheFile)) { uint8_t cacheData[BOOK_EDGE_CACHE_SIZE]; cacheData[0] = bmpFileSize & 0xFF; cacheData[1] = (bmpFileSize >> 8) & 0xFF; cacheData[2] = (bmpFileSize >> 16) & 0xFF; cacheData[3] = (bmpFileSize >> 24) & 0xFF; cacheData[4] = edges.top; cacheData[5] = edges.bottom; cacheData[6] = edges.left; cacheData[7] = edges.right; cacheData[8] = coverMode; cacheFile.write(cacheData, BOOK_EDGE_CACHE_SIZE); cacheFile.close(); Serial.printf("[%lu] [SLP] Saved book-level edge cache to %s\n", millis(), edgeCachePath.c_str()); } } return; } } renderDefaultSleepScreen(); } void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) { // Convert "/dir/file.bmp" to "/dir/.file.bmp.perim" const size_t lastSlash = bmpPath.find_last_of('/'); if (lastSlash == std::string::npos) { // No directory, just prepend dot return "." + bmpPath + ".perim"; } const std::string dir = bmpPath.substr(0, lastSlash + 1); const std::string filename = bmpPath.substr(lastSlash + 1); return dir + "." + filename + ".perim"; } uint8_t SleepActivity::quantizeGray(uint8_t lum) { // Quantize luminance (0-255) to 4-level grayscale (0-3) // Thresholds tuned for X4 display gray levels if (lum < 43) return 0; // black if (lum < 128) return 1; // dark gray if (lum < 213) return 2; // light gray return 3; // white } EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::string& bmpPath) const { const std::string cachePath = getEdgeCachePath(bmpPath); EdgeLuminance result = {128, 128, 128, 128}; // Default to neutral gray // Try to read from cache FsFile cacheFile; if (SdMan.openFileForRead("SLP", cachePath, cacheFile)) { uint8_t cacheData[EDGE_CACHE_SIZE]; if (cacheFile.read(cacheData, EDGE_CACHE_SIZE) == EDGE_CACHE_SIZE) { // Extract cached file size const uint32_t cachedSize = static_cast(cacheData[0]) | (static_cast(cacheData[1]) << 8) | (static_cast(cacheData[2]) << 16) | (static_cast(cacheData[3]) << 24); // Get current BMP file size from already-opened bitmap const uint32_t currentSize = bitmap.getFileSize(); // Validate cache if (cachedSize == currentSize && currentSize > 0) { result.top = cacheData[4]; result.bottom = cacheData[5]; result.left = cacheData[6]; result.right = cacheData[7]; Serial.printf("[%lu] [SLP] Edge cache hit for %s: T=%d B=%d L=%d R=%d\n", millis(), bmpPath.c_str(), result.top, result.bottom, result.left, result.right); cacheFile.close(); return result; } Serial.printf("[%lu] [SLP] Edge cache invalid (size mismatch: %lu vs %lu)\n", millis(), static_cast(cachedSize), static_cast(currentSize)); } cacheFile.close(); } // Cache miss - calculate edge luminance Serial.printf("[%lu] [SLP] Calculating edge luminance for %s\n", millis(), bmpPath.c_str()); result = bitmap.detectEdgeLuminance(2); // Sample 2 pixels deep for stability Serial.printf("[%lu] [SLP] Edge luminance detected: T=%d B=%d L=%d R=%d\n", millis(), result.top, result.bottom, result.left, result.right); // Get BMP file size from already-opened bitmap for cache const uint32_t fileSize = bitmap.getFileSize(); // Save to cache if (fileSize > 0 && SdMan.openFileForWrite("SLP", cachePath, cacheFile)) { uint8_t cacheData[EDGE_CACHE_SIZE]; cacheData[0] = fileSize & 0xFF; cacheData[1] = (fileSize >> 8) & 0xFF; cacheData[2] = (fileSize >> 16) & 0xFF; cacheData[3] = (fileSize >> 24) & 0xFF; cacheData[4] = result.top; cacheData[5] = result.bottom; cacheData[6] = result.left; cacheData[7] = result.right; cacheFile.write(cacheData, EDGE_CACHE_SIZE); cacheFile.close(); Serial.printf("[%lu] [SLP] Saved edge cache to %s\n", millis(), cachePath.c_str()); } return result; } std::string SleepActivity::getBookEdgeCachePath(const std::string& bookPath) { const std::string cacheDir = BookManager::getCacheDir(bookPath); if (cacheDir.empty()) { return ""; } return cacheDir + "/edge.bin"; } std::string SleepActivity::getCoverBmpPath(const std::string& cacheDir, const std::string& bookPath, bool cropped) { if (cacheDir.empty()) { return ""; } // EPUB uses different paths for fit vs crop modes if (StringUtils::checkFileExtension(bookPath, ".epub")) { return cropped ? (cacheDir + "/cover_crop.bmp") : (cacheDir + "/cover_fit.bmp"); } // TXT uses a single cover.bmp return cacheDir + "/cover.bmp"; } bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool cropped) const { // Try to render cover sleep screen using cached edge data without loading book metadata const std::string edgeCachePath = getBookEdgeCachePath(bookPath); if (edgeCachePath.empty()) { Serial.println("[SLP] Cannot get edge cache path"); return false; } // Check if edge cache exists FsFile cacheFile; if (!SdMan.openFileForRead("SLP", edgeCachePath, cacheFile)) { Serial.println("[SLP] No edge cache file found"); return false; } // Read cache data uint8_t cacheData[BOOK_EDGE_CACHE_SIZE]; if (cacheFile.read(cacheData, BOOK_EDGE_CACHE_SIZE) != BOOK_EDGE_CACHE_SIZE) { Serial.println("[SLP] Edge cache file too small"); cacheFile.close(); return false; } cacheFile.close(); // Extract cached values const uint32_t cachedBmpSize = static_cast(cacheData[0]) | (static_cast(cacheData[1]) << 8) | (static_cast(cacheData[2]) << 16) | (static_cast(cacheData[3]) << 24); EdgeLuminance cachedEdges; cachedEdges.top = cacheData[4]; cachedEdges.bottom = cacheData[5]; cachedEdges.left = cacheData[6]; cachedEdges.right = cacheData[7]; const uint8_t cachedCoverMode = cacheData[8]; // Check if cover mode matches (for EPUB) const uint8_t currentCoverMode = cropped ? 1 : 0; if (StringUtils::checkFileExtension(bookPath, ".epub") && cachedCoverMode != currentCoverMode) { Serial.printf("[SLP] Cover mode changed (cached=%d, current=%d), invalidating cache\n", cachedCoverMode, currentCoverMode); return false; } // Construct cover BMP path const std::string cacheDir = BookManager::getCacheDir(bookPath); const std::string coverBmpPath = getCoverBmpPath(cacheDir, bookPath, cropped); if (coverBmpPath.empty()) { Serial.println("[SLP] Cannot construct cover BMP path"); return false; } // Try to open the cover BMP FsFile bmpFile; if (!SdMan.openFileForRead("SLP", coverBmpPath, bmpFile)) { Serial.printf("[SLP] Cover BMP not found: %s\n", coverBmpPath.c_str()); return false; } // Check if BMP file size matches cache const uint32_t currentBmpSize = bmpFile.size(); if (currentBmpSize != cachedBmpSize || currentBmpSize == 0) { Serial.printf("[SLP] BMP size mismatch (cached=%lu, current=%lu)\n", static_cast(cachedBmpSize), static_cast(currentBmpSize)); bmpFile.close(); return false; } // Parse bitmap headers Bitmap bitmap(bmpFile); if (bitmap.parseHeaders() != BmpReaderError::Ok) { Serial.println("[SLP] Failed to parse cached cover BMP"); bmpFile.close(); return false; } Serial.printf("[%lu] [SLP] Using cached cover sleep: %s (T=%d B=%d L=%d R=%d)\n", millis(), coverBmpPath.c_str(), cachedEdges.top, cachedEdges.bottom, cachedEdges.left, cachedEdges.right); // Render the bitmap with cached edge values // We call renderBitmapSleepScreen which will use getEdgeLuminance internally, // but since the per-BMP cache should also exist (same values), it will be a cache hit renderBitmapSleepScreen(bitmap, coverBmpPath); return true; }