Compare commits

..
Author SHA1 Message Date
Leopoldo Pla SempereandJulia Nguyen 4970ef1f6b feat: support transparent PNG sleep overlays (#2974)
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-08-13 15:59:55 -04:00
Foulad f449ce9e7d fix: narrow pr-formatting-check.yml to pull_request_target only (#3006) 2026-08-13 19:52:26 +03:00
Thiago Kenji Okada 48e39eb0ee fix: prevent reader resume position rollback (#2962) 2026-08-13 12:05:01 +00:00
Justin Mitchell 9b1fb712de fix: Guard GPIO13 power control for Xteink C3 boards only (#2998) 2026-08-12 22:06:52 -04:00
Justin Mitchell e715532f90 fix: settings UI not updating after language change & line height padding (#2997) 2026-08-12 21:32:03 -04:00
Brian Pugh 70faa29dac perf: light-sleep idle + refresh downclock — ~3.2x active reading time (X3-measured) (#2525)
## Summary

Reduces active-reading power draw ~3.2x, measured end-to-end on an X3
with a [Nordic
PPK2](https://www.nordicsemi.com/Products/Development-hardware/Power-Profiler-Kit-2)
at the battery terminals (slim build, USB cable removed, 3.8v). Three
independent changes:

1. **Light-sleep between input polls when idle** (the big one): after 1
s of inactivity the main loop replaces its busy `delay(50)` with 50 ms
timer-paced `esp_light_sleep_start()` slices. Poll cadence and input
latency are unchanged — the CPU sleeps between polls instead of spinning
at 10 MHz. Guards: never sleeps while a `HalPowerManager::Lock` is held
(render in flight), WiFi is up, or USB is connected (light sleep kills
the CDC link).
2. **Two-stage idle backoff**: downclock at 500 ms, light sleep at 1 s,
replacing the flat 3 s full-speed window. Renders re-raise the clock via
`Lock` the moment they start, so the full-speed tail only ever served
loop bookkeeping. (The 3 s value in #852 was a placeholder;
responsiveness verified on device against stock.)
3. **Refresh BUSY-wait downclock**: drops the CPU to 10 MHz while
`pollBusy()` polls the BUSY pin (0.3–2 s per refresh), via new optional
hooks in the SDK — **depends on Free-Ink/freeink-sdk/pull/8**. Plus a 1
Hz throttle on the X3's I2C-based USB detection (was polled every loop).

(1+2) reduced average static-page-current from 9.68mA to 3.45mA.
Reducing the battery-monitor polling frequency further reduced it to
2.78mA.

Before merging, we'll need to update the submodule reference.

## Measured results (PPK2, X3)

| Metric | Before | After |
|---|---|---|
| Idle on a static page | 9.68 mA | **2.78 mA** |
| Fast page turn (incl. post-turn behavior) | ~100 mC | **28.98 mC** |
| Post-turn 160 MHz tail | 21.2 mA x 3 s | eliminated |
| Session average @ 30 s/page | ~11.6 mA | **~3.6 mA (~3.2x reading
time)** |
| Deep sleep | 12.8 µA | unchanged |

## Typical-day profiles

Assuming someone spends 30s per page.

- **Light**: 1 h reading, 10 lock/wake cycles, remaining ~23 h deep
sleep
- **Heavy**: 2 h reading, 20 lock/wake cycles, remaining ~22 h deep
sleep
- Per-day cost components: reading 11.6 → 3.6 mA (session avg),
lock+wake cycle
0.113 → 0.099 mAh each (sleep-screen mode), deep sleep 12.8 µA
(unchanged,
  ~0.29 mAh/day)
- Excluded: WiFi sessions, cold chapter indexing, self-discharge

### Days per charge (650mAh)

| Profile | Before | After | Ratio |
|---|---|---|---|
| Light (1 h/day, 10 locks) | 13.0 mAh/day → ~50 days | 4.9 mAh/day →
~133 days | 2.7Ă— |
| Heavy (2 h/day, 20 locks) | 25.7 mAh/day → ~25 days | 9.5 mAh/day →
~68 days | 2.7Ă— |
| Pure active reading | 11.6 mA → ~56 h | 3.6 mA → ~180 h | 3.2× |

### Caveats

- "After" saturates in reality: Li-ion self-discharge (~2–3%/month) is
not modeled
  and becomes the limiter once firmware draw is this low.
- Faster page-turners trend toward ~2.8Ă—, slower readers slightly above
3.2Ă—.
- Frequent locking dilutes the ratio (wake = full cold boot, barely
touched by the
power branch): at 10–20 locks/day, cycles are ~20% of the "after"
budget.
---

### AI Usage

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

Did you use AI tools to help write this code? _**YES**_

---------
2026-08-12 20:57:45 -04:00
Leopoldo Pla SempereandJulia Nguyen cb995809ba fix: backport File Transfer guard from CrossInk (#2990)
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-08-13 00:19:03 +03:00
Uri Tauber f16451c729 feat: render HTML dictionary definitions via EPUB engine (#2836) 2026-08-12 17:17:25 -04:00
57 changed files with 1603 additions and 321 deletions

No files matched your search

+1 -2
View File
@@ -1,7 +1,6 @@
name: "PR Formatting"
on:
pull_request:
pull_request_target:
types:
- opened
@@ -9,7 +8,7 @@ on:
- edited
permissions:
statuses: write
pull-requests: read
jobs:
title-check:
+6 -6
View File
@@ -501,7 +501,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
| **Transparent** | A BMP overlay drawn over the current screen. Supports alpha transparency in 32-bit BGRA images and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **Transparent** | A BMP or PNG overlay drawn over the current screen. Supports PNG and 32-bit BGRA alpha transparency, and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **None** | A blank screen. |
#### Cover settings
@@ -520,17 +520,17 @@ To use custom sleep images, set the sleep screen mode to **Custom** or **Cover +
#### Transparent overlay images
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP files on the SD card:
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP or PNG files on the SD card:
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place a file named `sleep-overlay.bmp` in the root directory. This takes priority over the `.sleep-overlay`/`sleep-overlay` directories.
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` or `.png` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place `sleep-overlay.bmp` or `sleep-overlay.png` in the root directory. A root BMP takes priority over a root PNG, and both take priority over the `.sleep-overlay`/`sleep-overlay` directories.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in these alpha images erase the content behind them.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a PNG with an alpha channel or a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in alpha images erase the content behind them.
> [!TIP]
> For best results:
> - For non-transparent **Custom** mode, use uncompressed BMP files with 24-bit color depth.
> - For **Transparent** mode, use a regular BMP for white-as-transparent artwork or an uncompressed 32-bit BGRA BMP for per-pixel alpha.
> - For **Transparent** mode, use a PNG or uncompressed 32-bit BGRA BMP for per-pixel alpha, or a regular BMP for white-as-transparent artwork.
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
+3 -1
View File
@@ -13,7 +13,7 @@ A dictionary folder must contain:
- `.syn` — synonym index (optional; maps alternate spellings and irregular forms to their headword)
- `.ifo` — metadata (optional)
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error), and HTML-formatted definitions render as raw markup rather than styled text.
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error).
## Setting Up a Dictionary
@@ -49,6 +49,8 @@ On the very first lookup with a dictionary (and again whenever the `.idx` or `.s
When a word is found, the definition screen shows the matched headword at the top and the definition text below, with a page counter for long definitions.
HTML dictionaries that declare `sametypesequence=h` use the EPUB text-layout engine for semantic formatting such as headings, bold, italics, lists, and line breaks. Images and CSS styling are ignored. Definitions that are too large or cannot be laid out within the available memory fall back to plain text.
- **Left/Right** or side **Up/Down** — previous / next page
- **Back** — return to word selection
+3 -3
View File
@@ -144,14 +144,14 @@ struct DirectPixelWriter {
// Write a single 2-bit dithered pixel value to the framebuffer.
// Must be called after beginRow() for the current row.
// No bounds checking — caller guarantees coordinates are valid.
inline void writePixel(int logicalX, uint8_t pixelValue) const {
inline void writePixel(int logicalX, uint8_t pixelValue, bool writeWhiteInBw = false) const {
// Determine whether to draw based on render mode
bool draw;
bool state;
switch (mode) {
case GfxRenderer::BW:
draw = (pixelValue < 3);
state = true;
draw = writeWhiteInBw || pixelValue < 3;
state = pixelValue < 3;
break;
case GfxRenderer::GRAYSCALE_MSB:
draw = (pixelValue == 1 || pixelValue == 2);
@@ -18,6 +18,9 @@ struct RenderConfig {
bool useDithering = true;
bool performanceMode = false;
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
float sourceCropX = 0.0f; // Fraction cropped equally from the left and right edges
float sourceCropY = 0.0f; // Fraction cropped equally from the top and bottom edges
bool preserveAlpha = false; // Skip transparent pixels instead of compositing them against white
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
@@ -7,6 +7,7 @@
#include <Memory.h>
#include <PNGdec.h>
#include <algorithm>
#include <cstdlib>
#include <memory>
#include <new>
@@ -21,6 +22,7 @@ namespace {
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the HalFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
PNG* decoder{nullptr};
GfxRenderer* renderer{nullptr};
const RenderConfig* config{nullptr};
int screenWidth{0};
@@ -30,14 +32,18 @@ struct PngContext {
float scale{1.f};
int srcWidth{0};
int srcHeight{0};
int cropLeft{0};
int cropTop{0};
int visibleWidth{0};
int visibleHeight{0};
int dstWidth{0};
int dstHeight{0};
int lastDstY{-1}; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching{false};
uint8_t* grayLineBuffer{nullptr};
uint8_t* alphaLineBuffer{nullptr};
};
// File I/O callbacks use pFile->fHandle to access the HalFile*,
@@ -131,19 +137,31 @@ uint8_t expandSampleToByte(uint8_t sample, int bitsPerSample) {
return static_cast<uint8_t>((sample * 255U) / maxSample);
}
// Convert entire source line to grayscale with alpha blending to white background.
uint8_t alphaThreshold4x4(const int x, const int y) {
static constexpr uint8_t BAYER_4X4[16] = {0, 128, 32, 160, 192, 64, 224, 96, 48, 176, 16, 144, 240, 112, 208, 80};
return BAYER_4X4[((y & 0x03) << 2) | (x & 0x03)];
}
// Convert an entire source line to grayscale and optionally retain alpha.
// Low-bit-depth grayscale/indexed scanlines are packed most-significant sample first.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, int bitsPerSample,
uint8_t* palette, int hasAlpha) {
uint8_t* palette, int hasAlpha, uint32_t transparentColor, uint8_t* alphaLine) {
const auto store = [grayLine, alphaLine](const int x, const uint8_t gray, const uint8_t alpha) {
grayLine[x] = alphaLine ? gray : static_cast<uint8_t>((gray * alpha + 255u * (255u - alpha)) / 255u);
if (alphaLine) alphaLine[x] = alpha;
};
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
if (bitsPerSample == 8) {
if (bitsPerSample == 8 && !hasAlpha && !alphaLine) {
memcpy(grayLine, pPixels, width);
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
const uint8_t sample = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t gray = expandSampleToByte(sample, bitsPerSample);
store(x, gray, hasAlpha && sample == static_cast<uint8_t>(transparentColor) ? 0 : 255);
}
}
break;
@@ -151,53 +169,44 @@ void convertLineToGray(const uint8_t* pPixels, uint8_t* grayLine, int width, int
case PNG_PIXEL_TRUECOLOR:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
const uint32_t color = (static_cast<uint32_t>(p[0]) << 16) | (static_cast<uint32_t>(p[1]) << 8) | p[2];
store(x, gray, hasAlpha && color == transparentColor ? 0 : 255);
}
break;
case PNG_PIXEL_INDEXED:
if (palette) {
if (hasAlpha) {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = palette[768 + idx];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
}
} else {
for (int x = 0; x < width; x++) {
uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
uint8_t* p = &palette[idx * 3];
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
for (int x = 0; x < width; x++) {
const uint8_t idx = readPackedSample(pPixels, x, bitsPerSample);
const uint8_t* p = &palette[idx * 3];
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, hasAlpha ? palette[768 + idx] : 255);
}
} else {
for (int x = 0; x < width; x++) {
grayLine[x] = expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample);
store(x, expandSampleToByte(readPackedSample(pPixels, x, bitsPerSample), bitsPerSample), 255);
}
}
break;
case PNG_PIXEL_GRAY_ALPHA:
for (int x = 0; x < width; x++) {
uint8_t gray = pPixels[x * 2];
uint8_t alpha = pPixels[x * 2 + 1];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
store(x, pPixels[x * 2], pPixels[x * 2 + 1]);
}
break;
case PNG_PIXEL_TRUECOLOR_ALPHA:
for (int x = 0; x < width; x++) {
const uint8_t* p = &pPixels[x * 4];
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
uint8_t alpha = p[3];
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
const uint8_t gray = static_cast<uint8_t>((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
store(x, gray, p[3]);
}
break;
default:
memset(grayLine, 128, width);
if (alphaLine) memset(alphaLine, 255, width);
break;
}
}
@@ -208,25 +217,29 @@ int pngDrawCallback(PNGDRAW* pDraw) {
int srcY = pDraw->y;
int srcWidth = ctx->srcWidth;
if (srcY < ctx->cropTop || srcY >= ctx->cropTop + ctx->visibleHeight) return 1;
const int visibleSrcY = srcY - ctx->cropTop;
// Map source rows with the exact output-height ratio. During downscaling,
// multiple source rows can select the same output row; during upscaling, one
// source row must be repeated across every output row in its range. Emitting
// only the first row of an upscale leaves zero-filled (black) gaps in the
// streamed pixel cache.
int firstDstY = (srcY * ctx->dstHeight) / ctx->srcHeight;
int firstDstY = (visibleSrcY * ctx->dstHeight) / ctx->visibleHeight;
int endDstY = firstDstY + 1;
if (ctx->dstHeight > ctx->srcHeight) {
endDstY = ((srcY + 1) * ctx->dstHeight) / ctx->srcHeight;
if (ctx->dstHeight > ctx->visibleHeight) {
endDstY = ((visibleSrcY + 1) * ctx->dstHeight) / ctx->visibleHeight;
}
if (firstDstY <= ctx->lastDstY) firstDstY = ctx->lastDstY + 1;
if (firstDstY >= endDstY || firstDstY >= ctx->dstHeight) return 1;
if (endDstY > ctx->dstHeight) endDstY = ctx->dstHeight;
// Convert entire source line to grayscale (improves cache locality)
// PNGdec parses tRNS while decoding, after open() returns, so query the
// transparent color here rather than caching its pre-decode value.
const uint32_t transparentColor = ctx->decoder ? ctx->decoder->getTransparentColor() : 0;
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->iBpp, pDraw->pPalette,
pDraw->iHasAlpha);
pDraw->iHasAlpha, transparentColor, ctx->alphaLineBuffer);
// Render scaled rows using Bresenham-style integer stepping (no floating-point division)
int dstWidth = ctx->dstWidth;
@@ -261,27 +274,30 @@ int pngDrawCallback(PNGDRAW* pDraw) {
}
}
int srcX = 0;
int srcX = ctx->cropLeft;
int error = 0;
for (int dstX = 0; dstX < dstWidth; dstX++) {
int outX = outXBase + dstX;
if (outX < screenWidth) {
uint8_t gray = ctx->grayLineBuffer[srcX];
const uint8_t alpha = ctx->alphaLineBuffer ? ctx->alphaLineBuffer[srcX] : 255;
if (alpha >= 8 && alpha > alphaThreshold4x4(outX, outY)) {
uint8_t gray = ctx->grayLineBuffer[srcX];
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
uint8_t ditheredGray;
if (useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
pw.writePixel(outX, ditheredGray, ctx->alphaLineBuffer != nullptr);
if (caching) cw.writePixel(outX, ditheredGray);
}
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
error += srcWidth;
// Bresenham-style stepping: advance srcX based on ratio visibleWidth/dstWidth
error += ctx->visibleWidth;
while (error >= dstWidth) {
error -= dstWidth;
srcX++;
@@ -340,6 +356,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
}
PngContext ctx;
ctx.decoder = png.get();
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
@@ -360,28 +377,36 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// Calculate output dimensions
ctx.srcWidth = png->getWidth();
ctx.srcHeight = png->getHeight();
ctx.cropLeft = static_cast<int>(ctx.srcWidth * std::clamp(config.sourceCropX, 0.0f, 0.99f) / 2.0f);
ctx.cropTop = static_cast<int>(ctx.srcHeight * std::clamp(config.sourceCropY, 0.0f, 0.99f) / 2.0f);
ctx.visibleWidth = ctx.srcWidth - 2 * ctx.cropLeft;
ctx.visibleHeight = ctx.srcHeight - 2 * ctx.cropTop;
if (ctx.visibleWidth <= 0 || ctx.visibleHeight <= 0) {
LOG_ERR("PNG", "PNG crop leaves no visible pixels");
return false;
}
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
ctx.dstWidth = config.maxWidth;
ctx.dstHeight = config.maxHeight;
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
ctx.scale = (float)ctx.dstWidth / ctx.visibleWidth;
} else {
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
float scaleX = (float)config.maxWidth / ctx.visibleWidth;
float scaleY = (float)config.maxHeight / ctx.visibleHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
ctx.dstWidth = (int)(ctx.visibleWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.visibleHeight * ctx.scale);
}
ctx.lastDstY = -1; // Reset row tracking
const int pixelType = png->getPixelType();
const int bitsPerSample = png->getBpp();
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth,
ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
LOG_DBG("PNG", "PNG %dx%d (visible %dx%d) -> %dx%d (scale %.2f), type: %d, bpp: %d", ctx.srcWidth, ctx.srcHeight,
ctx.visibleWidth, ctx.visibleHeight, ctx.dstWidth, ctx.dstHeight, ctx.scale, pixelType, bitsPerSample);
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType, bitsPerSample);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
@@ -410,12 +435,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
return false;
}
auto grayLineBuffer = makeUniqueNoThrow<uint8_t[]>(grayBufSize);
if (!grayLineBuffer) {
LOG_ERR("PNG", "Failed to allocate gray line buffer");
// tRNS chunks are parsed during decode(), so hasAlpha() is not reliable yet
// for color-key and indexed transparency. Reserve the alpha line whenever
// the caller requests preservation; opaque pixels simply store alpha 255.
const bool retainAlpha = config.preserveAlpha;
const size_t lineBufferBytes = grayBufSize * (retainAlpha ? 2u : 1u);
auto lineBuffers = makeUniqueNoThrow<uint8_t[]>(lineBufferBytes);
if (!lineBuffers) {
LOG_ERR("PNG", "Failed to allocate PNG line buffers (%u bytes)", static_cast<unsigned>(lineBufferBytes));
return false;
}
ctx.grayLineBuffer = grayLineBuffer.get();
ctx.grayLineBuffer = lineBuffers.get();
ctx.alphaLineBuffer = retainAlpha ? ctx.grayLineBuffer + grayBufSize : nullptr;
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
@@ -423,7 +454,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
// unlike the old full-image buffer it neither competes with the ~44KB decoder
// nor forces larger images to skip caching - which previously meant a full
// re-decode on every one of an image page's ~14 render passes.
ctx.caching = !config.cachePath.empty();
ctx.caching = !config.preserveAlpha && !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) {
LOG_ERR("PNG", "Failed to start cache stream, continuing without caching");
@@ -436,6 +467,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
unsigned long decodeTime = millis() - decodeStart;
ctx.grayLineBuffer = nullptr;
ctx.alphaLineBuffer = nullptr;
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
+4
View File
@@ -93,6 +93,10 @@ bool HalDisplay::isInverted() const { return einkDisplay.isInverted(); }
void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
void HalDisplay::setBusyWaitSliceHook(bool (*sliceHook)(int8_t busyPin, uint8_t busyLevel)) {
einkDisplay.setBusyWaitSliceHook(sliceHook);
}
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
uint8_t* HalDisplay::lendFrameBufferStorage(uint32_t* sizeOut) { return einkDisplay.lendBuildStorage(sizeOut); }
+4
View File
@@ -61,6 +61,10 @@ class HalDisplay {
// Power management
void deepSleep();
// Install the slice hook that replaces the BUSY poll delay on proven-long
// waits (see EpdBus::setBusyWaitSliceHook for the contract)
void setBusyWaitSliceHook(bool (*sliceHook)(int8_t busyPin, uint8_t busyLevel));
// Access to frame buffer
uint8_t* getFrameBuffer() const;
+45 -2
View File
@@ -6,6 +6,7 @@
#include <Wire.h>
#include <XteinkDetect.h>
#include <esp_sleep.h>
#include <soc/usb_serial_jtag_struct.h>
// Global HalGPIO instance
HalGPIO gpio;
@@ -140,11 +141,44 @@ void HalGPIO::begin() {
void HalGPIO::update() {
inputMgr.update();
const bool connected = isUsbConnected();
updateUsbState(millis());
}
void HalGPIO::updateUsbState(const unsigned long now) {
// SOF-based host-link sampling (see the member comment). A cheap register
// read, so it runs at its own short cadence on both devices and is never
// behind the I2C throttle below — a fresh enumeration must cancel light
// sleep within a poll or two, or the next slice kills the CDC link again.
if (sofLastSampleMs == 0 || now - sofLastSampleMs >= SOF_SAMPLE_MS) {
const auto sof = static_cast<uint16_t>(USB_SERIAL_JTAG.fram_num.sof_frame_index);
usbSofActive = (sof != lastSofFrameIndex);
lastSofFrameIndex = sof;
sofLastSampleMs = now;
}
// Throttle the X3's I2C-based USB detection; see USB_POLL_X3_MS. First call
// (usbLastPollMs == 0) always polls so boot state is correct. The combined
// verdict below is still recomputed every call so a SOF-detected attach is
// not held back by the throttle window.
if (usbLastPollMs == 0 || !deviceIsX3() || now - usbLastPollMs >= USB_POLL_X3_MS) {
usbLastPollMs = now;
usbElectricalConnected = isUsbElectricalConnected();
}
const bool connected = usbSofActive || usbElectricalConnected;
usbStateChanged = (connected != lastUsbConnected);
lastUsbConnected = connected;
}
void HalGPIO::pollUsbState() {
// Wait out the SOF sample floor so the comparison sees a real frame delta:
// two reads inside one USB frame compare equal and read as "no host".
const unsigned long elapsed = millis() - sofLastSampleMs;
if (sofLastSampleMs != 0 && elapsed < SOF_SAMPLE_MS) {
delay(SOF_SAMPLE_MS - elapsed);
}
updateUsbState(millis());
}
bool HalGPIO::wasUsbStateChanged() const { return usbStateChanged; }
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
@@ -157,6 +191,8 @@ bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleas
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
bool HalGPIO::isDebouncePending() const { return inputMgr.isDebouncePending(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
@@ -244,9 +280,16 @@ bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre
}
bool HalGPIO::isUsbConnected() const {
// Recent SOF activity means an enumerated host regardless of what the
// electrical check says (false at boot until update() has sampled twice).
return usbSofActive || isUsbElectricalConnected();
}
bool HalGPIO::isUsbElectricalConnected() const {
if (deviceIsX3()) {
// X3: infer USB/charging via BQ27220 Current() register (0x0C, signed mA).
// Positive current means charging.
// Positive current means charging. Misses a data-only cable and a full
// battery — the SOF check in update() covers those.
for (uint8_t attempt = 0; attempt < 2; ++attempt) {
int16_t currentMa = 0;
if (X3GPIO::readBQ27220CurrentMA(&currentMa)) {
+47
View File
@@ -45,6 +45,35 @@ class HalGPIO {
bool lastUsbConnected = false;
bool usbStateChanged = false;
unsigned long usbLastPollMs = 0;
bool usbElectricalConnected = false; // last result of the per-device electrical/charge check
// X3 USB detection is a BQ27220 I2C read (~0.3-1 ms of awake CPU per call);
// polled every loop it costs a few percent of the light-sleep idle floor for
// nothing. At >=1 s intervals the energy cost is unmeasurable (~µC/s), so 1 s
// is chosen for prompt plug/unplug UX (battery icon, light-sleep USB guard /
// CDC recovery). X4 detection is a single digitalRead and stays per-loop.
static constexpr unsigned long USB_POLL_X3_MS = 1000;
// USB-Serial-JTAG SOF activity, sampled by update(): the host sends a SOF
// frame every 1 ms while the bus is enumerated, so a frame index that moved
// between two samples means a live host link. Catches what the charge-based
// X3 check misses: a data-only cable, and any cable once the battery is full
// (charge current ~0). Samples must be >SOF_SAMPLE_MS apart — update() can be
// called back-to-back (inner input loops), and adjacent reads would compare
// equal and flicker the verdict.
uint16_t lastSofFrameIndex = 0;
unsigned long sofLastSampleMs = 0;
bool usbSofActive = false;
static constexpr unsigned long SOF_SAMPLE_MS = 10;
// Per-device electrical/charge-inference USB check (fresh read; X3 = BQ27220
// charge current over I2C, X4 = VBUS-driven level on GPIO20).
bool isUsbElectricalConnected() const;
// Shared body of update()/pollUsbState(): SOF sampling + throttled electrical
// check + combined-verdict edge tracking.
void updateUsbState(unsigned long now);
public:
enum class DeviceType : uint8_t { X4, X3 };
@@ -76,6 +105,11 @@ class HalGPIO {
bool wasAnyPressed() const;
bool wasReleased(uint8_t buttonIndex) const;
bool wasAnyReleased() const;
// True while a raw button-state change is still inside the debounce window.
// The idle loop polls fast while this is set so the confirming sample lands
// ~10 ms after the first; at the 50 ms light-sleep cadence a short tap can
// otherwise appear in a single sample and never commit (dropped press).
bool isDebouncePending() const;
unsigned long getHeldTime() const;
unsigned long getPowerButtonHeldTime() const;
bool hasTouch() const;
@@ -108,6 +142,19 @@ class HalGPIO {
// Check if USB is connected
bool isUsbConnected() const;
// Sample USB state without a full input update. Called during setup() BEFORE
// the first e-ink refresh: the boot paint happens before loop() ever runs
// update(), so without this the light-sleep slice guards see the unsampled
// default ("no USB") and sleeping through the boot refresh kills a live CDC
// link (charge-based X3 detection also reads false whenever the battery is
// full). Two calls >=SOF_SAMPLE_MS apart establish the SOF verdict; the
// method itself waits out the floor if called too soon after the last sample.
void pollUsbState();
// USB state as sampled by the last update() call.
// Prefer this in per-loop polling: isUsbConnected() performs a fresh I2C read on X3.
bool isUsbConnectedCached() const { return lastUsbConnected; }
// Returns true once per edge (plug or unplug) since the last update()
bool wasUsbStateChanged() const;
+175 -7
View File
@@ -4,6 +4,7 @@
#include <Logging.h>
#include <PowerManager.h>
#include <WiFi.h>
#include <driver/gpio.h>
#include <esp_sleep.h>
#include <soc/soc_caps.h>
@@ -13,13 +14,21 @@
HalPowerManager powerManager; // Singleton instance
// GPIO13 must stay high during light sleep on the C3 Xteink boards: it controls
// the X4 battery latch and the X3 SD power rail. Other boards use it for
// unrelated signals, including the X4 Pro display chip select.
static constexpr gpio_num_t XTEINK_C3_GPIO13 = GPIO_NUM_13;
void HalPowerManager::begin() {
if (BoardConfig::ACTIVE.batteryAdc >= 0) {
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
}
mainLoopTask = xTaskGetCurrentTaskHandle(); // Arduino runs setup() on the loop task
normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex();
assert(modeMutex != nullptr);
sleepMutex = xSemaphoreCreateMutex();
assert(sleepMutex != nullptr);
}
void HalPowerManager::setPowerSaving(bool enabled) {
@@ -67,13 +76,19 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
#endif
#if !SOC_PM_SUPPORT_EXT1_WAKEUP
if (gpio.isXteinkDevice() && !gpio.deviceIsX3()) {
// X4 GPIO13 is connected to the battery latch MOSFET. Keeping it low powers
// the MCU off on battery, while the SDK wake source still handles USB power.
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
gpio_set_level(GPIO_SPIWP, 0);
gpio_hold_en(GPIO_SPIWP);
if (gpio.isXteinkDevice()) {
// GPIO13 gates the battery MOSFET on both Xteink C3 boards; driving it low
// is the battery power-off (the SDK wake source still handles USB power).
// Unlike upstream, X3 is included: lightSleep() pad-holds the latch HIGH
// persistently while running (the IDF flash-leakage workaround would drop
// it mid-slice, hard-powering the device off), so the implicit low that
// upstream's X3 relies on never happens here — release the hold and drive
// the latch low explicitly. The hold_en survives deep sleep via the SDK's
// deepSleep() (esp_sleep_config_gpio_isolate + gpio_deep_sleep_hold_en).
gpio_hold_dis(XTEINK_C3_GPIO13);
gpio_set_direction(XTEINK_C3_GPIO13, GPIO_MODE_OUTPUT);
gpio_set_level(XTEINK_C3_GPIO13, 0);
gpio_hold_en(XTEINK_C3_GPIO13);
}
#endif
@@ -91,6 +106,159 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
freeink::PowerManager::deepSleepUntilPowerButton();
}
bool HalPowerManager::lightSleep(const HalGPIO& gpio) const {
// A performance Lock means a render (or similar) task is mid-flight; light sleep
// freezes the whole chip, so it would stall that task.
// Note: like setPowerSaving(), read without the mutex — stale in either
// direction is acceptable: a stale lock delays sleeping by one 50 ms slice;
// a Lock acquired after this check freezes that task for at most one slice
// (timer wake; RAM/peripheral state retained), which is cheaper than
// serializing Lock ctor/dtor against the entire sleep window.
if (currentLockMode != None) {
return false;
}
// Light sleep drops a WiFi association and kills an enumerated USB-CDC link.
if (WiFi.getMode() != WIFI_MODE_NULL || gpio.isUsbConnectedCached()) {
return false;
}
// Serialize wake-source arming with the render task's BUSY-wait slice (see
// sleepMutex declaration for the failure mode this prevents).
xSemaphoreTake(sleepMutex, portMAX_DELAY);
// Timer wake keeps the button/tilt poll cadence identical to the delay() it
// replaces: the front/side buttons are ADC resistor-ladder inputs whose
// pressed levels sit mid-rail, invisible to a digital GPIO wake, so they
// must be polled. The power button (a true GPIO) is ALSO armed, as a level
// wake at its pressed level: committing a press needs two update() samples
// >=5 ms apart, so at the timer-only 50 ms cadence a tap shorter than a
// slice could land in one sample — or none — and be dropped entirely
// (field-reported as unreliable short-press sleep). The wake is only an
// early poll: update()'s debounce still decides whether it was a real
// press, so a misread around the sleep transition costs one extra wake
// blip, never a phantom press. Idle cost is zero — the pin only holds its
// pressed level while a finger is on it.
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(LIGHT_SLEEP_SLICE_MS) * 1000ULL);
const int8_t powerPin = BoardConfig::ACTIVE.input.power;
if (powerPin >= 0) {
gpio_wakeup_enable(static_cast<gpio_num_t>(powerPin),
BoardConfig::ACTIVE.input.powerActiveHigh ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL);
esp_sleep_enable_gpio_wakeup();
}
// The C3 flash-leakage workaround pulls its DIO-unused SPIWP pad low during
// light sleep. Hold GPIO13 high only on the X3/X4 boards that use that pad as
// a power control; on other boards GPIO13 may be a bus signal.
if (gpio.isXteinkDevice()) {
gpio_set_level(XTEINK_C3_GPIO13, 1);
gpio_set_direction(XTEINK_C3_GPIO13, GPIO_MODE_OUTPUT);
gpio_hold_en(XTEINK_C3_GPIO13);
}
const esp_err_t err = esp_light_sleep_start();
// Disarm immediately: an armed timer wake persists across sleep calls and would
// carry over into startDeepSleep(), waking the device on USB power after 50 ms.
// gpio_wakeup_disable() clears only the wake-enable bit — the pin's LEVEL
// interrupt type survives it. A leftover level type is live ammunition for
// any later-installed GPIO ISR service: an asserted level feeding the
// shared service with no per-pin handler registered re-enters the ISR
// forever and livelocks the CPU. Clear the type explicitly.
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
if (powerPin >= 0) {
gpio_wakeup_disable(static_cast<gpio_num_t>(powerPin));
gpio_set_intr_type(static_cast<gpio_num_t>(powerPin), GPIO_INTR_DISABLE);
}
xSemaphoreGive(sleepMutex);
if (err != ESP_OK) {
// e.g. ESP_ERR_SLEEP_REJECT — we did not actually sleep
LOG_DBG("PWR", "Light sleep rejected: %d", static_cast<int>(err));
return false;
}
return true;
}
bool HalPowerManager::onEinkBusyWaitSlice(const int8_t busyPin, const uint8_t busyLevel) {
// Same exclusions as lightSleep(): light sleep drops a WiFi association and
// kills an enumerated USB-CDC link. No LOG here — this runs ~50x/s mid-refresh.
if (WiFi.getMode() != WIFI_MODE_NULL || gpio.isUsbConnectedCached()) {
return false;
}
// Mid-debounce: committing needs a second sample, so let the SDK's short poll
// delay run instead of halting the chip. Same guard lightSleep() gets in loop().
// Unsynchronized like the checks above; stale costs at most one slice.
if (gpio.isDebouncePending()) {
return false;
}
xSemaphoreTake(sleepMutex, portMAX_DELAY);
// Wake the instant BUSY leaves its active level (refresh complete). Level
// wake (not edge) means an already-completed refresh returns immediately
// instead of sleeping a full slice. The timer bound only exists so the main
// loop gets scheduling windows to keep sampling the ADC-ladder buttons.
const auto pin = static_cast<gpio_num_t>(busyPin);
gpio_wakeup_enable(pin, busyLevel == HIGH ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL);
// Also wake the instant the power button is pressed, so the main loop's poll
// sees even sub-slice taps mid-refresh (same rationale and safety argument
// as lightSleep(): the wake is an early poll, never a synthesized press).
const int8_t powerPin = BoardConfig::ACTIVE.input.power;
if (powerPin >= 0) {
gpio_wakeup_enable(static_cast<gpio_num_t>(powerPin),
BoardConfig::ACTIVE.input.powerActiveHigh ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL);
}
esp_sleep_enable_gpio_wakeup();
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(BUSY_SLEEP_SLICE_MS) * 1000ULL);
// Preserve the C3 Xteink GPIO13 power control during this sleep slice without
// reconfiguring GPIO13 on boards where it is a bus signal.
if (gpio.isXteinkDevice()) {
gpio_set_level(XTEINK_C3_GPIO13, 1);
gpio_set_direction(XTEINK_C3_GPIO13, GPIO_MODE_OUTPUT);
gpio_hold_en(XTEINK_C3_GPIO13);
}
const esp_err_t err = esp_light_sleep_start();
// Disarm everything armed above; an armed source persisting into
// startDeepSleep() would wake the device on USB power (see lightSleep()).
// As in lightSleep(): also clear the LEVEL interrupt types, which
// gpio_wakeup_disable() leaves behind (see the livelock note there).
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
gpio_wakeup_disable(pin);
gpio_set_intr_type(pin, GPIO_INTR_DISABLE);
if (powerPin >= 0) {
gpio_wakeup_disable(static_cast<gpio_num_t>(powerPin));
gpio_set_intr_type(static_cast<gpio_num_t>(powerPin), GPIO_INTR_DISABLE);
}
xSemaphoreGive(sleepMutex);
if (err != ESP_OK) {
return false; // e.g. ESP_ERR_SLEEP_REJECT — fall back to the SDK's poll delay
}
// Yield until the equal-priority main loop finishes an iteration, not just one
// tick: one tick is less awake CPU than an iteration needs, so we would re-halt
// the chip mid-poll. Capped so a loop parked on SD I/O can't hold the panel awake.
const uint32_t seenIterations = mainLoopIterations;
// Skipped entirely when the loop cannot make progress and the yield would just
// burn the cap awake: parked in requestUpdateAndWait() on this very render;
// never started (setup-time paints, iterations still 0); or this task IS the
// loop task (direct displayBuffer calls from setup()/loop() busy-wait here).
const bool loopCanPoll = !mainLoopBlocked && seenIterations != 0 && xTaskGetCurrentTaskHandle() != mainLoopTask;
unsigned yieldTicks = 0;
if (loopCanPoll) {
for (; yieldTicks < SLICE_YIELD_MAX_TICKS && mainLoopIterations == seenIterations; ++yieldTicks) {
vTaskDelay(1);
}
}
return true;
}
uint16_t HalPowerManager::getBatteryPercentage() const {
static const BatteryMonitor battery;
if (BoardConfig::ACTIVE.batteryGauge.gaugeAddr != 0) {
+65 -2
View File
@@ -23,6 +23,26 @@ class HalPowerManager {
enum LockMode { None, NormalSpeed };
LockMode currentLockMode = None;
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
// Bumped by the main loop, read by the render task (see onEinkBusyWaitSlice).
// Single writer, naturally aligned: the 32-bit access is atomic on RISC-V.
volatile uint32_t mainLoopIterations = 0;
// Cap on how long one slice may stay awake waiting for that iteration.
// Productive yields measure ~0.4 ms, so two ticks is ample headroom.
static constexpr unsigned SLICE_YIELD_MAX_TICKS = 2;
// Set while the main loop is blocked in requestUpdateAndWait(). Captured in
// begin(), which Arduino runs on the loop task, so the handle compare below
// ignores any other task that might wait on a render.
TaskHandle_t mainLoopTask = nullptr;
volatile bool mainLoopBlocked = false;
// Serializes wake-source arm -> esp_light_sleep_start() -> disarm across the
// two sleepers (main-loop lightSleep() and the render task's BUSY-wait slice).
// Without it, lightSleep()'s deliberately unsynchronized lock-mode read could
// let both tasks interleave arming/disarming: the loser's disarm-all can strip
// the winner's wake sources and leave it sleeping with none armed.
SemaphoreHandle_t sleepMutex = nullptr;
public:
#if BOARD_HAS_PSRAM
@@ -30,8 +50,20 @@ class HalPowerManager {
#else
static constexpr int LOW_POWER_FREQ = 10; // MHz
#endif
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
// Two-stage idle backoff. Renders re-raise the clock via Lock regardless, so the
// full-speed window only needs to cover rapid consecutive input (avoids clock
// thrash); polling stays at 100 Hz until light sleep takes over the cadence.
static constexpr unsigned long IDLE_DOWNCLOCK_MS = 500; // full speed -> LOW_POWER_FREQ
static constexpr unsigned long IDLE_LIGHT_SLEEP_MS = 1000; // 100 Hz polling -> light sleep
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
static constexpr unsigned long LIGHT_SLEEP_SLICE_MS = 50; // ms
// Timer bound for one BUSY-wait light-sleep slice. The refresh end itself
// wakes exactly via GPIO level wake on the BUSY pin; the timer only bounds
// how coarse main-loop button sampling gets during a long refresh.
static constexpr unsigned long BUSY_SLEEP_SLICE_MS = 20; // ms
void begin();
@@ -42,6 +74,37 @@ class HalPowerManager {
// Should be called inside main loop() to handle the currentLockMode
void startDeepSleep(HalGPIO& gpio) const;
// Light-sleep the CPU for LIGHT_SLEEP_SLICE_MS (timer wake; buttons are polled on
// wake at the same cadence as the delay() this replaces). Returns false WITHOUT
// sleeping when unsafe: a performance Lock is held (render in flight), WiFi is
// active, or USB is connected (light sleep kills the CDC link). The caller must
// fall back to delay() in that case.
bool lightSleep(const HalGPIO& gpio) const;
// BUSY-wait slice hook (EpdBus::setBusyWaitSliceHook): light-sleep the chip
// for up to BUSY_SLEEP_SLICE_MS while the panel refreshes autonomously,
// waking early the moment the BUSY pin leaves `busyLevel`. Returns false
// WITHOUT sleeping when unsafe (WiFi active, USB connected, or sleep
// rejected) — the SDK then falls back to its plain poll delay. Called from
// the render task; safe by construction (the waiter IS the locked task).
// Leaves the CPU clock alone: the chip is asleep for most of the wait, so a
// downclock buys nothing and only slows the main loop's awake windows.
bool onEinkBusyWaitSlice(int8_t busyPin, uint8_t busyLevel);
// Call at the end of every main-loop iteration; paces the slice hook's yield.
// Read-then-assign, not ++: C++20 deprecates increment on a volatile lvalue.
void noteMainLoopIteration() { mainLoopIterations = mainLoopIterations + 1; }
// Bracket a blocking wait on the render task (requestUpdateAndWait). While the
// main loop is parked there it cannot poll, so the slice hook skips its yield
// instead of burning the full cap awake once per slice for nothing.
void noteRenderWaitBegin() {
if (xTaskGetCurrentTaskHandle() == mainLoopTask) mainLoopBlocked = true;
}
void noteRenderWaitEnd() {
if (xTaskGetCurrentTaskHandle() == mainLoopTask) mainLoopBlocked = false;
}
// Get battery percentage (range 0-100)
uint16_t getBatteryPercentage() const;
+163 -66
View File
@@ -32,6 +32,7 @@ import re
import signal
import sys
import threading
import time
from collections import deque
from datetime import datetime
@@ -66,6 +67,11 @@ def build_arg_parser() -> argparse.ArgumentParser:
default="",
help="Suppress lines containing this keyword (case-insensitive)",
)
parser.add_argument(
"--no-graph",
action="store_true",
help="Disable the matplotlib memory graph (plain serial console + command prompt only)",
)
return parser
@@ -121,6 +127,13 @@ data_lock: threading.Lock = threading.Lock() # Prevent reading while writing
# Global shutdown flag
shutdown_event = threading.Event()
# Command-ack handshake: the firmware answers every CMD: line with CMDACK:<cmd>
# (a handler ran) or CMDERR:<reason>:<cmd> (e.g. unknown — includes commands
# compiled out of the running build). The reader thread sets ack_event when
# either arrives so input_worker can report success, rejection, or a timeout.
ACK_TIMEOUT_S = 2.0
ack_event = threading.Event()
# Initialize colors
init(autoreset=True)
@@ -232,12 +245,43 @@ def parse_memory_line(line: str) -> tuple[int | None, int | None, int | None]:
)
def serial_worker(ser, kwargs: dict[str, str]) -> None:
def reopen_serial(ser_holder: dict) -> bool:
"""
Close the (possibly dead) serial port and retry opening it until it comes back
or shutdown is requested. Devices re-enumerate on reboot/deep-sleep, so the
monitor must survive the port vanishing and reappearing. Returns True once
reconnected, False if shutting down.
"""
try:
ser_holder["ser"].close()
except (OSError, serial.SerialException):
pass
print(f"{Fore.YELLOW}Serial disconnected - waiting for device to come back...{Style.RESET_ALL}")
while not shutdown_event.is_set():
try:
# Same no-reset open as the initial connect: deassert DTR/RTS
# before open() so the reconnect never reboots the device.
ser = serial.Serial(None, ser_holder["baud"], timeout=0.1)
ser.port = ser_holder["port"]
ser.dtr = False
ser.rts = False
ser.open()
ser_holder["ser"] = ser
print(f"{Fore.GREEN}Reconnected to {ser_holder['port']}{Style.RESET_ALL}")
return True
except (OSError, serial.SerialException):
time.sleep(0.25)
return False
def serial_worker(ser_holder: dict, kwargs: dict[str, str]) -> None:
"""
Runs in a background thread. Handles reading serial data, printing to console,
updating memory usage data for graphing, and processing screenshot data.
Automatically reconnects when the device reboots or is replugged.
Monitors the global shutdown event for graceful termination.
"""
ser = ser_holder["ser"]
print(f"{Fore.CYAN}--- Opening serial port ---{Style.RESET_ALL}")
filter_keyword = kwargs.get("filter", "").lower()
suppress = kwargs.get("suppress", "").lower()
@@ -262,73 +306,88 @@ def serial_worker(ser, kwargs: dict[str, str]) -> None:
try:
while not shutdown_event.is_set():
if expecting_screenshot:
data = ser.read(screenshot_size - len(screenshot_data))
if not data:
try:
if expecting_screenshot:
data = ser.read(screenshot_size - len(screenshot_data))
if not data:
continue
screenshot_data += data
if len(screenshot_data) == screenshot_size:
if Image:
img = Image.frombytes("1", (800, 480), screenshot_data)
# We need to rotate the image because the raw data is in landscape mode
img = img.transpose(Image.ROTATE_270)
img.save("screenshot.bmp")
print(
f"{Fore.GREEN}Screenshot saved to screenshot.bmp{Style.RESET_ALL}"
)
else:
with open("screenshot.raw", "wb") as f:
f.write(screenshot_data)
print(
f"{Fore.GREEN}Screenshot saved to screenshot.raw (PIL not available){Style.RESET_ALL}"
)
expecting_screenshot = False
screenshot_data = b""
continue
screenshot_data += data
if len(screenshot_data) == screenshot_size:
if Image:
img = Image.frombytes("1", (800, 480), screenshot_data)
# We need to rotate the image because the raw data is in landscape mode
img = img.transpose(Image.ROTATE_270)
img.save("screenshot.bmp")
print(
f"{Fore.GREEN}Screenshot saved to screenshot.bmp{Style.RESET_ALL}"
)
else:
with open("screenshot.raw", "wb") as f:
f.write(screenshot_data)
print(
f"{Fore.GREEN}Screenshot saved to screenshot.raw (PIL not available){Style.RESET_ALL}"
)
expecting_screenshot = False
screenshot_data = b""
else:
try:
raw_data = ser.readline().decode("utf-8", errors="replace")
if not raw_data:
continue
raw_data = ser.readline().decode("utf-8", errors="replace")
clean_line = raw_data.strip()
if not clean_line:
continue
if not raw_data:
continue
if clean_line.startswith("SCREENSHOT_START:"):
screenshot_size = int(clean_line.split(":")[1])
expecting_screenshot = True
continue
elif clean_line == "SCREENSHOT_END":
continue # ignore
clean_line = raw_data.strip()
if not clean_line:
continue
# Add PC timestamp
pc_time = datetime.now().strftime("%H:%M:%S")
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
# Command acks bypass filter/suppress: they are direct feedback
# for a command the user just typed, never routine log noise.
if clean_line.startswith("CMDACK:"):
print(f"{Fore.GREEN}Command OK: {clean_line[len('CMDACK:'):]}{Style.RESET_ALL}")
ack_event.set()
continue
if clean_line.startswith("CMDERR:"):
print(f"{Fore.RED}Command REJECTED: {clean_line[len('CMDERR:'):]}{Style.RESET_ALL}")
ack_event.set()
continue
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val, max_alloc_val = parse_memory_line(formatted_line)
if free_val is not None and total_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024)
total_mem_data.append(total_val / 1024)
max_alloc_data.append((max_alloc_val or 0) / 1024)
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
if clean_line.startswith("SCREENSHOT_START:"):
screenshot_size = int(clean_line.split(":")[1])
expecting_screenshot = True
continue
elif clean_line == "SCREENSHOT_END":
continue # ignore
except (OSError, UnicodeDecodeError):
print(
f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}"
)
# Add PC timestamp
pc_time = datetime.now().strftime("%H:%M:%S")
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val, max_alloc_val = parse_memory_line(formatted_line)
if free_val is not None and total_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024)
total_mem_data.append(total_val / 1024)
max_alloc_data.append((max_alloc_val or 0) / 1024)
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except (OSError, serial.SerialException):
# Device rebooted, deep-slept, or was replugged: drop any partial
# screenshot transfer and wait for the port to come back.
expecting_screenshot = False
screenshot_data = b""
if not reopen_serial(ser_holder):
break
ser = ser_holder["ser"]
except KeyboardInterrupt:
# If thread is killed violently (e.g. main exit), silence errors
pass
@@ -336,17 +395,31 @@ def serial_worker(ser, kwargs: dict[str, str]) -> None:
pass # ser closed in main
def input_worker(ser) -> None:
def input_worker(ser_holder: dict) -> None:
"""
Runs in a background thread. Handles user input to send commands to the ESP32 device.
Monitors the global shutdown event for graceful termination on Ctrl-C.
"""
while not shutdown_event.is_set():
try:
cmd = input("Command: ")
ser.write(f"CMD:{cmd}\n".encode())
cmd = input("Command: ").strip()
if not cmd:
continue
ack_event.clear()
ser_holder["ser"].write(f"CMD:{cmd}\n".encode())
# The reader thread prints the ack/rejection line itself; this wait
# only exists to catch silence. SCREENSHOT acks after the ~48KB
# transfer, which finishes well inside the timeout at 115200 baud
# over USB-CDC (native USB, not actually rate-limited).
if not ack_event.wait(ACK_TIMEOUT_S):
print(
f"{Fore.YELLOW}No response to CMD:{cmd} after {ACK_TIMEOUT_S:g}s - device may be "
f"asleep/rebooting, or running firmware without command acks.{Style.RESET_ALL}"
)
except (EOFError, KeyboardInterrupt):
break
except (OSError, serial.SerialException):
print(f"{Fore.YELLOW}Device not connected - command dropped.{Style.RESET_ALL}")
def update_graph(frame) -> list: # pylint: disable=unused-argument
@@ -458,26 +531,50 @@ def main() -> None:
sys.exit(1)
try:
ser = serial.Serial(port, args.baud, timeout=0.1)
# Deassert DTR/RTS BEFORE opening: passing the port to the constructor
# opens immediately with DTR asserted, and the USB-Serial-JTAG
# peripheral interprets that as a reset — rebooting the device on every
# monitor attach (the `reset=11` bench artifact) and destroying any
# wedged state we're trying to observe post-mortem.
ser = serial.Serial(None, args.baud, timeout=0.1)
ser.port = port
ser.dtr = False
ser.rts = False
ser.open()
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
# Shared holder so the reader thread can transparently reconnect (device
# reboots / deep sleeps / replugs) and the input thread always writes to
# the live port object.
ser_holder = {"ser": ser, "port": port, "baud": args.baud}
# Set up signal handler for graceful shutdown
signal.signal(signal.SIGINT, signal_handler)
# 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes
myargs = vars(args) # Convert Namespace to dict for easier passing
t = threading.Thread(target=serial_worker, args=(ser, myargs), daemon=True)
t = threading.Thread(target=serial_worker, args=(ser_holder, myargs), daemon=True)
t.start()
# Start input thread
input_thread = threading.Thread(target=input_worker, args=(ser,), daemon=True)
input_thread = threading.Thread(target=input_worker, args=(ser_holder,), daemon=True)
input_thread.start()
if args.no_graph:
# Plain console mode: keep the main thread alive until Ctrl-C
print(f"{Fore.YELLOW}Graph disabled (--no-graph). Press Ctrl-C to exit.{Style.RESET_ALL}")
try:
while not shutdown_event.is_set():
time.sleep(0.5)
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
finally:
shutdown_event.set()
return
# 2. Set up the Graph (Main Thread)
try:
import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel
+4
View File
@@ -324,7 +324,11 @@ void ActivityManager::requestUpdateAndWait() {
assert(!holdingRenderLock && "Cannot call requestUpdateAndWait() while holding RenderLock");
xTaskNotify(renderTaskHandle, 1, eIncrement);
// Tell the power manager the loop is parked here: it cannot poll input until the
// render finishes, so the BUSY-wait slice hook should not yield to it meanwhile.
powerManager.noteRenderWaitBegin();
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
powerManager.noteRenderWaitEnd();
}
// RenderLock
+2 -2
View File
@@ -42,11 +42,11 @@ void UiListActivity::onRowAction(const fui::ActionEvent& event) {
}
bool UiListActivity::handleButtons() {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onBackButton();
return true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
const int selected = activeNav().selected;
if (selected >= 0 && selected < listCount()) activateIndex(selected);
return true;
+147 -20
View File
@@ -1,6 +1,7 @@
#include "SleepActivity.h"
#include <Epub.h>
#include <Epub/converters/PngToFramebufferConverter.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalDisplay.h>
@@ -8,6 +9,7 @@
#include <HalStorage.h>
#include <I18n.h>
#include <Memory.h>
#include <PNGdec.h>
#include <Txt.h>
#include <Xtc.h>
@@ -29,7 +31,8 @@
namespace {
// Kept separate from /sleep.bmp and /.sleep so alpha-overlay art does not mix with full-screen wallpapers.
constexpr char TRANSPARENT_SLEEP_ROOT[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_BMP[] = "/sleep-overlay.bmp";
constexpr char TRANSPARENT_SLEEP_ROOT_PNG[] = "/sleep-overlay.png";
constexpr char TRANSPARENT_SLEEP_DIR[] = "/.sleep-overlay";
constexpr char TRANSPARENT_SLEEP_LEGACY_DIR[] = "/sleep-overlay";
constexpr size_t MAX_SLEEP_FILE_NAME_LEN = 256;
@@ -71,6 +74,50 @@ uint32_t readLE32(HalFile& file) {
(static_cast<uint32_t>(b3) << 24);
}
uint32_t readBE32(HalFile& file) {
const int c0 = file.read();
const int c1 = file.read();
const int c2 = file.read();
const int c3 = file.read();
if (c0 < 0 || c1 < 0 || c2 < 0 || c3 < 0) return 0;
return (static_cast<uint32_t>(c0) << 24) | (static_cast<uint32_t>(c1) << 16) | (static_cast<uint32_t>(c2) << 8) |
static_cast<uint32_t>(c3);
}
bool isValidPngHeader(HalFile& file) {
static constexpr uint8_t PNG_SIGNATURE[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
static constexpr uint32_t MAX_SOURCE_PIXELS = 2048u * 1536u;
uint8_t signature[8];
if (!file.seek(0) || file.read(signature, sizeof(signature)) != static_cast<int>(sizeof(signature)) ||
!std::equal(std::begin(signature), std::end(signature), std::begin(PNG_SIGNATURE))) {
return false;
}
const uint32_t ihdrLength = readBE32(file);
char chunkType[4];
if (file.read(reinterpret_cast<uint8_t*>(chunkType), sizeof(chunkType)) != static_cast<int>(sizeof(chunkType)) ||
ihdrLength != 13 || !std::equal(std::begin(chunkType), std::end(chunkType), "IHDR")) {
return false;
}
const uint32_t width = readBE32(file);
const uint32_t height = readBE32(file);
const int bitDepth = file.read();
const int colorType = file.read();
const int compression = file.read();
const int filter = file.read();
const int interlace = file.read();
const bool supportedBitDepth =
bitDepth == 8 || ((colorType == PNG_PIXEL_GRAYSCALE || colorType == PNG_PIXEL_INDEXED) &&
(bitDepth == 1 || bitDepth == 2 || bitDepth == 4));
const bool supportedColorType = colorType == PNG_PIXEL_GRAYSCALE || colorType == PNG_PIXEL_TRUECOLOR ||
colorType == PNG_PIXEL_INDEXED || colorType == PNG_PIXEL_GRAY_ALPHA ||
colorType == PNG_PIXEL_TRUECOLOR_ALPHA;
return width > 0 && height > 0 && width <= 2048 && height <= 3072 && width * height <= MAX_SOURCE_PIXELS &&
supportedBitDepth && supportedColorType && compression == 0 && filter == 0 && interlace == 0;
}
BitmapPlacement calculateBitmapPlacement(const int bitmapWidth, const int bitmapHeight, const GfxRenderer& renderer) {
BitmapPlacement placement;
const auto pageWidth = renderer.getScreenWidth();
@@ -335,20 +382,26 @@ void pushRecentSleepIndex(const SleepRecentKind recentKind, const uint16_t idx)
}
}
bool findNextValidSleepBmp(HalFile& dir, char* name) {
bool findNextValidSleepImage(HalFile& dir, const SleepRecentKind recentKind, char* name) {
for (auto dirFile = dir.openNextFile(); dirFile; dirFile = dir.openNextFile()) {
if (dirFile.isDirectory()) continue;
dirFile.getName(name, MAX_SLEEP_FILE_NAME_LEN);
if (name[0] == '\0' || name[0] == '.') continue;
if (!FsHelpers::hasBmpExtension(name)) {
const bool isBmp = FsHelpers::hasBmpExtension(name);
const bool isPng = recentKind == SleepRecentKind::Overlay && FsHelpers::hasPngExtension(std::string_view{name});
if (!isBmp && !isPng) {
LOG_DBG("SLP", "Skipping unsupported sleep image: %s", name);
continue;
}
Bitmap bitmap(dirFile);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
const bool isValid = isBmp ? [&dirFile]() {
Bitmap bitmap(dirFile);
return bitmap.parseHeaders() == BmpReaderError::Ok;
}()
: isValidPngHeader(dirFile);
if (!isValid) {
LOG_DBG("SLP", "Skipping invalid sleep image: %s", name);
continue;
}
@@ -368,7 +421,7 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
}
uint16_t fileCount = 0;
while (fileCount < UINT16_MAX && findNextValidSleepBmp(dir, name.get())) ++fileCount;
while (fileCount < UINT16_MAX && findNextValidSleepImage(dir, recentKind, name.get())) ++fileCount;
if (fileCount == 0) return false;
// Pick a random wallpaper, excluding recently shown ones.
@@ -383,7 +436,7 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
dir.rewindDirectory();
for (uint16_t index = 0; index <= randomFileIndex; ++index) {
if (!findNextValidSleepBmp(dir, name.get())) return false;
if (!findNextValidSleepImage(dir, recentKind, name.get())) return false;
}
selectedPath.reserve(strlen(dirPath) + 1 + strlen(name.get()));
@@ -395,6 +448,34 @@ bool selectRandomSleepFile(const char* dirPath, const SleepRecentKind recentKind
return true;
}
bool drawSleepPopupPreservingFrame(GfxRenderer& renderer) {
const auto& metrics = UITheme::getInstance().getMetrics();
const int frameThickness = metrics.popupFrameThickness;
const int popupY = static_cast<int>(renderer.getScreenHeight() * metrics.popupTopOffsetRatio);
const int popupHeight = renderer.getLineHeight(UI_12_FONT_ID) + metrics.popupMarginY * 2;
const int bandTop = std::max(0, popupY - frameThickness);
const int bandBottom = std::min(renderer.getScreenHeight(), popupY + popupHeight + frameThickness);
const int bandHeight = bandBottom - bandTop;
const size_t bandBytes = renderer.getRegionByteSize(0, bandTop, renderer.getScreenWidth(), bandHeight);
auto savedBand = makeUniqueNoThrow<uint8_t[]>(bandBytes);
if (!savedBand) {
LOG_ERR("SLP", "OOM: sleep popup background (%u bytes)", static_cast<unsigned>(bandBytes));
return false;
}
if (!renderer.copyRegionToBuffer(0, bandTop, renderer.getScreenWidth(), bandHeight, savedBand.get(), bandBytes)) {
LOG_ERR("SLP", "Failed to save sleep popup background");
return false;
}
GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP));
if (!renderer.copyBufferToRegion(0, bandTop, renderer.getScreenWidth(), bandHeight, savedBand.get(), bandBytes)) {
LOG_ERR("SLP", "Failed to restore sleep popup background");
return false;
}
return true;
}
} // namespace
void SleepActivity::onEnter() {
@@ -421,6 +502,10 @@ void SleepActivity::onEnter() {
// output-level inversion first so the retained content keeps its visible
// polarity after the display driver returns to normal.
if (frameWasInverted) renderer.invertScreen();
if (APP_STATE.lastSleepFromReader) {
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
}
drawSleepPopupPreservingFrame(renderer);
if (APP_STATE.lastSleepFromReader) {
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
}
@@ -585,26 +670,68 @@ bool SleepActivity::renderSleepOverlayFile(HalFile& file, const char* pathForLog
return true;
}
void SleepActivity::renderTransparentCustomSleepScreen() const {
{
HalFile rootFile;
if (Storage.openFileForRead("SLP", TRANSPARENT_SLEEP_ROOT, rootFile)) {
if (renderSleepOverlayFile(rootFile, TRANSPARENT_SLEEP_ROOT)) return;
}
bool SleepActivity::renderTransparentOverlayPng(const std::string& path) const {
ImageDimensions dimensions;
if (!PngToFramebufferConverter::getDimensionsStatic(path, dimensions)) return false;
const auto placement = calculateBitmapPlacement(dimensions.width, dimensions.height, renderer);
RenderConfig config;
config.x = placement.x;
config.y = placement.y;
config.maxWidth = renderer.getScreenWidth();
config.maxHeight = renderer.getScreenHeight();
config.useDithering = false;
config.sourceCropX = placement.cropX;
config.sourceCropY = placement.cropY;
config.useExactDimensions = placement.cropX > 0.0f || placement.cropY > 0.0f;
config.preserveAlpha = true;
PngToFramebufferConverter converter;
LOG_DBG("SLP", "Rendering transparent PNG overlay: %s (%dx%d)", path.c_str(), dimensions.width, dimensions.height);
if (!converter.decodeToFramebuffer(path, renderer, config)) return false;
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (!converter.decodeToFramebuffer(path, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
renderer.copyGrayscaleLsbBuffers();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (!converter.decodeToFramebuffer(path, renderer, config)) {
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
return true;
}
bool SleepActivity::renderSleepOverlayPath(const std::string& path) const {
if (FsHelpers::hasPngExtension(path)) {
return Storage.exists(path.c_str()) && renderTransparentOverlayPng(path);
}
HalFile file;
return Storage.openFileForRead("SLP", path, file) && renderSleepOverlayFile(file, path.c_str());
}
void SleepActivity::renderTransparentCustomSleepScreen() const {
if (renderSleepOverlayPath(TRANSPARENT_SLEEP_ROOT_BMP)) return;
if (renderSleepOverlayPath(TRANSPARENT_SLEEP_ROOT_PNG)) return;
std::string selectedPath;
if (!selectRandomSleepFile(TRANSPARENT_SLEEP_DIR, SleepRecentKind::Overlay, selectedPath)) {
selectRandomSleepFile(TRANSPARENT_SLEEP_LEGACY_DIR, SleepRecentKind::Overlay, selectedPath);
}
if (!selectedPath.empty()) {
HalFile overlayFile;
if (Storage.openFileForRead("SLP", selectedPath, overlayFile) &&
renderSleepOverlayFile(overlayFile, selectedPath.c_str())) {
return;
}
}
if (!selectedPath.empty() && renderSleepOverlayPath(selectedPath)) return;
LOG_ERR("SLP", "No valid transparent sleep overlay found");
renderDefaultSleepScreen();
@@ -1,4 +1,6 @@
#pragma once
#include <string>
#include "activities/Activity.h"
class Bitmap;
@@ -16,6 +18,8 @@ class SleepActivity final : public Activity {
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap, bool preserveBackground = false) const;
bool renderSleepOverlayFile(HalFile& file, const char* pathForLog) const;
bool renderTransparentOverlayPng(const std::string& path) const;
bool renderSleepOverlayPath(const std::string& path) const;
void renderLastScreenSleepScreen() const;
void renderTransparentCustomSleepScreen() const;
void renderBlankSleepScreen() const;
@@ -51,6 +51,8 @@ void OpdsBookBrowserActivity::onEnter() {
searchTemplate = "";
currentPath = "";
selectorIndex = 0;
consumeConfirm = false;
consumeBack = false;
errorMessage.clear();
statusMessage = tr(STR_CHECKING_WIFI);
@@ -113,6 +115,15 @@ void OpdsBookBrowserActivity::loop() {
return;
}
if (consumeConfirm && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
consumeConfirm = false;
return;
}
if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) {
consumeBack = false;
return;
}
if (state == BrowserState::ERROR) {
int tx = 0;
int ty = 0;
@@ -544,6 +555,10 @@ void OpdsBookBrowserActivity::launchSearch() {
auto keyboard = std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SEARCH));
startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) {
// Swallow the release of the Confirm press that closed the keyboard only
// when that button is actually still held on resume — a blanket flag set
// at launch went stale on touch flows and ate the next genuine Confirm.
consumeConfirm = mappedInput.isPressed(MappedInputManager::Button::Confirm);
state = BrowserState::BROWSING;
if (!result.isCancelled) {
performSearch(std::get<KeyboardResult>(result.data).text);
@@ -37,6 +37,8 @@ class OpdsBookBrowserActivity final : public Activity, private UiAppHost {
std::vector<std::string> navigationHistory;
std::string currentPath;
std::string searchTemplate;
bool consumeConfirm = false;
bool consumeBack = false;
int selectorIndex = 0;
std::string errorMessage;
std::string statusMessage;
Loaded 20 of 57 files, more files were not shown because too many files have changed in this diff. Show more