Merge branch 'master' into mod/master

Resolve single conflict in SleepActivity.cpp: adopt upstream millis()
timestamp log format while preserving mod's edgeCachePath argument to
renderBitmapSleepScreen().

Upstream changes (14 commits): unified navigation handling, Italian
hyphenation, natural file sort, auto WiFi reconnect, power saving on
idle, OPDS fixes, uniform debug logging, and more.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-12 11:54:40 -05:00
47 changed files with 6007 additions and 429 deletions

View File

@@ -15,6 +15,7 @@ class MappedInputManager {
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
void update() const { gpio.update(); }
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;

View File

@@ -83,7 +83,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
lastBookFileName = path.substr(lastSlash + 1);
}
Serial.printf("Loading recent book: %s\n", path.c_str());
Serial.printf("[%lu] [RBS] Loading recent book: %s\n", millis(), path.c_str());
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {

View File

@@ -9,7 +9,7 @@ WifiCredentialStore WifiCredentialStore::instance;
namespace {
// File format version
constexpr uint8_t WIFI_FILE_VERSION = 1;
constexpr uint8_t WIFI_FILE_VERSION = 2; // Increased version
// WiFi credentials file path
constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
@@ -38,6 +38,7 @@ bool WifiCredentialStore::saveToFile() const {
// Write header
serialization::writePod(file, WIFI_FILE_VERSION);
serialization::writeString(file, lastConnectedSsid); // Save last connected SSID
serialization::writePod(file, static_cast<uint8_t>(credentials.size()));
// Write each credential
@@ -67,12 +68,18 @@ bool WifiCredentialStore::loadFromFile() {
// Read and verify version
uint8_t version;
serialization::readPod(file, version);
if (version != WIFI_FILE_VERSION) {
if (version > WIFI_FILE_VERSION) {
Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version);
file.close();
return false;
}
if (version >= 2) {
serialization::readString(file, lastConnectedSsid);
} else {
lastConnectedSsid.clear();
}
// Read credential count
uint8_t count;
serialization::readPod(file, count);
@@ -128,6 +135,9 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
if (cred != credentials.end()) {
credentials.erase(cred);
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
if (ssid == lastConnectedSsid) {
clearLastConnectedSsid();
}
return saveToFile();
}
return false; // Not found
@@ -146,8 +156,25 @@ const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssi
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
void WifiCredentialStore::setLastConnectedSsid(const std::string& ssid) {
if (lastConnectedSsid != ssid) {
lastConnectedSsid = ssid;
saveToFile();
}
}
const std::string& WifiCredentialStore::getLastConnectedSsid() const { return lastConnectedSsid; }
void WifiCredentialStore::clearLastConnectedSsid() {
if (!lastConnectedSsid.empty()) {
lastConnectedSsid.clear();
saveToFile();
}
}
void WifiCredentialStore::clearAll() {
credentials.clear();
lastConnectedSsid.clear();
saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
}

View File

@@ -16,6 +16,7 @@ class WifiCredentialStore {
private:
static WifiCredentialStore instance;
std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
static constexpr size_t MAX_NETWORKS = 8;
@@ -48,6 +49,11 @@ class WifiCredentialStore {
// Check if a network is saved
bool hasSavedCredential(const std::string& ssid) const;
// Last connected network
void setLastConnectedSsid(const std::string& ssid);
const std::string& getLastConnectedSsid() const;
void clearLastConnectedSsid();
// Clear all credentials
void clearAll();
};

View File

@@ -622,12 +622,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) {
Serial.println("[SLP] Failed to load last XTC");
Serial.printf("[%lu] [SLP] Failed to load last XTC\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
if (!lastXtc.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate XTC cover bmp");
Serial.printf("[%lu] [SLP] Failed to generate XTC cover bmp\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
@@ -636,12 +636,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastTxt.load()) {
Serial.println("[SLP] Failed to load last TXT");
Serial.printf("[%lu] [SLP] Failed to load last TXT\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
if (!lastTxt.generateCoverBmp()) {
Serial.println("[SLP] No cover image found for TXT file");
Serial.printf("[%lu] [SLP] No cover image found for TXT file\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
@@ -651,12 +651,12 @@ void SleepActivity::renderCoverSleepScreen() const {
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
// Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) {
Serial.println("[SLP] Failed to load last epub");
Serial.printf("[%lu] [SLP] Failed to load last epub\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
if (!lastEpub.generateCoverBmp(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp");
Serial.printf("[%lu] [SLP] Failed to generate cover bmp\n", millis());
return (this->*renderNoCoverSleepScreen)();
}
@@ -675,7 +675,7 @@ void SleepActivity::renderCoverSleepScreen() const {
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath.c_str());
Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap, edgeCachePath);
return;
}

View File

@@ -17,7 +17,6 @@
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
} // namespace
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@@ -118,12 +117,6 @@ void OpdsBookBrowserActivity::loop() {
// Handle browsing state
if (state == BrowserState::BROWSING) {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
@@ -135,20 +128,29 @@ void OpdsBookBrowserActivity::loop() {
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack();
} else if (prevReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
} else {
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
}
updateRequired = true;
} else if (nextReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
} else {
selectorIndex = (selectorIndex + 1) % entries.size();
}
updateRequired = true;
}
// Handle navigation
if (!entries.empty()) {
buttonNavigator.onNextRelease([this] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size());
updateRequired = true;
});
buttonNavigator.onNextContinuous([this] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
}
}
}

View File

@@ -9,6 +9,7 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Activity for browsing and downloading books from an OPDS server.
@@ -37,6 +38,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
BrowserState state = BrowserState::LOADING;
@@ -62,4 +64,5 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
bool preventAutoSleep() override { return true; }
};

View File

@@ -196,13 +196,18 @@ void HomeActivity::freeCoverBuffer() {
}
void HomeActivity::loop() {
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
const int menuCount = getMenuItemCount();
buttonNavigator.onNext([this, menuCount] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
updateRequired = true;
});
buttonNavigator.onPrevious([this, menuCount] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available
int idx = 0;
@@ -226,12 +231,6 @@ void HomeActivity::loop() {
} else if (menuSelectedIndex == settingsIdx) {
onSettingsOpen();
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
}
}

View File

@@ -8,6 +8,7 @@
#include "../Activity.h"
#include "./MyLibraryActivity.h"
#include "util/ButtonNavigator.h"
struct RecentBook;
struct Rect;
@@ -15,6 +16,7 @@ struct Rect;
class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
bool updateRequired = false;
bool recentsLoading = false;

View File

@@ -11,17 +11,58 @@
#include "util/StringUtils.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
if (str1.back() == '/' && str2.back() != '/') return true;
if (str1.back() != '/' && str2.back() == '/') return false;
return lexicographical_compare(
begin(str1), end(str1), begin(str2), end(str2),
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
// Directories first
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
});
}
@@ -109,13 +150,6 @@ void MyLibraryActivity::loop() {
return;
}
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -157,21 +191,26 @@ void MyLibraryActivity::loop() {
}
int listSize = static_cast<int>(files.size());
if (upReleased) {
if (skipPage) {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
} else if (downReleased) {
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1);
} else {
selectorIndex = (selectorIndex + 1) % listSize;
}
});
buttonNavigator.onPreviousRelease([this, listSize] {
selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
}
});
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
}
void MyLibraryActivity::displayTaskLoop() {
@@ -207,7 +246,7 @@ void MyLibraryActivity::render() const {
}
// Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down");
const auto labels = mappedInput.mapLabels(basepath == "/" ? "« Home" : "« Back", "Open", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
@@ -217,4 +256,4 @@ size_t MyLibraryActivity::findEntry(const std::string& name) const {
for (size_t i = 0; i < files.size(); i++)
if (files[i] == name) return i;
return 0;
}
}

View File

@@ -8,11 +8,14 @@
#include <vector>
#include "../Activity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class MyLibraryActivity final : public Activity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0;
bool updateRequired = false;

View File

@@ -12,7 +12,6 @@
#include "util/StringUtils.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
@@ -70,18 +69,11 @@ void RecentBooksActivity::onExit() {
}
void RecentBooksActivity::loop() {
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str());
Serial.printf("[%lu] [RBA] Selected recent book: %s\n", millis(), recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
return;
}
@@ -92,21 +84,26 @@ void RecentBooksActivity::loop() {
}
int listSize = static_cast<int>(recentBooks.size());
if (upReleased) {
if (skipPage) {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
} else if (downReleased) {
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1);
} else {
selectorIndex = (selectorIndex + 1) % listSize;
}
});
buttonNavigator.onPreviousRelease([this, listSize] {
selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
updateRequired = true;
}
});
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
}
void RecentBooksActivity::displayTaskLoop() {

View File

@@ -9,11 +9,13 @@
#include "../Activity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class RecentBooksActivity final : public Activity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0;
bool updateRequired = false;

View File

@@ -348,6 +348,9 @@ void CrossPointWebServerActivity::loop() {
// Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) {
yield();
// Force trigger an update of which buttons are being pressed so be have accurate state
// for back button checking
mappedInput.update();
// Check for exit button inside loop for responsiveness
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack();

View File

@@ -73,18 +73,15 @@ void NetworkModeSelectionActivity::loop() {
}
// Handle navigation
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
updateRequired = true;
});
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT);
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
});
}
void NetworkModeSelectionActivity::displayTaskLoop() {

View File

@@ -6,6 +6,7 @@
#include <functional>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
// Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
@@ -22,6 +23,8 @@ enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
class NetworkModeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectedIndex = 0;
bool updateRequired = false;
const std::function<void(NetworkMode)> onModeSelected;

View File

@@ -21,7 +21,8 @@ void WifiSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials - SD card operations need lock as we use SPI for both
// Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.loadFromFile();
xSemaphoreGive(renderingMutex);
@@ -37,6 +38,7 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false;
savePromptSelection = 0;
forgetPromptSelection = 0;
autoConnecting = false;
// Cache MAC address for display
uint8_t mac[6];
@@ -46,9 +48,7 @@ void WifiSelectionActivity::onEnter() {
mac[5]);
cachedMacAddress = std::string(macStr);
// Trigger first update to show scanning message
updateRequired = true;
// Task creation
xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask",
4096, // Stack size (larger for WiFi operations)
this, // Parameters
@@ -56,7 +56,26 @@ void WifiSelectionActivity::onEnter() {
&displayTaskHandle // Task handle
);
// Start WiFi scan
// Attempt to auto-connect to the last network
if (allowAutoConnect) {
const std::string lastSsid = WIFI_STORE.getLastConnectedSsid();
if (!lastSsid.empty()) {
const auto* cred = WIFI_STORE.findCredential(lastSsid);
if (cred) {
Serial.printf("[%lu] [WIFI] Attempting to auto-connect to %s\n", millis(), lastSsid.c_str());
selectedSSID = cred->ssid;
enteredPassword = cred->password;
selectedRequiresPassword = !cred->password.empty();
usedSavedPassword = true;
autoConnecting = true;
attemptConnection();
updateRequired = true;
return;
}
}
}
// Fallback to scanning
startWifiScan();
}
@@ -70,15 +89,17 @@ void WifiSelectionActivity::onExit() {
WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap());
// Note: We do NOT disconnect WiFi here - the parent activity (CrossPointWebServerActivity)
// manages WiFi connection state. We just clean up the scan and task.
// Note: We do NOT disconnect WiFi here - the parent activity
// (CrossPointWebServerActivity) manages WiFi connection state. We just clean
// up the scan and task.
// Acquire mutex before deleting task to ensure task isn't using it
// This prevents hangs/crashes if the task holds the mutex when deleted
Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task (we now hold the mutex, so task is blocked if it needs it)
// Delete the display task (we now hold the mutex, so task is blocked if it
// needs it)
Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis());
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
@@ -96,6 +117,7 @@ void WifiSelectionActivity::onExit() {
}
void WifiSelectionActivity::startWifiScan() {
autoConnecting = false;
state = WifiSelectionState::SCANNING;
networks.clear();
updateRequired = true;
@@ -181,6 +203,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
selectedRequiresPassword = network.isEncrypted;
usedSavedPassword = false;
enteredPassword.clear();
autoConnecting = false;
// Check if we have saved credentials for this network
const auto* savedCred = WIFI_STORE.findCredential(selectedSSID);
@@ -223,7 +246,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
}
void WifiSelectionActivity::attemptConnection() {
state = WifiSelectionState::CONNECTING;
state = autoConnecting ? WifiSelectionState::AUTO_CONNECTING : WifiSelectionState::CONNECTING;
connectionStartTime = millis();
connectedIP.clear();
connectionError.clear();
@@ -239,7 +262,7 @@ void WifiSelectionActivity::attemptConnection() {
}
void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING) {
if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) {
return;
}
@@ -251,6 +274,13 @@ void WifiSelectionActivity::checkConnectionStatus() {
char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
connectedIP = ipStr;
autoConnecting = false;
// Save this as the last connected network - SD card operations need lock as
// we use SPI for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setLastConnectedSsid(selectedSSID);
xSemaphoreGive(renderingMutex);
// If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server
@@ -260,7 +290,10 @@ void WifiSelectionActivity::checkConnectionStatus() {
updateRequired = true;
} else {
// Using saved password or open network - complete immediately
Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis());
Serial.printf(
"[%lu] [WIFI] Connected with saved/open credentials, "
"completing immediately\n",
millis());
onComplete(true);
}
return;
@@ -299,7 +332,7 @@ void WifiSelectionActivity::loop() {
}
// Check connection progress
if (state == WifiSelectionState::CONNECTING) {
if (state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING) {
checkConnectionStatus();
return;
}
@@ -368,17 +401,16 @@ void WifiSelectionActivity::loop() {
}
}
// Go back to network list (whether Cancel or Forget network was selected)
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
startWifiScan();
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
startWifiScan();
}
return;
}
// Handle connected state (should not normally be reached - connection completes immediately)
// Handle connected state (should not normally be reached - connection
// completes immediately)
if (state == WifiSelectionState::CONNECTED) {
// Safety fallback - immediately complete
onComplete(true);
@@ -389,12 +421,14 @@ void WifiSelectionActivity::loop() {
if (state == WifiSelectionState::CONNECTION_FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// If we used saved credentials, offer to forget the network
if (usedSavedPassword) {
// If we were auto-connecting or using a saved credential, offer to forget
// the network
if (autoConnecting || usedSavedPassword) {
autoConnecting = false;
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel"
} else {
// Go back to network list on failure
// Go back to network list on failure for non-saved credentials
state = WifiSelectionState::NETWORK_LIST;
}
updateRequired = true;
@@ -420,20 +454,33 @@ void WifiSelectionActivity::loop() {
return;
}
// Handle UP/DOWN navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (selectedNetworkIndex > 0) {
selectedNetworkIndex--;
updateRequired = true;
}
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) {
selectedNetworkIndex++;
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
startWifiScan();
return;
}
const bool leftPressed = mappedInput.wasPressed(MappedInputManager::Button::Left);
if (leftPressed) {
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
if (hasSavedPassword) {
selectedSSID = networks[selectedNetworkIndex].ssid;
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel"
updateRequired = true;
return;
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedNetworkIndex = ButtonNavigator::previousIndex(selectedNetworkIndex, networks.size());
updateRequired = true;
});
}
}
@@ -483,6 +530,9 @@ void WifiSelectionActivity::render() const {
renderer.clearScreen();
switch (state) {
case WifiSelectionState::AUTO_CONNECTING:
renderConnecting();
break;
case WifiSelectionState::SCANNING:
renderConnecting(); // Reuse connecting screen with different message
break;
@@ -586,7 +636,11 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
const char* forgetLabel = hasSavedPassword ? "Forget" : "";
const auto labels = mappedInput.mapLabels("« Back", "Connect", forgetLabel, "Refresh");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
@@ -690,8 +744,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");

View File

@@ -10,6 +10,7 @@
#include <vector>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
// Structure to hold WiFi network information
struct WifiNetworkInfo {
@@ -21,6 +22,7 @@ struct WifiNetworkInfo {
// WiFi selection states
enum class WifiSelectionState {
AUTO_CONNECTING, // Trying to connect to the last known network
SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network
@@ -45,6 +47,7 @@ enum class WifiSelectionState {
class WifiSelectionActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
WifiSelectionState state = WifiSelectionState::SCANNING;
int selectedNetworkIndex = 0;
@@ -68,6 +71,12 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
// Whether network was connected using a saved password (skip save prompt)
bool usedSavedPassword = false;
// Whether to attempt auto-connect on entry
const bool allowAutoConnect;
// Whether we are attempting to auto-connect
bool autoConnecting = false;
// Save/forget prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0;
int forgetPromptSelection = 0;
@@ -96,8 +105,10 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
public:
explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {}
const std::function<void(bool connected)>& onComplete, bool autoConnect = true)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput),
onComplete(onComplete),
allowAutoConnect(autoConnect) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -664,9 +664,9 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6);
f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage);
Serial.printf("[%lu] [ERS] Progress saved: Chapter %d, Page %d\n", millis(), spineIndex, currentPage);
} else {
Serial.printf("[ERS] Could not save progress!\n");
Serial.printf("[%lu] [ERS] Could not save progress!\n", millis());
}
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,

View File

@@ -6,11 +6,6 @@
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
int EpubReaderChapterSelectionActivity::getPageItems() const {
@@ -77,12 +72,6 @@ void EpubReaderChapterSelectionActivity::loop() {
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
@@ -95,21 +84,27 @@ void EpubReaderChapterSelectionActivity::loop() {
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems;
} else {
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
}
void EpubReaderChapterSelectionActivity::displayTaskLoop() {

View File

@@ -7,12 +7,14 @@
#include <memory>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::string epubPath;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int currentSpineIndex = 0;
int currentPage = 0;
int totalPagesInSpine = 0;

View File

@@ -48,16 +48,19 @@ void EpubReaderMenuActivity::loop() {
return;
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
// Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) {
// Cycle orientation preview locally; actual rotation happens on menu exit.

View File

@@ -9,6 +9,7 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
@@ -48,6 +49,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};

View File

@@ -79,25 +79,11 @@ void EpubReaderPercentSelectionActivity::loop() {
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
adjustPercent(-kSmallStep);
return;
}
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
adjustPercent(kSmallStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
adjustPercent(kLargeStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
adjustPercent(-kLargeStep);
return;
}
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
}
void EpubReaderPercentSelectionActivity::renderScreen() {

View File

@@ -7,6 +7,7 @@
#include "MappedInputManager.h"
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity {
public:
@@ -31,6 +32,7 @@ class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity
// FreeRTOS task and mutex for rendering.
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
// Callback invoked when the user confirms a percent.
const std::function<void(int)> onSelect;

View File

@@ -8,10 +8,6 @@
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
@@ -78,13 +74,8 @@ void XtcReaderChapterSelectionActivity::onExit() {
}
void XtcReaderChapterSelectionActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
const int totalItems = static_cast<int>(xtc->getChapters().size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters();
@@ -93,29 +84,27 @@ void XtcReaderChapterSelectionActivity::loop() {
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
} else {
selectorIndex = (selectorIndex + total - 1) % total;
}
updateRequired = true;
} else if (nextReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
updateRequired = true;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
}
void XtcReaderChapterSelectionActivity::displayTaskLoop() {

View File

@@ -7,11 +7,13 @@
#include <memory>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class XtcReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
uint32_t currentPage = 0;
int selectorIndex = 0;
bool updateRequired = false;

View File

@@ -63,15 +63,16 @@ void CalibreSettingsActivity::loop() {
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true;
}
});
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
}
void CalibreSettingsActivity::handleSelection() {

View File

@@ -6,6 +6,7 @@
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Submenu for OPDS Browser settings.
@@ -24,6 +25,7 @@ class CalibreSettingsActivity final : public ActivityWithSubactivity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
int selectedIndex = 0;

View File

@@ -64,15 +64,16 @@ void KOReaderSettingsActivity::loop() {
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true;
}
});
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
}
void KOReaderSettingsActivity::handleSelection() {

View File

@@ -6,6 +6,7 @@
#include <functional>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/**
* Submenu for KOReader Sync settings.
@@ -24,6 +25,7 @@ class KOReaderSettingsActivity final : public ActivityWithSubactivity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
int selectedIndex = 0;

View File

@@ -11,15 +11,12 @@
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "SettingsList.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace {
constexpr int changeTabsMs = 700;
} // namespace
void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop();
@@ -50,11 +47,13 @@ void SettingsActivity::onEnter() {
}
// Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(), SettingInfo::Action("Remap Front Buttons"));
systemSettings.push_back(SettingInfo::Action("KOReader Sync"));
systemSettings.push_back(SettingInfo::Action("OPDS Browser"));
systemSettings.push_back(SettingInfo::Action("Clear Cache"));
systemSettings.push_back(SettingInfo::Action("Check for updates"));
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action("Remap Front Buttons", SettingAction::RemapFrontButtons));
systemSettings.push_back(SettingInfo::Action("Network", SettingAction::Network));
systemSettings.push_back(SettingInfo::Action("KOReader Sync", SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action("OPDS Browser", SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action("Clear Cache", SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action("Check for updates", SettingAction::CheckForUpdates));
// Reset selection to first category
selectedCategoryIndex = 0;
@@ -116,28 +115,28 @@ void SettingsActivity::loop() {
return;
}
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool changeTab = mappedInput.getHeldTime() > changeTabsMs;
// Handle navigation
if (upReleased && changeTab) {
buttonNavigator.onNextRelease([this] {
selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this] {
selectedSettingIndex = ButtonNavigator::previousIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, &hasChangedCategory] {
hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1);
selectedCategoryIndex = ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount);
updateRequired = true;
} else if (downReleased && changeTab) {
});
buttonNavigator.onPreviousContinuous([this, &hasChangedCategory] {
hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
selectedCategoryIndex = ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount);
updateRequired = true;
} else if (upReleased || leftReleased) {
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
updateRequired = true;
} else if (rightReleased || downReleased) {
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
updateRequired = true;
}
});
if (hasChangedCategory) {
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
@@ -182,46 +181,45 @@ void SettingsActivity::toggleCurrentSetting() {
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
}
} else if (setting.type == SettingType::ACTION) {
if (strcmp(setting.name, "Remap Front Buttons") == 0) {
auto enterSubActivity = [this](Activity* activity) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ButtonRemapActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
enterNewActivity(activity);
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "KOReader Sync") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
};
auto onComplete = [this] {
exitActivity();
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
updateRequired = true;
};
auto onCompleteBool = [this](bool) {
exitActivity();
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Clear Cache") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Check for updates") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
updateRequired = true;
};
switch (setting.action) {
case SettingAction::RemapFrontButtons:
enterSubActivity(new ButtonRemapActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::KOReaderSync:
enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::OPDSBrowser:
enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::Network:
enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));
break;
case SettingAction::ClearCache:
enterSubActivity(new ClearCacheActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::CheckForUpdates:
enterSubActivity(new OtaUpdateActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::None:
// Do nothing
break;
}
} else {
return;
@@ -293,4 +291,4 @@ void SettingsActivity::render() const {
// Always use standard refresh for settings screen
renderer.displayBuffer();
}
}

