From e0b6b9b28a4ed34bd60a51a1e851ca8d75a81ebb Mon Sep 17 00:00:00 2001 From: V Date: Tue, 27 Jan 2026 14:30:27 +0000 Subject: [PATCH 1/8] refactor: Re-work for OTA feature (#509) ## Summary Finally, I have received my device and got to chance to work on OTA. https://github.com/crosspoint-reader/crosspoint-reader/issues/176 * **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) Existing OTA functionality is very buggy, many of times (I would say 8 out of 10) are end up with fail for me. When the time that it works it is very slow and take ages. For others looks like end up with crash or different issues. * **What changes are included?** To be honest, I'm not familiar with Arduino APIs of OTA process, but looks like not good as much esp-idf itself. I always found Arduino APIs very bulky for esp32. Wrappers and wrappers. ## Additional Context Right now, OTA takes ~ 3min 10sec (of course depends on size of .bin file). Can be tested with playing version info inside from `platform.ini` file. ``` [crosspoint] version = 0.14.0 ``` --- ### 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? _**< NO >**_ --- src/activities/settings/OtaUpdateActivity.cpp | 11 +- src/network/OtaUpdater.cpp | 219 +++++++++++++----- src/network/OtaUpdater.h | 15 +- 3 files changed, 174 insertions(+), 71 deletions(-) diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 0393847d..86dcf2ac 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -97,7 +97,7 @@ void OtaUpdateActivity::onExit() { void OtaUpdateActivity::displayTaskLoop() { while (true) { - if (updateRequired) { + if (updateRequired || updater.getRender()) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); @@ -115,8 +115,9 @@ void OtaUpdateActivity::render() { float updaterProgress = 0; if (state == UPDATE_IN_PROGRESS) { - Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.processedSize, updater.totalSize); - updaterProgress = static_cast(updater.processedSize) / static_cast(updater.totalSize); + Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.getProcessedSize(), + updater.getTotalSize()); + updaterProgress = static_cast(updater.getProcessedSize()) / static_cast(updater.getTotalSize()); // Only update every 2% at the most if (static_cast(updaterProgress * 50) == lastUpdaterPercentage / 2) { return; @@ -154,7 +155,7 @@ void OtaUpdateActivity::render() { (std::to_string(static_cast(updaterProgress * 100)) + "%").c_str()); renderer.drawCenteredText( UI_10_FONT_ID, 440, - (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str()); + (std::to_string(updater.getProcessedSize()) + " / " + std::to_string(updater.getTotalSize())).c_str()); renderer.displayBuffer(); return; } @@ -194,7 +195,7 @@ void OtaUpdateActivity::loop() { xSemaphoreGive(renderingMutex); updateRequired = true; vTaskDelay(10 / portTICK_PERIOD_MS); - const auto res = updater.installUpdate([this](const size_t, const size_t) { updateRequired = true; }); + const auto res = updater.installUpdate(); if (res != OtaUpdater::OK) { Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res); diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index d831af0a..1733e136 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -1,38 +1,123 @@ #include "OtaUpdater.h" #include -#include -#include + +#include "esp_http_client.h" +#include "esp_https_ota.h" +#include "esp_wifi.h" namespace { constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest"; + +/* This is buffer and size holder to keep upcoming data from latestReleaseUrl */ +char* local_buf; +int output_len; + +/* + * When esp_crt_bundle.h included, it is pointing wrong header file + * which is something under WifiClientSecure because of our framework based on arduno platform. + * To manage this obstacle, don't include anything, just extern and it will point correct one. + */ +extern "C" { +extern esp_err_t esp_crt_bundle_attach(void* conf); } +esp_err_t http_client_set_header_cb(esp_http_client_handle_t http_client) { + return esp_http_client_set_header(http_client, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); +} + +esp_err_t event_handler(esp_http_client_event_t* event) { + /* We do interested in only HTTP_EVENT_ON_DATA event only */ + if (event->event_id != HTTP_EVENT_ON_DATA) return ESP_OK; + + if (!esp_http_client_is_chunked_response(event->client)) { + int content_len = esp_http_client_get_content_length(event->client); + int copy_len = 0; + + if (local_buf == NULL) { + /* local_buf life span is tracked by caller checkForUpdate */ + local_buf = static_cast(calloc(content_len + 1, sizeof(char))); + output_len = 0; + if (local_buf == NULL) { + Serial.printf("[%lu] [OTA] HTTP Client Out of Memory Failed, Allocation %d\n", millis(), content_len); + return ESP_ERR_NO_MEM; + } + } + copy_len = min(event->data_len, (content_len - output_len)); + if (copy_len) { + memcpy(local_buf + output_len, event->data, copy_len); + } + output_len += copy_len; + } else { + /* Code might be hits here, It happened once (for version checking) but I need more logs to handle that */ + int chunked_len; + esp_http_client_get_chunk_length(event->client, &chunked_len); + Serial.printf("[%lu] [OTA] esp_http_client_is_chunked_response failed, chunked_len: %d\n", millis(), chunked_len); + } + + return ESP_OK; +} /* event_handler */ +} /* namespace */ + OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { - const std::unique_ptr client(new WiFiClientSecure); - client->setInsecure(); - HTTPClient http; + JsonDocument filter; + esp_err_t esp_err; + JsonDocument doc; - Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), latestReleaseUrl); + esp_http_client_config_t client_config = { + .url = latestReleaseUrl, + .event_handler = event_handler, + /* Default HTTP client buffer size 512 byte only */ + .buffer_size = 8192, + .buffer_size_tx = 8192, + .skip_cert_common_name_check = true, + .crt_bundle_attach = esp_crt_bundle_attach, + .keep_alive_enable = true, + }; - http.begin(*client, latestReleaseUrl); - http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + /* To track life time of local_buf, dtor will be called on exit from that function */ + struct localBufCleaner { + char** bufPtr; + ~localBufCleaner() { + if (*bufPtr) { + free(*bufPtr); + *bufPtr = NULL; + } + } + } localBufCleaner = {&local_buf}; - const int httpCode = http.GET(); - if (httpCode != HTTP_CODE_OK) { - Serial.printf("[%lu] [OTA] HTTP error: %d\n", millis(), httpCode); - http.end(); + esp_http_client_handle_t client_handle = esp_http_client_init(&client_config); + if (!client_handle) { + Serial.printf("[%lu] [OTA] HTTP Client Handle Failed\n", millis()); + return INTERNAL_UPDATE_ERROR; + } + + esp_err = esp_http_client_set_header(client_handle, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + if (esp_err != ESP_OK) { + Serial.printf("[%lu] [OTA] esp_http_client_set_header Failed : %s\n", millis(), esp_err_to_name(esp_err)); + esp_http_client_cleanup(client_handle); + return INTERNAL_UPDATE_ERROR; + } + + esp_err = esp_http_client_perform(client_handle); + if (esp_err != ESP_OK) { + Serial.printf("[%lu] [OTA] esp_http_client_perform Failed : %s\n", millis(), esp_err_to_name(esp_err)); + esp_http_client_cleanup(client_handle); return HTTP_ERROR; } - JsonDocument doc; - JsonDocument filter; + /* esp_http_client_close will be called inside cleanup as well*/ + esp_err = esp_http_client_cleanup(client_handle); + if (esp_err != ESP_OK) { + Serial.printf("[%lu] [OTA] esp_http_client_cleanupp Failed : %s\n", millis(), esp_err_to_name(esp_err)); + return INTERNAL_UPDATE_ERROR; + } + filter["tag_name"] = true; filter["assets"][0]["name"] = true; filter["assets"][0]["browser_download_url"] = true; filter["assets"][0]["size"] = true; - const DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter)); - http.end(); + const DeserializationError error = deserializeJson(doc, local_buf, DeserializationOption::Filter(filter)); if (error) { Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str()); return JSON_PARSE_ERROR; @@ -42,6 +127,7 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { Serial.printf("[%lu] [OTA] No tag_name found\n", millis()); return JSON_PARSE_ERROR; } + if (!doc["assets"].is()) { Serial.printf("[%lu] [OTA] No assets found\n", millis()); return JSON_PARSE_ERROR; @@ -104,67 +190,74 @@ bool OtaUpdater::isUpdateNewer() const { const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; } -OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function& onProgress) { +OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate() { if (!isUpdateNewer()) { return UPDATE_OLDER_ERROR; } - const std::unique_ptr client(new WiFiClientSecure); - client->setInsecure(); - HTTPClient http; + esp_https_ota_handle_t ota_handle = NULL; + esp_err_t esp_err; + /* Signal for OtaUpdateActivity */ + render = false; - Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), otaUrl.c_str()); + esp_http_client_config_t client_config = { + .url = otaUrl.c_str(), + .timeout_ms = 15000, + /* Default HTTP client buffer size 512 byte only + * not sufficent to handle URL redirection cases or + * parsing of large HTTP headers. + */ + .buffer_size = 8192, + .buffer_size_tx = 8192, + .skip_cert_common_name_check = true, + .crt_bundle_attach = esp_crt_bundle_attach, + .keep_alive_enable = true, + }; - http.begin(*client, otaUrl.c_str()); - http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); - const int httpCode = http.GET(); + esp_https_ota_config_t ota_config = { + .http_config = &client_config, + .http_client_init_cb = http_client_set_header_cb, + }; - if (httpCode != HTTP_CODE_OK) { - Serial.printf("[%lu] [OTA] Download failed: %d\n", millis(), httpCode); - http.end(); + /* For better timing and connectivity, we disable power saving for WiFi */ + esp_wifi_set_ps(WIFI_PS_NONE); + + esp_err = esp_https_ota_begin(&ota_config, &ota_handle); + if (esp_err != ESP_OK) { + Serial.printf("[%lu] [OTA] HTTP OTA Begin Failed: %s\n", millis(), esp_err_to_name(esp_err)); + return INTERNAL_UPDATE_ERROR; + } + + do { + esp_err = esp_https_ota_perform(ota_handle); + processedSize = esp_https_ota_get_image_len_read(ota_handle); + /* Sent signal to OtaUpdateActivity */ + render = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + } while (esp_err == ESP_ERR_HTTPS_OTA_IN_PROGRESS); + + /* Return back to default power saving for WiFi in case of failing */ + esp_wifi_set_ps(WIFI_PS_MIN_MODEM); + + if (esp_err != ESP_OK) { + Serial.printf("[%lu] [OTA] esp_https_ota_perform Failed: %s\n", millis(), esp_err_to_name(esp_err)); + esp_https_ota_finish(ota_handle); return HTTP_ERROR; } - // 2. Get length and stream - const size_t contentLength = http.getSize(); - - if (contentLength != otaSize) { - Serial.printf("[%lu] [OTA] Invalid content length\n", millis()); - http.end(); - return HTTP_ERROR; - } - - // 3. Begin the ESP-IDF Update process - if (!Update.begin(otaSize)) { - Serial.printf("[%lu] [OTA] Not enough space. Error: %s\n", millis(), Update.errorString()); - http.end(); + if (!esp_https_ota_is_complete_data_received(ota_handle)) { + Serial.printf("[%lu] [OTA] esp_https_ota_is_complete_data_received Failed: %s\n", millis(), + esp_err_to_name(esp_err)); + esp_https_ota_finish(ota_handle); return INTERNAL_UPDATE_ERROR; } - this->totalSize = otaSize; - Serial.printf("[%lu] [OTA] Update started\n", millis()); - Update.onProgress([this, onProgress](const size_t progress, const size_t total) { - this->processedSize = progress; - this->totalSize = total; - onProgress(progress, total); - }); - const size_t written = Update.writeStream(*client); - http.end(); - - if (written == otaSize) { - Serial.printf("[%lu] [OTA] Successfully written %u bytes\n", millis(), written); - } else { - Serial.printf("[%lu] [OTA] Written only %u/%u bytes. Error: %s\n", millis(), written, otaSize, - Update.errorString()); + esp_err = esp_https_ota_finish(ota_handle); + if (esp_err != ESP_OK) { + Serial.printf("[%lu] [OTA] esp_https_ota_finish Failed: %s\n", millis(), esp_err_to_name(esp_err)); return INTERNAL_UPDATE_ERROR; } - if (Update.end() && Update.isFinished()) { - Serial.printf("[%lu] [OTA] Update complete\n", millis()); - return OK; - } else { - Serial.printf("[%lu] [OTA] Error Occurred: %s\n", millis(), Update.errorString()); - return INTERNAL_UPDATE_ERROR; - } + Serial.printf("[%lu] [OTA] Update completed\n", millis()); + return OK; } diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h index 817f24b1..24e04cf5 100644 --- a/src/network/OtaUpdater.h +++ b/src/network/OtaUpdater.h @@ -8,6 +8,9 @@ class OtaUpdater { std::string latestVersion; std::string otaUrl; size_t otaSize = 0; + size_t processedSize = 0; + size_t totalSize = 0; + bool render = false; public: enum OtaUpdaterError { @@ -19,12 +22,18 @@ class OtaUpdater { INTERNAL_UPDATE_ERROR, OOM_ERROR, }; - size_t processedSize = 0; - size_t totalSize = 0; + + size_t getOtaSize() const { return otaSize; } + + size_t getProcessedSize() const { return processedSize; } + + size_t getTotalSize() const { return totalSize; } + + bool getRender() const { return render; } OtaUpdater() = default; bool isUpdateNewer() const; const std::string& getLatestVersion() const; OtaUpdaterError checkForUpdate(); - OtaUpdaterError installUpdate(const std::function& onProgress); + OtaUpdaterError installUpdate(); }; From 140fcb9db56c3d271116b98fcd2e8ecf28bb174a Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Tue, 27 Jan 2026 20:09:05 +0500 Subject: [PATCH 2/8] fix: missing front layout in mapLabels() (#564) ## Summary * adds missing front layout to mapLabels function --- src/MappedInputManager.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 14c45deb..25095be7 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -85,6 +85,8 @@ MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const return {previous, next, back, confirm}; case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return {previous, back, confirm, next}; + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + return {back, confirm, next, previous}; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: default: return {back, confirm, previous, next}; From 8c1c80787a1620b728c5afa926a7f95c66c8503d Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 28 Jan 2026 02:43:04 +1100 Subject: [PATCH 3/8] fix: Render keyboard entry over multiple lines (#567) ## Summary * Render keyboard entry over multiple lines * Grows display areas based on input text * Shown on OPDS entry, but applies everywhere ## Additional Context * Fixes https://github.com/crosspoint-reader/crosspoint-reader/issues/554 | One line | Multi-line | | --- | --- | | ![IMG_5925](https://github.com/user-attachments/assets/28be00a8-7b90-4bf6-9ebf-4d4ad6642bc9) | ![IMG_5926](https://github.com/user-attachments/assets/1c69a96f-d868-49a1-866c-546ca7b784ab) | --- ### 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? No --- src/activities/util/KeyboardEntryActivity.cpp | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 8c36ac33..3a6befac 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -256,8 +256,9 @@ void KeyboardEntryActivity::render() const { renderer.drawCenteredText(UI_10_FONT_ID, startY, title.c_str()); // Draw input field - const int inputY = startY + 22; - renderer.drawText(UI_10_FONT_ID, 10, inputY, "["); + const int inputStartY = startY + 22; + int inputEndY = startY + 22; + renderer.drawText(UI_10_FONT_ID, 10, inputStartY, "["); std::string displayText; if (isPassword) { @@ -269,19 +270,29 @@ void KeyboardEntryActivity::render() const { // Show cursor at end displayText += "_"; - // Truncate if too long for display - use actual character width from font - int approxCharWidth = renderer.getSpaceWidth(UI_10_FONT_ID); - if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width - const int maxDisplayLen = (pageWidth - 40) / approxCharWidth; - if (displayText.length() > static_cast(maxDisplayLen)) { - displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); - } + // Render input text across multiple lines + int lineStartIdx = 0; + int lineEndIdx = displayText.length(); + while (true) { + std::string lineText = displayText.substr(lineStartIdx, lineEndIdx - lineStartIdx); + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, lineText.c_str()); + if (textWidth <= pageWidth - 40) { + renderer.drawText(UI_10_FONT_ID, 20, inputEndY, lineText.c_str()); + if (lineEndIdx == displayText.length()) { + break; + } - renderer.drawText(UI_10_FONT_ID, 20, inputY, displayText.c_str()); - renderer.drawText(UI_10_FONT_ID, pageWidth - 15, inputY, "]"); + inputEndY += renderer.getLineHeight(UI_10_FONT_ID); + lineStartIdx = lineEndIdx; + lineEndIdx = displayText.length(); + } else { + lineEndIdx -= 1; + } + } + renderer.drawText(UI_10_FONT_ID, pageWidth - 15, inputEndY, "]"); // Draw keyboard - use compact spacing to fit 5 rows on screen - const int keyboardStartY = inputY + 25; + const int keyboardStartY = inputEndY + 25; constexpr int keyWidth = 18; constexpr int keyHeight = 18; constexpr int keySpacing = 3; From 5894ae5afe728efec73e8ab1ceaf1b0bfe4216cc Mon Sep 17 00:00:00 2001 From: Boris Faure Date: Tue, 27 Jan 2026 17:32:33 +0100 Subject: [PATCH 4/8] chore: .gitignore: add compile_commands.json & .cache (#568) ## Summary * **What is the goal of this PR?** Quality of Life * **What changes are included?** Add compile_commands.json & .cache to .gitignore . Both are use by clangd that can help IDE support. Run `pio run --target compiledb` to generate `compile_commands.json`. --- ### 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? _**NO**_ --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 754c9f68..ec281eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ lib/EpdFont/fontsrc *.generated.h .vs build -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +/compile_commands.json +/.cache From 712c566664577020a7ee382531346d51c464c35f Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 28 Jan 2026 03:33:36 +1100 Subject: [PATCH 5/8] fix: Correctly render italics on image alt placeholders (#569) ## Summary * Correctly render italics on image alt placeholders * Parser incorrectly handled depth of self-closing tags * Self-closing tags immediately call start and end tag ## Additional Context * Previously, it would incorrectly make the whole chapter bold/italics, or not italicised the image alt --- ### 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? No --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 86 ++++++++++++------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index f6d96be4..298c4ec6 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -84,41 +84,42 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (strcmp(name, "table") == 0) { // Add placeholder text self->startNewTextBlock(TextBlock::CENTER_ALIGN); - if (self->currentTextBlock) { - self->currentTextBlock->addWord("[Table omitted]", EpdFontFamily::ITALIC); - } - // Skip table contents - self->skipUntilDepth = self->depth; + self->italicUntilDepth = min(self->italicUntilDepth, self->depth); + // Advance depth before processing character data (like you would for a element with text) self->depth += 1; + self->characterData(userData, "[Table omitted]", strlen("[Table omitted]")); + + // Skip table contents (skip until parent as we pre-advanced depth above) + self->skipUntilDepth = self->depth - 1; return; } if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { // TODO: Start processing image tags - std::string alt; + std::string alt = "[Image]"; if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "alt") == 0) { - // add " " (counts as whitespace) at the end of alt - // so the corresponding text block ends. - // TODO: A zero-width breaking space would be more appropriate (once/if we support it) - alt = "[Image: " + std::string(atts[i + 1]) + "] "; + if (strlen(atts[i + 1]) > 0) { + alt = "[Image: " + std::string(atts[i + 1]) + "]"; + } + break; } } - Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); - - self->startNewTextBlock(TextBlock::CENTER_ALIGN); - self->italicUntilDepth = min(self->italicUntilDepth, self->depth); - self->depth += 1; - self->characterData(userData, alt.c_str(), alt.length()); - return; - } else { - // Skip for now - self->skipUntilDepth = self->depth; - self->depth += 1; - return; } + + Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); + + self->startNewTextBlock(TextBlock::CENTER_ALIGN); + self->italicUntilDepth = min(self->italicUntilDepth, self->depth); + // Advance depth before processing character data (like you would for a element with text) + self->depth += 1; + self->characterData(userData, alt.c_str(), alt.length()); + + // Skip table contents (skip until parent as we pre-advanced depth above) + self->skipUntilDepth = self->depth - 1; + return; } if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { @@ -143,25 +144,43 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { self->startNewTextBlock(TextBlock::CENTER_ALIGN); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); - } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { + self->depth += 1; + return; + } + + if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { if (strcmp(name, "br") == 0) { if (self->partWordBufferIndex > 0) { // flush word preceding
to currentTextBlock before calling startNewTextBlock self->flushPartWordBuffer(); } self->startNewTextBlock(self->currentTextBlock->getStyle()); - } else { - self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); - if (strcmp(name, "li") == 0) { - self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); - } + self->depth += 1; + return; } - } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { - self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); - } else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { - self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); + + self->startNewTextBlock(static_cast(self->paragraphAlignment)); + if (strcmp(name, "li") == 0) { + self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + } + + self->depth += 1; + return; } + if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { + self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); + self->depth += 1; + return; + } + + if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { + self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); + self->depth += 1; + return; + } + + // Unprocessed tag, just increasing depth and continue forward self->depth += 1; } @@ -227,7 +246,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n // text styling needs to be overhauled to fix it. const bool shouldBreakText = matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || - matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1; + matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || + strcmp(name, "table") == 0 || matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1; if (shouldBreakText) { self->flushPartWordBuffer(); From ebcd813ff6c49ccf9bafb3146b51d2c0d2278170 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 28 Jan 2026 04:08:04 +1100 Subject: [PATCH 6/8] chore: Cut release 0.16.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 7f42637d..e8574470 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 0.15.0 +version = 0.16.0 [base] platform = espressif32 @ 6.12.0 From 172916afd41e6f92ee32970055a2c982a7d6b3ec Mon Sep 17 00:00:00 2001 From: Eliz Date: Tue, 27 Jan 2026 17:25:03 +0000 Subject: [PATCH 7/8] feat: Display epub metadata on Recents (#511) * **What is the goal of this PR?** Implement a metadata viewer for the Recents screen * **What changes are included?** | Recents | Files | | --- | --- | | image | image | For the Files screen, I have not made any changes on purpose. For the Recents screen, we now display the Book title and author. If it is a file with no epub metadata like txt or md, we display the file name without the file extension. --- Did you use AI tools to help write this code? _**< YES >**_ Although I went trough all the code manually and made changes as well, please be aware the majority of the code is AI generated. --------- Co-authored-by: Eliz Kilic --- src/RecentBooksStore.cpp | 58 ++++++++++------ src/RecentBooksStore.h | 18 +++-- src/activities/home/MyLibraryActivity.cpp | 72 +++++++++++--------- src/activities/home/MyLibraryActivity.h | 4 +- src/activities/reader/EpubReaderActivity.cpp | 2 +- src/activities/reader/TxtReaderActivity.cpp | 2 +- src/activities/reader/XtcReaderActivity.cpp | 2 +- 7 files changed, 95 insertions(+), 63 deletions(-) diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 03cfbbd7..5932de36 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -7,22 +7,23 @@ #include namespace { -constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 1; +constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 2; constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin"; constexpr int MAX_RECENT_BOOKS = 10; } // namespace RecentBooksStore RecentBooksStore::instance; -void RecentBooksStore::addBook(const std::string& path) { +void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) { // Remove existing entry if present - auto it = std::find(recentBooks.begin(), recentBooks.end(), path); + auto it = + std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); if (it != recentBooks.end()) { recentBooks.erase(it); } // Add to front - recentBooks.insert(recentBooks.begin(), path); + recentBooks.insert(recentBooks.begin(), {path, title, author}); // Trim to max size if (recentBooks.size() > MAX_RECENT_BOOKS) { @@ -46,7 +47,9 @@ bool RecentBooksStore::saveToFile() const { serialization::writePod(outputFile, count); for (const auto& book : recentBooks) { - serialization::writeString(outputFile, book); + serialization::writeString(outputFile, book.path); + serialization::writeString(outputFile, book.title); + serialization::writeString(outputFile, book.author); } outputFile.close(); @@ -63,24 +66,41 @@ bool RecentBooksStore::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); if (version != RECENT_BOOKS_FILE_VERSION) { - Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); - inputFile.close(); - return false; - } + if (version == 1) { + // Old version, just read paths + uint8_t count; + serialization::readPod(inputFile, count); + recentBooks.clear(); + recentBooks.reserve(count); + for (uint8_t i = 0; i < count; i++) { + std::string path; + serialization::readString(inputFile, path); + // Title and author will be empty, they will be filled when the book is + // opened again + recentBooks.push_back({path, "", ""}); + } + } else { + Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); + inputFile.close(); + return false; + } + } else { + uint8_t count; + serialization::readPod(inputFile, count); - uint8_t count; - serialization::readPod(inputFile, count); + recentBooks.clear(); + recentBooks.reserve(count); - recentBooks.clear(); - recentBooks.reserve(count); - - for (uint8_t i = 0; i < count; i++) { - std::string path; - serialization::readString(inputFile, path); - recentBooks.push_back(path); + for (uint8_t i = 0; i < count; i++) { + std::string path, title, author; + serialization::readString(inputFile, path); + serialization::readString(inputFile, title); + serialization::readString(inputFile, author); + recentBooks.push_back({path, title, author}); + } } inputFile.close(); - Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), count); + Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), recentBooks.size()); return true; } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index b98bd406..7b87f1e0 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -2,11 +2,19 @@ #include #include +struct RecentBook { + std::string path; + std::string title; + std::string author; + + bool operator==(const RecentBook& other) const { return path == other.path; } +}; + class RecentBooksStore { // Static instance static RecentBooksStore instance; - std::vector recentBooks; + std::vector recentBooks; public: ~RecentBooksStore() = default; @@ -14,11 +22,11 @@ class RecentBooksStore { // Get singleton instance static RecentBooksStore& getInstance() { return instance; } - // Add a book path to the recent list (moves to front if already exists) - void addBook(const std::string& path); + // Add a book to the recent list (moves to front if already exists) + void addBook(const std::string& path, const std::string& title, const std::string& author); - // Get the list of recent book paths (most recent first) - const std::vector& getBooks() const { return recentBooks; } + // Get the list of recent books (most recent first) + const std::vector& getBooks() const { return recentBooks; } // Get the count of recent books int getCount() const { return static_cast(recentBooks.size()); } diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 1db32397..29c6ea73 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -16,6 +16,7 @@ namespace { constexpr int TAB_BAR_Y = 15; constexpr int CONTENT_START_Y = 60; constexpr int LINE_HEIGHT = 30; +constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items constexpr int LEFT_MARGIN = 20; constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator @@ -47,7 +48,7 @@ int MyLibraryActivity::getPageItems() const { int MyLibraryActivity::getCurrentItemCount() const { if (currentTab == Tab::Recent) { - return static_cast(bookTitles.size()); + return static_cast(recentBooks.size()); } return static_cast(files.size()); } @@ -65,34 +66,16 @@ int MyLibraryActivity::getCurrentPage() const { } void MyLibraryActivity::loadRecentBooks() { - constexpr size_t MAX_RECENT_BOOKS = 20; - - bookTitles.clear(); - bookPaths.clear(); + recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); - bookTitles.reserve(std::min(books.size(), MAX_RECENT_BOOKS)); - bookPaths.reserve(std::min(books.size(), MAX_RECENT_BOOKS)); - - for (const auto& path : books) { - // Limit to maximum number of recent books - if (bookTitles.size() >= MAX_RECENT_BOOKS) { - break; - } + recentBooks.reserve(books.size()); + for (const auto& book : books) { // Skip if file no longer exists - if (!SdMan.exists(path.c_str())) { + if (!SdMan.exists(book.path.c_str())) { continue; } - - // Extract filename from path for display - std::string title = path; - const size_t lastSlash = title.find_last_of('/'); - if (lastSlash != std::string::npos) { - title = title.substr(lastSlash + 1); - } - - bookTitles.push_back(title); - bookPaths.push_back(path); + recentBooks.push_back(book); } } @@ -176,8 +159,6 @@ void MyLibraryActivity::onExit() { vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; - bookTitles.clear(); - bookPaths.clear(); files.clear(); } @@ -207,8 +188,8 @@ void MyLibraryActivity::loop() { // Confirm button - open selected item if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (currentTab == Tab::Recent) { - if (!bookPaths.empty() && selectorIndex < static_cast(bookPaths.size())) { - onSelectBook(bookPaths[selectorIndex], currentTab); + if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { + onSelectBook(recentBooks[selectorIndex].path, currentTab); } } else { // Files tab @@ -333,7 +314,7 @@ void MyLibraryActivity::render() const { void MyLibraryActivity::renderRecentTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); - const int bookCount = static_cast(bookTitles.size()); + const int bookCount = static_cast(recentBooks.size()); if (bookCount == 0) { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); @@ -343,14 +324,37 @@ void MyLibraryActivity::renderRecentTab() const { const auto pageStartIndex = selectorIndex / pageItems * pageItems; // Draw selection highlight - renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, - LINE_HEIGHT); + renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, + pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT); // Draw items for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { - auto item = renderer.truncatedText(UI_10_FONT_ID, bookTitles[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), - i != selectorIndex); + const auto& book = recentBooks[i]; + const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; + + // Line 1: Title + std::string title = book.title; + if (title.empty()) { + // Fallback for older entries or files without metadata + title = book.path; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + const size_t dot = title.find_last_of('.'); + if (dot != std::string::npos) { + title.resize(dot); + } + } + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex); + + // Line 2: Author + if (!book.author.empty()) { + auto truncatedAuthor = + renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex); + } } } diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index c6c52b68..39a27ed7 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -8,6 +8,7 @@ #include #include "../Activity.h" +#include "RecentBooksStore.h" class MyLibraryActivity final : public Activity { public: @@ -22,8 +23,7 @@ class MyLibraryActivity final : public Activity { bool updateRequired = false; // Recent tab state - std::vector bookTitles; // Display titles for each book - std::vector bookPaths; // Paths for each visible book (excludes missing) + std::vector recentBooks; // Files tab state (from FileSelectionActivity) std::string basepath = "/"; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 89be3bc7..509f2eaf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -85,7 +85,7 @@ void EpubReaderActivity::onEnter() { // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(epub->getPath()); + RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); // Trigger first update updateRequired = true; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 7df083a6..e4978221 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -60,7 +60,7 @@ void TxtReaderActivity::onEnter() { // Save current txt as last opened file and add to recent books APP_STATE.openEpubPath = txt->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(txt->getPath()); + RECENT_BOOKS.addBook(txt->getPath(), "", ""); // Trigger first update updateRequired = true; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 9761e27d..c97f2094 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -45,7 +45,7 @@ void XtcReaderActivity::onEnter() { // Save current XTC as last opened book and add to recent books APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(xtc->getPath()); + RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor()); // Trigger first update updateRequired = true; From da4d3b5ea5d0141567ea598a332c92c9b1655e3e Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Tue, 27 Jan 2026 18:50:15 +0100 Subject: [PATCH 8/8] feat: add HalDisplay and HalGPIO (#522) ## Summary Extracted some changes from https://github.com/crosspoint-reader/crosspoint-reader/pull/500 to make reviewing easier This PR adds HAL (Hardware Abstraction Layer) for display and GPIO components, making it easier to write a stub or an emulated implementation of the hardware. SD card HAL will be added via another PR, because it's a bit more tricky. --- ### 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? **NO** --- lib/GfxRenderer/GfxRenderer.cpp | 62 ++++++------ lib/GfxRenderer/GfxRenderer.h | 12 +-- lib/hal/HalDisplay.cpp | 51 ++++++++++ lib/hal/HalDisplay.h | 52 ++++++++++ lib/hal/HalGPIO.cpp | 55 +++++++++++ lib/hal/HalGPIO.h | 61 ++++++++++++ src/MappedInputManager.cpp | 48 +++++---- src/MappedInputManager.h | 8 +- src/activities/boot_sleep/SleepActivity.cpp | 6 +- src/activities/reader/EpubReaderActivity.cpp | 4 +- src/activities/reader/TxtReaderActivity.cpp | 4 +- src/activities/reader/XtcReaderActivity.cpp | 4 +- .../util/FullScreenMessageActivity.h | 6 +- src/main.cpp | 97 ++++++------------- 14 files changed, 322 insertions(+), 148 deletions(-) create mode 100644 lib/hal/HalDisplay.cpp create mode 100644 lib/hal/HalDisplay.h create mode 100644 lib/hal/HalGPIO.cpp create mode 100644 lib/hal/HalGPIO.h diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1dbe8ee6..fa1c61c6 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int // Logical portrait (480x800) → panel (800x480) // Rotation: 90 degrees clockwise *rotatedX = y; - *rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; + *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x; break; } case LandscapeClockwise: { // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) - *rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x; - *rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y; + *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x; + *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y; break; } case PortraitInverted: { // Logical portrait (480x800) → panel (800x480) // Rotation: 90 degrees counter-clockwise - *rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y; + *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y; *rotatedY = x; break; } @@ -36,7 +36,7 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int } void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + uint8_t* frameBuffer = display.getFrameBuffer(); // Early return if no framebuffer is set if (!frameBuffer) { @@ -49,14 +49,13 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { rotateCoordinates(x, y, &rotatedX, &rotatedY); // Bounds checking against physical panel dimensions - if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || - rotatedY >= EInkDisplay::DISPLAY_HEIGHT) { + if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) { Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); return; } // Calculate byte position and bit position - const uint16_t byteIndex = rotatedY * EInkDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); + const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first if (state) { @@ -164,7 +163,7 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co break; } // TODO: Rotate bits - einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); + display.drawImage(bitmap, rotatedX, rotatedY, width, height); } void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, @@ -399,22 +398,20 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi free(nodeX); } -void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } +void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); } void GfxRenderer::invertScreen() const { - uint8_t* buffer = einkDisplay.getFrameBuffer(); + uint8_t* buffer = display.getFrameBuffer(); if (!buffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); return; } - for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { + for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) { buffer[i] = ~buffer[i]; } } -void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const { - einkDisplay.displayBuffer(refreshMode); -} +void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { display.displayBuffer(refreshMode); } std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, const EpdFontFamily::Style style) const { @@ -433,13 +430,13 @@ int GfxRenderer::getScreenWidth() const { case Portrait: case PortraitInverted: // 480px wide in portrait logical coordinates - return EInkDisplay::DISPLAY_HEIGHT; + return HalDisplay::DISPLAY_HEIGHT; case LandscapeClockwise: case LandscapeCounterClockwise: // 800px wide in landscape logical coordinates - return EInkDisplay::DISPLAY_WIDTH; + return HalDisplay::DISPLAY_WIDTH; } - return EInkDisplay::DISPLAY_HEIGHT; + return HalDisplay::DISPLAY_HEIGHT; } int GfxRenderer::getScreenHeight() const { @@ -447,13 +444,13 @@ int GfxRenderer::getScreenHeight() const { case Portrait: case PortraitInverted: // 800px tall in portrait logical coordinates - return EInkDisplay::DISPLAY_WIDTH; + return HalDisplay::DISPLAY_WIDTH; case LandscapeClockwise: case LandscapeCounterClockwise: // 480px tall in landscape logical coordinates - return EInkDisplay::DISPLAY_HEIGHT; + return HalDisplay::DISPLAY_HEIGHT; } - return EInkDisplay::DISPLAY_WIDTH; + return HalDisplay::DISPLAY_WIDTH; } int GfxRenderer::getSpaceWidth(const int fontId) const { @@ -653,17 +650,18 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y } } -uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } +uint8_t* GfxRenderer::getFrameBuffer() const { return display.getFrameBuffer(); } -size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } +size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; } -void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); } +// unused +// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); } -void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); } +void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(display.getFrameBuffer()); } -void GfxRenderer::copyGrayscaleMsbBuffers() const { einkDisplay.copyGrayscaleMsbBuffers(einkDisplay.getFrameBuffer()); } +void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(display.getFrameBuffer()); } -void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); } +void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(); } void GfxRenderer::freeBwBufferChunks() { for (auto& bwBufferChunk : bwBufferChunks) { @@ -681,7 +679,7 @@ void GfxRenderer::freeBwBufferChunks() { * Returns true if buffer was stored successfully, false if allocation failed. */ bool GfxRenderer::storeBwBuffer() { - const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + const uint8_t* frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); return false; @@ -736,7 +734,7 @@ void GfxRenderer::restoreBwBuffer() { return; } - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + uint8_t* frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); freeBwBufferChunks(); @@ -755,7 +753,7 @@ void GfxRenderer::restoreBwBuffer() { memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); } - einkDisplay.cleanupGrayscaleBuffers(frameBuffer); + display.cleanupGrayscaleBuffers(frameBuffer); freeBwBufferChunks(); Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); @@ -766,9 +764,9 @@ void GfxRenderer::restoreBwBuffer() { * Use this when BW buffer was re-rendered instead of stored/restored. */ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + uint8_t* frameBuffer = display.getFrameBuffer(); if (frameBuffer) { - einkDisplay.cleanupGrayscaleBuffers(frameBuffer); + display.cleanupGrayscaleBuffers(frameBuffer); } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b1fea69b..733975f4 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include @@ -21,11 +21,11 @@ class GfxRenderer { private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory - static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; - static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE, + static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; + static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE, "BW buffer chunking does not line up with display buffer size"); - EInkDisplay& einkDisplay; + HalDisplay& display; RenderMode renderMode; Orientation orientation; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; @@ -36,7 +36,7 @@ class GfxRenderer { void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; public: - explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {} + explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} ~GfxRenderer() { freeBwBufferChunks(); } static constexpr int VIEWABLE_MARGIN_TOP = 9; @@ -54,7 +54,7 @@ class GfxRenderer { // Screen ops int getScreenWidth() const; int getScreenHeight() const; - void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const; + void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; // EXPERIMENTAL: Windowed update - display only a rectangular region void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp new file mode 100644 index 00000000..6f69d7fc --- /dev/null +++ b/lib/hal/HalDisplay.cpp @@ -0,0 +1,51 @@ +#include +#include + +#define SD_SPI_MISO 7 + +HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {} + +HalDisplay::~HalDisplay() {} + +void HalDisplay::begin() { einkDisplay.begin(); } + +void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); } + +void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, + bool fromProgmem) const { + einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem); +} + +EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) { + switch (mode) { + case HalDisplay::FULL_REFRESH: + return EInkDisplay::FULL_REFRESH; + case HalDisplay::HALF_REFRESH: + return EInkDisplay::HALF_REFRESH; + case HalDisplay::FAST_REFRESH: + default: + return EInkDisplay::FAST_REFRESH; + } +} + +void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode) { einkDisplay.displayBuffer(convertRefreshMode(mode)); } + +void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { + einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); +} + +void HalDisplay::deepSleep() { einkDisplay.deepSleep(); } + +uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } + +void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) { + einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer); +} + +void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); } + +void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); } + +void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); } + +void HalDisplay::displayGrayBuffer() { einkDisplay.displayGrayBuffer(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h new file mode 100644 index 00000000..6eb7156b --- /dev/null +++ b/lib/hal/HalDisplay.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include + +class HalDisplay { + public: + // Constructor with pin configuration + HalDisplay(); + + // Destructor + ~HalDisplay(); + + // Refresh modes + enum RefreshMode { + FULL_REFRESH, // Full refresh with complete waveform + HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed + FAST_REFRESH // Fast refresh using custom LUT + }; + + // Initialize the display hardware and driver + void begin(); + + // Display dimensions + static constexpr uint16_t DISPLAY_WIDTH = EInkDisplay::DISPLAY_WIDTH; + static constexpr uint16_t DISPLAY_HEIGHT = EInkDisplay::DISPLAY_HEIGHT; + static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8; + static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT; + + // Frame buffer operations + void clearScreen(uint8_t color = 0xFF) const; + void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, + bool fromProgmem = false) const; + + void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH); + void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + + // Power management + void deepSleep(); + + // Access to frame buffer + uint8_t* getFrameBuffer() const; + + void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer); + void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer); + void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer); + void cleanupGrayscaleBuffers(const uint8_t* bwBuffer); + + void displayGrayBuffer(); + + private: + EInkDisplay einkDisplay; +}; diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp new file mode 100644 index 00000000..803efba0 --- /dev/null +++ b/lib/hal/HalGPIO.cpp @@ -0,0 +1,55 @@ +#include +#include +#include + +void HalGPIO::begin() { + inputMgr.begin(); + SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS); + pinMode(BAT_GPIO0, INPUT); + pinMode(UART0_RXD, INPUT); +} + +void HalGPIO::update() { inputMgr.update(); } + +bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); } + +bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); } + +bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); } + +bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); } + +bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); } + +unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } + +void HalGPIO::startDeepSleep() { + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); + // Ensure that the power button has been released to avoid immediately turning back on if you're holding it + while (inputMgr.isPressed(BTN_POWER)) { + delay(50); + inputMgr.update(); + } + // Enter Deep Sleep + esp_deep_sleep_start(); +} + +int HalGPIO::getBatteryPercentage() const { + static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); + return battery.readPercentage(); +} + +bool HalGPIO::isUsbConnected() const { + // U0RXD/GPIO20 reads HIGH when USB is connected + return digitalRead(UART0_RXD) == HIGH; +} + +bool HalGPIO::isWakeupByPowerButton() const { + const auto wakeupCause = esp_sleep_get_wakeup_cause(); + const auto resetReason = esp_reset_reason(); + if (isUsbConnected()) { + return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; + } else { + return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); + } +} diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h new file mode 100644 index 00000000..11ffb22e --- /dev/null +++ b/lib/hal/HalGPIO.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) +#define EPD_SCLK 8 // SPI Clock +#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In) +#define EPD_CS 21 // Chip Select +#define EPD_DC 4 // Data/Command +#define EPD_RST 5 // Reset +#define EPD_BUSY 6 // Busy + +#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out) + +#define BAT_GPIO0 0 // Battery voltage + +#define UART0_RXD 20 // Used for USB connection detection + +class HalGPIO { +#if CROSSPOINT_EMULATED == 0 + InputManager inputMgr; +#endif + + public: + HalGPIO() = default; + + // Start button GPIO and setup SPI for screen and SD card + void begin(); + + // Button input methods + void update(); + bool isPressed(uint8_t buttonIndex) const; + bool wasPressed(uint8_t buttonIndex) const; + bool wasAnyPressed() const; + bool wasReleased(uint8_t buttonIndex) const; + bool wasAnyReleased() const; + unsigned long getHeldTime() const; + + // Setup wake up GPIO and enter deep sleep + void startDeepSleep(); + + // Get battery percentage (range 0-100) + int getBatteryPercentage() const; + + // Check if USB is connected + bool isUsbConnected() const; + + // Check if wakeup was caused by power button press + bool isWakeupByPowerButton() const; + + // Button indices + static constexpr uint8_t BTN_BACK = 0; + static constexpr uint8_t BTN_CONFIRM = 1; + static constexpr uint8_t BTN_LEFT = 2; + static constexpr uint8_t BTN_RIGHT = 3; + static constexpr uint8_t BTN_UP = 4; + static constexpr uint8_t BTN_DOWN = 5; + static constexpr uint8_t BTN_POWER = 6; +}; diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 25095be7..e5423724 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -19,20 +19,20 @@ struct SideLayoutMap { // Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT. constexpr FrontLayoutMap kFrontLayouts[] = { - {InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_RIGHT}, - {InputManager::BTN_LEFT, InputManager::BTN_RIGHT, InputManager::BTN_BACK, InputManager::BTN_CONFIRM}, - {InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_BACK, InputManager::BTN_RIGHT}, - {InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_RIGHT, InputManager::BTN_LEFT}, + {HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT}, + {HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT, HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM}, + {HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_BACK, HalGPIO::BTN_RIGHT}, + {HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_RIGHT, HalGPIO::BTN_LEFT}, }; // Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT. constexpr SideLayoutMap kSideLayouts[] = { - {InputManager::BTN_UP, InputManager::BTN_DOWN}, - {InputManager::BTN_DOWN, InputManager::BTN_UP}, + {HalGPIO::BTN_UP, HalGPIO::BTN_DOWN}, + {HalGPIO::BTN_DOWN, HalGPIO::BTN_UP}, }; } // namespace -bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn)(uint8_t) const) const { +bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const { const auto frontLayout = static_cast(SETTINGS.frontButtonLayout); const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); const auto& front = kFrontLayouts[frontLayout]; @@ -40,41 +40,39 @@ bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn) switch (button) { case Button::Back: - return (inputManager.*fn)(front.back); + return (gpio.*fn)(front.back); case Button::Confirm: - return (inputManager.*fn)(front.confirm); + return (gpio.*fn)(front.confirm); case Button::Left: - return (inputManager.*fn)(front.left); + return (gpio.*fn)(front.left); case Button::Right: - return (inputManager.*fn)(front.right); + return (gpio.*fn)(front.right); case Button::Up: - return (inputManager.*fn)(InputManager::BTN_UP); + return (gpio.*fn)(HalGPIO::BTN_UP); case Button::Down: - return (inputManager.*fn)(InputManager::BTN_DOWN); + return (gpio.*fn)(HalGPIO::BTN_DOWN); case Button::Power: - return (inputManager.*fn)(InputManager::BTN_POWER); + return (gpio.*fn)(HalGPIO::BTN_POWER); case Button::PageBack: - return (inputManager.*fn)(side.pageBack); + return (gpio.*fn)(side.pageBack); case Button::PageForward: - return (inputManager.*fn)(side.pageForward); + return (gpio.*fn)(side.pageForward); } return false; } -bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &InputManager::wasPressed); } +bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); } -bool MappedInputManager::wasReleased(const Button button) const { - return mapButton(button, &InputManager::wasReleased); -} +bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); } -bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &InputManager::isPressed); } +bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); } -bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); } +bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); } -bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); } +bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); } -unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); } +unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); } MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const { @@ -91,4 +89,4 @@ MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const default: return {back, confirm, previous, next}; } -} +} \ No newline at end of file diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index bee7cd4b..f507a928 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -1,6 +1,6 @@ #pragma once -#include +#include class MappedInputManager { public: @@ -13,7 +13,7 @@ class MappedInputManager { const char* btn4; }; - explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {} + explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {} bool wasPressed(Button button) const; bool wasReleased(Button button) const; @@ -24,7 +24,7 @@ class MappedInputManager { Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const; private: - InputManager& inputManager; + HalGPIO& gpio; - bool mapButton(Button button, bool (InputManager::*fn)(uint8_t) const) const; + bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const; }; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 95fe742f..aace2095 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -133,7 +133,7 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.invertScreen(); } - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { @@ -189,7 +189,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { renderer.invertScreen(); } - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); if (hasGreyscale) { bitmap.rewindToData(); @@ -280,5 +280,5 @@ void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 509f2eaf..58668c68 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -345,7 +345,7 @@ void EpubReaderActivity::renderScreen() { auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { const int fillWidth = (barWidth - 2) * progress / 100; renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); - renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), @@ -428,7 +428,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index e4978221..e9303de3 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -256,7 +256,7 @@ void TxtReaderActivity::buildPageIndex() { // Fill progress bar const int fillWidth = (barWidth - 2) * progressPercent / 100; renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); - renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); } // Yield to other tasks periodically @@ -484,7 +484,7 @@ void TxtReaderActivity::renderPage() { renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index c97f2094..f579abcd 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -276,7 +276,7 @@ void XtcReaderActivity::renderPage() { // Display BW with conditional refresh based on pagesUntilFullRefresh if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); @@ -356,7 +356,7 @@ void XtcReaderActivity::renderPage() { // Display with appropriate refresh if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); diff --git a/src/activities/util/FullScreenMessageActivity.h b/src/activities/util/FullScreenMessageActivity.h index 3e975c91..93909503 100644 --- a/src/activities/util/FullScreenMessageActivity.h +++ b/src/activities/util/FullScreenMessageActivity.h @@ -1,6 +1,6 @@ #pragma once -#include #include +#include #include #include @@ -10,12 +10,12 @@ class FullScreenMessageActivity final : public Activity { std::string text; EpdFontFamily::Style style; - EInkDisplay::RefreshMode refreshMode; + HalDisplay::RefreshMode refreshMode; public: explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text, const EpdFontFamily::Style style = EpdFontFamily::REGULAR, - const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) + const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) : Activity("FullScreenMessage", renderer, mappedInput), text(std::move(text)), style(style), diff --git a/src/main.cpp b/src/main.cpp index 8a081fd8..2308f0a2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,8 +1,8 @@ #include -#include #include #include -#include +#include +#include #include #include #include @@ -26,23 +26,10 @@ #include "activities/util/FullScreenMessageActivity.h" #include "fontIds.h" -#define SPI_FQ 40000000 -// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) -#define EPD_SCLK 8 // SPI Clock -#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In) -#define EPD_CS 21 // Chip Select -#define EPD_DC 4 // Data/Command -#define EPD_RST 5 // Reset -#define EPD_BUSY 6 // Busy - -#define UART0_RXD 20 // Used for USB connection detection - -#define SD_SPI_MISO 7 - -EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY); -InputManager inputManager; -MappedInputManager mappedInputManager(inputManager); -GfxRenderer renderer(einkDisplay); +HalDisplay display; +HalGPIO gpio; +MappedInputManager mappedInputManager(gpio); +GfxRenderer renderer(display); Activity* currentActivity; // Fonts @@ -170,21 +157,20 @@ void verifyPowerButtonDuration() { const uint16_t calibratedPressDuration = (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; - inputManager.update(); - // Verify the user has actually pressed + gpio.update(); // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state - while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) { + while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) { delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration. - inputManager.update(); + gpio.update(); } t2 = millis(); - if (inputManager.isPressed(InputManager::BTN_POWER)) { + if (gpio.isPressed(HalGPIO::BTN_POWER)) { do { delay(10); - inputManager.update(); - } while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration); - abort = inputManager.getHeldTime() < calibratedPressDuration; + gpio.update(); + } while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() < calibratedPressDuration); + abort = gpio.getHeldTime() < calibratedPressDuration; } else { abort = true; } @@ -192,16 +178,15 @@ void verifyPowerButtonDuration() { if (abort) { // Button released too early. Returning to sleep. // IMPORTANT: Re-arm the wakeup trigger before sleeping again - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - esp_deep_sleep_start(); + gpio.startDeepSleep(); } } void waitForPowerRelease() { - inputManager.update(); - while (inputManager.isPressed(InputManager::BTN_POWER)) { + gpio.update(); + while (gpio.isPressed(HalGPIO::BTN_POWER)) { delay(50); - inputManager.update(); + gpio.update(); } } @@ -210,14 +195,11 @@ void enterDeepSleep() { exitActivity(); enterNewActivity(new SleepActivity(renderer, mappedInputManager)); - einkDisplay.deepSleep(); + display.deepSleep(); Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - // Ensure that the power button has been released to avoid immediately turning back on if you're holding it - waitForPowerRelease(); - // Enter Deep Sleep - esp_deep_sleep_start(); + + gpio.startDeepSleep(); } void onGoHome(); @@ -261,7 +243,7 @@ void onGoHome() { } void setupDisplayAndFonts() { - einkDisplay.begin(); + display.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); #ifndef OMIT_FONTS @@ -284,27 +266,13 @@ void setupDisplayAndFonts() { Serial.printf("[%lu] [ ] Fonts setup\n", millis()); } -bool isUsbConnected() { - // U0RXD/GPIO20 reads HIGH when USB is connected - return digitalRead(UART0_RXD) == HIGH; -} - -bool isWakeupByPowerButton() { - const auto wakeupCause = esp_sleep_get_wakeup_cause(); - const auto resetReason = esp_reset_reason(); - if (isUsbConnected()) { - return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; - } else { - return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); - } -} - void setup() { t1 = millis(); + gpio.begin(); + // Only start serial if USB connected - pinMode(UART0_RXD, INPUT); - if (isUsbConnected()) { + if (gpio.isUsbConnected()) { Serial.begin(115200); // Wait up to 3 seconds for Serial to be ready to catch early logs unsigned long start = millis(); @@ -313,13 +281,6 @@ void setup() { } } - inputManager.begin(); - // Initialize pins - pinMode(BAT_GPIO0, INPUT); - - // Initialize SPI with custom pins - SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); - // SD Card Initialization // We need 6 open files concurrently when parsing a new chapter if (!SdMan.begin()) { @@ -333,7 +294,7 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - if (isWakeupByPowerButton()) { + if (gpio.isWakeupByPowerButton()) { // For normal wakeups, verify power button press duration Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); verifyPowerButtonDuration(); @@ -370,7 +331,7 @@ void loop() { const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0; - inputManager.update(); + gpio.update(); if (Serial && millis() - lastMemPrint >= 10000) { Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), @@ -380,8 +341,7 @@ void loop() { // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); - if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() || - (currentActivity && currentActivity->preventAutoSleep())) { + if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) { lastActivityTime = millis(); // Reset inactivity timer } @@ -393,8 +353,7 @@ void loop() { return; } - if (inputManager.isPressed(InputManager::BTN_POWER) && - inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) { + if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) { enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start return;