Compare commits

...

9 Commits

Author SHA1 Message Date
pablohc
871c702084
Merge 436dc9e593 into e5c0ddc9fa 2026-02-01 12:54:03 +00:00
Uri Tauber
e5c0ddc9fa
feat: Debugging monitor script (#555)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* **What is the goal of this PR?**
Add a debugging script to help developers monitor the ESP32 serial port
directly from a PC.

* **What changes are included?**
Added a new script: scripts/debugging_monitor.py

## Additional Context

While working on a new Crosspoint-Reader feature, it quickly became
clear that watching the ESP32 serial output without any visual cues was
inconvenient and easy to mess up.

This script improves the debugging experience by reading data from the
serial port and providing:

1. A timestamp prefix for every log line (instead of milliseconds since
power-up)
2. Color-coded output for different message types
3. A secondary window displaying a live graph of RAM usage, which is
especially useful for tracking the memory impact of new features

<img width="1916" height="1049" alt="Screenshot_20260126_183811"
src="https://github.com/user-attachments/assets/6291887f-ac17-43ac-9e43-f5dec8a7097e"
/>

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY >**_
I wrote the initial version of the script. Gemini was used to help add
the Matplotlib-based graphing and threading logic.
2026-02-01 22:53:20 +11:00
Arthur Tazhitdinov
b1dcb7733b
fix: truncating chapter titles using UTF-8 safe function (#599)
## Summary

* Truncating chapter titles using utf8 safe functions (Cyrillic titles
were split mid codepoint)
* refactoring of lib/Utf8

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_
2026-02-01 22:23:48 +11:00
Arthur Tazhitdinov
0d82b03981
fix: don't wake up after USB connect (#644)
## Summary

* fixes problem that if short power button press is enabled, connecting
device to usb leads to waking up
2026-02-01 22:19:33 +11:00
Dave Allie
5a97334ace
Revert "fix: don't wake up after USB connect" (#643)
Reverts crosspoint-reader/crosspoint-reader#576

Causing a boot loop on master
2026-02-01 21:35:25 +11:00
pablohc
436dc9e593 style: fix Serial.printf formatting in Xtc.cpp 2026-01-26 14:42:50 +01:00
pablohc
85b7e8124d style: fix formatting issues 2026-01-26 14:21:50 +01:00
pablohc
6d6559022c style: fix formatting issues in cover generation code 2026-01-26 13:47:32 +01:00
pablohc
5671b05d04 fix: correct home cover dimensions 2026-01-26 12:54:44 +01:00
16 changed files with 402 additions and 138 deletions

View File

@ -95,6 +95,20 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
```sh ```sh
pio run --target upload pio run --target upload
``` ```
### Debugging
After flashing the new features, its recommended to capture detailed logs from the serial port.
First, make sure all required Python packages are installed:
```python
python3 -m pip install serial colorama matplotlib
```
after that run the script:
```sh
python3 scripts/debugging_monitor.py
```
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
## Internals ## Internals

View File

@ -428,28 +428,28 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false; return false;
} }
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } std::string Epub::getCoverHomeBmpPath() const { return cachePath + "/cover_home.bmp"; }
bool Epub::generateThumbBmp() const { bool Epub::generateCoverHomeBmp() const {
// Already generated, return true // Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) { if (SdMan.exists(getCoverHomeBmpPath().c_str())) {
return true; return true;
} }
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); Serial.printf("[%lu] [EBP] Cannot generate home BMP, cache not loaded\n", millis());
return false; return false;
} }
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) { if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); Serial.printf("[%lu] [EBP] No known cover image for home screen\n", millis());
return false; return false;
} }
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Generating home BMP from JPG cover image\n", millis());
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg; FsFile coverJpg;
@ -463,30 +463,50 @@ bool Epub::generateThumbBmp() const {
return false; return false;
} }
FsFile thumbBmp; FsFile homeBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { if (!SdMan.openFileForWrite("EBP", getCoverHomeBmpPath(), homeBmp)) {
coverJpg.close(); coverJpg.close();
return false; return false;
} }
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// For home screen, use 400px height with proportional width for optimal performance
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed) // Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240; constexpr int HOME_TARGET_HEIGHT = 400;
constexpr int THUMB_TARGET_HEIGHT = 400;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, FsFile tempJpg;
THUMB_TARGET_HEIGHT); if (!SdMan.openFileForRead("EBP", coverJpgTempPath, tempJpg)) {
coverJpg.close();
return false;
}
// First get JPEG dimensions to calculate proper width
int jpegWidth, jpegHeight;
if (!JpegToBmpConverter::getJpegDimensions(tempJpg, &jpegWidth, &jpegHeight)) {
Serial.printf("[%lu] [EBP] Failed to get JPEG dimensions for home cover\n", millis());
coverJpg.close();
tempJpg.close();
return false;
}
tempJpg.close();
// Calculate proportional width for 400px height
const int targetWidth = (400 * jpegWidth) / jpegHeight;
const bool success =
JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, homeBmp, targetWidth, HOME_TARGET_HEIGHT);
coverJpg.close(); coverJpg.close();
thumbBmp.close(); homeBmp.close();
SdMan.remove(coverJpgTempPath.c_str()); SdMan.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Failed to generate home BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str()); SdMan.remove(getCoverHomeBmpPath().c_str());
} }
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), Serial.printf("[%lu] [EBP] Generated home BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no"); success ? "yes" : "no");
return success; return success;
} else { } else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping home screen\n", millis());
} }
return false; return false;

