Project: Open-source e-reader firmware for Xteink X4 (ESP32-C3) Mission: Provide a lightweight, high-performance reading experience focused on EPUB rendering on constrained hardware.
- Role: Senior Embedded Systems Engineer (ESP-IDF/Arduino-ESP32 specialized).
- Primary Constraint: 380KB RAM is the hard ceiling. Stability is non-negotiable.
- Evidence-Based Reasoning: Before proposing a change, you MUST cite the specific file path and line numbers that justify the modification.
- Anti-Hallucination: Do not assume the existence of libraries or ESP-IDF functions. If you are unsure of an API's availability for the ESP32-C3 RISC-V target, check the freeink-sdk source or the FreeInk SDK docs (https://freeink.org/llms.txt for an LLM-readable index) first.
- No Unfounded Claims: Do not claim performance gains or memory savings without explaining the technical mechanism (e.g., DRAM vs IRAM usage).
- Resource Justification: You must justify any new heap allocation (new, malloc, std::vector) or explain why a stack/static alternative was rejected.
- Verification: After suggesting a fix, instruct the user on how to verify it (e.g., monitoring heap via Serial or checking a specific cache file).
CRITICAL: Detect the host platform at session start to choose appropriate tools and commands.
# Detect platform (run once per session)
uname -s
# Returns: MINGW64_NT-* (Windows Git Bash), Linux, Darwin (macOS)Detection Required: Run uname -s at session start to determine platform
- Windows (Git Bash): Unix commands,
C:\paths in Windows but/in bash, limited glob (usefind+xargs) - Linux/WSL: Full bash, Unix paths, native glob support
Cross-Platform Code Formatting:
./bin/clang-format-fix -gNever invoke or probe clang-format directly. The repository wrapper is the only sanctioned entry point.
- MCUs: ESP32-C3 (single-core RISC-V @ 160MHz) and ESP32-S3 (
sticky, dual-core Xtensa LX7) - RAM: ~380KB usable on ESP32-C3 (VERY LIMITED - primary project constraint)
- NO PSRAM on C3.
- Single Buffer Mode: Only ONE 48KB framebuffer (not double-buffered)
- Flash: 16MB (Instruction storage and static data)
- Display: 800x480 E-Ink (Slow refresh, monochrome, 1-2s full update)
- Framebuffer: 48,000 bytes (800 × 480 ÷ 8)
- Storage: SD Card (Used for books and aggressive caching)
- Stack Safety: Limit local function variables to < 256 bytes. The ESP32-C3 default stack is small; use std::unique_ptr or static pools for larger buffers.
- Heap Fragmentation: Avoid repeated new/delete in loops. Allocate buffers once during onEnter() and reuse them.
- Flash Persistence: Large constant data (UI strings, lookup tables) MUST be marked static const to stay in Flash (Instruction Bus), freeing DRAM.
- String Policy: Prohibit std::string and Arduino String in hot paths. Use std::string_view for read-only access and snprintf with fixed char[] buffers for construction.
- UI Strings: All user-facing text must use the
tr()macro (e.g.,tr(STR_LOADING)) for i18n support. Never hardcode UI strings directly. For the avoidance of doubt, logging messages (LOG_DBG/LOG_ERR) can be hardcoded, but user-facing text must usetr(). constexprFirst: Compile-time constants and lookup tables must beconstexpr, not juststatic const. This moves computation to compile time, enables dead-branch elimination, and guarantees flash placement. Usestatic constexprfor class-level constants.std::vectorPre-allocation: Always call.reserve(N)before anypush_back()loop. Each growth event allocates a new block (2×), copies all elements, then frees the old one — three heap operations that fragment DRAM. When the final size is unknown, estimate conservatively.- SD Persistence Throttling: Settings, state, credentials, and other
PersistableStoreJSON files live on SD under/.crosspoint/throughHalStorage; SPIFFS is not mounted. Guard redundant writes and debounce progress saves to avoid serialization, SD I/O, andstorageMutexcost. newis not nothrow on ESP32: With-fno-exceptions, barenewthat fails callsabort()— it does NOT returnnullptr. Always usenew (std::nothrow)and null-check the result, or usemakeUniqueNoThrow<T>()fromlib/Memory/Memory.h. Never write barenewfor any fallible allocation.
PlatformIO is BOTH a VS Code extension AND a CLI tool:
-
VS Code Extension (Recommended):
-
Extension ID:
platformio.platformio-ide(see.vscode/extensions.json) -
Provides: Toolbar buttons, IntelliSense, integrated build/upload/monitor
-
Configuration:
.vscode/c_cpp_properties.json,.vscode/tasks.json -
Usage: Click Build (✓), Upload (→), or Monitor (🔌) buttons
-
-
CLI Tool (
piocommand):-
Installation: Python package (typically
pip install platformio) -
Windows Location:
C:\Users\<user>\AppData\Local\Programs\Python\Python3xx\Scripts\pio.exe -
Verify:
which pio(Git Bash) orwhere.exe pio(cmd) -
Usage:
pio run,pio run -t upload, etc.
-
Configuration Files:
platformio.ini: Main build configuration (committed to git)platformio.local.ini: Local overrides (gitignored, create if needed)partitions.csv: ESP32 flash partition layout
- Standard: C++20 (
-std=c++2a). No Exceptions, No RTTI. - Logging: ALWAYS use
LOG_INF,LOG_DBG, orLOG_ERRfromLogging.h. Raw Serial output is deprecated. - Environments (in
platformio.ini):default: Development (LOG_LEVEL=2, serial enabled)gh_release: Production (LOG_LEVEL=0)gh_release_rc: Release candidate (LOG_LEVEL=1)slim: Minimal build (no serial logging)
These flags in platformio.ini fundamentally affect firmware behavior:
-DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 // Single framebuffer (saves 48KB RAM!)
-DARDUINO_USB_MODE=1 // Enable USB CDC
-DARDUINO_USB_CDC_ON_BOOT=1 // Serial available immediately at boot
-DXML_CONTEXT_BYTES=1024 // XML parser memory limit (EPUB parsing)
-DUSE_UTF8_LONG_NAMES=1 // SD card long filename support
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 // Avoid zlib name conflicts
-DXML_GE=0 // Disable XML general entities (security)
-DDESTRUCTOR_CLOSES_FILE=1 // FsFile destructor auto-closes (SdFat)DESTRUCTOR_CLOSES_FILE implications:
-
SdFat's
FsBaseFiledestructor callsclose()automatically when the object goes out of scope -
Do NOT add explicit
file.close()calls for localFsFilevariables — the destructor handles it -
Explicit
close()is still required in these cases:-
Close before delete: Must close before
Storage.remove()on the same path -
Close before reopen: Must close before reopening the same
FsFilevariable (e.g., write then reopen for read, or rewrite the same path) -
Member variables:
FsFilemembers persist beyond any single function scope, so close at the intended release point (e.g., inonExit())
-
SINGLE_BUFFER_MODE implications:
- Only ONE framebuffer exists (not double-buffered)
- Grayscale rendering requires temporary buffer allocation (
renderer.storeBwBuffer()) - Must call
renderer.restoreBwBuffer()to free temporary buffers - See lib/GfxRenderer/GfxRenderer.cpp:439-440 for malloc usage
- lib/: Internal libraries (Epub engine, GfxRenderer, UITheme, I18n)
- lib/hal/: Hardware Abstraction Layer (HalDisplay, HalGPIO, HalStorage)
- lib/I18n/: Internationalization (translations in
translations/*.yaml, generated string tables)
- src/activities/: UI logic using the Activity Lifecycle (onEnter, loop, onExit)
- freeink-sdk/: Low-level SDK (EInkDisplay, InputManager, BatteryMonitor, SDCardManager)
- .crosspoint/: SD-based binary cache for EPUB metadata and pre-rendered layout sections
CRITICAL: Always use HAL classes, NOT SDK classes directly.
| HAL Class | Wraps SDK Class | Purpose | Singleton Macro |
|---|---|---|---|
HalDisplay |
EInkDisplay |
E-ink display control | (none) |
HalGPIO |
InputManager |
Button input handling | (none) |
HalStorage |
SDCardManager |
SD card file I/O | Storage |
Location: lib/hal/
Why HAL?
- Provides consistent error logging per module
- Abstracts SDK implementation details
- Centralizes resource management
Example - HalStorage:
#include <HalStorage.h>
// Use Storage singleton (defined via macro)
HalFile file;
if (Storage.openFileForRead("MODULE", "/path/to/file.bin", file)) {
// Read from file
// No file.close() needed — DESTRUCTOR_CLOSES_FILE=1 handles it at scope exit
}Usage: Use HalFile (the mutex-wrapping handle), NOT raw SdFat FsFile or Arduino File. Do NOT add file.close() for local variables (see DESTRUCTOR_CLOSES_FILE above).
SdFat is not thread-safe; all SD access MUST go through HalStorage:
- SdFat's
SdSpiCardtracks SPI bus state with an unsynchronizedm_spiActivebool. Two tasks calling SdFat concurrently can confuse that state machine and end with one task callingSPIClass::endTransaction()against a paramLock the other task is holding. That trips FreeRTOS'sxTaskPriorityDisinheritassert (tasks.c:5156, pxTCB == pxCurrentTCBs[0]) and panics the system. See SdFat issue #518. HalStorageserializes everything viastorageMutex. Downstream code usesHalFile(declared in<HalStorage.h>); every method call (read, write, seek, close) takes the mutex.HalFile's destructor also takes the mutex before letting the underlying SdFatFsFileclose.- Never call into
SdFat/SdSpiCard/FsBaseFile/SDCardManager/ rawFsFiledirectly — that bypasses the mutex.
- Classes: PascalCase (e.g., EpubReaderActivity)
- Methods/Variables: camelCase (e.g., renderPage())
- Constants: UPPER_SNAKE_CASE (e.g., MAX_BUFFER_SIZE)
- Private Members: memberVariable (no prefix)
- File Names: Match Class names (e.g., EpubReaderActivity.cpp)
- Use #pragma once for all header files.
- Keep comments short and write them for the merged state, as if the code had always worked this way.
- Remove before/after narration, investigation measurements, and rationale that belongs in the commit message.
- Keep only non-obvious mechanism, field/parameter meaning, or the reason a special case exists.
- Smart Pointers: Prefer std::unique_ptr.
- RAII: Use destructors for cleanup. Call
vTaskDelete()explicitly for deterministic task release. Do NOT callfile.close()on localFsFilevariables —DESTRUCTOR_CLOSES_FILE=1handles it at scope exit (see Critical Build Flags).
string_view is not null-terminated. Passing .data() to any C-style API (drawText, snprintf, strcmp, SdFat file paths) is undefined behaviour when the view is a substring or a view of a non-null-terminated buffer.
Rule: string_view is safe only when passing to C++ APIs that accept string_view. For any C API boundary, convert explicitly:
// WRONG - undefined behaviour if view is a substring:
renderer.drawText(font, x, y, myView.data(), true);
// CORRECT - guaranteed null-terminated:
renderer.drawText(font, x, y, std::string(myView).c_str(), true);
// CORRECT - for short strings, use a stack buffer:
char buf[64];
snprintf(buf, sizeof(buf), "%.*s", (int)myView.size(), myView.data());All code runs from flash via the instruction cache. During internal-flash operations such as OTA writes or NVS updates, the cache is briefly suspended. Any code that can execute during this window — ISRs in particular — must reside in IRAM or it will crash silently.
// ISR handler: must be in IRAM
void IRAM_ATTR gpioISR() { ... }
// Data accessed from IRAM_ATTR code: must be in DRAM, never a flash const
static DRAM_ATTR uint32_t isrEventFlags = 0;Rules:
- All ISR handlers:
IRAM_ATTR - Data read by
IRAM_ATTRcode:DRAM_ATTR(a flash-residentstatic constwill fault) - Normal task code does not need
IRAM_ATTR
xSemaphoreTake() (mutex) cannot be called from ISR context — it will crash. Use the correct primitive for each communication direction:
| Direction | Correct primitive |
|---|---|
| ISR → task (data) | xQueueSendFromISR() + portYIELD_FROM_ISR() |
| ISR → task (signal) | xSemaphoreGiveFromISR() + portYIELD_FROM_ISR() |
| Task → task | xSemaphoreTake() / mutex |
| Simple flag (single writer ISR) | volatile bool + portENTER_CRITICAL_ISR() |
ESP32-C3 faults on unaligned multi-byte loads. Never cast a uint8_t* buffer to a wider pointer type and dereference it directly. Use memcpy for any unaligned read:
// WRONG — faults if buf is not 4-byte aligned:
uint32_t val = *reinterpret_cast<const uint32_t*>(buf);
// CORRECT:
uint32_t val;
memcpy(&val, buf, sizeof(val));This applies to all cache deserialization code and any raw buffer-to-struct casting. __attribute__((packed)) structs have the same hazard when accessed via member reference.
Each template instantiation generates a separate binary copy. std::function<void()> adds ~2–4 KB per unique signature and heap-allocates its closure. Avoid both in library code and any path called from the render loop:
// Avoid — heap-allocating, large binary footprint:
std::function<void()> callback;
// Prefer — zero overhead:
void (*callback)() = nullptr;
// For member function + context (common activity callback pattern):
struct Callback { void* ctx; void (*fn)(void*); };When a template is necessary, limit instantiations: use explicit template instantiation in a .cpp file to prevent the compiler from generating duplicates across translation units.
Source: src/main.cpp:132-143, lib/GfxRenderer/GfxRenderer.cpp:10
Pattern Hierarchy:
- LOG_ERR + return false (90%):
LOG_ERR("MOD", "Failed: %s", reason); return false; - LOG_ERR + fallback:
LOG_ERR("MOD", "Unavailable"); useDefault(); - assert(false): Only for fatal "impossible" states (framebuffer missing)
- ESP.restart(): Only for recovery (OTA complete)
Rules: NO exceptions, NO abort(), ALWAYS log before error return
Prefer makeUniqueNoThrow over malloc. Both are nothrow (return nullptr on OOM rather than calling abort()), but malloc requires a manual free on every return path — a common source of leaks. makeUniqueNoThrow<uint8_t[]>(size) from lib/Memory/Memory.h frees automatically when it goes out of scope.
Preferred pattern:
#include <Memory.h>
auto buffer = makeUniqueNoThrow<uint8_t[]>(bufferSize);
if (!buffer) {
LOG_ERR("MODULE", "OOM: %d bytes", bufferSize);
return false;
}
processData(buffer.get(), bufferSize);
// freed automatically — no manual free needed, no leak on early returnmalloc or new (std::nothrow) are still acceptable when the buffer must be passed to a C API that takes ownership and frees it itself (e.g., certain SDK callbacks). In that case follow the manual pattern:
auto* buffer = static_cast<uint8_t*>(malloc(bufferSize)); // or new (std::nothrow) uint8_t[bufferSize]
if (!buffer) {
LOG_ERR("MODULE", "OOM: %d bytes", bufferSize);
return false;
}
sdkApiThatTakesOwnership(buffer, bufferSize); // SDK calls free() / delete[]Rules:
- Prefer
makeUniqueNoThrow— automatic cleanup eliminates leak risk on error paths - ALWAYS check for nullptr after any allocation and
LOG_ERRbefore returning false - Raw allocation only when a C API takes ownership; document why in a comment
Examples in codebase:
- Memory utilities: Memory.h (
makeUniqueNoThrow) - Cover image buffers: HomeActivity.cpp:166
- Bitmap rendering: GfxRenderer.cpp:439-440
CRITICAL: With -fno-exceptions, bare new on OOM calls abort() — it does NOT return nullptr. Always use makeUniqueNoThrow from lib/Memory/Memory.h, which wraps new (std::nothrow) and returns a std::unique_ptr that is null on OOM and automatically frees on scope exit.
Preferred pattern:
#include <Memory.h>
auto obj = makeUniqueNoThrow<MyClass>(args);
if (!obj) { LOG_ERR("MOD", "OOM: MyClass"); return false; }
auto buf = makeUniqueNoThrow<uint8_t[]>(size);
if (!buf) { LOG_ERR("MOD", "OOM: %d bytes", size); return false; }
// Pass to C APIs via .get(); unique_ptr frees automatically on return
someApi(buf.get(), size);new (std::nothrow) directly is acceptable when the object must be passed to a C API that takes ownership and calls delete itself:
auto* obj = new (std::nothrow) MyClass(args);
if (!obj) { LOG_ERR("MOD", "OOM: MyClass"); return false; }
sdkApiThatTakesOwnership(obj); // SDK calls deleteRules:
- Prefer
makeUniqueNoThrow— automatic cleanup eliminates leak risk on error paths - NEVER use bare
new— alwaysmakeUniqueNoThrowornew (std::nothrow) - ALWAYS
LOG_ERRbefore returning false on OOM - Use
.get()to pass the raw pointer to C-style APIs; ownership stays with theunique_ptr new (std::nothrow)directly only when a C API takes ownership; document why in a comment
Examples in codebase:
- Memory utilities: Memory.h (
makeUniqueNoThrow)
- No Hardcoding: Never assume 800 or 480. Use renderer.getScreenWidth() and renderer.getScreenHeight().
- Viewable Area: Use renderer.getOrientedViewableTRBL() to stay within physical bezel margins.
Source: src/MappedInputManager.cpp:20-55
Constraint: Physical button positions are fixed on hardware, but their logical functions change based on user settings and screen orientation.
Button Categories:
-
Physical Fixed (Up/Down side buttons):
-
Button::Up→ AlwaysHalGPIO::BTN_UP -
Button::Down→ AlwaysHalGPIO::BTN_DOWN
-
-
User Remappable (Front buttons):
-
Button::Back→ Maps toSETTINGS.frontButtonBack(hardware index) -
Button::Confirm→ Maps toSETTINGS.frontButtonConfirm -
Button::Left→ Maps toSETTINGS.frontButtonLeft -
Button::Right→ Maps toSETTINGS.frontButtonRight
-
-
Reader-Specific (Page navigation with optional swap):
-
Button::PageBack→ Uses side button (swappable viaSETTINGS.sideButtonLayout) -
Button::PageForward→ Uses side button (swappable)
-
Implementation:
- Activities use logical buttons (e.g.,
Button::Confirm) MappedInputManagertranslates to physical hardware buttons- User can remap front buttons in settings
- Orientation changes handled separately by renderer coordinate transforms
Rule: Always use MappedInputManager::Button::* enums, never raw HalGPIO::BTN_* indices (except in ButtonRemapActivity).
- Rule: All UI rendering must go through the GUI macro (UITheme).
- Do not hardcode fonts, colors, or positioning. This ensures orientation-aware layout consistency.
Available Singletons:
#define SETTINGS CrossPointSettings::getInstance() // User settings
#define APP_STATE CrossPointState::getInstance() // Runtime state
#define GUI UITheme::getInstance() // Current theme
#define Storage HalStorage::getInstance() // SD card I/O
#define I18N I18n::getInstance() // InternationalizationSource: src/main.cpp:132-143
CRITICAL: Activities are heap-allocated and deleted on exit.
// main.cpp navigation pattern
void exitActivity() {
if (currentActivity) {
currentActivity->onExit();
delete currentActivity; // Activity deleted here!
currentActivity = nullptr;
}
}
void enterNewActivity(Activity* activity) {
currentActivity = activity; // Heap-allocated activity
currentActivity->onEnter();
}Memory Implications:
- Activity navigation =
deleteold activity +newcreate next activity - Any memory allocated in
onEnter()MUST be freed inonExit() - FreeRTOS tasks MUST be deleted in
onExit()before activity destruction - Member
FsFilehandles MUST be closed inonExit()(localFsFilevariables auto-close via destructor)
Activity Pattern:
void onEnter() { Activity::onEnter(); /* alloc: buffer, tasks */ render(); }
void loop() { mappedInput.update(); /* handle input */ }
void onExit() { /* free: vTaskDelete, free buffer, close member FsFiles */ Activity::onExit(); }Critical: Free resources in reverse order. Delete tasks BEFORE activity destruction.
Source: src/activities/util/KeyboardEntryActivity.cpp:45-50
Pattern: See Activity Lifecycle above. xTaskCreate(&taskTrampoline, "Name", stackSize, this, 1, &handle)
Stack Sizing (in BYTES, not words):
- 2048: Simple rendering (most activities)
- 4096: Network, EPUB parsing
- Monitor:
uxTaskGetStackHighWaterMark()if crashes
Rules: Always vTaskDelete() in onExit() before destruction. Use mutex if shared state.
Source: src/main.cpp:40-115
All fonts are loaded as global static objects at firmware startup:
- Noto Serif: 12, 14, 16, 18pt (4 styles each: regular, bold, italic, bold-italic)
- Noto Sans: 12, 14, 16, 18pt (4 styles each)
- Ubuntu UI fonts: 10, 12pt (2 styles)
Total: ~80+ global EpdFont and EpdFontFamily objects
Compilation Flag:
#ifndef OMIT_FONTS
// Most fonts loaded here
#endifImplications:
- Fonts stored in Flash (marked as
static constinlib/EpdFont/builtinFonts/) - Font rendering data cached in DRAM when first used
OMIT_FONTScan reduce binary size for minimal builds- Font IDs defined in src/fontIds.h
Usage:
#include "fontIds.h"
renderer.insertFont(FONT_UI_MEDIUM, ui12FontFamily);
renderer.drawText(FONT_UI_MEDIUM, x, y, "Hello", true);Via CLI:
# Build firmware (default environment)
pio run
# Build and upload to device
pio run -t upload
# Build specific environment
pio run -e gh_release
# Clean build artifacts
pio run -t cleanVia VS Code:
- Use PlatformIO toolbar: Build (✓), Upload (→), Clean (🗑️)
- Or Command Palette:
PlatformIO: Build,PlatformIO: Upload, etc.
# Enhanced monitor with color/logging (recommended)
python3 scripts/debugging_monitor.py
# Standard PlatformIO monitor
pio device monitorVia VS Code: Click Monitor (🔌) button in PlatformIO toolbar
# Static analysis (cppcheck)
pio check
# Format only Git-modified C/C++ files, on every host
./bin/clang-format-fix -gDo not run raw clang-format or probe it with command -v; use the wrapper even for diagnostics.
Common Crash Causes:
-
Out of Memory (Most common):
LOG_DBG("MEM", "Free heap: %d bytes", ESP.getFreeHeap());
-
Monitor heap usage throughout activity lifecycle
-
Check if large allocations (>10KB) occur before crash
-
Verify buffers are freed in
onExit()
-
-
Stack Overflow:
LOG_DBG("TASK", "Stack high water: %d", uxTaskGetStackHighWaterMark(taskHandle));
-
Occurs during deep recursion or large local variables
-
Increase task stack size in
xTaskCreate()(2048 → 4096) -
Move large buffers to heap with malloc
-
-
Use-After-Free:
-
Activity deleted but task still running
-
Always
vTaskDelete()inonExit()BEFORE activity destruction -
Set pointers to
nullptrafterfree()
-
-
Corrupt Cache Files:
-
Delete
.crosspoint/directory on SD card -
Forces clean re-parse of all EPUBs
-
Check file format versions in docs/file-formats.md
-
-
Watchdog Timeout:
-
Loop/task blocked for >5 seconds
-
Add
vTaskDelay(1)in tight loops -
Check for blocking I/O operations
-
Verification Steps:
- Check serial output for stack traces
- Monitor heap with
ESP.getFreeHeap()before/after operations - Verify task deletion with task list (
vTaskList()) - Test with
LOG_LEVEL=2(debug logging enabled)
CRITICAL: ALWAYS verify repository context before git operations. This could be:
- A fork with
originpointing to personal repo,upstreamto main repo - A direct clone with
originpointing to main repo - Multiple collaborator remotes
Verification Commands (run at session start):
# Check current branch
git branch --show-current
# Check all remotes
git remote -v
# Check working tree status
git status --shortExample Output (forked repository):
origin https://github.com/<your-username>/crosspoint-reader.git (fetch/push)
upstream https://github.com/crosspoint-reader/crosspoint-reader.git (fetch/push)
- Integration branches and PR comparisons target
develop, notmasteror the remote's symbolic HEAD. - Never push to any remote or open/close a PR without explicit user approval. Complete local work and any requested local commit, then stop.
- If the user explicitly approves a push, inspect remotes again and use
forkfor the feature branch unless the user specifies otherwise. - Never add Claude, Codex, or assistant self-attribution as a commit co-author or generated-by trailer.
- When a change supersedes or adapts another person's PR, verify the original human author from Git/GitHub and add that person as
Co-Authored-By; skip bot authors.
For feature/fix branches:
feature/<short-description> # New features
fix/<issue-number>-<description> # Bug fixes
refactor/<component-name> # Code refactoring
docs/<topic> # Documentation updates
Examples:
feature/sd-download-progressfix/123-orientation-crashrefactor/hal-storage
Pattern:
<type>: <short summary (50 chars max)>
<optional detailed description>
Types: feat, fix, refactor, docs, test, chore, perf
Example:
feat: add real-time SD download progress bar
Implements progress tracking for book downloads using
UITheme progress bar component with heap-safe updates.
Tested in all 4 orientations with 5MB+ files.
DO commit when:
- User explicitly requests: "commit these changes"
- Feature is complete and tested on device
- Bug fix is verified working
- Refactoring preserves all functionality
- All tests pass (
pio runsucceeds)
DO NOT commit when:
- Changes are untested on actual hardware
- Build fails or has warnings
- Experimenting or debugging in progress
- User hasn't explicitly requested commit
- Files excluded by
.gitignorewould be included — always rungit statusand cross-check against.gitignorebefore staging (e.g.,*.generated.h,.pio/,compile_commands.json,platformio.local.ini)
Rule: If uncertain, ASK before committing.
NEVER manually edit these files - they are regenerated automatically:
-
HTML Headers (generated by
scripts/build_html.py):-
src/network/html/*.generated.h -
Source: HTML templates in
data/html/directory -
Triggered: During PlatformIO
pre:build step -
To modify: Edit source HTML in
data/html/, not generated headers
-
-
I18n Headers (generated by
scripts/gen_i18n.py):-
lib/I18n/I18nKeys.h,lib/I18n/I18nStrings.h,lib/I18n/I18nStrings.cpp -
Source: YAML translation files in
lib/I18n/translations/(one per language) -
To modify: Edit source YAML files, then run
python scripts/gen_i18n.py lib/I18n/translations lib/I18n/ -
Commit: Source YAML files only. All three generated files (
I18nKeys.h,I18nStrings.h,I18nStrings.cpp) are in.gitignoreand regenerated at build time.
-
-
Build Artifacts (in
.gitignore):-
.pio/- PlatformIO build output -
build/- Compiled binaries -
*.generated.h- Any auto-generated headers -
compile_commands.json- LSP/IDE metadata
-
To change HTML pages:
- Edit source:
data/html/<pagename>.html - Build:
pio run(auto-triggersscripts/build_html.py) - Generated headers update:
src/network/html/<pagename>Html.generated.h - Commit ONLY source HTML, NOT generated
.generated.hfiles
To add/modify translations (i18n):
- Edit or add YAML file:
lib/I18n/translations/<language>.yaml- Each file must contain:
_language_name,_language_code,_order,_bcp47, andSTR_*keys - English (
english.yaml) is the reference; missing keys in other languages fall back to English
- Each file must contain:
- Run generator:
python scripts/gen_i18n.py lib/I18n/translations lib/I18n/ - Generated files update:
I18nKeys.h,I18nStrings.h,I18nStrings.cpp - Commit source YAML files only. All three generated files are in
.gitignoreand regenerated at build time.
To use translated strings in code:
#include <I18n.h>
// Use tr() macro with StrId enum (defined in generated I18nKeys.h)
renderer.drawText(FONT_UI, x, y, tr(STR_LOADING), true);To add custom fonts:
- Place source fonts in
lib/EpdFont/fontsrc/(gitignored) - Run conversion script (see
lib/EpdFont/README) - Update global font objects in
src/main.cpp:40-115 - Add font ID constant to
src/fontIds.h
Purpose: Personal development settings that should NEVER be committed.
Use Cases:
- Serial port configuration (varies by machine)
- Debug flags for specific testing
- Local build optimizations
- Developer-specific paths
Example platformio.local.ini:
# platformio.local.ini (gitignored)
[env:default]
upload_port = COM7 # Windows: COMx, Linux: /dev/ttyUSBx
monitor_port = COM7
build_flags =
${base.build_flags}
-DMY_DEBUG_FLAG=1 # Personal debug flags
-DTEST_FEATURE_ENABLED=1Configuration Hierarchy:
platformio.ini- Committed, shared project settingsplatformio.local.ini- Gitignored, personal overrides- Local file extends/overrides base config
Rules:
- NEVER commit
platformio.local.ini - NEVER put personal info (serial ports, credentials) in main
platformio.ini - Use
${base.build_flags}to extend (not replace) base flags
AI agent scope (what you CAN verify):
- ✅ Build: Build once after the last code edit with the relevant
pio runtarget. Do not clean by default, repeat a target that already passed, or rebuild after formatting/comment-only/documentation-only changes. - ✅ Quality:
pio checkwhen relevant +./bin/clang-format-fix -g - ✅ Format: Commit messages (
feat:/fix:), no.gitignore-excluded files staged (e.g.,*.generated.h,.pio/,platformio.local.ini) - ✅ CI: Fix GitHub Actions failures before review
- ✅ Code review: Ensure orientation-aware logic is correct in all 4 modes by inspecting switch/case coverage
Human tester scope (flag these for the user):
6. 🔲 Device: Test on hardware
7. 🔲 Orientations: Verify all 4 modes (Portrait/Inverted/Landscape CW/CCW)
8. 🔲 Heap: ESP.getFreeHeap() > 50KB, no leaks
9. 🔲 Cache: If EPUB modified, delete .crosspoint/ and verify re-parse
GitHub Actions run automatically on pull requests:
| Workflow | File | Purpose |
|---|---|---|
| Build Check | .github/workflows/ci.yml |
Verifies code compiles |
| Format Check | .github/workflows/pr-formatting-check.yml |
Validates clang-format |
| Release Build | .github/workflows/release.yml |
Production releases |
| RC Build | .github/workflows/release_candidate.yml |
Release candidates |
Rules:
- Fix CI failures BEFORE requesting review
- CI runs on: Push to PR, PR updates
- Format check fails → Run
./bin/clang-format-fix -g - Build check fails → Fix compile errors
- Enhanced:
python3 scripts/debugging_monitor.py(color-coded, recommended) - Standard:
pio device monitor(basic, no colors) - VS Code: Monitor (🔌) button (IDE-integrated)
Heap: LOG_DBG("MEM", "Free: %d", ESP.getFreeHeap()); (every 5s in loop)
Stack: uxTaskGetStackHighWaterMark(nullptr) (< 512 bytes → increase stack)
Flush: logSerial.flush(); (force output before crash)
Port Detection: Windows: mode | Linux: ls /dev/ttyUSB* /dev/ttyACM* or dmesg | grep tty
Location: .crosspoint/ directory on SD card root
Structure: .crosspoint/epub_<hash>/{book.bin, progress.bin, cover.bmp, sections/*.bin}
Hash: std::hash<std::string>{}(filepath) → Moving/renaming file = new hash = lost progress
Cache is automatically invalidated when:
-
File format version changes (see
docs/file-formats.md)-
book.binversion number incremented -
section.binversion number incremented
-
-
Render settings change:
-
Font family or size (
SETTINGS.fontFamily,SETTINGS.fontSize) -
Line spacing (
SETTINGS.lineSpacing) -
Paragraph spacing (
SETTINGS.extraParagraphSpacing) -
Screen margins (
SETTINGS.screenMargin)
-
-
Viewport dimensions change:
-
Screen orientation change
-
Display resolution change
-
-
Book file modified:
- Moved, renamed, or content changed (new hash)
Manual Cache Clear (safe operations):
# Delete ALL caches (forces full regeneration)
rm -rf /path/to/sd/.crosspoint/
# Delete specific book cache
rm -rf /path/to/sd/.crosspoint/epub_<hash>/
# Keep progress, delete only rendered sections
rm -rf /path/to/sd/.crosspoint/epub_<hash>/sections/When to Clear Cache:
- EPUB parsing errors after code changes to
lib/Epub/ - Corrupt rendering (missing text, wrong layout)
- Testing cache generation logic
- After modifying:
lib/Epub/Epub/Section.cpplib/Epub/Epub/BookMetadataCache.cpp- Render settings in
CrossPointSettings
Source: lib/Epub/Epub/Section.cpp, lib/Epub/Epub/BookMetadataCache.cpp
Current Versions (as of docs/file-formats.md):
book.bin: Version 7 (metadata structure)section.bin: Version 25 (layout structure)
Version Increment Rules:
- ALWAYS increment version BEFORE changing binary structure
- Version mismatch → Cache auto-invalidated and regenerated
- Document format changes in
docs/file-formats.md
Example (incrementing section format version):
// lib/Epub/Epub/Section.cpp
static constexpr uint8_t SECTION_FILE_VERSION = 26; // Was 25, now 26
// Add new field to structure
struct PageLine {
// ... existing fields ...
uint16_t newField; // New field added
};Philosophy: We are building a dedicated e-reader, not a Swiss Army knife. If a feature adds RAM pressure without significantly improving the reading experience, it is Out of Scope.