commits
Rename all references across the codebase:
* crate names: pulp-os -> plump, pulp-kernel -> plump-kernel
* Rust paths: pulp_os:: -> plump::, pulp_kernel:: -> plump_kernel::
* SD directory: _PULP -> _PLUMP, PULP_DIR -> PLUMP_DIR
* storage API: all *_in_pulp* methods -> *_in_plump*
* mDNS hostname: pulp.local -> plump.local (DNS wire format
updated for 5-char hostname: length prefix, response offsets,
query parser offsets, min packet length)
* UI strings: status bar, boot console, upload page title
* repository URLs in Cargo.toml
Replace the poll-based settings propagation (which re-signaled
idle timeout and sunlight mode every 5s via STATUS_DUE) with a
generation-counter approach that only applies changes when the
app layer reports a mutation.
* add `AppliedSettings` struct to `Kernel`; tracks the last
values pushed to hardware (sleep_timeout, sunlight_fix,
swap_buttons) with a generation counter
* `sync()` diffs against current `SystemSettings` and applies
only changed fields; logs each change for serial debugging
* `init_from()` snapshots boot-time values without diffing
* scheduler main loop: compare `settings_generation()` once per
iteration; call `sync()` only when generation differs
* add `on_swap_buttons_changed()` to `AppLayer` trait so the
kernel can notify the distro when swap_buttons changes
(ButtonMapper + feedback labels live in the distro)
* remove settings re-apply from `poll_housekeeping_inner()`;
STATUS_DUE now only logs stats
* remove `sync_button_config()` from `run_background()`; was
running every ~10ms tick; make it private (boot-only)
* housekeeping methods no longer need `AppLayer` generic param
This fixes the idle-sleep-never-fires bug: the timer is no longer
restarted every 5s by redundant same-value `set_idle_timeout()`
calls. Combined with the previous defense-in-depth commit, idle
sleep is now robust against both scheduler-side and task-side
same-value noise.
Add `settings_generation() -> u32` to the `AppLayer` trait so the
kernel can detect settings changes without polling individual fields.
* `SettingsApp` gets a `generation: u32` field, bumped on every
mutation (increment/decrement/mark_save_needed) and on initial
load from SD
* `AppManager` delegates `settings_generation()` to the underlying
`SettingsApp`
* increment() and decrement() now call mark_save_needed() instead
of setting the flag directly, so generation is always in sync
The kernel side (AppliedSettings, scheduler wiring) comes in the
next commits.
The idle_timeout_task restarts its Timer whenever IDLE_TIMEOUT_MINS
is signaled, even if the new value is identical to the current one.
Since poll_housekeeping_inner re-signals the timeout every 5s via
STATUS_DUE, this effectively prevents idle sleep from ever firing.
Compare the received value against the current timeout_mins and
continue (re-enter the inner select3 loop) instead of breaking to
the outer loop when unchanged. The scheduler-side fix (coming next)
will stop the redundant signals, but the task itself should be robust
against same-value noise.
Follows Rust Type::from_*() constructor convention
Co-authored-by: Claude <noreply@anthropic.com>
Add load_cover_for() and has_cover_for() that internalize the
hash→dir computation (cache_dir_for_filename + dir_name_str),
eliminating a repeated 3-line pattern
* home.rs: two sites simplified
* reader/mod.rs: one site simplified
* cache_dir_for_filename made module-private
Co-authored-by: Claude <noreply@anthropic.com>
Replace draw_progress_bar and draw_loading_indicator free
functions with ProgressBar and LoadingIndicator structs that
follow the existing BitmapLabel builder pattern:
ProgressBar::new(region, pct).draw(strip)
LoadingIndicator::new(region, msg, pct).draw(strip)
Co-authored-by: Claude <noreply@anthropic.com>
Replace three free functions (draw_selection,
draw_selection_if_visible, selection_fg) with a SelectableRow
struct that holds region + selected state and exposes draw(),
draw_if_visible(), and fg() methods. Follows the existing
BitmapLabel/BitmapDynLabel widget pattern.
Co-authored-by: Claude <noreply@anthropic.com>
Move all RTC and SD persistence operations from module-level
free functions to associated functions / methods on RtcSession.
* peek_valid → RtcSession::rtc_peek_valid()
* is_valid_session → RtcSession::rtc_consume()
* load/save/clear → rtc_load/rtc_save/rtc_clear
* save_to_sd/load_from_sd/clear_sd → methods on RtcSession
* SESSION_FILE made module-private
Co-authored-by: Claude <noreply@anthropic.com>
* parse_settings_txt / write_settings_txt → SystemSettings
methods (parse_txt, write_txt)
* reading_theme(idx) → SystemSettings::reading_theme(&self)
+ ReadingTheme::from_idx(u8) for standalone lookups
* text_alignment_name(idx) →
SystemSettings::text_alignment_name(&self)
* TEXT_ALIGNMENT_NAMES made module-private
Co-authored-by: Claude <noreply@anthropic.com>
Replace the (u32, u32, u16) tuple return from load_book_stats
with a named ReadingStats struct that owns load/save methods.
* eliminates cryptic tuple destructuring at all call sites
* reader stores one ReadingStats field instead of three scalars
* BookStats embeds ReadingStats instead of duplicating fields
* parse_stats now fills ReadingStats directly
Co-authored-by: Claude <noreply@anthropic.com>
Show only reading time ("5h 23m") in the home screen book card
stats line. Falls back to "X% read" when no time stats exist.
Remove the unused recent_stats_pages field.
Co-authored-by: Claude <noreply@anthropic.com>
* run the reader grayscale pass after a full GC refresh when the
frame stayed stable through the waveform
* sync plain BW content back into both RAM planes afterwards so
later partial DU updates still compute correct deltas
The partial refresh path already reapplied grayscale antialiasing,
but the full ghost-clearing path stopped after the BW waveform.
That made AA text lose its gray levels until a later partial redraw.
Co-authored-by: Pi <pi@tim.local>
* skip one leading blank hard break when a new page starts with
carried paragraph spacing
* apply the same rule to proportional and monospace wrapping
so EPUB and plain-text pages stay consistent
Paragraph breaks are encoded as blank lines. When the previous
page ended on the last text line of a paragraph, that blank line
became the first rendered line on the next page and wasted a row.
This keeps the first printed content tight against the top margin.
Co-authored-by: Pi <pi@tim.local>
* prescan cached chapter chunks before wrapping page offsets
* reset cached inline image heights when paging state resets
* keeps preindexed offsets aligned with the real image heights
used when the page is later rendered
Without the prescan, cached-chapter preindexing reserved the
fallback image height while page rendering used the actual inline
image height. That made later pages start too early and duplicate
text after inline images.
Co-authored-by: Pi <pi@tim.local>
Wake-to-reader should render the cached cover on the first
restore frame instead of flashing a generic loading screen
before the normal metadata/loading UI takes over.
This mirrors the normal open path closely enough to remove
the double-loading feel without changing the restore state
machine.
Co-authored-by: Claude <noreply@anthropic.com>
Fresh opens and wake restores should still show the richer
cover/title loading screen, but in-reader page turns and
chapter jumps should not flash that metadata UI.
Co-authored-by: Claude <noreply@anthropic.com>
Precompute justification metrics once after page wrap instead
of re-running measure_line() on every strip pass during draw.
* add LineMeasure cache (37 × 6B = 222B) to PageState
* precompute_line_metrics() runs after wrap_lines_counted()
in both load_and_prefetch paths (ch_cache and SD read)
* draw() looks up cached metrics instead of calling
measure_line() which eliminates ~400+ calls per page render
(37 lines × 12 strips, more with gray passes)
* add Region::intersects strip culling for fullscreen and
inline image blits before calling blit_1bpp
Text line strip culling was investigated but not added:
with 270° rotation, strips are vertical columns spanning
the full display height, so all text lines intersect all
strips. Image culling helps since images are often narrower.
No rendering behavior change same LineMeasure values,
just cached instead of recomputed.
Co-authored-by: Claude <noreply@anthropic.com>
* add a small input policy layer that turns power short presses
into a semantic MenuTap and keeps long press reserved for sleep
* let AppManager toggle the quick menu from that semantic input
instead of opening it on Press(Menu)
* defer semantic menu actions until EPD waveforms finish so
quick-menu state cannot race deferred transitions
Long power holds previously opened the quick menu on press before
the scheduler saw LongPress(Power). This keeps short and long
power actions distinct without changing other button semantics.
Co-authored-by: Claude <noreply@anthropic.com>
* update KernelHandle module comment to reflect its slimmed
role (sd accessor, sync-reader bridges, dir-cache, info)
* simplify bookmarks.rs storage import to named constants
Co-authored-by: Claude <noreply@anthropic.com>
* delete 7 remaining KernelHandle forwarding methods:
read_app_data_start, write_app_data, ensure_app_subdir,
read_app_subdir_chunk, write_app_subdir, append_app_subdir,
file_size_app_subdir
* migrate ~30 app callsites across home, settings, reader,
images, cover_cache, stats to k.sd().method() form
* PULP_DIR is now explicit at callsites that previously hid
it behind read_app_data_start/write_app_data wrappers
Co-authored-by: Claude <noreply@anthropic.com>
* add KernelHandle::sd() accessor giving apps direct &SdStorage
* delete 11 pure-forwarding methods: file_size, read_chunk,
read_file_start, save_title, read_cache_chunk, write_cache,
append_cache, write_cache_at, delete_cache, cache_file_size,
delete_file
* migrate ~46 app callsites to k.sd().method() form across
epubs.rs, paging.rs, images.rs, mod.rs, files.rs
* update CellReader/CellWriter to go through sd() directly
Co-authored-by: Claude <noreply@anthropic.com>
* migrate 19 remaining free functions (file_size, read_file_chunk,
read_file_start, write_file_in_dir, read_file_start_in_dir,
ensure_pulp_dir_async, ensure_pulp_subdir, all _in_pulp and
_in_pulp_subdir variants, save_title) onto impl SdStorage
* update 9 kernel-internal callsites: bookmarks, rtc_session,
dir_cache, sleep_image, main.rs
* update all KernelHandle forwarding methods to use method syntax
* remove unused storage imports from sleep_image and main.rs
All storage I/O now goes through SdStorage methods. The free
function API is gone; only the macros (op_*, in_dir!, in_subdir!)
remain as internal implementation details of impl SdStorage.
Co-authored-by: Claude <noreply@anthropic.com>
* delete 4 thin wrappers (write_file, append_root_file,
delete_file, list_root_files) callers use SdStorage
methods directly since the upload perf commit
* delete 3 never-called functions: ensure_dir,
append_file_in_dir, read_file_chunk_in_dir
* delete unused KernelHandle::delete_app_subdir (0 callers)
* inline append_file_in_dir call in save_title
Co-authored-by: Claude <noreply@anthropic.com>
* introduce OpenFile newtype wrapping embedded-sdmmc RawFile
with write(), close(self), and Drop safety net for leaked handles
* add SdStorage methods: create_file, write_file, append_root_file,
delete_file, list_root_files — old free functions become wrappers
* refactor handle_upload to open file once per upload instead of
open/write/close per ~2KB chunk (~500× fewer SD metadata flushes/MB)
* double TCP RX buffer (2K→4K) and work buffer (2K→4K) to increase
throughput; bump TX buffer (1.5K→2K) for faster response serving
* reduce socket close delay from 2×50ms to 1×10ms (saves 90ms/req)
* add heap usage logging at server ready, upload start, and upload
done to inform future buffer size tuning
Co-authored-by: Claude <noreply@anthropic.com>
* tags deferred RECENT/stats flushes as opportunistic,
transition, or sleep so perf logs show why they fired
* adds a perf::reader flush_deferred event with attempted
writes, state, success, and elapsed time
This makes it easier to confirm that deferred persistence
stays off the page-turn path while still flushing in the
expected windows.
Co-authored-by: Claude <noreply@anthropic.com>
Remove synchronous write_recent() (~72ms) and stats_flush()
(~168ms) from the reader background() critical path, saving
~240ms per page turn.
* add flush_deferred_persistence() hook to App + AppLayer traits
with default no-op; reader implements 30s debounce logic
* scheduler calls opportunistic flush only in no-redraw windows;
force-flushes on app transitions and before deep sleep
* AppManager dispatches to all singletons (not just active app)
so failed reader flushes retry while another app is active
* write_recent() and stats_flush() now return Result; dirty flags
cleared only on success for automatic retry
* remove pending_recent_write; unify RECENT dirtying under the
debounced record_position_change(is_page_turn) helper
* jumps (TOC, quick chapter, long-press) dirty RECENT but do not
increment page-turn stats
* add stats_clock_running flag with pause/resume on suspend/exit/
resume/enter_error so deferred flushes don't count menu time
* add TODO comment in home.rs about stale recent-card data
Co-authored-by: Claude <noreply@anthropic.com>
* wire SD I/O counters into storage op macros
* add perf_event! to reader state machine transitions
* break down load_and_prefetch into timed stages
* instrument scheduler render and boot paths
* change a bunch of info! logs to debug! repo-wide
Co-authored-by: Claude <noreply@anthropic.com>
Add a compile-time-gated, no-allocation perf instrumentation module
on top of the existing log + esp-println backend. This is the
infrastructure for profiling reader/storage/render performance
on-device.
* perf_event! macro for structured key=value point events
* perf_scope! RAII timing guard (sync-only, logs elapsed_ms on drop)
* perf_begin! for manual timestamp capture inside perf_event!
* SD I/O counters with snapshot/delta for per-scope summaries
(critical_section::Mutex<Cell<u32>>; no AtomicU32 on RV32IMC)
* all macros compile to nothing without --features perf
* counter functions are always callable (empty inlines when off)
Logging config improvements:
* default ESP_LOG changed to debug,esp_hal=info for better dev output
* documented ESP_LOG profiles for perf-only/quiet/verbose modes
* perf:: target prefix in messages for grepability
Co-authored-by: Claude <noreply@anthropic.com>
* proactively free ch_cache before cover thumbnail decode to
avoid OOM and heap fragmentation on the 172 KB ESP32-C3 heap
* extract OOM-retry pattern into EpubState::oom_retry() helper,
deduplicating ~30 lines across three image decode call sites
* log original smol-epub error string in decode_image_streaming
before wrapping into the unified Error type
Cover thumbnails are generated once after OPF parse, when the
chapter cache may already hold up to 96 KB. Large DEFLATED
cover JPEGs need ~79-95 KB peak for the streaming decoder,
exceeding available heap. The previous code had OOM-retry
logic for inline images but not for cover generation.
Co-authored-by: Claude <noreply@anthropic.com>
* simplifies the right chrome label to just the chapter counter
* moves page-in-chapter progress into a 7px bottom fill bar
* lifts the title and chapter row above the bar so the chrome stays
legible
This makes the bottom bar cleaner
Co-authored-by: Claude <noreply@anthropic.com>
* reuses the existing home-screen cover thumbnail cache for the
reader loading screen
* keeps first-open behavior unchanged by skipping any new decode work
in the critical loading path
This keeps the change localized to reader UI code and avoids touching
shared grayscale or image decode paths.
Co-authored-by: Claude <noreply@anthropic.com>
* hides the reader chrome and generic loading label while a book
opens
* avoids showing raw fat32 filenames as the main loading title
* prefills cached epub titles from the chapter cache header on
reopen
This makes first opens feel cleaner and lets cached reopens show
the real book title much earlier without changing the main cache
flow.
Co-authored-by: Claude <noreply@anthropic.com>
* show book title, stage label, and progress bar while loading
* stages map to percentage: bookmark→init→opf→toc→cache→index→page
* chapter and image caching phases report sub-progress
* disable grayscale during loading states (only needed for ready/toc)
* skip chrome and position overlay during grayscale pass
* add draw_truncated_text helper with utf8-safe ellipsis truncation
The grayscale LUT works better when the panel already has the
base black and white structure in place. The previous white
clear made the sleep wallpaper look washed out.
Render the wallpaper as a normal full black and white frame
first, then run the grayscale pass as an overlay. This matches
our reader text AA flow more closely and fixed the on-device
sleep image quality.
Co-authored-by: Claude <noreply@anthropic.com>
The grayscale LUT uses 00 for "no change", so white areas of the
sleep wallpaper were leaving old screen contents behind and looking
greyed out.
Run a full GC white clear before the grayscale pass so the wallpaper
starts from a known white panel state. Keep separate timing logs for
the clear and grayscale phases to make sleep-path regressions easier
to spot on device.
Co-authored-by: Claude <noreply@anthropic.com>
The sleep wallpaper loader was reopening and seeking the BMP
for every single row, which turned into a 15-20s wait time
before sleeping.
Read contiguous BMP row batches into a heap buffer instead.
The batch stays off the stack, caps itself at 16KB, and backs
off to smaller batches if the extra allocation cannot fit.
This keeps the 6-chunk image storage layout while making the
8bpp case use a few dozen SD reads instead of hundreds.
Co-authored-by: Claude <noreply@anthropic.com>
The single 96KB Vec panicked because neither heap pool (108KB
main DRAM, 62KB reclaimed bootloader) can hold it contiguously.
Split into 6 chunks of ~16KB each (134 rows per chunk). The
allocator spreads them across both pools; worst case fits 4
in pool 1 and 2 in pool 2. The draw closure iterates all 6
chunks per strip call; blit_2bpp clips to the current strip
window so only the overlapping chunk draws pixels.
No EPD driver changes needed, grayscale_pass works as-is
since the draw closure is just Fn(&mut StripBuffer).
Co-authored-by: Claude <noreply@anthropic.com>
Integrate the sleep_image loader into the enter_sleep flow.
If SLEEP.BMP exists on SD root, load and render it as a 4-level
grayscale image via the SSD1677 dual-plane grayscale pass.
Falls back to the "(sleep)" text if no image is found.
* load image before sd_card_sleep (SD must be alive for reads)
* construct full-screen RenderState for grayscale_pass
* guard with epd.init() for edge case of immediate-after-boot sleep
* all timing logged to serial for profiling
Co-authored-by: Claude <noreply@anthropic.com>
Add sleep_image module that loads SLEEP.BMP from SD card root
and converts it to 2bpp grayscale via Atkinson dithering for
the SSD1677's dual-plane grayscale mode.
* supports 1-bit, 8-bit palette, and 24-bit RGB uncompressed BMPs
* requires exact 480x800 dimensions (no scaling)
* reads row-by-row from SD to avoid holding full BMP in RAM
* Atkinson dithering to 4 gray levels (96KB output buffer)
* this is the first heap allocation in the kernel, justified
because it only happens once right before MCU deep-sleep reset
Co-authored-by: Claude <noreply@anthropic.com>
Add detailed logs around boot, render, loading indicator, and
reader background state transitions. This makes it easier to
see where resume and first-page latency is going on device.
Co-authored-by: Claude <noreply@anthropic.com>
Use the restored page as a hint when resolving offsets, and
push TOC parsing, title saves, recent updates, and cover
thumbnail generation until after the first page is visible.
This gets the reader back on screen sooner after sleep or
book open without dropping the follow-up metadata work.
Co-authored-by: Claude <noreply@anthropic.com>
The reader position was not restored correctly after sleep because
bookmark_load overwrote the session restore_offset with stale
bookmark data. Bookmarks are only flushed periodically or on
navigation not on every page turn, so they lag behind the
actual reading position.
* skip bookmark_load when restore_offset is already set from
the RTC/SD session, which captures the exact page at sleep time
* call save_active_state before collect_session in the sleep path
so the bookmark cache is updated with the current position
* add save_active_state to AppLayer trait, dispatches save_state
on the active app
Co-authored-by: Claude <noreply@anthropic.com>
On ESP32-C3 battery wake, a voltage sag triggers the brownout detector
which maps to ChipPowerOn reset, the bootloader then zeros all RTC FAST
memory, destroying the saved session. USB power avoids this because the
supply is stable.
* save session to both RTC memory and _PULP/SESSION.BIN on SD before
entering deep sleep - ~76ms total, was ~0ms RTC-only
* on boot, try RTC first then fall back to SD if invalid
* has_valid_session checks both sources to decide whether to skip the
boot console
* fix magic not being set: collect_session never called mark_valid so
the SD copy had magic=0 while save only patched it into RTC memory
after serialization
* fix alignment UB: load_from_sd read into u8 buf then ptr::read as
RtcSession with align 4 -- RISC-V misaligned access. now uses
repr C align 4 wrapper for the read buffer
* remove brownout detector disable -- does not work, register resets
to default during wake power-up sequence
* log reset reason on boot for diagnostics
Co-authored-by: Claude <noreply@anthropic.com>
On battery power, the CPU wake-up from deep sleep causes a brief voltage
sag that triggers the brownout detector. This produces a SysBrownOut
reset instead of CoreDeepSleep, and the bootloader zeros all RTC FAST
memory destroying the saved session data.
* disable brownout detector (RTC_CNTL_BROWN_OUT_REG bit 30) right before
entering deep sleep so wake is classified as a proper DEEPSLEEP_RESET
and RTC memory is preserved
* the detector is re-enabled by the application startup code on every
boot, so this only affects the wake transition
* log reset reason on boot for diagnosing RTC persistence
* confirmed safe: no flash corruption or bricking risk, this
is an established workaround for ESP32 battery wake issues
Co-authored-by: Claude <noreply@anthropic.com>
* apply_session() no longer calls on_enter() on apps after
restore_state(), which was resetting state that was just restored
(home.selected→0, files.scroll→0, reader.chapter→0, etc.)
* each app's restore_state() now sets up ALL state needed to resume:
home sets needs_load_recent + battery, files sets stale_cache +
title_scanning, reader does full pipeline setup with chapter/offset
pre-populated from RTC memory
* skip boot console on valid RTC wake (saves ~1.6s full EPD refresh)
* skip home recent book load when waking to non-Home app
* add per-stage timing instrumentation to boot and sleep paths
* add peek_valid() to rtc_session for non-consuming validity check
The root cause was that apply_session() called on_enter() on every
app in the nav stack after restore_state(). Each on_enter()
unconditionally reset its state, so the chapter/offset/selection
restored from RTC FAST memory was immediately destroyed. The reader
partially recovered via bookmark_load (SD I/O), defeating the
purpose of RTC persistence.
Co-authored-by: Claude <noreply@anthropic.com>
* add Left/Justify text alignment setting to SystemSettings
with SETTINGS.TXT persistence and Settings UI
* extend LineSpan with line-ending metadata (SoftWrap/HardBreak/
BufferEnd) packed into flags bits 4-5 for zero struct growth
* add measure_line() helper that computes natural rendered width
and counts stretchable gaps (ASCII spaces only, not NBSP)
* implement draw-time justification: distribute extra pixels
across inter-word gaps on soft-wrapped non-heading lines
* polish heuristics: require ≥2 gaps, skip <3px spare, cap at
40% of line width, cap per-gap extra at 3× space width
Justification is draw-only, pagination, page offsets, and
bookmarks are completely unaffected by the alignment setting.
Co-authored-by: Claude <noreply@anthropic.com>
* add BitmapFont::truncate_len() helper that returns the
byte offset where text should be cut so that the visible
portion plus a trailing "…" fits within a pixel budget
* truncate title and author in the book card using the
new helper so long names don't overflow the card border
* increase card border stroke from 2 to 3 px to eliminate
thin-line e-paper ghosting artifacts
Co-authored-by: Claude <noreply@anthropic.com>
After submit() puts a task in the WORK_IN channel, the
Embassy executor may not have scheduled the worker yet, so
status() still shows IDLE. The bg_cache_step recovery code
saw "idle + no result" and prematurely recovered, causing
spurious "worker idle with no result" warnings on every
background tick during chapter caching.
Fix by making is_idle() also check WORK_IN.is_empty(), so
it only returns true when the worker has no pending work.
Co-authored-by: Claude <noreply@anthropic.com>
The cache file was only created when `ch == 0`, but when a
bookmark restores the reader to a later chapter, the first
cached chapter is not 0. This caused `cache_file_size` to
fail with "open file failed" because the file didn't exist.
Check whether the file exists instead of assuming chapter 0
is always cached first.
Co-authored-by: Claude <noreply@anthropic.com>
Cover support:
* add cover_cache module with shared save/load helpers
for persistent 1-bit cover thumbnails (COVER.BIN)
* generate cover thumb after OPF parse via streaming SD
decode, with ensure_app_subdir before save
* home screen renders cover on left side of book card
with text/stats on the right; falls back to centered
text when no cover is cached
Reading statistics:
* add stats.rs app with per-book page/time/session
tracking stored as key=value files in _PULP/STATS/
* reader accumulates stats on page turns, flushes to SD
* home card shows "342 pages · 5h 23m" instead of bare
percentage when stats are available
* wire Stats into AppId, manager, home menu, and main
Co-authored-by: Claude <noreply@anthropic.com>
* build CTRL2 dynamically based on power_is_on and sunlight_mode
so we skip CLOCK_ON + ANALOG_ON when the booster is already
running; avoids the re-start transient that caused extra
visible flashes during GC refresh
* lower booster soft-start 5th byte from 0x80 to 0x40 to match
CrossPoint Reader's gentler ramp (less aggressive voltage swing)
* remove unconditional power_off before GC in scheduler, the display was
being powered down then immediately back up, adding an unnecessary
visual flash cycle
* consolidate update_full_async to delegate to start_full_update
so all full-refresh paths (boot, sleep, GC) share the same
dynamic CTRL2 logic
Co-authored-by: Claude <noreply@anthropic.com>
Use RoundedRectangle from embedded-graphics for the book
card (r=8) and menu buttons (r=4). Progress bar outline
and fill also get subtle rounding (r=3).
Co-authored-by: Claude <noreply@anthropic.com>
Replace the "Continue" button with a book card that shows
the current book's title, author, and a progress bar. When
no book has been opened, the card shows a placeholder.
Reader changes:
* extend RECENT file format to filename\0title\0author\0pct
(backwards-compatible: old format parsed as filename-only)
* write RECENT on book open and after OPF parse (metadata)
* set recent_dirty flag on page turns, flush in background()
Home screen changes:
* parse extended RECENT format into title/author/progress
* always show the book card (item 0) with border, title,
author, progress bar, and percentage text
* menu items (Files/Bookmarks/Settings/Upload) below card
* selection_region() maps item 0 to card, others to buttons
Co-authored-by: Claude <noreply@anthropic.com>
Replace the centered "pulp-os" heading with a top status bar
showing "pulp-os" left-aligned and battery percentage on the
right. Battery is read from the kernel on enter/resume.
* add STATUS_TITLE_REGION and STATUS_BAT_REGION constants
* cache bat_pct in HomeApp, populated via battery_percentage()
* menu items start below the status bar instead of the heading
Co-authored-by: Claude <noreply@anthropic.com>
Move the reader to a minimal layout: text starts near the top
of the screen (y=8), header chrome (book title + page info)
moves to a bottom bar, and hardware button labels are hidden.
* add hide_button_bar() to App trait so the reader can suppress
the BACK/OK/arrow overlays drawn by AppManager
* move HEADER_REGION and STATUS_REGION from top (y=6) to bottom
(y=778), reclaiming ~16px for text
* add SCREEN_PAD (4px) to avoid display edge clipping on both
top and bottom
* add reader_status setting (Show/Hide) to toggle the bottom
chrome bar, persisted to SETTINGS.TXT
* when chrome is hidden, text area extends to the bottom edge
for maximum reading space
Co-authored-by: Claude <noreply@anthropic.com>
The text_area_h calculation used a hardcoded 4px bottom padding
instead of BUTTON_BAR_H (26px), causing the last line of text
to render behind the button labels (BACK, OK, <<, >>).
* use BUTTON_BAR_H in both the const TEXT_AREA_H and the runtime
apply_theme_layout() calculation
* fixes all reading themes (Compact/Default/Relaxed/Spacious)
Co-authored-by: Claude <noreply@anthropic.com>
sync_quick_menu() calls on_quick_cycle_update on every close,
even when the user didn't change any values. The reader
unconditionally set state = NeedIndex, triggering a full page
re-layout and "Loading page..." screen on every menu dismiss.
Guard the font size handler with a value != check so it only
re-indexes when the size actually changed.
Known issue: when text AA is enabled and the quick menu border
bisects a text line, closing the menu leaves a small artifact
on that line (the partial-region gray pass only covers the menu
area, so the split line gets an inconsistent gray/BW boundary).
Fix in a future pass.
Co-authored-by: Claude <noreply@anthropic.com>
Port CrossPoint Reader's antialiased text rendering to pulp-os.
The SSD1677's BW and RED RAM planes are loaded with separate
LSB/MSB bitmaps derived from 2bpp font coverage data, then a
custom waveform LUT drives each {RED,BW} bit pair to one of
four gray levels in a single refresh cycle.
Font pipeline changes:
* build.rs now rasterizes 2bpp glyphs (4 coverage levels)
instead of 1bpp with a hard threshold
* coverage quantization: <64→white, <128→light, <192→dark, ≥192→black
* bitmap packing: 4 pixels/byte MSB-first (stride = w/4)
* flash cost ~547 KB total (was ~274 KB at 1bpp)
Strip buffer changes:
* add GrayMode enum (Bw/GrayLsb/GrayMsb) to StripBuffer
* gray modes clear buffer to 0x00 (not 0xFF) so unmarked
pixels map to LUT entry 00 (no change)
* add blit_2bpp() + optimized blit_2bpp_270() that select
pixels per mode: Bw=any non-zero, Lsb=val≥2, Msb=val 1|2
* BW mode preserves fg color param for inverted text
Display driver changes:
* add WRITE_LUT (0x32), voltage commands, and 112-byte
grayscale LUT waveform from CrossPoint
* add grayscale_pass(): streams LSB strips→BW RAM, MSB
strips→RED RAM, loads custom LUT, triggers refresh
* after gray refresh, phase3_sync restores BW content to
both planes so subsequent DU refreshes compute correct
pixel deltas (red_stale stays false)
Scheduler integration:
* gray pass runs after successful partial refresh when
text_aa enabled AND wants_grayscale() returns true
* wants_grayscale() = reader active + quick menu closed,
preventing gray artifacts on overlays and non-reader apps
Settings:
* add text_aa bool to SystemSettings (default off)
* add "Text AA" toggle as 8th settings item
Co-authored-by: Claude <noreply@anthropic.com>
Rename all references across the codebase:
* crate names: pulp-os -> plump, pulp-kernel -> plump-kernel
* Rust paths: pulp_os:: -> plump::, pulp_kernel:: -> plump_kernel::
* SD directory: _PULP -> _PLUMP, PULP_DIR -> PLUMP_DIR
* storage API: all *_in_pulp* methods -> *_in_plump*
* mDNS hostname: pulp.local -> plump.local (DNS wire format
updated for 5-char hostname: length prefix, response offsets,
query parser offsets, min packet length)
* UI strings: status bar, boot console, upload page title
* repository URLs in Cargo.toml
Replace the poll-based settings propagation (which re-signaled
idle timeout and sunlight mode every 5s via STATUS_DUE) with a
generation-counter approach that only applies changes when the
app layer reports a mutation.
* add `AppliedSettings` struct to `Kernel`; tracks the last
values pushed to hardware (sleep_timeout, sunlight_fix,
swap_buttons) with a generation counter
* `sync()` diffs against current `SystemSettings` and applies
only changed fields; logs each change for serial debugging
* `init_from()` snapshots boot-time values without diffing
* scheduler main loop: compare `settings_generation()` once per
iteration; call `sync()` only when generation differs
* add `on_swap_buttons_changed()` to `AppLayer` trait so the
kernel can notify the distro when swap_buttons changes
(ButtonMapper + feedback labels live in the distro)
* remove settings re-apply from `poll_housekeeping_inner()`;
STATUS_DUE now only logs stats
* remove `sync_button_config()` from `run_background()`; was
running every ~10ms tick; make it private (boot-only)
* housekeeping methods no longer need `AppLayer` generic param
This fixes the idle-sleep-never-fires bug: the timer is no longer
restarted every 5s by redundant same-value `set_idle_timeout()`
calls. Combined with the previous defense-in-depth commit, idle
sleep is now robust against both scheduler-side and task-side
same-value noise.
Add `settings_generation() -> u32` to the `AppLayer` trait so the
kernel can detect settings changes without polling individual fields.
* `SettingsApp` gets a `generation: u32` field, bumped on every
mutation (increment/decrement/mark_save_needed) and on initial
load from SD
* `AppManager` delegates `settings_generation()` to the underlying
`SettingsApp`
* increment() and decrement() now call mark_save_needed() instead
of setting the flag directly, so generation is always in sync
The kernel side (AppliedSettings, scheduler wiring) comes in the
next commits.
The idle_timeout_task restarts its Timer whenever IDLE_TIMEOUT_MINS
is signaled, even if the new value is identical to the current one.
Since poll_housekeeping_inner re-signals the timeout every 5s via
STATUS_DUE, this effectively prevents idle sleep from ever firing.
Compare the received value against the current timeout_mins and
continue (re-enter the inner select3 loop) instead of breaking to
the outer loop when unchanged. The scheduler-side fix (coming next)
will stop the redundant signals, but the task itself should be robust
against same-value noise.
Add load_cover_for() and has_cover_for() that internalize the
hash→dir computation (cache_dir_for_filename + dir_name_str),
eliminating a repeated 3-line pattern
* home.rs: two sites simplified
* reader/mod.rs: one site simplified
* cache_dir_for_filename made module-private
Co-authored-by: Claude <noreply@anthropic.com>
Replace draw_progress_bar and draw_loading_indicator free
functions with ProgressBar and LoadingIndicator structs that
follow the existing BitmapLabel builder pattern:
ProgressBar::new(region, pct).draw(strip)
LoadingIndicator::new(region, msg, pct).draw(strip)
Co-authored-by: Claude <noreply@anthropic.com>
Replace three free functions (draw_selection,
draw_selection_if_visible, selection_fg) with a SelectableRow
struct that holds region + selected state and exposes draw(),
draw_if_visible(), and fg() methods. Follows the existing
BitmapLabel/BitmapDynLabel widget pattern.
Co-authored-by: Claude <noreply@anthropic.com>
Move all RTC and SD persistence operations from module-level
free functions to associated functions / methods on RtcSession.
* peek_valid → RtcSession::rtc_peek_valid()
* is_valid_session → RtcSession::rtc_consume()
* load/save/clear → rtc_load/rtc_save/rtc_clear
* save_to_sd/load_from_sd/clear_sd → methods on RtcSession
* SESSION_FILE made module-private
Co-authored-by: Claude <noreply@anthropic.com>
* parse_settings_txt / write_settings_txt → SystemSettings
methods (parse_txt, write_txt)
* reading_theme(idx) → SystemSettings::reading_theme(&self)
+ ReadingTheme::from_idx(u8) for standalone lookups
* text_alignment_name(idx) →
SystemSettings::text_alignment_name(&self)
* TEXT_ALIGNMENT_NAMES made module-private
Co-authored-by: Claude <noreply@anthropic.com>
Replace the (u32, u32, u16) tuple return from load_book_stats
with a named ReadingStats struct that owns load/save methods.
* eliminates cryptic tuple destructuring at all call sites
* reader stores one ReadingStats field instead of three scalars
* BookStats embeds ReadingStats instead of duplicating fields
* parse_stats now fills ReadingStats directly
Co-authored-by: Claude <noreply@anthropic.com>
* run the reader grayscale pass after a full GC refresh when the
frame stayed stable through the waveform
* sync plain BW content back into both RAM planes afterwards so
later partial DU updates still compute correct deltas
The partial refresh path already reapplied grayscale antialiasing,
but the full ghost-clearing path stopped after the BW waveform.
That made AA text lose its gray levels until a later partial redraw.
Co-authored-by: Pi <pi@tim.local>
* skip one leading blank hard break when a new page starts with
carried paragraph spacing
* apply the same rule to proportional and monospace wrapping
so EPUB and plain-text pages stay consistent
Paragraph breaks are encoded as blank lines. When the previous
page ended on the last text line of a paragraph, that blank line
became the first rendered line on the next page and wasted a row.
This keeps the first printed content tight against the top margin.
Co-authored-by: Pi <pi@tim.local>
* prescan cached chapter chunks before wrapping page offsets
* reset cached inline image heights when paging state resets
* keeps preindexed offsets aligned with the real image heights
used when the page is later rendered
Without the prescan, cached-chapter preindexing reserved the
fallback image height while page rendering used the actual inline
image height. That made later pages start too early and duplicate
text after inline images.
Co-authored-by: Pi <pi@tim.local>
Wake-to-reader should render the cached cover on the first
restore frame instead of flashing a generic loading screen
before the normal metadata/loading UI takes over.
This mirrors the normal open path closely enough to remove
the double-loading feel without changing the restore state
machine.
Co-authored-by: Claude <noreply@anthropic.com>
Precompute justification metrics once after page wrap instead
of re-running measure_line() on every strip pass during draw.
* add LineMeasure cache (37 × 6B = 222B) to PageState
* precompute_line_metrics() runs after wrap_lines_counted()
in both load_and_prefetch paths (ch_cache and SD read)
* draw() looks up cached metrics instead of calling
measure_line() which eliminates ~400+ calls per page render
(37 lines × 12 strips, more with gray passes)
* add Region::intersects strip culling for fullscreen and
inline image blits before calling blit_1bpp
Text line strip culling was investigated but not added:
with 270° rotation, strips are vertical columns spanning
the full display height, so all text lines intersect all
strips. Image culling helps since images are often narrower.
No rendering behavior change same LineMeasure values,
just cached instead of recomputed.
Co-authored-by: Claude <noreply@anthropic.com>
* add a small input policy layer that turns power short presses
into a semantic MenuTap and keeps long press reserved for sleep
* let AppManager toggle the quick menu from that semantic input
instead of opening it on Press(Menu)
* defer semantic menu actions until EPD waveforms finish so
quick-menu state cannot race deferred transitions
Long power holds previously opened the quick menu on press before
the scheduler saw LongPress(Power). This keeps short and long
power actions distinct without changing other button semantics.
Co-authored-by: Claude <noreply@anthropic.com>
* delete 7 remaining KernelHandle forwarding methods:
read_app_data_start, write_app_data, ensure_app_subdir,
read_app_subdir_chunk, write_app_subdir, append_app_subdir,
file_size_app_subdir
* migrate ~30 app callsites across home, settings, reader,
images, cover_cache, stats to k.sd().method() form
* PULP_DIR is now explicit at callsites that previously hid
it behind read_app_data_start/write_app_data wrappers
Co-authored-by: Claude <noreply@anthropic.com>
* add KernelHandle::sd() accessor giving apps direct &SdStorage
* delete 11 pure-forwarding methods: file_size, read_chunk,
read_file_start, save_title, read_cache_chunk, write_cache,
append_cache, write_cache_at, delete_cache, cache_file_size,
delete_file
* migrate ~46 app callsites to k.sd().method() form across
epubs.rs, paging.rs, images.rs, mod.rs, files.rs
* update CellReader/CellWriter to go through sd() directly
Co-authored-by: Claude <noreply@anthropic.com>
* migrate 19 remaining free functions (file_size, read_file_chunk,
read_file_start, write_file_in_dir, read_file_start_in_dir,
ensure_pulp_dir_async, ensure_pulp_subdir, all _in_pulp and
_in_pulp_subdir variants, save_title) onto impl SdStorage
* update 9 kernel-internal callsites: bookmarks, rtc_session,
dir_cache, sleep_image, main.rs
* update all KernelHandle forwarding methods to use method syntax
* remove unused storage imports from sleep_image and main.rs
All storage I/O now goes through SdStorage methods. The free
function API is gone; only the macros (op_*, in_dir!, in_subdir!)
remain as internal implementation details of impl SdStorage.
Co-authored-by: Claude <noreply@anthropic.com>
* delete 4 thin wrappers (write_file, append_root_file,
delete_file, list_root_files) callers use SdStorage
methods directly since the upload perf commit
* delete 3 never-called functions: ensure_dir,
append_file_in_dir, read_file_chunk_in_dir
* delete unused KernelHandle::delete_app_subdir (0 callers)
* inline append_file_in_dir call in save_title
Co-authored-by: Claude <noreply@anthropic.com>
* introduce OpenFile newtype wrapping embedded-sdmmc RawFile
with write(), close(self), and Drop safety net for leaked handles
* add SdStorage methods: create_file, write_file, append_root_file,
delete_file, list_root_files — old free functions become wrappers
* refactor handle_upload to open file once per upload instead of
open/write/close per ~2KB chunk (~500× fewer SD metadata flushes/MB)
* double TCP RX buffer (2K→4K) and work buffer (2K→4K) to increase
throughput; bump TX buffer (1.5K→2K) for faster response serving
* reduce socket close delay from 2×50ms to 1×10ms (saves 90ms/req)
* add heap usage logging at server ready, upload start, and upload
done to inform future buffer size tuning
Co-authored-by: Claude <noreply@anthropic.com>
* tags deferred RECENT/stats flushes as opportunistic,
transition, or sleep so perf logs show why they fired
* adds a perf::reader flush_deferred event with attempted
writes, state, success, and elapsed time
This makes it easier to confirm that deferred persistence
stays off the page-turn path while still flushing in the
expected windows.
Co-authored-by: Claude <noreply@anthropic.com>
Remove synchronous write_recent() (~72ms) and stats_flush()
(~168ms) from the reader background() critical path, saving
~240ms per page turn.
* add flush_deferred_persistence() hook to App + AppLayer traits
with default no-op; reader implements 30s debounce logic
* scheduler calls opportunistic flush only in no-redraw windows;
force-flushes on app transitions and before deep sleep
* AppManager dispatches to all singletons (not just active app)
so failed reader flushes retry while another app is active
* write_recent() and stats_flush() now return Result; dirty flags
cleared only on success for automatic retry
* remove pending_recent_write; unify RECENT dirtying under the
debounced record_position_change(is_page_turn) helper
* jumps (TOC, quick chapter, long-press) dirty RECENT but do not
increment page-turn stats
* add stats_clock_running flag with pause/resume on suspend/exit/
resume/enter_error so deferred flushes don't count menu time
* add TODO comment in home.rs about stale recent-card data
Co-authored-by: Claude <noreply@anthropic.com>
Add a compile-time-gated, no-allocation perf instrumentation module
on top of the existing log + esp-println backend. This is the
infrastructure for profiling reader/storage/render performance
on-device.
* perf_event! macro for structured key=value point events
* perf_scope! RAII timing guard (sync-only, logs elapsed_ms on drop)
* perf_begin! for manual timestamp capture inside perf_event!
* SD I/O counters with snapshot/delta for per-scope summaries
(critical_section::Mutex<Cell<u32>>; no AtomicU32 on RV32IMC)
* all macros compile to nothing without --features perf
* counter functions are always callable (empty inlines when off)
Logging config improvements:
* default ESP_LOG changed to debug,esp_hal=info for better dev output
* documented ESP_LOG profiles for perf-only/quiet/verbose modes
* perf:: target prefix in messages for grepability
Co-authored-by: Claude <noreply@anthropic.com>
* proactively free ch_cache before cover thumbnail decode to
avoid OOM and heap fragmentation on the 172 KB ESP32-C3 heap
* extract OOM-retry pattern into EpubState::oom_retry() helper,
deduplicating ~30 lines across three image decode call sites
* log original smol-epub error string in decode_image_streaming
before wrapping into the unified Error type
Cover thumbnails are generated once after OPF parse, when the
chapter cache may already hold up to 96 KB. Large DEFLATED
cover JPEGs need ~79-95 KB peak for the streaming decoder,
exceeding available heap. The previous code had OOM-retry
logic for inline images but not for cover generation.
Co-authored-by: Claude <noreply@anthropic.com>
* reuses the existing home-screen cover thumbnail cache for the
reader loading screen
* keeps first-open behavior unchanged by skipping any new decode work
in the critical loading path
This keeps the change localized to reader UI code and avoids touching
shared grayscale or image decode paths.
Co-authored-by: Claude <noreply@anthropic.com>
* hides the reader chrome and generic loading label while a book
opens
* avoids showing raw fat32 filenames as the main loading title
* prefills cached epub titles from the chapter cache header on
reopen
This makes first opens feel cleaner and lets cached reopens show
the real book title much earlier without changing the main cache
flow.
Co-authored-by: Claude <noreply@anthropic.com>
* show book title, stage label, and progress bar while loading
* stages map to percentage: bookmark→init→opf→toc→cache→index→page
* chapter and image caching phases report sub-progress
* disable grayscale during loading states (only needed for ready/toc)
* skip chrome and position overlay during grayscale pass
* add draw_truncated_text helper with utf8-safe ellipsis truncation
The grayscale LUT works better when the panel already has the
base black and white structure in place. The previous white
clear made the sleep wallpaper look washed out.
Render the wallpaper as a normal full black and white frame
first, then run the grayscale pass as an overlay. This matches
our reader text AA flow more closely and fixed the on-device
sleep image quality.
Co-authored-by: Claude <noreply@anthropic.com>
The grayscale LUT uses 00 for "no change", so white areas of the
sleep wallpaper were leaving old screen contents behind and looking
greyed out.
Run a full GC white clear before the grayscale pass so the wallpaper
starts from a known white panel state. Keep separate timing logs for
the clear and grayscale phases to make sleep-path regressions easier
to spot on device.
Co-authored-by: Claude <noreply@anthropic.com>
The sleep wallpaper loader was reopening and seeking the BMP
for every single row, which turned into a 15-20s wait time
before sleeping.
Read contiguous BMP row batches into a heap buffer instead.
The batch stays off the stack, caps itself at 16KB, and backs
off to smaller batches if the extra allocation cannot fit.
This keeps the 6-chunk image storage layout while making the
8bpp case use a few dozen SD reads instead of hundreds.
Co-authored-by: Claude <noreply@anthropic.com>
The single 96KB Vec panicked because neither heap pool (108KB
main DRAM, 62KB reclaimed bootloader) can hold it contiguously.
Split into 6 chunks of ~16KB each (134 rows per chunk). The
allocator spreads them across both pools; worst case fits 4
in pool 1 and 2 in pool 2. The draw closure iterates all 6
chunks per strip call; blit_2bpp clips to the current strip
window so only the overlapping chunk draws pixels.
No EPD driver changes needed, grayscale_pass works as-is
since the draw closure is just Fn(&mut StripBuffer).
Co-authored-by: Claude <noreply@anthropic.com>
Integrate the sleep_image loader into the enter_sleep flow.
If SLEEP.BMP exists on SD root, load and render it as a 4-level
grayscale image via the SSD1677 dual-plane grayscale pass.
Falls back to the "(sleep)" text if no image is found.
* load image before sd_card_sleep (SD must be alive for reads)
* construct full-screen RenderState for grayscale_pass
* guard with epd.init() for edge case of immediate-after-boot sleep
* all timing logged to serial for profiling
Co-authored-by: Claude <noreply@anthropic.com>
Add sleep_image module that loads SLEEP.BMP from SD card root
and converts it to 2bpp grayscale via Atkinson dithering for
the SSD1677's dual-plane grayscale mode.
* supports 1-bit, 8-bit palette, and 24-bit RGB uncompressed BMPs
* requires exact 480x800 dimensions (no scaling)
* reads row-by-row from SD to avoid holding full BMP in RAM
* Atkinson dithering to 4 gray levels (96KB output buffer)
* this is the first heap allocation in the kernel, justified
because it only happens once right before MCU deep-sleep reset
Co-authored-by: Claude <noreply@anthropic.com>
Use the restored page as a hint when resolving offsets, and
push TOC parsing, title saves, recent updates, and cover
thumbnail generation until after the first page is visible.
This gets the reader back on screen sooner after sleep or
book open without dropping the follow-up metadata work.
Co-authored-by: Claude <noreply@anthropic.com>
The reader position was not restored correctly after sleep because
bookmark_load overwrote the session restore_offset with stale
bookmark data. Bookmarks are only flushed periodically or on
navigation not on every page turn, so they lag behind the
actual reading position.
* skip bookmark_load when restore_offset is already set from
the RTC/SD session, which captures the exact page at sleep time
* call save_active_state before collect_session in the sleep path
so the bookmark cache is updated with the current position
* add save_active_state to AppLayer trait, dispatches save_state
on the active app
Co-authored-by: Claude <noreply@anthropic.com>
On ESP32-C3 battery wake, a voltage sag triggers the brownout detector
which maps to ChipPowerOn reset, the bootloader then zeros all RTC FAST
memory, destroying the saved session. USB power avoids this because the
supply is stable.
* save session to both RTC memory and _PULP/SESSION.BIN on SD before
entering deep sleep - ~76ms total, was ~0ms RTC-only
* on boot, try RTC first then fall back to SD if invalid
* has_valid_session checks both sources to decide whether to skip the
boot console
* fix magic not being set: collect_session never called mark_valid so
the SD copy had magic=0 while save only patched it into RTC memory
after serialization
* fix alignment UB: load_from_sd read into u8 buf then ptr::read as
RtcSession with align 4 -- RISC-V misaligned access. now uses
repr C align 4 wrapper for the read buffer
* remove brownout detector disable -- does not work, register resets
to default during wake power-up sequence
* log reset reason on boot for diagnostics
Co-authored-by: Claude <noreply@anthropic.com>
On battery power, the CPU wake-up from deep sleep causes a brief voltage
sag that triggers the brownout detector. This produces a SysBrownOut
reset instead of CoreDeepSleep, and the bootloader zeros all RTC FAST
memory destroying the saved session data.
* disable brownout detector (RTC_CNTL_BROWN_OUT_REG bit 30) right before
entering deep sleep so wake is classified as a proper DEEPSLEEP_RESET
and RTC memory is preserved
* the detector is re-enabled by the application startup code on every
boot, so this only affects the wake transition
* log reset reason on boot for diagnosing RTC persistence
* confirmed safe: no flash corruption or bricking risk, this
is an established workaround for ESP32 battery wake issues
Co-authored-by: Claude <noreply@anthropic.com>
* apply_session() no longer calls on_enter() on apps after
restore_state(), which was resetting state that was just restored
(home.selected→0, files.scroll→0, reader.chapter→0, etc.)
* each app's restore_state() now sets up ALL state needed to resume:
home sets needs_load_recent + battery, files sets stale_cache +
title_scanning, reader does full pipeline setup with chapter/offset
pre-populated from RTC memory
* skip boot console on valid RTC wake (saves ~1.6s full EPD refresh)
* skip home recent book load when waking to non-Home app
* add per-stage timing instrumentation to boot and sleep paths
* add peek_valid() to rtc_session for non-consuming validity check
The root cause was that apply_session() called on_enter() on every
app in the nav stack after restore_state(). Each on_enter()
unconditionally reset its state, so the chapter/offset/selection
restored from RTC FAST memory was immediately destroyed. The reader
partially recovered via bookmark_load (SD I/O), defeating the
purpose of RTC persistence.
Co-authored-by: Claude <noreply@anthropic.com>
* add Left/Justify text alignment setting to SystemSettings
with SETTINGS.TXT persistence and Settings UI
* extend LineSpan with line-ending metadata (SoftWrap/HardBreak/
BufferEnd) packed into flags bits 4-5 for zero struct growth
* add measure_line() helper that computes natural rendered width
and counts stretchable gaps (ASCII spaces only, not NBSP)
* implement draw-time justification: distribute extra pixels
across inter-word gaps on soft-wrapped non-heading lines
* polish heuristics: require ≥2 gaps, skip <3px spare, cap at
40% of line width, cap per-gap extra at 3× space width
Justification is draw-only, pagination, page offsets, and
bookmarks are completely unaffected by the alignment setting.
Co-authored-by: Claude <noreply@anthropic.com>
* add BitmapFont::truncate_len() helper that returns the
byte offset where text should be cut so that the visible
portion plus a trailing "…" fits within a pixel budget
* truncate title and author in the book card using the
new helper so long names don't overflow the card border
* increase card border stroke from 2 to 3 px to eliminate
thin-line e-paper ghosting artifacts
Co-authored-by: Claude <noreply@anthropic.com>
After submit() puts a task in the WORK_IN channel, the
Embassy executor may not have scheduled the worker yet, so
status() still shows IDLE. The bg_cache_step recovery code
saw "idle + no result" and prematurely recovered, causing
spurious "worker idle with no result" warnings on every
background tick during chapter caching.
Fix by making is_idle() also check WORK_IN.is_empty(), so
it only returns true when the worker has no pending work.
Co-authored-by: Claude <noreply@anthropic.com>
The cache file was only created when `ch == 0`, but when a
bookmark restores the reader to a later chapter, the first
cached chapter is not 0. This caused `cache_file_size` to
fail with "open file failed" because the file didn't exist.
Check whether the file exists instead of assuming chapter 0
is always cached first.
Co-authored-by: Claude <noreply@anthropic.com>
Cover support:
* add cover_cache module with shared save/load helpers
for persistent 1-bit cover thumbnails (COVER.BIN)
* generate cover thumb after OPF parse via streaming SD
decode, with ensure_app_subdir before save
* home screen renders cover on left side of book card
with text/stats on the right; falls back to centered
text when no cover is cached
Reading statistics:
* add stats.rs app with per-book page/time/session
tracking stored as key=value files in _PULP/STATS/
* reader accumulates stats on page turns, flushes to SD
* home card shows "342 pages · 5h 23m" instead of bare
percentage when stats are available
* wire Stats into AppId, manager, home menu, and main
Co-authored-by: Claude <noreply@anthropic.com>
* build CTRL2 dynamically based on power_is_on and sunlight_mode
so we skip CLOCK_ON + ANALOG_ON when the booster is already
running; avoids the re-start transient that caused extra
visible flashes during GC refresh
* lower booster soft-start 5th byte from 0x80 to 0x40 to match
CrossPoint Reader's gentler ramp (less aggressive voltage swing)
* remove unconditional power_off before GC in scheduler, the display was
being powered down then immediately back up, adding an unnecessary
visual flash cycle
* consolidate update_full_async to delegate to start_full_update
so all full-refresh paths (boot, sleep, GC) share the same
dynamic CTRL2 logic
Co-authored-by: Claude <noreply@anthropic.com>
Replace the "Continue" button with a book card that shows
the current book's title, author, and a progress bar. When
no book has been opened, the card shows a placeholder.
Reader changes:
* extend RECENT file format to filename\0title\0author\0pct
(backwards-compatible: old format parsed as filename-only)
* write RECENT on book open and after OPF parse (metadata)
* set recent_dirty flag on page turns, flush in background()
Home screen changes:
* parse extended RECENT format into title/author/progress
* always show the book card (item 0) with border, title,
author, progress bar, and percentage text
* menu items (Files/Bookmarks/Settings/Upload) below card
* selection_region() maps item 0 to card, others to buttons
Co-authored-by: Claude <noreply@anthropic.com>
Replace the centered "pulp-os" heading with a top status bar
showing "pulp-os" left-aligned and battery percentage on the
right. Battery is read from the kernel on enter/resume.
* add STATUS_TITLE_REGION and STATUS_BAT_REGION constants
* cache bat_pct in HomeApp, populated via battery_percentage()
* menu items start below the status bar instead of the heading
Co-authored-by: Claude <noreply@anthropic.com>
Move the reader to a minimal layout: text starts near the top
of the screen (y=8), header chrome (book title + page info)
moves to a bottom bar, and hardware button labels are hidden.
* add hide_button_bar() to App trait so the reader can suppress
the BACK/OK/arrow overlays drawn by AppManager
* move HEADER_REGION and STATUS_REGION from top (y=6) to bottom
(y=778), reclaiming ~16px for text
* add SCREEN_PAD (4px) to avoid display edge clipping on both
top and bottom
* add reader_status setting (Show/Hide) to toggle the bottom
chrome bar, persisted to SETTINGS.TXT
* when chrome is hidden, text area extends to the bottom edge
for maximum reading space
Co-authored-by: Claude <noreply@anthropic.com>
The text_area_h calculation used a hardcoded 4px bottom padding
instead of BUTTON_BAR_H (26px), causing the last line of text
to render behind the button labels (BACK, OK, <<, >>).
* use BUTTON_BAR_H in both the const TEXT_AREA_H and the runtime
apply_theme_layout() calculation
* fixes all reading themes (Compact/Default/Relaxed/Spacious)
Co-authored-by: Claude <noreply@anthropic.com>
sync_quick_menu() calls on_quick_cycle_update on every close,
even when the user didn't change any values. The reader
unconditionally set state = NeedIndex, triggering a full page
re-layout and "Loading page..." screen on every menu dismiss.
Guard the font size handler with a value != check so it only
re-indexes when the size actually changed.
Known issue: when text AA is enabled and the quick menu border
bisects a text line, closing the menu leaves a small artifact
on that line (the partial-region gray pass only covers the menu
area, so the split line gets an inconsistent gray/BW boundary).
Fix in a future pass.
Co-authored-by: Claude <noreply@anthropic.com>
Port CrossPoint Reader's antialiased text rendering to pulp-os.
The SSD1677's BW and RED RAM planes are loaded with separate
LSB/MSB bitmaps derived from 2bpp font coverage data, then a
custom waveform LUT drives each {RED,BW} bit pair to one of
four gray levels in a single refresh cycle.
Font pipeline changes:
* build.rs now rasterizes 2bpp glyphs (4 coverage levels)
instead of 1bpp with a hard threshold
* coverage quantization: <64→white, <128→light, <192→dark, ≥192→black
* bitmap packing: 4 pixels/byte MSB-first (stride = w/4)
* flash cost ~547 KB total (was ~274 KB at 1bpp)
Strip buffer changes:
* add GrayMode enum (Bw/GrayLsb/GrayMsb) to StripBuffer
* gray modes clear buffer to 0x00 (not 0xFF) so unmarked
pixels map to LUT entry 00 (no change)
* add blit_2bpp() + optimized blit_2bpp_270() that select
pixels per mode: Bw=any non-zero, Lsb=val≥2, Msb=val 1|2
* BW mode preserves fg color param for inverted text
Display driver changes:
* add WRITE_LUT (0x32), voltage commands, and 112-byte
grayscale LUT waveform from CrossPoint
* add grayscale_pass(): streams LSB strips→BW RAM, MSB
strips→RED RAM, loads custom LUT, triggers refresh
* after gray refresh, phase3_sync restores BW content to
both planes so subsequent DU refreshes compute correct
pixel deltas (red_stale stays false)
Scheduler integration:
* gray pass runs after successful partial refresh when
text_aa enabled AND wants_grayscale() returns true
* wants_grayscale() = reader active + quick menu closed,
preventing gray artifacts on overlays and non-reader apps
Settings:
* add text_aa bool to SystemSettings (default off)
* add "Text AA" toggle as 8th settings item
Co-authored-by: Claude <noreply@anthropic.com>