View File

@ -47,8 +47,9 @@ class Epub {
const std::string& getLanguage() const; const std::string& getLanguage() const;
std::string getCoverBmpPath(bool cropped = false) const; std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const; bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const; // Home screen support (optimized 400px height covers for Continue Reading card)
bool generateThumbBmp() const; std::string getCoverHomeBmpPath() const;
bool generateCoverHomeBmp() const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const; bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;

View File

@ -415,13 +415,21 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const { const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
std::string item = text; std::string item = text;
int itemWidth = getTextWidth(fontId, item.c_str(), style); const char* ellipsis = "...";
while (itemWidth > maxWidth && item.length() > 8) { int textWidth = getTextWidth(fontId, item.c_str(), style);
item.replace(item.length() - 5, 5, "..."); if (textWidth <= maxWidth) {
itemWidth = getTextWidth(fontId, item.c_str(), style); // Text fits, return as is
return item;
} }
return item;
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
utf8RemoveLastChar(item);
}
return item.empty() ? ellipsis : item + ellipsis;
} }
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation // Note: Internal driver treats screen in command orientation; this library exposes a logical orientation

View File

@ -569,3 +569,32 @@ bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print
int targetMaxHeight) { int targetMaxHeight) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true); return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
} }
// Get JPEG dimensions without full conversion
bool JpegToBmpConverter::getJpegDimensions(FsFile& jpegFile, int* width, int* height) {
// Reset file position to beginning
if (!jpegFile.seek(0)) {
Serial.printf("[%lu] [JPG] Failed to seek to beginning of JPEG file\n", millis());
return false;
}
// Initialize JPEG decoder
pjpeg_image_info_t imageInfo = {};
JpegReadContext context{jpegFile, {}, 0, 0};
const int decodeStatus = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, false);
if (decodeStatus != 0) {
Serial.printf("[%lu] [JPG] pjpeg_decode_init failed with status %d\n", millis(), decodeStatus);
return false;
}
// Get dimensions from image info
*width = imageInfo.m_width;
*height = imageInfo.m_height;
Serial.printf("[%lu] [JPG] Read JPEG dimensions: %dx%d\n", millis(), *width, *height);
// Reset file position after reading
jpegFile.seek(0);
return true;
}

View File

@ -16,4 +16,6 @@ class JpegToBmpConverter {
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
// Extract JPEG dimensions without loading full image
static bool getJpegDimensions(FsFile& jpegFile, int* width, int* height);
}; };

View File

