4 Commits

Author SHA1 Message Date
cottongin
7c80227e54 fix: scope NTP power lock before vTaskDelete, revert clock refresh override
vTaskDelete(nullptr) does not unwind the C++ stack, so the RAII
HalPowerManager::Lock destructor never ran -- permanently holding the
lock and preventing low-power mode. Fix by wrapping the lock in a block
scope that exits before vTaskDelete().

Revert the setPowerSaving(false) calls in the clock refresh block; the
RenderLock already handles CPU frequency during display updates.

Made-with: Cursor
2026-03-09 06:08:31 -04:00
cottongin
6cf212d12a fix: prevent idle freeze by hardening NTP and clock power management
BootNtpSync now acquires a HalPowerManager::Lock for the entire WiFi/NTP
task lifecycle, keeping the CPU at full speed during scan, connect, sync,
and teardown. The clock refresh logic in the main loop now explicitly
restores CPU frequency and resets the activity timer before requesting a
render, preventing display SPI operations from running at 10 MHz.

Made-with: Cursor
2026-03-09 05:19:48 -04:00
cottongin
4851016c47 fix: adjust BookInfo landscape layout for side button hint gutters
In landscape CW/CCW the physical buttons are on the left/right side,
not the bottom. Reserve a horizontal gutter (sideButtonHintsWidth)
on the appropriate side and remove the bottom buttonHintsHeight
padding, following the established pattern from other activities.

Made-with: Cursor
2026-03-09 04:28:26 -04:00
cottongin
630fb56a11 feat: side-by-side cover layout for BookInfo in landscape
In landscape orientation the cover is pinned on the left panel
(filling the content height) while metadata fields scroll
independently on the right. Portrait layout is unchanged.

Made-with: Cursor
2026-03-09 04:00:41 -04:00
7 changed files with 215 additions and 48 deletions

View File

@@ -0,0 +1,26 @@
# BookInfo: Landscape Side-by-Side Layout
**Date**: 2026-03-09
**Task**: Implement a side-by-side layout for BookInfo in landscape orientation -- cover fixed on the left, scrollable metadata fields on the right. Portrait layout unchanged.
## Changes Made
### `src/activities/home/BookInfoActivity.h`
- Added `bool isLandscape` and `int coverPanelWidth` member variables.
### `src/activities/home/BookInfoActivity.cpp`
- **`onEnter()`**: Detects orientation via `renderer.getOrientation()`. In landscape, the cover thumbnail height fills the full content area (between header and button hints) instead of 2/5 of screen height.
- **`buildLayout()`**: Reads cover bitmap dimensions first. In landscape with a cover, computes `coverPanelWidth` (cover width + padding, capped at 40% of screen). Text fields are wrapped to the narrower right-panel width. Cover height is excluded from `contentHeight` (it sits beside, not above, the fields).
- **`render()`**: In landscape, draws the cover at a fixed position in the left panel (unaffected by scroll). Fields render in the right panel at `x = coverPanelWidth`. In portrait, existing behavior is preserved (cover above fields, both scroll together). The header fill + draw-on-top pattern continues to prevent content bleeding into the header zone.
## Behavior Summary
- **Portrait** (480x800): Cover on top, fields below, everything scrolls vertically (no change).
- **Landscape** (800x480): Cover pinned on left (centered vertically, fills content height), metadata fields scroll independently on the right.
- If no cover exists in landscape, the full screen width is used for fields (same as portrait but in landscape dimensions).
## Follow-up
None -- ready for hardware testing in all 4 orientations.

View File