View File

@@ -8,16 +8,28 @@
#include <vector>
#include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
enum class SettingAction {
None,
RemapFrontButtons,
KOReaderSync,
OPDSBrowser,
Network,
ClearCache,
CheckForUpdates,
};
struct SettingInfo {
const char* name;
SettingType type;
uint8_t CrossPointSettings::* valuePtr = nullptr;
std::vector<std::string> enumValues;
SettingAction action = SettingAction::None;
struct ValueRange {
uint8_t min;
@@ -62,10 +74,11 @@ struct SettingInfo {
return s;
}
static SettingInfo Action(const char* name) {
static SettingInfo Action(const char* name, SettingAction action) {
SettingInfo s;
s.name = name;
s.type = SettingType::ACTION;
s.action = action;
return s;
}
@@ -124,6 +137,7 @@ struct SettingInfo {
class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category
int selectedSettingIndex = 0;
@@ -154,4 +168,4 @@ class SettingsActivity final : public ActivityWithSubactivity {
void onEnter() override;
void onExit() override;
void loop() override;
};
};

View File

@@ -142,37 +142,24 @@ void KeyboardEntryActivity::handleKeyPress() {
}
void KeyboardEntryActivity::loop() {
// Navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (selectedRow > 0) {
selectedRow--;
// Clamp column to valid range for new row
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to bottom row
selectedRow = NUM_ROWS - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
// Handle navigation
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] {
selectedRow = ButtonNavigator::previousIndex(selectedRow, NUM_ROWS);
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to top row
selectedRow = 0;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
updateRequired = true;
}
});
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] {
selectedRow = ButtonNavigator::nextIndex(selectedRow, NUM_ROWS);
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
@@ -191,20 +178,14 @@ void KeyboardEntryActivity::loop() {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) {
selectedCol--;
} else {
// Wrap to end of current row
selectedCol = maxCol;
selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1);
}
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
@@ -223,18 +204,11 @@ void KeyboardEntryActivity::loop() {
// At done button, wrap to beginning of row
selectedCol = SHIFT_COL;
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) {
selectedCol++;
} else {
// Wrap to beginning of current row
selectedCol = 0;
selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 1);
}
updateRequired = true;
}
});
// Selection
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {

View File

@@ -9,6 +9,7 @@
#include <utility>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
/**
* Reusable keyboard entry activity for text input.
@@ -65,6 +66,7 @@ class KeyboardEntryActivity : public Activity {
bool isPassword;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false;
// Keyboard state

View File

@@ -27,6 +27,7 @@
#include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/ButtonNavigator.h"
HalDisplay display;
HalGPIO gpio;
@@ -304,6 +305,7 @@ void setup() {
SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile();
UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager);
switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton:
@@ -408,6 +410,13 @@ void loop() {
if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {
delay(10); // Normal delay when no activity requires fast response
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // 3 seconds
if (millis() - lastActivityTime >= IDLE_POWER_SAVING_MS) {
// If we've been inactive for a while, increase the delay to save power
delay(50);
} else {
// Short delay to prevent tight loop while still being responsive
delay(10);
}
}
}

View File

@@ -0,0 +1,124 @@
#include "ButtonNavigator.h"
const MappedInputManager* ButtonNavigator::mappedInput = nullptr;
void ButtonNavigator::onNext(const Callback& callback) {
onNextPress(callback);
onNextContinuous(callback);
}
void ButtonNavigator::onPrevious(const Callback& callback) {
onPreviousPress(callback);
onPreviousContinuous(callback);
}
void ButtonNavigator::onPressAndContinuous(const Buttons& buttons, const Callback& callback) {
onPress(buttons, callback);
onContinuous(buttons, callback);
}
void ButtonNavigator::onNextPress(const Callback& callback) { onPress(getNextButtons(), callback); }
void ButtonNavigator::onPreviousPress(const Callback& callback) { onPress(getPreviousButtons(), callback); }
void ButtonNavigator::onNextRelease(const Callback& callback) { onRelease(getNextButtons(), callback); }
void ButtonNavigator::onPreviousRelease(const Callback& callback) { onRelease(getPreviousButtons(), callback); }
void ButtonNavigator::onNextContinuous(const Callback& callback) { onContinuous(getNextButtons(), callback); }
void ButtonNavigator::onPreviousContinuous(const Callback& callback) { onContinuous(getPreviousButtons(), callback); }
void ButtonNavigator::onPress(const Buttons& buttons, const Callback& callback) {
const bool wasPressed = std::any_of(buttons.begin(), buttons.end(), [](const MappedInputManager::Button button) {
return mappedInput != nullptr && mappedInput->wasPressed(button);
});
if (wasPressed) {
callback();
}
}
void ButtonNavigator::onRelease(const Buttons& buttons, const Callback& callback) {
const bool wasReleased = std::any_of(buttons.begin(), buttons.end(), [](const MappedInputManager::Button button) {
return mappedInput != nullptr && mappedInput->wasReleased(button);
});
if (wasReleased) {
if (lastContinuousNavTime == 0) {
callback();
}
lastContinuousNavTime = 0;
}
}
void ButtonNavigator::onContinuous(const Buttons& buttons, const Callback& callback) {
const bool isPressed = std::any_of(buttons.begin(), buttons.end(), [this](const MappedInputManager::Button button) {
return mappedInput != nullptr && mappedInput->isPressed(button) && shouldNavigateContinuously();
});
if (isPressed) {
callback();
lastContinuousNavTime = millis();
}
}
bool ButtonNavigator::shouldNavigateContinuously() const {
if (!mappedInput) return false;
const bool buttonHeldLongEnough = mappedInput->getHeldTime() > continuousStartMs;
const bool navigationIntervalElapsed = (millis() - lastContinuousNavTime) > continuousIntervalMs;
return buttonHeldLongEnough && navigationIntervalElapsed;
}
int ButtonNavigator::nextIndex(const int currentIndex, const int totalItems) {
if (totalItems <= 0) return 0;
// Calculate the next index with wrap-around
return (currentIndex + 1) % totalItems;
}
int ButtonNavigator::previousIndex(const int currentIndex, const int totalItems) {
if (totalItems <= 0) return 0;
// Calculate the previous index with wrap-around
return (currentIndex + totalItems - 1) % totalItems;
}
int ButtonNavigator::nextPageIndex(const int currentIndex, const int totalItems, const int itemsPerPage) {
if (totalItems <= 0 || itemsPerPage <= 0) return 0;
// When items fit on one page, use index navigation instead
if (totalItems <= itemsPerPage) {
return nextIndex(currentIndex, totalItems);
}
const int lastPageIndex = (totalItems - 1) / itemsPerPage;
const int currentPageIndex = currentIndex / itemsPerPage;
if (currentPageIndex < lastPageIndex) {
return (currentPageIndex + 1) * itemsPerPage;
}
return 0;
}
int ButtonNavigator::previousPageIndex(const int currentIndex, const int totalItems, const int itemsPerPage) {
if (totalItems <= 0 || itemsPerPage <= 0) return 0;
// When items fit on one page, use index navigation instead
if (totalItems <= itemsPerPage) {
return previousIndex(currentIndex, totalItems);
}
const int lastPageIndex = (totalItems - 1) / itemsPerPage;
const int currentPageIndex = currentIndex / itemsPerPage;
if (currentPageIndex > 0) {
return (currentPageIndex - 1) * itemsPerPage;
}
return lastPageIndex * itemsPerPage;
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <functional>
#include <vector>
#include "MappedInputManager.h"
class ButtonNavigator final {
using Callback = std::function<void()>;
using Buttons = std::vector<MappedInputManager::Button>;
const uint16_t continuousStartMs;
const uint16_t continuousIntervalMs;
uint32_t lastContinuousNavTime = 0;
static const MappedInputManager* mappedInput;
[[nodiscard]] bool shouldNavigateContinuously() const;
public:
explicit ButtonNavigator(const uint16_t continuousIntervalMs = 500, const uint16_t continuousStartMs = 500)
: continuousStartMs(continuousStartMs), continuousIntervalMs(continuousIntervalMs) {}
static void setMappedInputManager(const MappedInputManager& mappedInputManager) { mappedInput = &mappedInputManager; }
void onNext(const Callback& callback);
void onPrevious(const Callback& callback);
void onPressAndContinuous(const Buttons& buttons, const Callback& callback);
void onNextPress(const Callback& callback);
void onPreviousPress(const Callback& callback);
void onPress(const Buttons& buttons, const Callback& callback);
void onNextRelease(const Callback& callback);
void onPreviousRelease(const Callback& callback);
void onRelease(const Buttons& buttons, const Callback& callback);
void onNextContinuous(const Callback& callback);
void onPreviousContinuous(const Callback& callback);
void onContinuous(const Buttons& buttons, const Callback& callback);
[[nodiscard]] static int nextIndex(int currentIndex, int totalItems);
[[nodiscard]] static int previousIndex(int currentIndex, int totalItems);
[[nodiscard]] static int nextPageIndex(int currentIndex, int totalItems, int itemsPerPage);
[[nodiscard]] static int previousPageIndex(int currentIndex, int totalItems, int itemsPerPage);
[[nodiscard]] static Buttons getNextButtons() {
return {MappedInputManager::Button::Down, MappedInputManager::Button::Right};
}
[[nodiscard]] static Buttons getPreviousButtons() {
return {MappedInputManager::Button::Up, MappedInputManager::Button::Left};
}
};

View File

@@ -25,6 +25,10 @@ std::string extractHost(const std::string& url) {
}
std::string buildUrl(const std::string& serverUrl, const std::string& path) {
// If path is already an absolute URL (has protocol), use it directly
if (path.find("://") != std::string::npos) {
return path;
}
const std::string urlWithProtocol = ensureProtocol(serverUrl);
if (path.empty()) {
return urlWithProtocol;