@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
return cp; return cp;
} }
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}

View File

@ -1,7 +1,11 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <string>
#define REPLACEMENT_GLYPH 0xFFFD #define REPLACEMENT_GLYPH 0xFFFD
uint32_t utf8NextCodepoint(const unsigned char** string); uint32_t utf8NextCodepoint(const unsigned char** string);
// Remove the last UTF-8 codepoint from a std::string and return the new size.
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);

View File

@ -301,11 +301,11 @@ bool Xtc::generateCoverBmp() const {
return true; return true;
} }
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } std::string Xtc::getCoverHomeBmpPath() const { return cachePath + "/cover_home.bmp"; }
bool Xtc::generateThumbBmp() const { bool Xtc::generateCoverHomeBmp() const {
// Already generated // Already generated
if (SdMan.exists(getThumbBmpPath().c_str())) { if (SdMan.exists(getCoverHomeBmpPath().c_str())) {
return true; return true;
} }
@ -332,43 +332,18 @@ bool Xtc::generateThumbBmp() const {
// Get bit depth // Get bit depth
const uint8_t bitDepth = parser->getBitDepth(); const uint8_t bitDepth = parser->getBitDepth();
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) // For home screen, use 400px height with proportional width for optimal performance
constexpr int THUMB_TARGET_WIDTH = 240; // Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_HEIGHT = 400; constexpr int HOME_TARGET_HEIGHT = 400;
// Calculate scale factor // Calculate proportional width for 400px height
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width; const uint16_t targetWidth = static_cast<uint16_t>((HOME_TARGET_HEIGHT * pageInfo.width) / pageInfo.height);
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
// Only scale down, never up Serial.printf("[%lu] [XTC] Generating home BMP: %dx%d -> %dx%d\n", millis(), pageInfo.width, pageInfo.height,
if (scale >= 1.0f) { targetWidth, HOME_TARGET_HEIGHT);
// Page is already small enough, just use cover.bmp
// Copy cover.bmp to thumb.bmp
if (generateCoverBmp()) {
FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
dst.write(buffer, bytesRead);
}
dst.close();
}
src.close();
}
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath().c_str());
}
return false;
}
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
pageInfo.height, thumbWidth, thumbHeight, scale); pageInfo.height, targetWidth, HOME_TARGET_HEIGHT);
// Allocate buffer for page data // Allocate buffer for page data
size_t bitmapSize; size_t bitmapSize;
@ -393,15 +368,15 @@ bool Xtc::generateThumbBmp() const {
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp; FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { if (!SdMan.openFileForWrite("XTC", getCoverHomeBmpPath(), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
// Write 1-bit BMP header for fast home screen rendering // Write 1-bit BMP header for fast home screen rendering
const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes const uint32_t rowSize = (targetWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes
const uint32_t imageSize = rowSize * thumbHeight; const uint32_t imageSize = rowSize * HOME_TARGET_HEIGHT;
const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette
// File header // File header
@ -416,9 +391,9 @@ bool Xtc::generateThumbBmp() const {
// DIB header // DIB header
uint32_t dibHeaderSize = 40; uint32_t dibHeaderSize = 40;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&dibHeaderSize), 4);
int32_t widthVal = thumbWidth; int32_t widthVal = targetWidth;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&widthVal), 4);
int32_t heightVal = -static_cast<int32_t>(thumbHeight); // Negative for top-down int32_t heightVal = -static_cast<int32_t>(HOME_TARGET_HEIGHT); // Negative for top-down
thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4); thumbBmp.write(reinterpret_cast<const uint8_t*>(&heightVal), 4);
uint16_t planes = 1; uint16_t planes = 1;
thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2); thumbBmp.write(reinterpret_cast<const uint8_t*>(&planes), 2);
@ -451,8 +426,8 @@ bool Xtc::generateThumbBmp() const {
return false; return false;
} }
// Fixed-point scale factor (16.16) // Fixed-point scale factor (16.16) - scale to fit 400px height
uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f / scale); uint32_t scaleInv_fp = static_cast<uint32_t>(65536.0f * static_cast<float>(pageInfo.height) / HOME_TARGET_HEIGHT);
// Pre-calculate plane info for 2-bit mode // Pre-calculate plane info for 2-bit mode
const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) : 0; const size_t planeSize = (bitDepth == 2) ? ((static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8) : 0;
@ -461,7 +436,7 @@ bool Xtc::generateThumbBmp() const {
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { for (uint16_t dstY = 0; dstY < HOME_TARGET_HEIGHT; dstY++) {
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
// Calculate source Y range with bounds checking // Calculate source Y range with bounds checking
@ -472,7 +447,7 @@ bool Xtc::generateThumbBmp() const {
if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1;
if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height;
for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { for (uint16_t dstX = 0; dstX < targetWidth; dstX++) {
// Calculate source X range with bounds checking // Calculate source X range with bounds checking
uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16; uint32_t srcXStart = (static_cast<uint32_t>(dstX) * scaleInv_fp) >> 16;
uint32_t srcXEnd = (static_cast<uint32_t>(dstX + 1) * scaleInv_fp) >> 16; uint32_t srcXEnd = (static_cast<uint32_t>(dstX + 1) * scaleInv_fp) >> 16;
@ -557,8 +532,8 @@ bool Xtc::generateThumbBmp() const {
thumbBmp.close(); thumbBmp.close();
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, Serial.printf("[%lu] [XTC] Generated home BMP (%dx%d): %s\n", millis(), targetWidth, HOME_TARGET_HEIGHT,
getThumbBmpPath().c_str()); getCoverHomeBmpPath().c_str());
return true; return true;
} }

View File

@ -63,9 +63,9 @@ class Xtc {
// Cover image support (for sleep screen) // Cover image support (for sleep screen)
std::string getCoverBmpPath() const; std::string getCoverBmpPath() const;
bool generateCoverBmp() const; bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card) // Home screen support (optimized 400px height covers for Continue Reading card)
std::string getThumbBmpPath() const; std::string getCoverHomeBmpPath() const;
bool generateThumbBmp() const; bool generateCoverHomeBmp() const;
// Page access // Page access
uint32_t getPageCount() const; uint32_t getPageCount() const;

214
scripts/debugging_monitor.py Executable file
View File

@ -0,0 +1,214 @@
import sys
import argparse
import re
import threading
from datetime import datetime
from collections import deque
import time
# Try to import potentially missing packages
try:
import serial
from colorama import init, Fore, Style
import matplotlib.pyplot as plt
import matplotlib.animation as animation
except ImportError as e:
missing_package = e.name
print("\n" + "!" * 50)
print(f" Error: The required package '{missing_package}' is not installed.")
print("!" * 50)
print(f"\nTo fix this, please run the following command in your terminal:\n")
install_cmd = "pip install "
packages = []
if 'serial' in str(e): packages.append("pyserial")
if 'colorama' in str(e): packages.append("colorama")
if 'matplotlib' in str(e): packages.append("matplotlib")
print(f" {install_cmd}{' '.join(packages)}")
print("\nExiting...")
sys.exit(1)
# --- Global Variables for Data Sharing ---
# Store last 50 data points
MAX_POINTS = 50
time_data = deque(maxlen=MAX_POINTS)
free_mem_data = deque(maxlen=MAX_POINTS)
total_mem_data = deque(maxlen=MAX_POINTS)
data_lock = threading.Lock() # Prevent reading while writing
# Initialize colors
init(autoreset=True)
def get_color_for_line(line):
"""
Classify log lines by type and assign appropriate colors.
"""
line_upper = line.upper()
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]):
return Fore.RED
if "[MEM]" in line_upper or "FREE:" in line_upper:
return Fore.CYAN
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
return Fore.MAGENTA
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
return Fore.GREEN
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
return Fore.YELLOW
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
return Fore.BLUE
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
return Fore.LIGHTYELLOW_EX
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
return Fore.LIGHTBLACK_EX
if "[RBS]" in line_upper:
return Fore.LIGHTCYAN_EX
if "[KRS]" in line_upper:
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
return Fore.LIGHTGREEN_EX
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
return Fore.LIGHTYELLOW_EX
return Fore.WHITE
def parse_memory_line(line):
"""
Extracts Free and Total bytes from the specific log line.
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
"""
# Regex to find 'Free: <digits>' and 'Total: <digits>'
match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line)
if match:
try:
free_bytes = int(match.group(1))
total_bytes = int(match.group(2))
return free_bytes, total_bytes
except ValueError:
return None, None
return None, None
def serial_worker(port, baud):
"""
Runs in a background thread. Handles reading serial, printing to console,
and updating the data lists.
"""
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
try:
ser = serial.Serial(port, baud, timeout=0.1)
ser.dtr = False
ser.rts = False
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
try:
while True:
try:
raw_data = ser.readline().decode('utf-8', errors='replace')
if not raw_data:
continue
clean_line = raw_data.strip()
if not clean_line:
continue
# Add PC timestamp
pc_time = datetime.now().strftime("%H:%M:%S")
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except OSError:
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}")
break
except Exception as e:
# If thread is killed violently (e.g. main exit), silence errors
pass
finally:
if 'ser' in locals() and ser.is_open:
ser.close()
def update_graph(frame):
"""
Called by Matplotlib animation to redraw the chart.
"""
with data_lock:
if not time_data:
return
# Convert deques to lists for plotting
x = list(time_data)
y_free = list(free_mem_data)
y_total = list(total_mem_data)
plt.cla() # Clear axis
# Plot Total RAM
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--')
# Plot Free RAM
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o')
# Fill area under Free RAM
plt.fill_between(x, y_free, color='green', alpha=0.1)
plt.title("ESP32 Memory Monitor")
plt.ylabel("Memory (KB)")
plt.xlabel("Time")
plt.legend(loc='upper left')
plt.grid(True, linestyle=':', alpha=0.6)
# Rotate date labels
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
def main():
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate")
args = parser.parse_args()
# 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True)
t.start()
# 2. Set up the Graph (Main Thread)
try:
plt.style.use('light_background')
except:
pass
fig = plt.figure(figsize=(10, 6))
# Update graph every 1000ms
ani = animation.FuncAnimation(fig, update_graph, interval=1000)
try:
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}")
plt.show()
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
plt.close('all') # Force close any lingering plot windows
if __name__ == "__main__":
main()