@@ -0,0 +1,27 @@
# BookInfo: Landscape Button Hint Gutter Fix
**Date**: 2026-03-09
**Task**: Adjust BookInfo's content area and header placement in landscape orientations to account for button hints appearing on a side instead of the bottom.
## Problem
In landscape orientations the physical front buttons end up on a side of the screen (CW = left, CCW = right). BookInfo was reserving `buttonHintsHeight` at the bottom in all orientations, wasting vertical space in landscape and not accounting for the side gutter needed to avoid overlapping the side-drawn button hints.
## Changes Made
### `src/activities/home/BookInfoActivity.cpp`
- **`buildLayout()`**: Added `hintGutterWidth` computation (`metrics.sideButtonHintsWidth` in landscape, 0 in portrait). Text wrapping width (`contentW`) now subtracts `hintGutterWidth`. Bottom padding for `contentHeight` uses only `verticalSpacing` in landscape (no `buttonHintsHeight` since there are no bottom hints).
- **`render()`**: Added `isLandscapeCw`, `hintGutterWidth`, and `contentX` computations following the established codebase pattern. In CW, `contentX = hintGutterWidth` shifts all content right (hints on left). In CCW, `contentX = 0` and content width is reduced (hints on right). Cover X position offset by `contentX`. Field X offset by `contentX`. `contentBottom` no longer subtracts `buttonHintsHeight` in landscape. Header `fillRect` and `drawHeader` Rect both adjusted by `contentX` and `hintGutterWidth`.
## Pattern Followed
The established pattern from `LookedUpWordsActivity`, `DictionarySuggestionsActivity`, `EpubReaderMenuActivity`, and others:
```cpp
const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
```
## Follow-up
Ready for hardware testing in all 4 orientations.

View File

@@ -0,0 +1,28 @@
# Fix Idle Freeze: NTP Power Lock and Clock Refresh Hardening
**Date**: 2026-03-09
## Task Description
Fix device freeze during idle, where the device stops responding to button presses after idling on the Home screen and requires a hard reset. Root cause was the mod-specific clock refresh logic in the main loop triggering a display render while the CPU is at reduced frequency (10 MHz low-power mode), combined with the background BootNtpSync task running WiFi/NTP operations without holding a power lock.
## Root Cause
The freeze coincides with the clock minute boundary. The mod's clock refresh code detects the minute change and calls `activityManager.requestUpdate()`, triggering a Home screen render while the CPU is at 10 MHz. SPI display operations at reduced APB frequency can deadlock the display communication. Additionally, `BootNtpSync` runs WiFi/NTP on a background task with no power lock, risking instability during WiFi teardown when the main loop may enter low-power mode.
## Changes Made
### 1. `src/util/BootNtpSync.cpp`
- Added `#include <HalPowerManager.h>`
- Added `HalPowerManager::Lock powerLock;` at the top of `taskFunc()` to keep the CPU at full speed for the entire duration of WiFi scanning, connection, NTP sync, and teardown
### 2. `src/main.cpp` (clock refresh block, ~lines 408-428)
- In the `sawInvalidTime` branch: added `lastActivityTime = millis()` and `powerManager.setPowerSaving(false)` before calling `requestUpdate()`
- In the minute-change branch: added `lastActivityTime = millis()` and `powerManager.setPowerSaving(false)` before calling `requestUpdate()`
- This ensures the CPU is restored to 160 MHz before any render-related code executes and prevents immediate re-entry into low-power mode
## Follow-up Items
- Verify on device that the freeze no longer occurs after idling for extended periods
- Monitor heap usage to confirm the power lock doesn't introduce memory issues
- Test that NTP sync still completes successfully with the power lock in place

View File

@@ -0,0 +1,38 @@
# Fix Idle Freeze: NTP Power Lock (Corrected)
**Date**: 2026-03-09
## Task Description
Fix device freeze during idle caused by power management issues with NTP sync and clock refresh. This is a correction to the initial fix (commit `6cf212d`) which introduced two new problems.
## Root Cause
The original freeze at the clock minute boundary was caused by the BootNtpSync background task running WiFi/NTP operations without a power lock, allowing the main loop to drop the CPU to 10 MHz during active WiFi/SPI operations.
## Initial Fix Attempt (commit 6cf212d) -- Two Problems
1. **BootNtpSync Lock**: Added `HalPowerManager::Lock` as a local variable in `taskFunc()`. However, `vTaskDelete(nullptr)` at the end of the function kills the FreeRTOS task immediately without C++ stack unwinding, so the lock destructor never ran. The lock was permanently held, preventing the device from ever entering low-power mode.
2. **Clock Refresh Power Override**: Added `setPowerSaving(false)` and `lastActivityTime` reset in the clock refresh block of `main.cpp`. This was unnecessary -- the `RenderLock` inside the render path already handles CPU frequency restoration. The explicit calls prevented the device from entering low-power mode by resetting the idle timer every minute.
## Corrected Fix
### `src/util/BootNtpSync.cpp`
- Wrapped the `HalPowerManager::Lock` and all WiFi/NTP work in a block scope `{}` that ends before `vTaskDelete(nullptr)`
- The lock destructor now runs when the block scope exits, properly releasing the power lock
- `running = false`, `taskHandle = nullptr`, log message, and `vTaskDelete()` remain outside the scope since they don't need full CPU speed
### `src/main.cpp`
- Reverted the `setPowerSaving(false)` and `lastActivityTime = millis()` additions from both clock refresh branches
- Clock refresh relies on the existing `RenderLock` mechanism to handle CPU frequency during display updates
## Key Lesson
FreeRTOS `vTaskDelete(nullptr)` does not unwind the C++ stack. RAII objects (like `HalPowerManager::Lock`) placed as local variables in a task function will never have their destructors called. Always use explicit block scoping or manual release before `vTaskDelete()`.
## Follow-up Items
- Verify on device that low-power mode is entered after idle and that NTP sync still completes
- Verify the clock still updates at minute boundaries without freezing
- Consider auditing other FreeRTOS task functions in the codebase for similar RAII + `vTaskDelete` issues