View File

@ -4,6 +4,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <Utf8.h>
#include <Xtc.h> #include <Xtc.h>
#include <cstring> #include <cstring>
@ -59,8 +60,8 @@ void HomeActivity::onEnter() {
lastBookAuthor = std::string(epub.getAuthor()); lastBookAuthor = std::string(epub.getAuthor());
} }
// Try to generate thumbnail image for Continue Reading card // Try to generate thumbnail image for Continue Reading card
if (epub.generateThumbBmp()) { if (epub.generateCoverHomeBmp()) {
coverBmpPath = epub.getThumbBmpPath(); coverBmpPath = epub.getCoverHomeBmpPath();
hasCoverImage = true; hasCoverImage = true;
} }
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
@ -75,8 +76,8 @@ void HomeActivity::onEnter() {
lastBookAuthor = std::string(xtc.getAuthor()); lastBookAuthor = std::string(xtc.getAuthor());
} }
// Try to generate thumbnail image for Continue Reading card // Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) { if (xtc.generateCoverHomeBmp()) {
coverBmpPath = xtc.getThumbBmpPath(); coverBmpPath = xtc.getCoverHomeBmpPath();
hasCoverImage = true; hasCoverImage = true;
} }
} }
@ -223,10 +224,32 @@ void HomeActivity::render() {
constexpr int bottomMargin = 60; constexpr int bottomMargin = 60;
// --- Top "book" card for the current title (selectorIndex == 0) --- // --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = pageWidth / 2; // Load cover image to get its dimensions
const int bookHeight = pageHeight / 2; int coverWidth = 0;
const int bookX = (pageWidth - bookWidth) / 2; int coverHeight = 0;
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty()) {
FsFile coverFile;
if (SdMan.openFileForRead("HOME", coverBmpPath, coverFile)) {
Bitmap testBitmap(coverFile);
if (testBitmap.parseHeaders() == BmpReaderError::Ok) {
coverWidth = testBitmap.getWidth();
coverHeight = testBitmap.getHeight();
}
coverFile.close();
}
}
// Calculate card dimensions based on cover image
// Use 400px height as specified, with proportional width
constexpr int CARD_HEIGHT = 400;
const int cardWidth = (coverWidth > 0 && coverHeight > 0)
? (CARD_HEIGHT * coverWidth) / coverHeight
: 240; // Fallback to 240px width if no image (maintain aspect ratio)
const int bookX = (pageWidth - cardWidth) / 2;
constexpr int bookY = 30; constexpr int bookY = 30;
const int bookWidth = cardWidth;
const int bookHeight = CARD_HEIGHT;
const bool bookSelected = hasContinueReading && selectorIndex == 0; const bool bookSelected = hasContinueReading && selectorIndex == 0;
// Bookmark dimensions (used in multiple places) // Bookmark dimensions (used in multiple places)
@ -245,27 +268,9 @@ void HomeActivity::render() {
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Calculate position to center image within the book card // Since the book card already has the exact same size as the image,
int coverX, coverY; // we can draw it at the same position with the same dimensions
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// Draw the cover image centered within the book card
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
// Draw border around the card // Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight); renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
@ -366,7 +371,7 @@ void HomeActivity::render() {
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back // Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..." lines.back().resize(lines.back().size() - 3); // Remove "..."
StringUtils::utf8RemoveLastChar(lines.back()); utf8RemoveLastChar(lines.back());
lines.back().append("..."); lines.back().append("...");
} }
break; break;
@ -375,7 +380,7 @@ void HomeActivity::render() {
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) { while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe) // Word itself is too long, trim it (UTF-8 safe)
StringUtils::utf8RemoveLastChar(i); utf8RemoveLastChar(i);
// Check if we have room for ellipsis // Check if we have room for ellipsis
std::string withEllipsis = i + "..."; std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
@ -428,7 +433,7 @@ void HomeActivity::render() {
if (!lastBookAuthor.empty()) { if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor; std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
} }
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
@ -462,14 +467,14 @@ void HomeActivity::render() {
// Trim author if too long (UTF-8 safe) // Trim author if too long (UTF-8 safe)
bool wasTrimmed = false; bool wasTrimmed = false;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
wasTrimmed = true; wasTrimmed = true;
} }
if (wasTrimmed && !trimmedAuthor.empty()) { if (wasTrimmed && !trimmedAuthor.empty()) {
// Make room for ellipsis // Make room for ellipsis
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
!trimmedAuthor.empty()) { !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
} }
trimmedAuthor.append("..."); trimmedAuthor.append("...");
} }

View File

@ -570,8 +570,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
titleMarginLeftAdjusted = titleMarginLeft; titleMarginLeftAdjusted = titleMarginLeft;
} }
while (titleWidth > availableTitleSpace && title.length() > 11) { if (titleWidth > availableTitleSpace) {
title.replace(title.length() - 8, 8, "..."); title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }
} }

View File

@ -533,8 +533,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
std::string title = txt->getTitle(); std::string title = txt->getTitle();
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) { if (titleWidth > availableTextWidth) {
title.replace(title.length() - 8, 8, "..."); title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }

View File

@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension); return localFile.endsWith(localExtension);
} }
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
// Walk back to find the start of the last UTF-8 character
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}
} // namespace StringUtils } // namespace StringUtils

View File

@ -19,10 +19,4 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension);
// UTF-8 safe string truncation - removes one character from the end
// Returns the new size after removing one UTF-8 character
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, size_t numChars);
} // namespace StringUtils } // namespace StringUtils