View File

@@ -43,6 +43,10 @@ std::string normalizeWhitespace(const std::string& s) {
void BookInfoActivity::onEnter() { void BookInfoActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
const auto orient = renderer.getOrientation();
isLandscape = orient == GfxRenderer::Orientation::LandscapeClockwise ||
orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
std::string fileName = filePath; std::string fileName = filePath;
const size_t lastSlash = filePath.rfind('/'); const size_t lastSlash = filePath.rfind('/');
if (lastSlash != std::string::npos) { if (lastSlash != std::string::npos) {
@@ -58,6 +62,11 @@ void BookInfoActivity::onEnter() {
} }
} }
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentBottom = renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing;
const int coverH = isLandscape ? (contentBottom - contentTop) : (renderer.getScreenHeight() * 2 / 5);
BookMetadataCache::BookMetadata meta; BookMetadataCache::BookMetadata meta;
if (FsHelpers::hasEpubExtension(fileName)) { if (FsHelpers::hasEpubExtension(fileName)) {
@@ -74,7 +83,6 @@ void BookInfoActivity::onEnter() {
meta = epub.getMetadata(); meta = epub.getMetadata();
meta.description = normalizeWhitespace(meta.description); meta.description = normalizeWhitespace(meta.description);
const int coverH = renderer.getScreenHeight() * 2 / 5;
if (epub.generateThumbBmp(coverH)) { if (epub.generateThumbBmp(coverH)) {
coverBmpPath = epub.getThumbBmpPath(coverH); coverBmpPath = epub.getThumbBmpPath(coverH);
} else { } else {
@@ -103,7 +111,6 @@ void BookInfoActivity::onEnter() {
meta.title = xtc.getTitle(); meta.title = xtc.getTitle();
meta.author = xtc.getAuthor(); meta.author = xtc.getAuthor();
const int coverH = renderer.getScreenHeight() * 2 / 5;
if (xtc.generateThumbBmp(coverH)) { if (xtc.generateThumbBmp(coverH)) {
coverBmpPath = xtc.getThumbBmpPath(coverH); coverBmpPath = xtc.getThumbBmpPath(coverH);
} else { } else {
@@ -132,8 +139,32 @@ void BookInfoActivity::onExit() { Activity::onExit(); }
void BookInfoActivity::buildLayout(const BookMetadataCache::BookMetadata& meta, size_t fileSize) { void BookInfoActivity::buildLayout(const BookMetadataCache::BookMetadata& meta, size_t fileSize) {
const auto& metrics = UITheme::getInstance().getMetrics(); const auto& metrics = UITheme::getInstance().getMetrics();
const int pageW = renderer.getScreenWidth();
const int sidePad = metrics.contentSidePadding; const int sidePad = metrics.contentSidePadding;
const int contentW = renderer.getScreenWidth() - sidePad * 2; const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0;
if (!coverBmpPath.empty()) {
FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
coverDisplayHeight = bitmap.getHeight();
coverDisplayWidth = bitmap.getWidth();
}
file.close();
}
}
coverPanelWidth = 0;
if (isLandscape && coverDisplayWidth > 0) {
coverPanelWidth = std::min(coverDisplayWidth + sidePad * 2, pageW * 2 / 5);
}
const int availW = pageW - hintGutterWidth;
const int contentW = (isLandscape && coverPanelWidth > 0)
? availW - coverPanelWidth - sidePad
: availW - sidePad * 2;
fields.reserve(13); fields.reserve(13);
auto addField = [&](const char* label, const std::string& text, bool bold, EpdFontFamily::Style style) { auto addField = [&](const char* label, const std::string& text, bool bold, EpdFontFamily::Style style) {
@@ -185,19 +216,8 @@ void BookInfoActivity::buildLayout(const BookMetadataCache::BookMetadata& meta,
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
int h = contentTop; int h = contentTop;
if (!coverBmpPath.empty()) { if (!isLandscape && coverDisplayHeight > 0 && !coverBmpPath.empty()) {
FsFile file; h += coverDisplayHeight + COVER_GAP;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
coverDisplayHeight = bitmap.getHeight();
coverDisplayWidth = bitmap.getWidth();
}
file.close();
}
if (coverDisplayHeight > 0) {
h += coverDisplayHeight + COVER_GAP;
}
} }
for (const auto& field : fields) { for (const auto& field : fields) {
@@ -207,7 +227,7 @@ void BookInfoActivity::buildLayout(const BookMetadataCache::BookMetadata& meta,
h += static_cast<int>(field.lines.size()) * lineH12; h += static_cast<int>(field.lines.size()) * lineH12;
h += SECTION_GAP; h += SECTION_GAP;
} }
h += metrics.buttonHintsHeight + metrics.verticalSpacing; h += isLandscape ? metrics.verticalSpacing : (metrics.buttonHintsHeight + metrics.verticalSpacing);
contentHeight = h; contentHeight = h;
} }
@@ -252,12 +272,32 @@ void BookInfoActivity::render(RenderLock&&) {
const int sidePad = metrics.contentSidePadding; const int sidePad = metrics.contentSidePadding;
const int lineH10 = renderer.getLineHeight(UI_10_FONT_ID); const int lineH10 = renderer.getLineHeight(UI_10_FONT_ID);
const int lineH12 = renderer.getLineHeight(UI_12_FONT_ID); const int lineH12 = renderer.getLineHeight(UI_12_FONT_ID);
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentBottom = pageH - metrics.buttonHintsHeight - metrics.verticalSpacing;
const bool isLandscapeCw = renderer.getOrientation() == GfxRenderer::Orientation::LandscapeClockwise;
const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0;
const int contentX = (isLandscape && isLandscapeCw) ? hintGutterWidth : 0;
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentBottom = isLandscape ? pageH - metrics.verticalSpacing
: pageH - metrics.buttonHintsHeight - metrics.verticalSpacing;
if (isLandscape && coverPanelWidth > 0 && !coverBmpPath.empty() && coverDisplayHeight > 0) {
FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
const int availH = contentBottom - contentTop;
const int coverX = contentX + (coverPanelWidth - coverDisplayWidth) / 2;
renderer.drawBitmap1Bit(bitmap, coverX, contentTop, coverDisplayWidth, availH);
}
file.close();
}
}
const int fieldX = (isLandscape && coverPanelWidth > 0) ? contentX + coverPanelWidth : contentX + sidePad;
int y = contentTop - scrollOffset; int y = contentTop - scrollOffset;
if (!coverBmpPath.empty() && coverDisplayHeight > 0) { if (!isLandscape && !coverBmpPath.empty() && coverDisplayHeight > 0) {
if (y + coverDisplayHeight > 0 && y < contentBottom) { if (y + coverDisplayHeight > 0 && y < contentBottom) {
FsFile file; FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) { if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
@@ -277,7 +317,7 @@ void BookInfoActivity::render(RenderLock&&) {
if (field.label) { if (field.label) {
if (y + lineH10 > contentTop && y < contentBottom) { if (y + lineH10 > contentTop && y < contentBottom) {
renderer.drawText(UI_10_FONT_ID, sidePad, y, field.label, true, EpdFontFamily::BOLD); renderer.drawText(UI_10_FONT_ID, fieldX, y, field.label, true, EpdFontFamily::BOLD);
} }
y += lineH10 + LABEL_VALUE_GAP; y += lineH10 + LABEL_VALUE_GAP;
} }
@@ -286,15 +326,16 @@ void BookInfoActivity::render(RenderLock&&) {
for (const auto& line : field.lines) { for (const auto& line : field.lines) {
if (y >= contentBottom) break; if (y >= contentBottom) break;
if (y + lineH12 > contentTop) { if (y + lineH12 > contentTop) {
renderer.drawText(UI_12_FONT_ID, sidePad, y, line.c_str(), true, style); renderer.drawText(UI_12_FONT_ID, fieldX, y, line.c_str(), true, style);
} }
y += lineH12; y += lineH12;
} }
y += SECTION_GAP; y += SECTION_GAP;
} }
renderer.fillRect(0, 0, pageW, contentTop, false); renderer.fillRect(contentX, 0, pageW - hintGutterWidth, contentTop, false);
GUI.drawHeader(renderer, Rect(0, metrics.topPadding, pageW, metrics.headerHeight), tr(STR_BOOK_INFO)); GUI.drawHeader(renderer, Rect(contentX, metrics.topPadding, pageW - hintGutterWidth, metrics.headerHeight),
tr(STR_BOOK_INFO));
const bool canScrollDown = scrollOffset + pageH < contentHeight; const bool canScrollDown = scrollOffset + pageH < contentHeight;
const bool canScrollUp = scrollOffset > 0; const bool canScrollUp = scrollOffset > 0;

View File

@@ -32,6 +32,8 @@ class BookInfoActivity final : public Activity {
int contentHeight = 0; int contentHeight = 0;
int coverDisplayHeight = 0; int coverDisplayHeight = 0;
int coverDisplayWidth = 0; int coverDisplayWidth = 0;
int coverPanelWidth = 0;
bool isLandscape = false;
void buildLayout(const BookMetadataCache::BookMetadata& meta, size_t fileSize); void buildLayout(const BookMetadataCache::BookMetadata& meta, size_t fileSize);
static std::string formatFileSize(size_t bytes); static std::string formatFileSize(size_t bytes);

View File

@@ -10,6 +10,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include <HalPowerManager.h>
#include "util/TimeSync.h" #include "util/TimeSync.h"
namespace BootNtpSync { namespace BootNtpSync {
@@ -93,35 +94,39 @@ static bool tryConnectToSavedNetwork(const TaskParams& params) {
} }
static void taskFunc(void* param) { static void taskFunc(void* param) {
auto* params = static_cast<TaskParams*>(param); {
HalPowerManager::Lock powerLock;
auto* params = static_cast<TaskParams*>(param);
bool connected = tryConnectToSavedNetwork(*params); bool connected = tryConnectToSavedNetwork(*params);
if (!connected && running) { if (!connected && running) {
LOG_DBG("BNTP", "First scan failed, retrying in 3s..."); LOG_DBG("BNTP", "First scan failed, retrying in 3s...");
vTaskDelay(3000 / portTICK_PERIOD_MS); vTaskDelay(3000 / portTICK_PERIOD_MS);
if (running) { if (running) {
connected = tryConnectToSavedNetwork(*params); connected = tryConnectToSavedNetwork(*params);
}
} }
if (connected && running) {
LOG_DBG("BNTP", "Starting NTP sync...");
bool synced = TimeSync::waitForNtpSync(5000);
TimeSync::stopNtpSync();
if (synced) {
LOG_DBG("BNTP", "NTP sync successful");
} else {
LOG_DBG("BNTP", "NTP sync timed out, continuing without time");
}
}
WiFi.disconnect(false);
vTaskDelay(100 / portTICK_PERIOD_MS);
WiFi.mode(WIFI_OFF);
vTaskDelay(100 / portTICK_PERIOD_MS);
delete params;
} }
if (connected && running) {
LOG_DBG("BNTP", "Starting NTP sync...");
bool synced = TimeSync::waitForNtpSync(5000);
TimeSync::stopNtpSync();
if (synced) {
LOG_DBG("BNTP", "NTP sync successful");
} else {
LOG_DBG("BNTP", "NTP sync timed out, continuing without time");
}
}
WiFi.disconnect(false);
vTaskDelay(100 / portTICK_PERIOD_MS);
WiFi.mode(WIFI_OFF);
vTaskDelay(100 / portTICK_PERIOD_MS);
delete params;
running = false; running = false;
taskHandle = nullptr; taskHandle = nullptr;
LOG_DBG("BNTP", "Boot NTP task complete"); LOG_DBG("BNTP", "Boot NTP task complete");