From 32fc82a0a0aa770d67776663c54a92cbd932f135 Mon Sep 17 00:00:00 2001 From: Shahriar Yazdipour <8194807+yazdipour@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:40:50 +0200 Subject: [PATCH 1/8] feat(opds): download opds files in specific dir --- src/activities/browser/OpdsBookBrowserActivity.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index acb0413e1f..b9eaa4e076 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -343,8 +343,10 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { // Build full download URL relative to the current feed, not the root server URL const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href); + const std::string serverDir = "/" + StringUtils::sanitizeFilename(server.name); + Storage.mkdir(serverDir.c_str()); std::string filename = - "/" + StringUtils::sanitizeFilename(buildBookFilenameBase(book, server.filenameFormat)) + ".epub"; + serverDir + "/" + StringUtils::sanitizeFilename(buildBookFilenameBase(book, server.filenameFormat)) + ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); bool cancelRequested = false; From 154b3bb6e9cc0998ac2b2e58b1c9f98cde6f918b Mon Sep 17 00:00:00 2001 From: Shahriar Yazdipour <8194807+yazdipour@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:19:38 +0200 Subject: [PATCH 2/8] feat(TRMNL): implement TRMNL Lockscreen --- CHANGELOG.md | 1 + lib/I18n/translations/english.yaml | 9 + lib/JsonParser/TrmnlDisplayJsonParser.cpp | 88 ++ lib/JsonParser/TrmnlDisplayJsonParser.h | 40 + src/CrossPointSettings.cpp | 1 + src/CrossPointSettings.h | 6 + src/SettingsList.h | 17 +- src/activities/boot_sleep/SleepActivity.cpp | 79 ++ src/activities/boot_sleep/SleepActivity.h | 3 + .../network/WifiSelectionActivity.cpp | 4 +- src/activities/settings/SettingsActivity.cpp | 6 + src/activities/settings/SettingsActivity.h | 1 + .../settings/TrmnlSettingsActivity.cpp | 123 +++ .../settings/TrmnlSettingsActivity.h | 20 + src/main.cpp | 2 + src/network/HttpDownloader.cpp | 753 +++++++++--------- src/network/HttpDownloader.h | 33 +- src/trmnl/README.md | 12 + src/trmnl/TrmnlDisplayConfig.h | 20 + src/trmnl/TrmnlSleepClient.cpp | 211 +++++ src/trmnl/TrmnlSleepClient.h | 41 + test/README | 21 + test/run_trmnl_display_json_parser_test.sh | 30 + .../TrmnlDisplayJsonParserTest.cpp | 154 ++++ 24 files changed, 1271 insertions(+), 404 deletions(-) create mode 100644 lib/JsonParser/TrmnlDisplayJsonParser.cpp create mode 100644 lib/JsonParser/TrmnlDisplayJsonParser.h create mode 100644 src/activities/settings/TrmnlSettingsActivity.cpp create mode 100644 src/activities/settings/TrmnlSettingsActivity.h create mode 100644 src/trmnl/README.md create mode 100644 src/trmnl/TrmnlDisplayConfig.h create mode 100644 src/trmnl/TrmnlSleepClient.cpp create mode 100644 src/trmnl/TrmnlSleepClient.h create mode 100755 test/run_trmnl_display_json_parser_test.sh create mode 100644 test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 34961ff542..342e4fcd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ - File Browser long-press folder action for choosing a custom sleep-image folder instead of only `/.sleep` or `/sleep`. - Expanded X3 Reading Stats, including streaks, time charts, editable dates, all-time backups, reset controls, an idle-time threshold, and the `Minimal Stats` sleep screen. - `Reset Reading Pace` in the EPUB reader menu when Time Left is enabled, for clearing only the time-left pace estimate while keeping book reading stats. +- Added TRMNL as a sleep-screen wallpaper source with configurable server, API key, device ID, and orientation. ### Changed - Display, Reader, and Controls settings now open list menus instead of cycling through options one by one. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index ecbcd18d5a..8176a60839 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -342,6 +342,15 @@ STR_THEME_MINIMAL: "Minimal" STR_THEME_DASHBOARD: "Dashboard" STR_THEME_MINIMAL_STATS: "Minimal Stats" STR_QUICK_RESUME: "Quick Resume" +STR_TRMNL: "TRMNL" +STR_TRMNL_SETTINGS: "TRMNL Settings" +STR_TRMNL_SERVER_URL: "TRMNL Server URL" +STR_TRMNL_API_KEY: "TRMNL API Key" +STR_TRMNL_DEVICE_ID: "TRMNL Device ID" +STR_TRMNL_ORIENTATION: "TRMNL Orientation" +STR_TRMNL_HORIZONTAL: "Horizontal" +STR_TRMNL_VERTICAL: "Vertical" +STR_TRMNL_FETCH_FAILED: "TRMNL fetch failed" STR_RECENT_BOOKS_VIEW: "Recent Books View" STR_LIST_VIEW: "List" STR_GRID_VIEW: "Grid" diff --git a/lib/JsonParser/TrmnlDisplayJsonParser.cpp b/lib/JsonParser/TrmnlDisplayJsonParser.cpp new file mode 100644 index 0000000000..46ac06629c --- /dev/null +++ b/lib/JsonParser/TrmnlDisplayJsonParser.cpp @@ -0,0 +1,88 @@ +#include "TrmnlDisplayJsonParser.h" + +#include +#include + +namespace { +void safeCopy(char* dst, const size_t dstSize, const char* src, const size_t srcLen) { + const size_t n = srcLen < dstSize - 1 ? srcLen : dstSize - 1; + memcpy(dst, src, n); + dst[n] = '\0'; +} +} // namespace + +TrmnlDisplayJsonParser::TrmnlDisplayJsonParser() + : parser(JsonCallbacks{this, sOnKey, sOnString, sOnNumber, sOnBool, sOnNull, sOnObjectStart, sOnObjectEnd, + sOnArrayStart, sOnArrayEnd}) { + reset(); +} + +void TrmnlDisplayJsonParser::reset() { + parser.reset(); + lastKey = LastKey::NONE; + depth = 0; + imageUrl[0] = '\0'; + filename[0] = '\0'; + refreshRateSeconds = 0; + imageUrlFound = false; +} + +void TrmnlDisplayJsonParser::feed(const char* data, const size_t len) { parser.feed(data, len); } +bool TrmnlDisplayJsonParser::foundImageUrl() const { return imageUrlFound; } +bool TrmnlDisplayJsonParser::hasError() const { return parser.hasError(); } +const char* TrmnlDisplayJsonParser::getImageUrl() const { return imageUrl; } +const char* TrmnlDisplayJsonParser::getFilename() const { return filename; } +uint32_t TrmnlDisplayJsonParser::getRefreshRateSeconds() const { return refreshRateSeconds; } + +void TrmnlDisplayJsonParser::sOnKey(void* ctx, const char* key, const size_t len) { + auto* self = static_cast(ctx); + if (self->depth != 1) { + self->lastKey = LastKey::NONE; + return; + } + if (len == 9 && memcmp(key, "image_url", 9) == 0) { + self->lastKey = LastKey::IMAGE_URL; + } else if ((len == 8 && memcmp(key, "filename", 8) == 0) || (len == 10 && memcmp(key, "image_name", 10) == 0)) { + self->lastKey = LastKey::FILENAME; + } else if (len == 12 && memcmp(key, "refresh_rate", 12) == 0) { + self->lastKey = LastKey::REFRESH_RATE; + } else { + self->lastKey = LastKey::NONE; + } +} + +void TrmnlDisplayJsonParser::sOnString(void* ctx, const char* value, const size_t len) { + auto* self = static_cast(ctx); + if (self->depth == 1 && self->lastKey == LastKey::IMAGE_URL) { + safeCopy(self->imageUrl, sizeof(self->imageUrl), value, len); + self->imageUrlFound = self->imageUrl[0] != '\0'; + } else if (self->depth == 1 && self->lastKey == LastKey::FILENAME) { + safeCopy(self->filename, sizeof(self->filename), value, len); + } + self->lastKey = LastKey::NONE; +} + +void TrmnlDisplayJsonParser::sOnNumber(void* ctx, const char* value, const size_t /*len*/) { + auto* self = static_cast(ctx); + if (self->depth == 1 && self->lastKey == LastKey::REFRESH_RATE) { + self->refreshRateSeconds = static_cast(strtoul(value, nullptr, 10)); + } + self->lastKey = LastKey::NONE; +} + +void TrmnlDisplayJsonParser::sOnBool(void* ctx, bool /*value*/) { + static_cast(ctx)->lastKey = LastKey::NONE; +} +void TrmnlDisplayJsonParser::sOnNull(void* ctx) { static_cast(ctx)->lastKey = LastKey::NONE; } +void TrmnlDisplayJsonParser::sOnObjectStart(void* ctx) { + auto* self = static_cast(ctx); + self->depth++; + self->lastKey = LastKey::NONE; +} +void TrmnlDisplayJsonParser::sOnObjectEnd(void* ctx) { + auto* self = static_cast(ctx); + if (self->depth > 0) self->depth--; + self->lastKey = LastKey::NONE; +} +void TrmnlDisplayJsonParser::sOnArrayStart(void* ctx) { sOnObjectStart(ctx); } +void TrmnlDisplayJsonParser::sOnArrayEnd(void* ctx) { sOnObjectEnd(ctx); } diff --git a/lib/JsonParser/TrmnlDisplayJsonParser.h b/lib/JsonParser/TrmnlDisplayJsonParser.h new file mode 100644 index 0000000000..a5edd31d3c --- /dev/null +++ b/lib/JsonParser/TrmnlDisplayJsonParser.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include "StreamingJsonParser.h" + +class TrmnlDisplayJsonParser { + public: + TrmnlDisplayJsonParser(); + void reset(); + void feed(const char* data, size_t len); + + bool foundImageUrl() const; + bool hasError() const; + const char* getImageUrl() const; + const char* getFilename() const; + uint32_t getRefreshRateSeconds() const; + + private: + enum class LastKey : uint8_t { NONE, IMAGE_URL, FILENAME, REFRESH_RATE }; + + static void sOnKey(void* ctx, const char* key, size_t len); + static void sOnString(void* ctx, const char* value, size_t len); + static void sOnNumber(void* ctx, const char* value, size_t len); + static void sOnBool(void* ctx, bool value); + static void sOnNull(void* ctx); + static void sOnObjectStart(void* ctx); + static void sOnObjectEnd(void* ctx); + static void sOnArrayStart(void* ctx); + static void sOnArrayEnd(void* ctx); + + StreamingJsonParser parser; + LastKey lastKey = LastKey::NONE; + uint16_t depth = 0; + char imageUrl[512] = ""; + char filename[128] = ""; + uint32_t refreshRateSeconds = 0; + bool imageUrlFound = false; +}; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index de1a7d61dc..18fb4e1289 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -47,6 +47,7 @@ constexpr uint8_t SLEEP_SCREEN_STORAGE_ORDER[] = { static_cast(CrossPointSettings::QUICK_RESUME), static_cast(CrossPointSettings::MINIMAL_STATS_SLEEP), static_cast(CrossPointSettings::DASHBOARD_SLEEP), + static_cast(CrossPointSettings::TRMNL), }; constexpr uint8_t SLEEP_SCREEN_STORAGE_ORDER_COUNT = sizeof(SLEEP_SCREEN_STORAGE_ORDER) / sizeof(SLEEP_SCREEN_STORAGE_ORDER[0]); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 5170eb95cd..784132254d 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -31,6 +31,7 @@ class CrossPointSettings { QUICK_RESUME = 9, MINIMAL_STATS_SLEEP = 10, DASHBOARD_SLEEP = 11, + TRMNL = 12, SLEEP_SCREEN_MODE_COUNT }; enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT }; @@ -40,6 +41,7 @@ class CrossPointSettings { INVERTED_BLACK_AND_WHITE = 2, SLEEP_SCREEN_COVER_FILTER_COUNT }; + enum TRMNL_ORIENTATION { TRMNL_LANDSCAPE = 0, TRMNL_PORTRAIT = 1, TRMNL_ORIENTATION_COUNT }; // Status bar enum - legacy enum STATUS_BAR_MODE { @@ -387,6 +389,10 @@ class CrossPointSettings { char opdsServerUrl[128] = ""; char opdsUsername[64] = ""; char opdsPassword[64] = ""; + char trmnlServerUrl[160] = ""; + char trmnlApiKey[128] = ""; + char trmnlDeviceId[32] = ""; + uint8_t trmnlOrientation = TRMNL_LANDSCAPE; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press page turn button behavior diff --git a/src/SettingsList.h b/src/SettingsList.h index ade130cd3d..4812348235 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -240,7 +240,7 @@ inline SettingInfo buildSleepScreenSetting() { StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen, {StrId::STR_NONE_OPT, StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_COVER_CUSTOM, StrId::STR_PAGE_OVERLAY, StrId::STR_READING_STATS, StrId::STR_THEME_MINIMAL, - StrId::STR_THEME_MINIMAL_STATS, StrId::STR_THEME_DASHBOARD, StrId::STR_QUICK_RESUME}, + StrId::STR_THEME_MINIMAL_STATS, StrId::STR_THEME_DASHBOARD, StrId::STR_TRMNL, StrId::STR_QUICK_RESUME}, "sleepScreen", StrId::STR_CAT_DISPLAY); s.withEnumRawValues({ static_cast(CrossPointSettings::BLANK), @@ -254,7 +254,9 @@ inline SettingInfo buildSleepScreenSetting() { static_cast(CrossPointSettings::MINIMAL_SLEEP), static_cast(CrossPointSettings::MINIMAL_STATS_SLEEP), static_cast(CrossPointSettings::DASHBOARD_SLEEP), + static_cast(CrossPointSettings::TRMNL), static_cast(CrossPointSettings::QUICK_RESUME), + static_cast(CrossPointSettings::TRMNL), }); return s; } @@ -587,6 +589,16 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist add(SettingInfo::Toggle(StrId::STR_TRACK_READING_STATS, &CrossPointSettings::trackReadingStats, "trackReadingStats", StrId::STR_CAT_SYSTEM)); #endif + add(SettingInfo::String(StrId::STR_TRMNL_SERVER_URL, SETTINGS.trmnlServerUrl, sizeof(SETTINGS.trmnlServerUrl), + "trmnlServerUrl", StrId::STR_TRMNL_SETTINGS)); + add(SettingInfo::String(StrId::STR_TRMNL_API_KEY, SETTINGS.trmnlApiKey, sizeof(SETTINGS.trmnlApiKey), + "trmnlApiKey", StrId::STR_TRMNL_SETTINGS) + .withObfuscated()); + add(SettingInfo::String(StrId::STR_TRMNL_DEVICE_ID, SETTINGS.trmnlDeviceId, sizeof(SETTINGS.trmnlDeviceId), + "trmnlDeviceId", StrId::STR_TRMNL_SETTINGS)); + add(SettingInfo::Enum(StrId::STR_TRMNL_ORIENTATION, &CrossPointSettings::trmnlOrientation, + {StrId::STR_TRMNL_HORIZONTAL, StrId::STR_TRMNL_VERTICAL}, "trmnlOrientation", + StrId::STR_TRMNL_SETTINGS)); // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- add(SettingInfo::DynamicString( @@ -914,11 +926,12 @@ inline std::vector buildDisplaySleepSettingsList(const std::vector< inline std::vector buildSystemSettingsParentList(const std::vector& allSettings) { std::vector systemSettings; - systemSettings.reserve(8); + systemSettings.reserve(9); systemSettings.push_back(SettingInfo::Submenu(StrId::STR_SYSTEM_DEVICE, SettingAction::SystemDevice)); systemSettings.push_back(SettingInfo::Submenu(StrId::STR_SYSTEM_FILES_CACHE, SettingAction::SystemFilesCache)); systemSettings.push_back(SettingInfo::Submenu(StrId::STR_READING_STATS, SettingAction::SystemReadingStats)); systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); + systemSettings.push_back(SettingInfo::Action(StrId::STR_TRMNL_SETTINGS, SettingAction::TrmnlSettings)); systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index cac7298a00..7a667b0c5f 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -30,6 +30,7 @@ #include "fontIds.h" #include "images/Logo120.h" #include "images/MoonIcon.h" +#include "trmnl/TrmnlSleepClient.h" namespace { @@ -459,6 +460,8 @@ void SleepActivity::onEnter() { return renderReadingStatsSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_SLEEP): return renderMinimalSleepScreen(); + case (CrossPointSettings::SLEEP_SCREEN_MODE::TRMNL): + return renderTrmnlSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_STATS_SLEEP): return renderMinimalStatsSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::DASHBOARD_SLEEP): @@ -607,6 +610,82 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { } } +bool SleepActivity::renderPngSleepScreen(const std::string& path) const { + if (!Storage.exists(path.c_str())) return false; + constexpr size_t MIN_FREE_HEAP = 60 * 1024; + if (ESP.getFreeHeap() < MIN_FREE_HEAP) { + LOG_ERR("SLP", "Not enough heap for PNG sleep decoder: %u free", ESP.getFreeHeap()); + return false; + } + PNG* png = new (std::nothrow) PNG(); + if (!png) return false; + int rc = png->open(path.c_str(), pngSleepOpen, pngSleepClose, pngSleepRead, pngSleepSeek, pngOverlayDraw); + if (rc != PNG_SUCCESS) { + delete png; + LOG_ERR("SLP", "PNG sleep open failed for %s: %d", path.c_str(), rc); + return false; + } + + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const int srcW = png->getWidth(); + const int srcH = png->getHeight(); + float yScale = 1.0f; + int dstW = srcW; + int dstH = srcH; + if (srcW > pageWidth || srcH > pageHeight) { + const float scaleX = static_cast(pageWidth) / srcW; + const float scaleY = static_cast(pageHeight) / srcH; + const float scale = (scaleX < scaleY) ? scaleX : scaleY; + dstW = static_cast(srcW * scale); + dstH = static_cast(srcH * scale); + yScale = static_cast(dstH) / srcH; + } + + renderer.clearScreen(); + PngOverlayCtx ctx{&renderer, pageWidth, pageHeight, srcW, dstW, (pageWidth - dstW) / 2, (pageHeight - dstH) / 2, + yScale, -1, -2, png}; + rc = png->decode(&ctx, 0); + png->close(); + delete png; + if (rc != PNG_SUCCESS) { + LOG_ERR("SLP", "PNG sleep decode failed for %s: %d", path.c_str(), rc); + return false; + } + renderer.displayBuffer(HalDisplay::HALF_REFRESH, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); + return true; +} + +bool SleepActivity::renderTrmnlCachedImage() const { + if (renderPngSleepScreen(TrmnlSleepClient::CACHE_PNG)) return true; + FsFile file; + if (Storage.openFileForRead("TRM", TrmnlSleepClient::CACHE_BMP, file)) { + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap); + file.close(); + return true; + } + file.close(); + } + return false; +} + +void SleepActivity::renderTrmnlSleepScreen() const { + const auto trmnlOrientation = SETTINGS.trmnlOrientation == CrossPointSettings::TRMNL_PORTRAIT + ? trmnl::Orientation::Portrait + : trmnl::Orientation::Landscape; + renderer.setOrientation(trmnlOrientation == trmnl::Orientation::Portrait + ? GfxRenderer::Orientation::Portrait + : GfxRenderer::Orientation::LandscapeCounterClockwise); + const TrmnlSleepClient::Config config{SETTINGS.trmnlServerUrl, SETTINGS.trmnlApiKey, SETTINGS.trmnlDeviceId, + trmnl::displaySizeFor(trmnlOrientation), trmnl::modelFor(trmnlOrientation)}; + TrmnlSleepClient::fetchLatest(config); + if (!renderTrmnlCachedImage()) { + renderDefaultSleepScreen(); + } +} + void SleepActivity::renderCoverSleepScreen() const { void (SleepActivity::*renderNoCoverSleepScreen)() const; switch (SETTINGS.sleepScreen) { diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 3be8cf2e6a..21e5657301 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -25,6 +25,9 @@ class SleepActivity final : public Activity { void renderMinimalStatsSleepScreen() const; void renderDashboardSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; + bool renderPngSleepScreen(const std::string& path) const; + bool renderTrmnlCachedImage() const; + void renderTrmnlSleepScreen() const; void renderLastScreenSleepScreen() const; void renderBlankSleepScreen() const; void renderOverlaySleepScreen() const; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 9a372fd810..e74069e99d 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -409,9 +409,7 @@ void WifiSelectionActivity::attemptConnection() { // Abort any in-progress SDK auto-connect before our explicit begin(). // Do not erase the AP config or power-cycle the radio; some routers fail the // next WPA handshake after that heavier reset. - if (!WiFi.disconnect(false, false, 1000)) { - LOG_DBG("WIFI", "Disconnect before begin timed out; continuing with explicit begin"); - } + WiFi.disconnect(false, false); delay(100); #ifndef SIMULATOR sLastStaDisconnectReason = 0; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 2dde6e2577..8fb31e2874 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -27,6 +27,7 @@ #include "SdFirmwareUpdateActivity.h" #include "SettingsList.h" #include "StatusBarSettingsActivity.h" +#include "TrmnlSettingsActivity.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/reader/GlobalReadingStats.h" #include "activities/util/ConfirmationActivity.h" @@ -699,6 +700,9 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::KOReaderSync: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::TrmnlSettings: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::OPDSBrowser: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; @@ -915,6 +919,8 @@ void SettingsActivity::render(RenderLock&&) { } else if (setting.stringMaxLen > 0) { valueText = reinterpret_cast(&SETTINGS) + setting.stringOffset; } + } else if (setting.type == SettingType::ACTION && setting.action == SettingAction::TrmnlSettings) { + valueText = SETTINGS.trmnlServerUrl[0] == '\0' ? tr(STR_NOT_SET) : SETTINGS.trmnlServerUrl; } return valueText; }, diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index e927ef1a74..4a4ac0dea7 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -39,6 +39,7 @@ enum class SettingAction { Language, DownloadFonts, ClockSync, + TrmnlSettings, }; struct SettingInfo { diff --git a/src/activities/settings/TrmnlSettingsActivity.cpp b/src/activities/settings/TrmnlSettingsActivity.cpp new file mode 100644 index 0000000000..981e06bd16 --- /dev/null +++ b/src/activities/settings/TrmnlSettingsActivity.cpp @@ -0,0 +1,123 @@ +#include "TrmnlSettingsActivity.h" + +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" + +namespace { +constexpr int MENU_ITEMS = 4; +const StrId menuNames[MENU_ITEMS] = {StrId::STR_TRMNL_SERVER_URL, StrId::STR_TRMNL_API_KEY, + StrId::STR_TRMNL_DEVICE_ID, StrId::STR_TRMNL_ORIENTATION}; + +void copySetting(char* dest, const size_t destSize, const std::string& value) { + strncpy(dest, value.c_str(), destSize - 1); + dest[destSize - 1] = '\0'; +} +} // namespace + +void TrmnlSettingsActivity::onEnter() { + Activity::onEnter(); + selectedIndex = 0; + requestUpdate(); +} + +void TrmnlSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + buttonNavigator.onNext([this] { + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; + requestUpdate(); + }); + buttonNavigator.onPrevious([this] { + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; + requestUpdate(); + }); +} + +void TrmnlSettingsActivity::handleSelection() { + if (selectedIndex == 0) { + const std::string currentUrl = SETTINGS.trmnlServerUrl; + const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_TRMNL_SERVER_URL), + prefillUrl, sizeof(SETTINGS.trmnlServerUrl) - 1, + InputType::Url), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + const std::string urlToSave = + (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; + copySetting(SETTINGS.trmnlServerUrl, sizeof(SETTINGS.trmnlServerUrl), urlToSave); + SETTINGS.saveToFile(); + requestUpdate(); + } + }); + } else if (selectedIndex == 1) { + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_TRMNL_API_KEY), + SETTINGS.trmnlApiKey, + sizeof(SETTINGS.trmnlApiKey) - 1, + InputType::Password), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + copySetting(SETTINGS.trmnlApiKey, sizeof(SETTINGS.trmnlApiKey), kb.text); + SETTINGS.saveToFile(); + requestUpdate(); + } + }); + } else if (selectedIndex == 2) { + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_TRMNL_DEVICE_ID), + SETTINGS.trmnlDeviceId, + sizeof(SETTINGS.trmnlDeviceId) - 1, InputType::Text), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + copySetting(SETTINGS.trmnlDeviceId, sizeof(SETTINGS.trmnlDeviceId), kb.text); + SETTINGS.saveToFile(); + requestUpdate(); + } + }); + } else if (selectedIndex == 3) { + SETTINGS.trmnlOrientation = (SETTINGS.trmnlOrientation + 1) % CrossPointSettings::TRMNL_ORIENTATION_COUNT; + SETTINGS.saveToFile(); + requestUpdate(); + } +} + +void TrmnlSettingsActivity::render(RenderLock&&) { + renderer.clearScreen(); + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_TRMNL_SETTINGS)); + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, MENU_ITEMS, static_cast(selectedIndex), + [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, + [](int index) { + if (index == 0) return SETTINGS.trmnlServerUrl[0] == '\0' ? std::string(tr(STR_NOT_SET)) : SETTINGS.trmnlServerUrl; + if (index == 1) return SETTINGS.trmnlApiKey[0] == '\0' ? std::string(tr(STR_NOT_SET)) : std::string("******"); + if (index == 2) return SETTINGS.trmnlDeviceId[0] == '\0' ? std::string(tr(STR_DEFAULT_VALUE)) : SETTINGS.trmnlDeviceId; + if (index == 3) { + return SETTINGS.trmnlOrientation == CrossPointSettings::TRMNL_PORTRAIT ? std::string(tr(STR_TRMNL_VERTICAL)) + : std::string(tr(STR_TRMNL_HORIZONTAL)); + } + return std::string(tr(STR_NOT_SET)); + }, + true); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} diff --git a/src/activities/settings/TrmnlSettingsActivity.h b/src/activities/settings/TrmnlSettingsActivity.h new file mode 100644 index 0000000000..90d1eb24f4 --- /dev/null +++ b/src/activities/settings/TrmnlSettingsActivity.h @@ -0,0 +1,20 @@ +#pragma once + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +class TrmnlSettingsActivity final : public Activity { + public: + TrmnlSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("TrmnlSettings", renderer, mappedInput) {} + + void onEnter() override; + void loop() override; + void render(RenderLock&& lock) override; + + private: + ButtonNavigator buttonNavigator; + uint8_t selectedIndex = 0; + + void handleSelection(); +}; diff --git a/src/main.cpp b/src/main.cpp index 08f3c03253..50368d0324 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -817,7 +817,9 @@ void setup() { HalSystem::checkPanic(); SETTINGS.loadFromFile(); +#ifndef SIMULATOR Storage.installDateTimeCallback(&SETTINGS.clockUtcOffsetQ); +#endif APP_STATE.loadFromFile(); RECENT_BOOKS.loadFromFile(); I18N.setLanguage(static_cast(SETTINGS.language)); diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index ed9ccdf751..729d2700c1 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -1,32 +1,31 @@ #include "HttpDownloader.h" #include +#include #include -#include +#include +#include +#include #include #include -#include -#include -#include -#include -#include -#include +#include +#include +#include #include #include "AppVersion.h" #include "network/WifiPowerSaveGuard.h" +#include "util/UrlUtils.h" namespace { constexpr size_t PROGRESS_UPDATE_BYTES = 64 * 1024; constexpr uint32_t PROGRESS_UPDATE_MS = 250; -constexpr int HTTP_RX_BUF = 4096; -constexpr int HTTP_TX_BUF = 1024; -constexpr int HTTP_TIMEOUT_MS = 60000; -constexpr int HTTP_READ_POLL_TIMEOUT_MS = 5000; +constexpr size_t DEFAULT_DOWNLOAD_BUFFER_SIZE = 1024; +constexpr uint16_t HTTP_RESPONSE_TIMEOUT_MS = 15000; +constexpr int32_t HTTP_CONNECT_TIMEOUT_MS = 10000; +constexpr uint32_t HTTPS_HANDSHAKE_TIMEOUT_SECONDS = 10; constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 30000; -constexpr size_t DEFAULT_DOWNLOAD_BUFFER_SIZE = 2048; -constexpr uint8_t MAX_REDIRECTS = 5; void logNetworkState(const char* phase) { LOG_DBG("HTTP", "%s: heap free=%u maxAlloc=%u wifi=%d rssi=%d", phase, ESP.getFreeHeap(), ESP.getMaxAllocHeap(), @@ -39,94 +38,25 @@ void logDownloadState(const char* phase, const size_t downloaded, const size_t t logNetworkState(phase); } -bool isRedirect(const int status) { - return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; -} - -esp_err_t captureLocationHeader(esp_http_client_event_t* evt) { - auto* location = static_cast(evt->user_data); - if (evt->event_id == HTTP_EVENT_ON_HEADER && location != nullptr && evt->header_key != nullptr && - evt->header_value != nullptr && strcasecmp(evt->header_key, "Location") == 0) { - location->assign(evt->header_value); +bool isCancelRequested(bool* cancelFlag, const HttpDownloader::CancelCallback& shouldCancel) { + if (cancelFlag && *cancelFlag) { + return true; } - return ESP_OK; -} - -struct ParsedUrl { - bool https = false; - std::string host; - std::string path; - uint16_t port = 80; -}; - -bool parseUrl(const std::string& url, ParsedUrl& out) { - const size_t schemeEnd = url.find("://"); - if (schemeEnd == std::string::npos) return false; - - const std::string scheme = url.substr(0, schemeEnd); - out.https = scheme == "https"; - if (!out.https && scheme != "http") return false; - - const size_t hostStart = schemeEnd + 3; - const size_t pathStart = url.find('/', hostStart); - const std::string hostPort = - url.substr(hostStart, pathStart == std::string::npos ? std::string::npos : pathStart - hostStart); - out.path = pathStart == std::string::npos ? "/" : url.substr(pathStart); - out.port = out.https ? 443 : 80; - - const size_t portSep = hostPort.rfind(':'); - if (portSep != std::string::npos) { - out.host = hostPort.substr(0, portSep); - const std::string portText = hostPort.substr(portSep + 1); - if (portText.empty()) return false; - uint32_t parsedPort = 0; - for (const char c : portText) { - if (c < '0' || c > '9') return false; - parsedPort = parsedPort * 10 + static_cast(c - '0'); - if (parsedPort > UINT16_MAX) return false; + if (shouldCancel && shouldCancel()) { + if (cancelFlag) { + *cancelFlag = true; } - if (parsedPort == 0) return false; - out.port = static_cast(parsedPort); - } else { - out.host = hostPort; - } - - return !out.host.empty() && !out.path.empty(); -} - -bool sameOrigin(const ParsedUrl& a, const ParsedUrl& b) { - return a.https == b.https && a.port == b.port && strcasecmp(a.host.c_str(), b.host.c_str()) == 0; -} - -const char* schemeName(const ParsedUrl& url) { return url.https ? "https" : "http"; } - -std::string buildRedirectUrl(const std::string& baseUrl, const std::string& location) { - if (location.starts_with("http://") || location.starts_with("https://")) return location; - - ParsedUrl base; - if (!parseUrl(baseUrl, base)) return location; - - std::string origin = base.https ? "https://" : "http://"; - origin += base.host; - if ((base.https && base.port != 443) || (!base.https && base.port != 80)) { - origin += ":"; - origin += std::to_string(base.port); + return true; } - - if (!location.empty() && location[0] == '/') return origin + location; - - const size_t lastSlash = base.path.rfind('/'); - const std::string parent = lastSlash == std::string::npos ? "/" : base.path.substr(0, lastSlash + 1); - return origin + parent + location; + return false; } -bool isCancelRequested(bool* cancelFlag, const HttpDownloader::CancelCallback& shouldCancel) { - if (cancelFlag && *cancelFlag) return true; - if (shouldCancel && shouldCancel()) { - if (cancelFlag) *cancelFlag = true; - return true; +void addHeaders(HTTPClient& http, const HttpDownloader::Header* headers, const size_t headerCount) { + for (size_t i = 0; i < headerCount; i++) { + if (headers[i].name && headers[i].value) { + http.addHeader(headers[i].name, headers[i].value); + } } - return false; } class ProgressNotifier { @@ -135,14 +65,14 @@ class ProgressNotifier { : total_(total), progress_(std::move(progress)) {} void notify(size_t downloaded, bool force) { - if (!progress_ || total_ == 0) return; - - const uint32_t now = millis(); - if (force || downloaded == total_ || downloaded - lastProgressBytes_ >= PROGRESS_UPDATE_BYTES || - now - lastProgressMs_ >= PROGRESS_UPDATE_MS) { - lastProgressBytes_ = downloaded; - lastProgressMs_ = now; - progress_(downloaded, total_); + if (progress_ && total_ > 0) { + const uint32_t now = millis(); + if (force || downloaded == total_ || downloaded - lastProgressBytes_ >= PROGRESS_UPDATE_BYTES || + now - lastProgressMs_ >= PROGRESS_UPDATE_MS) { + lastProgressBytes_ = downloaded; + lastProgressMs_ = now; + progress_(downloaded, total_); + } } } @@ -153,279 +83,288 @@ class ProgressNotifier { HttpDownloader::ProgressCallback progress_; }; -struct Sink { - std::function write; - HttpDownloader::ProgressCallback progress; - bool* cancelFlag = nullptr; - HttpDownloader::CancelCallback shouldCancel; - size_t resumeOffset = 0; - size_t downloaded = 0; - size_t total = 0; - bool rangeIgnored = false; -}; +class FileWriteStream final : public Stream { + public: + FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress, bool* cancelFlag, + HttpDownloader::CancelCallback shouldCancel, const size_t maxBytes) + : file_(file), + progress_(total, std::move(progress)), + cancelFlag_(cancelFlag), + shouldCancel_(std::move(shouldCancel)), + maxBytes_(maxBytes) {} + + size_t write(uint8_t byte) override { return write(&byte, 1); } + + size_t write(const uint8_t* buffer, size_t size) override { + if (!writeOk_) { + return 0; + } -void setRequestHeaders(esp_http_client_handle_t client, const std::string& username, const std::string& password, - size_t resumeOffset, bool sendAuthorization) { - esp_http_client_set_header(client, "User-Agent", "CrossInk-ESP32-" CROSSINK_VERSION); - esp_http_client_set_header(client, "Connection", "close"); - if (resumeOffset > 0) { - char rangeHeader[40]; - snprintf(rangeHeader, sizeof(rangeHeader), "bytes=%zu-", resumeOffset); - esp_http_client_set_header(client, "Range", rangeHeader); - LOG_DBG("HTTP", "Resuming download at byte %zu", resumeOffset); - } - if (sendAuthorization) { - const std::string credentials = username + ":" + password; - const String header = "Basic " + base64::encode(credentials.c_str()); - esp_http_client_set_header(client, "Authorization", header.c_str()); + if (isCancelRequested(cancelFlag_, shouldCancel_)) { + writeOk_ = false; + return 0; + } + if (maxBytes_ > 0 && downloaded_ + size > maxBytes_) { + sizeLimitExceeded_ = true; + writeOk_ = false; + return 0; + } + const size_t accepted = file_.write(buffer, size); + if (accepted != size) { + writeOk_ = false; + } + downloaded_ += accepted; + progress_.notify(downloaded_, false); + return accepted; } -} -void logTlsError(esp_http_client_handle_t client, const char* phase) { - int tlsError = 0; - int tlsFlags = 0; - const esp_err_t err = esp_http_client_get_and_clear_last_tls_error(client, &tlsError, &tlsFlags); - if (err != ESP_OK || tlsError != 0 || tlsFlags != 0) { - const int tlsCode = tlsError < 0 ? -tlsError : tlsError; - LOG_ERR("HTTP", "%s TLS error: err=%s mbedtls=0x%x flags=0x%x", phase, esp_err_to_name(err), tlsCode, tlsFlags); - } -} + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override { file_.flush(); } -HttpDownloader::DownloadError runGet(const std::string& url, const std::string& username, const std::string& password, - Sink& sink, const size_t bufferSize) { - std::string currentUrl = url; - - ParsedUrl credentialOrigin; - const bool hasCredentials = !username.empty() && !password.empty() && parseUrl(url, credentialOrigin); - - for (uint8_t hop = 0; hop < MAX_REDIRECTS; ++hop) { - ParsedUrl currentOrigin; - const bool currentParsed = parseUrl(currentUrl, currentOrigin); - const bool sendAuthorization = hasCredentials && currentParsed && sameOrigin(currentOrigin, credentialOrigin); - std::string redirectLocation; - - esp_http_client_config_t config = {}; - config.url = currentUrl.c_str(); - config.buffer_size = HTTP_RX_BUF; - config.buffer_size_tx = HTTP_TX_BUF; - config.timeout_ms = HTTP_TIMEOUT_MS; - config.crt_bundle_attach = esp_crt_bundle_attach; - config.keep_alive_enable = false; - config.event_handler = captureLocationHeader; - config.user_data = &redirectLocation; - - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { - LOG_ERR("HTTP", "Client init failed"); - logNetworkState("Client init failure"); - return HttpDownloader::HTTP_ERROR; - } + size_t downloaded() const { return downloaded_; } + bool ok() const { return writeOk_; } + bool sizeLimitExceeded() const { return sizeLimitExceeded_; } + void finishProgress() { progress_.notify(downloaded_, true); } - setRequestHeaders(client, username, password, sink.resumeOffset, sendAuthorization); + private: + FsFile& file_; + size_t downloaded_ = 0; + bool writeOk_ = true; + bool sizeLimitExceeded_ = false; + ProgressNotifier progress_; + bool* cancelFlag_; + HttpDownloader::CancelCallback shouldCancel_; + size_t maxBytes_; +}; - esp_err_t err = esp_http_client_open(client, 0); - if (err != ESP_OK) { - LOG_ERR("HTTP", "Open failed: %s", esp_err_to_name(err)); - logTlsError(client, "Open failure"); - logNetworkState("Open failure"); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } +class LimitedWriteStream final : public Stream { + public: + LimitedWriteStream(Stream& out, const size_t maxBytes) : out_(out), maxBytes_(maxBytes) {} - int64_t responseLength = esp_http_client_fetch_headers(client); - const int status = esp_http_client_get_status_code(client); - if (responseLength < 0) { - LOG_ERR("HTTP", "Fetch headers failed: %lld", static_cast(responseLength)); - logNetworkState("Fetch headers failure"); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; + size_t write(uint8_t byte) override { return write(&byte, 1); } + + size_t write(const uint8_t* buffer, size_t size) override { + if (maxBytes_ > 0 && written_ + size > maxBytes_) { + sizeLimitExceeded_ = true; + return 0; + } + const size_t written = out_.write(buffer, size); + written_ += written; + if (written != size) { + writeOk_ = false; } + return written; + } - if (isRedirect(status)) { - if (redirectLocation.empty()) { - LOG_ERR("HTTP", "Redirect missing Location header"); - logNetworkState("Redirect missing Location"); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override { out_.flush(); } - const std::string redirectUrl = buildRedirectUrl(currentUrl, redirectLocation); - ParsedUrl redirect; - if (!parseUrl(redirectUrl, redirect)) { - LOG_ERR("HTTP", "Rejected redirect with unsupported Location"); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } - if (currentParsed && currentOrigin.https && !redirect.https) { - LOG_ERR("HTTP", "Rejected HTTPS downgrade redirect to %s", redirect.host.c_str()); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } - if (hasCredentials && !sameOrigin(redirect, credentialOrigin)) { - LOG_ERR("HTTP", "Rejected credentialed redirect to different origin: %s://%s:%u", schemeName(redirect), - redirect.host.c_str(), redirect.port); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } - currentUrl = redirectUrl; - LOG_DBG("HTTP", "Redirecting to: %s", redirect.host.c_str()); - esp_http_client_cleanup(client); - continue; - } + bool ok() const { return writeOk_; } + bool sizeLimitExceeded() const { return sizeLimitExceeded_; } - const bool isResumeResponse = sink.resumeOffset > 0 && status == 206; - if (status != 200 && !isResumeResponse) { - LOG_ERR("HTTP", "Unexpected status: %d", status); - logNetworkState("Unexpected status"); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } - if (sink.resumeOffset > 0 && !isResumeResponse) { - LOG_DBG("HTTP", "Server ignored range request; restarting download"); - sink.rangeIgnored = true; - sink.resumeOffset = 0; - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } + private: + Stream& out_; + size_t maxBytes_; + size_t written_ = 0; + bool writeOk_ = true; + bool sizeLimitExceeded_ = false; +}; - const size_t bodyLength = responseLength > 0 ? static_cast(responseLength) : 0; - sink.total = bodyLength > 0 ? sink.resumeOffset + bodyLength : 0; - sink.downloaded = sink.resumeOffset; - if (sink.total > 0) { - LOG_DBG("HTTP", "Content-Length: %zu", sink.total); - } else { - LOG_DBG("HTTP", "Content-Length: unknown"); - } -#ifdef ESP_ERR_HTTP_EAGAIN - err = esp_http_client_set_timeout_ms(client, HTTP_READ_POLL_TIMEOUT_MS); - if (err != ESP_OK) { - LOG_ERR("HTTP", "Failed to set read timeout: %s", esp_err_to_name(err)); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } -#endif +HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& file, const size_t contentLength, + HttpDownloader::ProgressCallback progress, size_t& downloaded, + bool* cancelFlag, const size_t bufferSize, + const HttpDownloader::CancelCallback& shouldCancel) { + auto* stream = http.getStreamPtr(); + if (!stream) { + LOG_ERR("HTTP", "Failed to get response stream"); + return HttpDownloader::HTTP_ERROR; + } - auto buffer = makeUniqueNoThrow(bufferSize); - if (!buffer) { - LOG_ERR("HTTP", "Failed to allocate %zu byte download buffer", bufferSize); - logNetworkState("Download buffer allocation failure"); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } + std::unique_ptr buffer(new (std::nothrow) uint8_t[bufferSize]); + if (!buffer) { + LOG_ERR("HTTP", "Failed to allocate %zu byte download buffer", bufferSize); + return HttpDownloader::HTTP_ERROR; + } - ProgressNotifier progressNotifier(sink.total, std::move(sink.progress)); - LOG_DBG("HTTP", "Reading body: buffer=%zu bytes", bufferSize); -#ifdef ESP_ERR_HTTP_EAGAIN - uint32_t lastReadMs = millis(); -#endif - while (true) { - if (isCancelRequested(sink.cancelFlag, sink.shouldCancel)) { - esp_http_client_cleanup(client); - return HttpDownloader::ABORTED; - } + ProgressNotifier progressNotifier(contentLength, std::move(progress)); + uint32_t lastProgressMs = millis(); + while (downloaded < contentLength) { + if (isCancelRequested(cancelFlag, shouldCancel)) { + return HttpDownloader::ABORTED; + } + const size_t remaining = contentLength - downloaded; - const int bytesRead = esp_http_client_read(client, buffer.get(), bufferSize); - if (bytesRead < 0) { -#ifdef ESP_ERR_HTTP_EAGAIN - if (bytesRead == -ESP_ERR_HTTP_EAGAIN) { - const uint32_t idleMs = millis() - lastReadMs; - if (idleMs >= DOWNLOAD_IDLE_TIMEOUT_MS) { - logDownloadState("Read timed out", sink.downloaded, sink.total, idleMs); - esp_http_client_cleanup(client); - return HttpDownloader::HTTP_ERROR; - } - delay(1); - continue; - } -#endif - LOG_ERR("HTTP", "Read error after %zu/%zu bytes", sink.downloaded, sink.total); - logNetworkState("Read error"); - esp_http_client_cleanup(client); + int available = stream->available(); + if (available <= 0) { + if (!http.connected()) { + logDownloadState("Connection closed", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } - if (bytesRead == 0) break; - - if (!sink.write(reinterpret_cast(buffer.get()), static_cast(bytesRead))) { - LOG_ERR("HTTP", "Write failed after %zu/%zu bytes", sink.downloaded, sink.total); - logNetworkState("Write failure"); - esp_http_client_cleanup(client); - return HttpDownloader::FILE_ERROR; + if (millis() - lastProgressMs >= DOWNLOAD_IDLE_TIMEOUT_MS) { + logDownloadState("Read timed out", downloaded, contentLength, millis() - lastProgressMs); + return HttpDownloader::HTTP_ERROR; } + delay(1); + continue; + } - sink.downloaded += static_cast(bytesRead); -#ifdef ESP_ERR_HTTP_EAGAIN - lastReadMs = millis(); -#endif - if (sink.total > 0 && sink.total <= PROGRESS_UPDATE_BYTES) { - LOG_DBG("HTTP", "Read progress: %zu/%zu bytes", sink.downloaded, sink.total); + const size_t toRead = std::min({bufferSize, remaining, static_cast(available)}); + const size_t bytesRead = stream->readBytes(buffer.get(), toRead); + if (bytesRead == 0) { + if (millis() - lastProgressMs < DOWNLOAD_IDLE_TIMEOUT_MS) { + delay(1); + continue; } - progressNotifier.notify(sink.downloaded, false); - if (sink.total > 0 && sink.downloaded >= sink.total) break; - delay(0); + logDownloadState("Read timed out", downloaded, contentLength, millis() - lastProgressMs); + return HttpDownloader::HTTP_ERROR; } - const bool complete = esp_http_client_is_complete_data_received(client); - esp_http_client_cleanup(client); - progressNotifier.notify(sink.downloaded, true); - if (!complete) { - LOG_ERR("HTTP", "Incomplete: got %zu of %zu bytes", sink.downloaded, sink.total); - logNetworkState("Incomplete transfer"); - return HttpDownloader::HTTP_ERROR; + const size_t accepted = file.write(buffer.get(), bytesRead); + downloaded += accepted; + lastProgressMs = millis(); + progressNotifier.notify(downloaded, false); + + if (accepted != bytesRead) { + logDownloadState("Write failed", downloaded, contentLength, 0); + return HttpDownloader::FILE_ERROR; } - return HttpDownloader::OK; + delay(0); } - LOG_ERR("HTTP", "Redirect limit exceeded"); - logNetworkState("Redirect limit exceeded"); - return HttpDownloader::HTTP_ERROR; + progressNotifier.notify(downloaded, true); + return HttpDownloader::OK; } } // namespace bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username, - const std::string& password) { - return fetchUrl( - url, [&outContent](const uint8_t* data, size_t len) { return outContent.write(data, len) == len; }, username, - password); -} - -bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username, - const std::string& password) { - outContent.clear(); - return fetchUrl( - url, - [&outContent](const uint8_t* data, size_t len) { - outContent.append(reinterpret_cast(data), len); - return true; - }, - username, password); -} - -bool HttpDownloader::fetchUrl(const std::string& url, const DataCallback& onData, const std::string& username, - const std::string& password) { + const std::string& password, const Header* headers, const size_t headerCount, + const size_t maxBytes) { WifiPowerSaveGuard wifiPowerSaveGuard; (void)wifiPowerSaveGuard; + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new (std::nothrow) NetworkClientSecure(); + if (!secureClient) { + LOG_ERR("HTTP", "Failed to allocate secure client"); + return false; + } + secureClient->setHandshakeTimeout(HTTPS_HANDSHAKE_TIMEOUT_SECONDS); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + auto* plainClient = new (std::nothrow) NetworkClient(); + if (!plainClient) { + LOG_ERR("HTTP", "Failed to allocate client"); + return false; + } + client.reset(plainClient); + } + HTTPClient http; + LOG_DBG("HTTP", "Fetching: %s", url.c_str()); - if (!onData) { - LOG_ERR("HTTP", "Fetch failed: missing data callback"); + http.begin(*client, url.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setReuse(false); + http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS); + http.setTimeout(HTTP_RESPONSE_TIMEOUT_MS); + http.addHeader("User-Agent", "CrossInk-ESP32-" CROSSINK_VERSION); + addHeaders(http, headers, headerCount); + + if (!username.empty() && !password.empty()) { + std::string credentials = username + ":" + password; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + LOG_ERR("HTTP", "Fetch failed: %d", httpCode); + http.end(); + return false; + } + + const int64_t reportedLength = http.getSize(); + if (maxBytes > 0 && reportedLength > static_cast(maxBytes)) { + LOG_ERR("HTTP", "Fetch too large: %lld > %zu", static_cast(reportedLength), maxBytes); + http.end(); + return false; + } + + LimitedWriteStream limitedStream(outContent, maxBytes); + const int writeResult = http.writeToStream(&limitedStream); + + http.end(); + + if (writeResult < 0 || !limitedStream.ok() || limitedStream.sizeLimitExceeded()) { + LOG_ERR("HTTP", "writeToStream error: %d (%s)", writeResult, HTTPClient::errorToString(writeResult).c_str()); return false; } - Sink sink; - sink.write = onData; - return runGet(url, username, password, sink, DEFAULT_DOWNLOAD_BUFFER_SIZE) == OK; + LOG_DBG("HTTP", "Fetch success"); + return true; +} + +bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username, + const std::string& password, const Header* headers, const size_t headerCount, + const size_t maxBytes) { + StreamString stream; + if (!fetchUrl(url, stream, username, password, headers, headerCount, maxBytes)) { + return false; + } + outContent = stream.c_str(); + return true; } HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, - ProgressCallback progress, bool* cancelFlag, - const std::string& username, const std::string& password, - DownloadOptions options) { + ProgressCallback progress, bool* cancelFlag, + const std::string& username, const std::string& password, + DownloadOptions options, const Header* headers, + const size_t headerCount) { WifiPowerSaveGuard wifiPowerSaveGuard; (void)wifiPowerSaveGuard; + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new (std::nothrow) NetworkClientSecure(); + if (!secureClient) { + LOG_ERR("HTTP", "Failed to allocate secure client"); + return HTTP_ERROR; + } + secureClient->setHandshakeTimeout(HTTPS_HANDSHAKE_TIMEOUT_SECONDS); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + auto* plainClient = new (std::nothrow) NetworkClient(); + if (!plainClient) { + LOG_ERR("HTTP", "Failed to allocate client"); + return HTTP_ERROR; + } + client.reset(plainClient); + } + HTTPClient http; const size_t bufferSize = options.bufferSize > 0 ? options.bufferSize : DEFAULT_DOWNLOAD_BUFFER_SIZE; + + LOG_DBG("HTTP", "Downloading: %s", url.c_str()); + LOG_DBG("HTTP", "Destination: %s", destPath.c_str()); + LOG_DBG("HTTP", "Timeouts: connect=%ld ms response=%u ms idle=%lu ms buffer=%zu bytes", + static_cast(HTTP_CONNECT_TIMEOUT_MS), HTTP_RESPONSE_TIMEOUT_MS, + static_cast(DOWNLOAD_IDLE_TIMEOUT_MS), bufferSize); + + http.begin(*client, url.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setReuse(false); + http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS); + http.setTimeout(HTTP_RESPONSE_TIMEOUT_MS); + http.addHeader("User-Agent", "CrossInk-ESP32-" CROSSINK_VERSION); + addHeaders(http, headers, headerCount); + size_t resumeOffset = 0; if (options.resumePartial && Storage.exists(destPath.c_str())) { FsFile existingFile; @@ -434,73 +373,121 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& existingFile.close(); } } + if (resumeOffset > 0) { + char rangeHeader[40]; + snprintf(rangeHeader, sizeof(rangeHeader), "bytes=%zu-", resumeOffset); + http.addHeader("Range", rangeHeader); + LOG_DBG("HTTP", "Resuming download at byte %zu", resumeOffset); + } - LOG_DBG("HTTP", "Downloading: %s", url.c_str()); - LOG_DBG("HTTP", "Destination: %s", destPath.c_str()); - LOG_DBG("HTTP", "Timeout: %d ms buffer=%zu bytes", HTTP_TIMEOUT_MS, bufferSize); + if (!username.empty() && !password.empty()) { + std::string credentials = username + ":" + password; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } - if (resumeOffset == 0 && Storage.exists(destPath.c_str())) { + const int httpCode = http.GET(); + const bool isResumeResponse = resumeOffset > 0 && httpCode == 206; + if (httpCode != HTTP_CODE_OK && !isResumeResponse) { + if (httpCode < 0) { + LOG_ERR("HTTP", "Download failed: %d (%s)", httpCode, HTTPClient::errorToString(httpCode).c_str()); + logNetworkState("Download failure"); + } else { + LOG_ERR("HTTP", "Download failed: %d", httpCode); + } + http.end(); + return HTTP_ERROR; + } + if (resumeOffset > 0 && !isResumeResponse) { + LOG_DBG("HTTP", "Server ignored range request; restarting download"); Storage.remove(destPath.c_str()); + resumeOffset = 0; } - Sink sink; - sink.progress = std::move(progress); - sink.cancelFlag = cancelFlag; - sink.shouldCancel = std::move(options.shouldCancel); - sink.resumeOffset = resumeOffset; + const int64_t reportedLength = http.getSize(); + const size_t responseLength = reportedLength > 0 ? static_cast(reportedLength) : 0; + const size_t contentLength = responseLength > 0 ? resumeOffset + responseLength : 0; + if (contentLength > 0) { + LOG_DBG("HTTP", "Content-Length: %zu", contentLength); + if (options.maxBytes > 0 && contentLength > options.maxBytes) { + LOG_ERR("HTTP", "Download too large: %zu > %zu", contentLength, options.maxBytes); + http.end(); + return SIZE_LIMIT_EXCEEDED; + } + } else { + LOG_DBG("HTTP", "Content-Length: unknown"); + } + if (resumeOffset > 0) { + LOG_DBG("HTTP", "Resume offset: %zu bytes", resumeOffset); + } + + // Remove existing file if present, unless this is a resumable append. + if (resumeOffset == 0 && Storage.exists(destPath.c_str())) { + Storage.remove(destPath.c_str()); + } + // Open file for writing FsFile file; - bool fileOpen = false; - auto openOutputFile = [&]() { - if (fileOpen) return true; - if (sink.resumeOffset > 0) { - file = Storage.open(destPath.c_str(), O_WRONLY | O_APPEND); - } else { - fileOpen = Storage.openFileForWrite("HTTP", destPath.c_str(), file); - if (!fileOpen) { - LOG_ERR("HTTP", "Failed to open file for writing"); - return false; - } - } - fileOpen = file; - if (!fileOpen) { - LOG_ERR("HTTP", "Failed to open file for writing"); - } - return fileOpen; - }; + if (resumeOffset > 0) { + file = Storage.open(destPath.c_str(), O_WRONLY | O_APPEND); + } else if (!Storage.openFileForWrite("HTTP", destPath.c_str(), file)) { + LOG_ERR("HTTP", "Failed to open file for writing"); + http.end(); + return FILE_ERROR; + } + if (!file) { + LOG_ERR("HTTP", "Failed to open file for writing"); + http.end(); + return FILE_ERROR; + } - sink.write = [&](const uint8_t* data, size_t len) { return openOutputFile() && file.write(data, len) == len; }; + size_t downloaded = resumeOffset; + DownloadError transferError = OK; + int writeResult = 0; - DownloadError result = runGet(url, username, password, sink, bufferSize); - if (sink.rangeIgnored) { - if (fileOpen) { - file.close(); - fileOpen = false; + if (contentLength > 0) { + transferError = downloadKnownLengthBody(http, file, contentLength, std::move(progress), downloaded, cancelFlag, + bufferSize, options.shouldCancel); + } else { + // Let HTTPClient handle chunked decoding and stream body bytes into the file. + FileWriteStream fileStream(file, contentLength, std::move(progress), cancelFlag, std::move(options.shouldCancel), + options.maxBytes); + writeResult = http.writeToStream(&fileStream); + fileStream.finishProgress(); + downloaded = fileStream.downloaded(); + if (cancelFlag && *cancelFlag) { + transferError = ABORTED; + } else if (fileStream.sizeLimitExceeded()) { + transferError = SIZE_LIMIT_EXCEEDED; + } else if (writeResult < 0) { + transferError = HTTP_ERROR; + } else if (!fileStream.ok()) { + LOG_ERR("HTTP", "Write failed during download"); + transferError = FILE_ERROR; } - Storage.remove(destPath.c_str()); - sink.rangeIgnored = false; - sink.resumeOffset = 0; - sink.downloaded = 0; - sink.total = 0; - sink.write = [&](const uint8_t* data, size_t len) { return openOutputFile() && file.write(data, len) == len; }; - result = runGet(url, username, password, sink, bufferSize); } - if (fileOpen) { - file.flush(); - file.close(); - } + file.flush(); + + file.close(); + http.end(); - if (result != OK) { + if (transferError != OK) { + if (writeResult < 0) { + LOG_ERR("HTTP", "writeToStream error: %d (%s)", writeResult, HTTPClient::errorToString(writeResult).c_str()); + } LOG_ERR("HTTP", "Transfer failed: error=%d downloaded=%zu expected=%zu preservePartial=%d resumePartial=%d", - static_cast(result), sink.downloaded, sink.total, options.preservePartial, options.resumePartial); - if (result == ABORTED || !options.preservePartial) { + static_cast(transferError), downloaded, contentLength, options.preservePartial, options.resumePartial); + if (transferError == ABORTED || !options.preservePartial) { Storage.remove(destPath.c_str()); } - return result; + return transferError; } - if (sink.downloaded == 0) { + LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded); + + // Guard against partial writes even if HTTPClient completes. + if (contentLength == 0 && downloaded == 0) { LOG_ERR("HTTP", "Download failed: no data received"); if (!options.preservePartial) { Storage.remove(destPath.c_str()); @@ -508,14 +495,14 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& return HTTP_ERROR; } - if (sink.total > 0 && sink.downloaded != sink.total) { - LOG_ERR("HTTP", "Size mismatch: got %zu, expected %zu", sink.downloaded, sink.total); + // Verify download size if known + if (contentLength > 0 && downloaded != contentLength) { + LOG_ERR("HTTP", "Size mismatch: got %zu, expected %zu", downloaded, contentLength); if (!options.preservePartial) { Storage.remove(destPath.c_str()); } return HTTP_ERROR; } - LOG_DBG("HTTP", "Downloaded %zu bytes", sink.downloaded); return OK; } diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index 7febe253f5..65553e7c74 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -8,52 +8,52 @@ /** * HTTP client utility for fetching content and downloading files. - * Streams requests through esp_http_client so large downloads do not need to - * fit in RAM. + * Wraps NetworkClientSecure and HTTPClient for HTTPS requests. */ class HttpDownloader { public: using ProgressCallback = std::function; using CancelCallback = std::function; - // Called with each body chunk as it arrives; return false to abort. Lets a - // streaming parser consume the response without buffering the whole body. - using DataCallback = std::function; + + struct Header { + const char* name; + const char* value; + }; enum DownloadError { OK = 0, HTTP_ERROR, FILE_ERROR, ABORTED, + SIZE_LIMIT_EXCEEDED, }; struct DownloadOptions { explicit DownloadOptions(bool preservePartial = false, bool resumePartial = false, - CancelCallback shouldCancel = nullptr, size_t bufferSize = 0) + CancelCallback shouldCancel = nullptr, size_t bufferSize = 1024, size_t maxBytes = 0) : preservePartial(preservePartial), resumePartial(resumePartial), shouldCancel(std::move(shouldCancel)), - bufferSize(bufferSize) {} + bufferSize(bufferSize), + maxBytes(maxBytes) {} bool preservePartial; bool resumePartial; CancelCallback shouldCancel; size_t bufferSize; + size_t maxBytes; }; /** * Fetch text content from a URL with optional credentials. */ static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "", - const std::string& password = ""); + const std::string& password = "", const Header* headers = nullptr, size_t headerCount = 0, + size_t maxBytes = 0); static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "", - const std::string& password = ""); - - /** - * Stream the response body to onData as it arrives, without buffering it. - */ - static bool fetchUrl(const std::string& url, const DataCallback& onData, const std::string& username = "", - const std::string& password = ""); + const std::string& password = "", const Header* headers = nullptr, size_t headerCount = 0, + size_t maxBytes = 0); /** * Download a file to the SD card with optional credentials. @@ -61,5 +61,6 @@ class HttpDownloader { static DownloadError downloadToFile(const std::string& url, const std::string& destPath, ProgressCallback progress = nullptr, bool* cancelFlag = nullptr, const std::string& username = "", const std::string& password = "", - DownloadOptions options = DownloadOptions()); + DownloadOptions options = DownloadOptions(), const Header* headers = nullptr, + size_t headerCount = 0); }; diff --git a/src/trmnl/README.md b/src/trmnl/README.md new file mode 100644 index 0000000000..b85cdfcc10 --- /dev/null +++ b/src/trmnl/README.md @@ -0,0 +1,12 @@ +# TRMNL Module + +This directory contains the app-facing TRMNL sleep-screen implementation. + +- `TrmnlDisplayConfig.h` is portable, host-testable display sizing logic. +- `TrmnlSleepClient.h/.cpp` owns TRMNL `/api/display` fetch, image download, + cache replacement, and temporary Wi-Fi connection setup. + +The rest of the app should call this module instead of duplicating TRMNL +network, cache, or orientation behavior. Porting to another fork should mainly +require adapting the service dependencies used by `TrmnlSleepClient.cpp`: +storage, HTTP download, Wi-Fi credential lookup, URL helpers, and logging. diff --git a/src/trmnl/TrmnlDisplayConfig.h b/src/trmnl/TrmnlDisplayConfig.h new file mode 100644 index 0000000000..2166a206df --- /dev/null +++ b/src/trmnl/TrmnlDisplayConfig.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace trmnl { + +enum class Orientation : uint8_t { Landscape = 0, Portrait = 1 }; + +struct DisplaySize { + int width; + int height; +}; + +constexpr DisplaySize displaySizeFor(const Orientation orientation) { + return orientation == Orientation::Portrait ? DisplaySize{480, 800} : DisplaySize{800, 480}; +} + +constexpr const char* modelFor(const Orientation /*orientation*/) { return "og_png"; } + +} // namespace trmnl diff --git a/src/trmnl/TrmnlSleepClient.cpp b/src/trmnl/TrmnlSleepClient.cpp new file mode 100644 index 0000000000..ae9bd820dd --- /dev/null +++ b/src/trmnl/TrmnlSleepClient.cpp @@ -0,0 +1,211 @@ +#include "TrmnlSleepClient.h" + +#include +#include +#include +#include +#include + +#include + +#include "WifiCredentialStore.h" +#include "network/HttpDownloader.h" +#include "util/UrlUtils.h" + +namespace { +const WifiCredential* getTrmnlWifiCredential() { + WIFI_STORE.loadFromFile(); + const std::string& lastSsid = WIFI_STORE.getLastConnectedSsid(); + if (!lastSsid.empty()) { + LOG_INF("TRM", "Trying last used WiFi: %s", lastSsid.c_str()); + if (const auto* cred = WIFI_STORE.findCredential(lastSsid)) { + return cred; + } + } + const auto& credentials = WIFI_STORE.getCredentials(); + if (!credentials.empty()) { + LOG_INF("TRM", "Trying first available WiFi: %s", credentials.front().ssid.c_str()); + return &credentials.front(); + } + LOG_ERR("TRM", "No WiFi credentials found"); + return nullptr; +} + +bool validateBmpFile(const std::string& path) { + FsFile file; + if (!Storage.openFileForRead("TRM", path.c_str(), file)) { + return false; + } + Bitmap bitmap(file, true); + const bool ok = bitmap.parseHeaders() == BmpReaderError::Ok; + file.close(); + return ok; +} +} // namespace + +bool TrmnlSleepClient::hasConfig(const Config& config) { + return config.serverUrl && config.serverUrl[0] != '\0' && config.apiKey && config.apiKey[0] != '\0'; +} + +bool TrmnlSleepClient::connectWifi() { + const auto* cred = getTrmnlWifiCredential(); + if (!cred || cred->ssid.empty()) { + return false; + } + + LOG_INF("TRM", "Connecting to WiFi: %s", cred->ssid.c_str()); + WiFi.persistent(false); + WiFi.mode(WIFI_STA); + WiFi.disconnect(true, true); + delay(100); + WiFi.setSleep(false); + WiFi.begin(cred->ssid.c_str(), cred->password.c_str()); + + uint8_t attempt = 0; + while (attempt < WIFI_RETRIES) { + delay(WIFI_RETRY_DELAY_MS); + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { + LOG_INF("TRM", "WiFi connected after %d attempts", attempt + 1); + WIFI_STORE.setLastConnectedSsid(cred->ssid); + return true; + } + attempt++; + } + + LOG_ERR("TRM", "Wi-Fi connect failed after %d attempts", WIFI_RETRIES); + return false; +} + +void TrmnlSleepClient::disconnectWifi() { + WiFi.disconnect(false); + WiFi.mode(WIFI_OFF); +} + +std::string TrmnlSleepClient::resolveDeviceId(const char* configuredDeviceId) { + if (configuredDeviceId && configuredDeviceId[0] != '\0') { + return configuredDeviceId; + } + uint8_t mac[6] = {}; + WiFi.macAddress(mac); + char macStr[18]; + snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return macStr; +} + +TrmnlSleepClient::ImageKind TrmnlSleepClient::detectImageKind(const std::string& path) { + FsFile file; + if (!Storage.openFileForRead("TRM", path.c_str(), file)) { + return ImageKind::Unknown; + } + uint8_t header[8] = {}; + const int bytesRead = file.read(header, sizeof(header)); + file.close(); + if (bytesRead >= 2 && header[0] == 'B' && header[1] == 'M') { + return ImageKind::Bmp; + } + if (bytesRead >= 8 && header[0] == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G' && + header[4] == '\r' && header[5] == '\n' && header[6] == 0x1A && header[7] == '\n') { + return ImageKind::Png; + } + return ImageKind::Unknown; +} + +bool TrmnlSleepClient::validateImage(const std::string& path, ImageKind& kind) { + kind = detectImageKind(path); + if (kind == ImageKind::Bmp) { + return validateBmpFile(path); + } + return kind == ImageKind::Png; +} + +const char* TrmnlSleepClient::cachePathFor(const ImageKind kind) { + if (kind == ImageKind::Png) return CACHE_PNG; + if (kind == ImageKind::Bmp) return CACHE_BMP; + return nullptr; +} + +bool TrmnlSleepClient::replaceCache(const std::string& tmpPath, const ImageKind kind) { + const char* destPath = cachePathFor(kind); + if (!destPath) { + Storage.remove(tmpPath.c_str()); + return false; + } + const char* staleOther = kind == ImageKind::Png ? CACHE_BMP : CACHE_PNG; + if (Storage.exists(destPath) && !Storage.remove(destPath)) { + return false; + } + if (!Storage.rename(tmpPath.c_str(), destPath)) { + Storage.remove(tmpPath.c_str()); + return false; + } + if (Storage.exists(staleOther)) { + Storage.remove(staleOther); + } + return true; +} + +bool TrmnlSleepClient::fetchLatest(const Config& config) { + if (!hasConfig(config)) { + const bool serverOk = config.serverUrl && config.serverUrl[0] != '\0'; + const bool apiKeyOk = config.apiKey && config.apiKey[0] != '\0'; + LOG_ERR("TRM", "TRMNL settings incomplete (serverUrl=%s apiKey=%s)", + serverOk ? "ok" : "empty", apiKeyOk ? "ok" : "empty"); + return false; + } + + LOG_INF("TRM", "Fetching latest TRMNL image from %s", config.serverUrl); + Storage.mkdir("/.crosspoint"); + if (!connectWifi()) { + LOG_ERR("TRM", "Failed to connect WiFi for TRMNL fetch"); + disconnectWifi(); + return false; + } + LOG_INF("TRM", "WiFi connected, fetching from server"); + + const std::string deviceId = resolveDeviceId(config.deviceId); + char widthHeader[8]; + char heightHeader[8]; + snprintf(widthHeader, sizeof(widthHeader), "%d", config.displaySize.width); + snprintf(heightHeader, sizeof(heightHeader), "%d", config.displaySize.height); + const HttpDownloader::Header headers[] = {{"ID", deviceId.c_str()}, + {"Access-Token", config.apiKey}, + {"Width", widthHeader}, + {"Height", heightHeader}, + {"Model", config.model ? config.model : "og_png"}}; + const std::string serverUrl = UrlUtils::ensureProtocol(config.serverUrl); + const std::string displayUrl = UrlUtils::buildUrl(serverUrl, "/api/display"); + + std::string response; + bool fetched = HttpDownloader::fetchUrl(displayUrl, response, "", "", headers, sizeof(headers) / sizeof(headers[0]), + DISPLAY_JSON_MAX_BYTES); + if (fetched) { + TrmnlDisplayJsonParser parser; + parser.feed(response.c_str(), response.length()); + fetched = !parser.hasError() && parser.foundImageUrl(); + if (fetched) { + const std::string imageUrl = UrlUtils::buildUrl(serverUrl, parser.getImageUrl()); + const bool sameHost = UrlUtils::extractHost(imageUrl) == UrlUtils::extractHost(serverUrl); + const HttpDownloader::Header* imageHeaders = sameHost ? headers : nullptr; + const size_t imageHeaderCount = sameHost ? sizeof(headers) / sizeof(headers[0]) : 0; + if (Storage.exists(CACHE_TMP)) { + Storage.remove(CACHE_TMP); + } + const HttpDownloader::DownloadOptions options(false, false, nullptr, 1024, IMAGE_MAX_BYTES); + fetched = HttpDownloader::downloadToFile(imageUrl, CACHE_TMP, nullptr, nullptr, "", "", options, imageHeaders, + imageHeaderCount) == HttpDownloader::OK; + if (fetched) { + ImageKind kind = ImageKind::Unknown; + fetched = validateImage(CACHE_TMP, kind) && replaceCache(CACHE_TMP, kind); + } + } + } + + disconnectWifi(); + if (!fetched) { + LOG_ERR("TRM", "TRMNL fetch failed (fetch=%d validate=%d)", fetched ? 1 : 0, Storage.exists(CACHE_TMP) ? 1 : 0); + Storage.remove(CACHE_TMP); + } else { + LOG_INF("TRM", "TRMNL image fetched successfully"); + } + return fetched; +} diff --git a/src/trmnl/TrmnlSleepClient.h b/src/trmnl/TrmnlSleepClient.h new file mode 100644 index 0000000000..02899b160a --- /dev/null +++ b/src/trmnl/TrmnlSleepClient.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +#include "TrmnlDisplayConfig.h" + +class TrmnlSleepClient { + public: + enum class ImageKind : uint8_t { Unknown, Bmp, Png }; + + struct Config { + const char* serverUrl; + const char* apiKey; + const char* deviceId; + trmnl::DisplaySize displaySize; + const char* model; + }; + + static constexpr const char* CACHE_BMP = "/.crosspoint/trmnl_sleep.bmp"; + static constexpr const char* CACHE_PNG = "/.crosspoint/trmnl_sleep.png"; + + static bool hasConfig(const Config& config); + static bool fetchLatest(const Config& config); + static ImageKind detectImageKind(const std::string& path); + static bool validateImage(const std::string& path, ImageKind& kind); + static const char* cachePathFor(ImageKind kind); + + private: + static constexpr const char* CACHE_TMP = "/.crosspoint/trmnl_sleep.tmp"; + static constexpr uint8_t WIFI_RETRIES = 20; + static constexpr uint16_t WIFI_RETRY_DELAY_MS = 500; + static constexpr size_t DISPLAY_JSON_MAX_BYTES = 4096; + static constexpr size_t IMAGE_MAX_BYTES = 256 * 1024; + + static bool connectWifi(); + static void disconnectWifi(); + static std::string resolveDeviceId(const char* configuredDeviceId); + static bool replaceCache(const std::string& tmpPath, ImageKind kind); +}; diff --git a/test/README b/test/README index fb3802bfaa..87802d9470 100644 --- a/test/README +++ b/test/README @@ -1,3 +1,14 @@ +# CrossXT - Yet Another Crosspoint Fork + +### Main features of this fork: +- TRMNL Lock Screen (I used https://github.com/yazdipour/byos_next as Server) +- OPDS Downloads going to specified directory instead of root +> [Branch of CrossInk](https://github.com/yazdipour/CrossXT/tree/development) +> [Branch of CPR-vCodex](https://github.com/yazdipour/CrossXT/tree/main-vcodex) + +![CrossXT with TRMNL Lock Screen](https://github.com/yazdipour/byos_next/raw/main/docs/screenshots/xteink-x4.jpg) + +## CrossInk This directory is intended for PlatformIO Test Runner and project tests. @@ -10,6 +21,16 @@ in the development cycle. More information about PlatformIO Unit Testing: - https://docs.platformio.org/en/latest/advanced/unit-testing/index.html +TRMNL display parser and display config: + +```sh +test/run_trmnl_display_json_parser_test.sh +``` + +This validates the tiny TRMNL `/api/display` response parser and the portable +TRMNL display orientation sizing used by the sleep screen fetch path. It runs +on the host with `c++` and does not require PlatformIO or device hardware. + Simulator smoke test: - Run `./scripts/run_simulator_smoke_test.py` from the repo root. - The script builds `env:simulator`, creates a temporary `fs_`, copies a test EPUB into it, opens common screens, renders them, drives reader page/menu input, and fails on crashes or missing success output. diff --git a/test/run_trmnl_display_json_parser_test.sh b/test/run_trmnl_display_json_parser_test.sh new file mode 100755 index 0000000000..8475395687 --- /dev/null +++ b/test/run_trmnl_display_json_parser_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build/trmnl_display_json_parser" +BINARY="$BUILD_DIR/TrmnlDisplayJsonParserTest" + +mkdir -p "$BUILD_DIR" + +SOURCES=( + "$ROOT_DIR/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp" + "$ROOT_DIR/lib/JsonParser/TrmnlDisplayJsonParser.cpp" + "$ROOT_DIR/lib/JsonParser/StreamingJsonParser.cpp" +) + +CXXFLAGS=( + -std=c++20 + -O2 + -Wall + -Wextra + -pedantic + -I"$ROOT_DIR" + -I"$ROOT_DIR/lib" + -I"$ROOT_DIR/lib/JsonParser" + -I"$ROOT_DIR/src" +) + +c++ "${CXXFLAGS[@]}" "${SOURCES[@]}" -o "$BINARY" + +"$BINARY" "$@" diff --git a/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp b/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp new file mode 100644 index 0000000000..1413545a14 --- /dev/null +++ b/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp @@ -0,0 +1,154 @@ +#include +#include + +#include "lib/JsonParser/TrmnlDisplayJsonParser.h" +#include "src/trmnl/TrmnlDisplayConfig.h" + +static int testsPassed = 0; +static int testsFailed = 0; + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL: %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_FALSE(cond) ASSERT_TRUE(!(cond)) + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + fprintf(stderr, " FAIL: %s:%d: %s != expected\n", __FILE__, __LINE__, #a); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define ASSERT_STREQ(a, b) \ + do { \ + const char* _a = (a); \ + const char* _b = (b); \ + if (strcmp(_a, _b) != 0) { \ + fprintf(stderr, " FAIL: %s:%d: \"%s\" != \"%s\"\n", __FILE__, __LINE__, _a, _b); \ + testsFailed++; \ + return; \ + } \ + } while (0) + +#define PASS() testsPassed++ + +static void feedChunked(TrmnlDisplayJsonParser& parser, const char* json, const size_t chunkSize) { + const size_t len = strlen(json); + for (size_t off = 0; off < len; off += chunkSize) { + const size_t n = len - off < chunkSize ? len - off : chunkSize; + parser.feed(json + off, n); + } +} + +void testParsesDisplayResponse() { + printf("testParsesDisplayResponse...\n"); + + const char* json = R"({ + "status": 0, + "image_url": "https://example.test/api/bitmap/calendar.png", + "filename": "calendar.png", + "refresh_rate": 300, + "update_firmware": false + })"; + + TrmnlDisplayJsonParser parser; + parser.feed(json, strlen(json)); + + ASSERT_FALSE(parser.hasError()); + ASSERT_TRUE(parser.foundImageUrl()); + ASSERT_STREQ(parser.getImageUrl(), "https://example.test/api/bitmap/calendar.png"); + ASSERT_STREQ(parser.getFilename(), "calendar.png"); + ASSERT_EQ(parser.getRefreshRateSeconds(), 300u); + + printf(" passed\n"); + PASS(); +} + +void testParsesChunkedMinifiedResponse() { + printf("testParsesChunkedMinifiedResponse...\n"); + + const char* json = + R"({"image_url":"http://192.168.1.50:3000/api/display.bmp","image_name":"display.bmp","refresh_rate":60})"; + + TrmnlDisplayJsonParser parser; + feedChunked(parser, json, 7); + + ASSERT_FALSE(parser.hasError()); + ASSERT_TRUE(parser.foundImageUrl()); + ASSERT_STREQ(parser.getImageUrl(), "http://192.168.1.50:3000/api/display.bmp"); + ASSERT_STREQ(parser.getFilename(), "display.bmp"); + ASSERT_EQ(parser.getRefreshRateSeconds(), 60u); + + printf(" passed\n"); + PASS(); +} + +void testIgnoresNestedImageUrl() { + printf("testIgnoresNestedImageUrl...\n"); + + const char* json = R"({"nested":{"image_url":"bad"},"image_url":"https://ok.test/screen.png"})"; + + TrmnlDisplayJsonParser parser; + parser.feed(json, strlen(json)); + + ASSERT_FALSE(parser.hasError()); + ASSERT_TRUE(parser.foundImageUrl()); + ASSERT_STREQ(parser.getImageUrl(), "https://ok.test/screen.png"); + + printf(" passed\n"); + PASS(); +} + +void testMissingImageUrl() { + printf("testMissingImageUrl...\n"); + + const char* json = R"({"refresh_rate":300,"filename":"calendar.png"})"; + + TrmnlDisplayJsonParser parser; + parser.feed(json, strlen(json)); + + ASSERT_FALSE(parser.hasError()); + ASSERT_FALSE(parser.foundImageUrl()); + ASSERT_STREQ(parser.getImageUrl(), ""); + ASSERT_STREQ(parser.getFilename(), "calendar.png"); + + printf(" passed\n"); + PASS(); +} + +void testDisplaySizeForOrientation() { + printf("testDisplaySizeForOrientation...\n"); + + const auto landscape = trmnl::displaySizeFor(trmnl::Orientation::Landscape); + const auto portrait = trmnl::displaySizeFor(trmnl::Orientation::Portrait); + + ASSERT_EQ(landscape.width, 800); + ASSERT_EQ(landscape.height, 480); + ASSERT_EQ(portrait.width, 480); + ASSERT_EQ(portrait.height, 800); + ASSERT_STREQ(trmnl::modelFor(trmnl::Orientation::Landscape), "og_png"); + + printf(" passed\n"); + PASS(); +} + +int main() { + testParsesDisplayResponse(); + testParsesChunkedMinifiedResponse(); + testIgnoresNestedImageUrl(); + testMissingImageUrl(); + testDisplaySizeForOrientation(); + + printf("\n%d passed, %d failed\n", testsPassed, testsFailed); + return testsFailed == 0 ? 0 : 1; +} From b746f89d74f41b64569335161c7baa006ce0365f Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Thu, 2 Jul 2026 12:44:27 -0400 Subject: [PATCH 3/8] feat(power): short-press power button refreshes TRMNL sleep screen in place Short-pressing the power button while the device is deep-asleep with the TRMNL sleep screen active now re-fetches and re-renders the TRMNL image and goes straight back to sleep, instead of either silently doing nothing (the previous IGNORE default) or fully booting into Home/Reader. A long press continues to wake normally, unchanged. Root cause / design: SHORT_PWRBTN (the existing configurable "what does a short press do" enum, e.g. FORCE_REFRESH) only dispatches from the main loop, once the device has *already* fully booted -- it never fires while still asleep. What actually gates a press while asleep is HalGPIO::verifyPowerButtonWakeup(), called from setup() before any activity is pushed. Wiring this feature through SHORT_PWRBTN as originally scoped would still flash Home/Reader before refreshing; the correct hook is the wake gate itself. verifyPowerButtonWakeup is refactored (behavior-preserving) to delegate its calibrated press-duration polling to a new HalGPIO::measurePowerButtonPressWasShort(), which the new TRMNL path also calls -- always allowing the boot to continue (shortPressAllowed=true) when sleepScreen==TRMNL, then measuring short vs. long against the existing long-press threshold to decide refresh-in-place vs. normal wake. The refresh itself reuses enterDeepSleep() -> activityManager.goToSleep() -> SleepActivity::renderTrmnlSleepScreen(), the same call the periodic TRMNL refresh already uses, rather than duplicating it. enterDeepSleep() gained a preserveLastSleepFromReader parameter: called this early (before Home/Reader/Boot is ever pushed), activityManager.isReaderActivity() would wrongly report false and corrupt APP_STATE.lastSleepFromReader, breaking reader-resume on the next real wake. All other call sites are unaffected (default false). A new "Refreshing" status string (STR_REFRESHING) is shown in place of the "BOOTING" splash during this path, which skips the splash entirely. Not gated behind a new SHORT_PWRBTN option: that enum doesn't govern this code path at all (see above), so adding one wouldn't be reachable here. Instead this activates automatically whenever sleepScreen==TRMNL, which is itself an explicit, existing user choice. Verification: pio run -e simulator builds clean. The simulator's wake path (external crosspoint-simulator dependency, not part of this repo) cannot measure press duration at all -- confirmed via .pio/libdeps/simulator/simulator/src/HalGPIO.cpp, where verifyPowerButtonWakeup is a no-op and any keypress triggers an immediate reboot regardless of duration -- so measurePowerButtonPressWasShort is only compiled for real hardware (#ifndef SIMULATOR) and the short/long distinction itself needs a real device flash to confirm. The rest of the new logic (wake routing, display setup, "Refreshing" render, TRMNL fetch with graceful fallback on missing config, SleepActivity entry, clean re-sleep, and the lastSleepFromReader preservation fix) was verified by directly driving the simulator's WakeupReason::PowerButton path via CROSSPOINT_SIM_WAKE_REASON=power against a local settings file with sleepScreen=TRMNL, temporarily forcing the refresh branch on to confirm it runs end-to-end with no crashes, and confirming lastSleepFromReader survives the refresh when set to true beforehand. The temporary override is not part of this commit. Co-Authored-By: Claude Sonnet 5 --- lib/I18n/translations/english.yaml | 1 + lib/hal/HalGPIO.cpp | 26 +++++++------ lib/hal/HalGPIO.h | 7 ++++ src/GlobalActions.h | 2 +- src/main.cpp | 59 +++++++++++++++++++++++++++--- 5 files changed, 77 insertions(+), 18 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8176a60839..12cc51ccf8 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -4,6 +4,7 @@ _order: "0" STR_CROSSINK: "CrossInk" STR_BOOTING: "BOOTING" +STR_REFRESHING: "REFRESHING" STR_SLEEPING: "SLEEPING" STR_ENTERING_SLEEP: "Going to sleep" STR_BROWSE_FILES: "Browse Files" diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index eec802721a..a71427d3bc 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -249,10 +249,16 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre } // TODO: Intermittent edge case remains: a single tap followed by another single tap // can still power on the device. Tighten wake debounce/state handling here. + if (measurePowerButtonPressWasShort(requiredDurationMs)) { + startDeepSleep(); + } +} +bool HalGPIO::measurePowerButtonPressWasShort(uint16_t longPressDurationMs) { // Calibrate: subtract boot time already elapsed, assuming button held since boot const uint16_t calibration = millis(); - const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1; + const uint16_t calibratedDuration = + (calibration < longPressDurationMs) ? (longPressDurationMs - calibration) : 1; const auto start = millis(); inputMgr.update(); @@ -261,17 +267,15 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre delay(10); inputMgr.update(); } - if (inputMgr.isPressed(BTN_POWER)) { - do { - delay(10); - inputMgr.update(); - } while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration); - if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) { - startDeepSleep(); - } - } else { - startDeepSleep(); + if (!inputMgr.isPressed(BTN_POWER)) { + // Never registered as pressed (already released) -- treat as short. + return true; } + do { + delay(10); + inputMgr.update(); + } while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration); + return inputMgr.getPowerButtonHeldTime() < calibratedDuration; } bool HalGPIO::isUsbConnected() const { diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 94e9bcd711..6b5acad525 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -80,6 +80,13 @@ class HalGPIO { // Should only be called when wakeup reason is PowerButton. void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed); + // Measures the power-button press that caused a PowerButton wakeup and reports + // whether it was short (released before longPressDurationMs) or long. Unlike + // verifyPowerButtonWakeup, this never rejects the wakeup -- the caller decides + // what a short vs. long press means. Should only be called when wakeup reason + // is PowerButton. + bool measurePowerButtonPressWasShort(uint16_t longPressDurationMs); + // Check if USB is connected bool isUsbConnected() const; diff --git a/src/GlobalActions.h b/src/GlobalActions.h index a860eddd93..2f49a62425 100644 --- a/src/GlobalActions.h +++ b/src/GlobalActions.h @@ -33,4 +33,4 @@ inline bool isPowerButtonActionAvailableOutsideReader(const CrossPointSettings:: } } -void enterDeepSleep(bool fromTimeout = false); +void enterDeepSleep(bool fromTimeout = false, bool preserveLastSleepFromReader = false); diff --git a/src/main.cpp b/src/main.cpp index 50368d0324..d377566524 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -441,6 +441,15 @@ void waitForPowerRelease() { } } +// Status screen shown while a short power-button press refreshes the TRMNL sleep +// screen in place, in lieu of the normal "BOOTING" splash (which this path skips). +void showTrmnlRefreshingStatus() { + const auto pageHeight = renderer.getScreenHeight(); + renderer.clearScreen(); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2, tr(STR_REFRESHING)); + renderer.displayBuffer(); +} + bool isGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action) { return isPowerButtonActionAvailableOutsideReader(action); } @@ -625,9 +634,14 @@ static bool loadSleepFrameBuffer() { } // Enter deep sleep mode -void enterDeepSleep(bool fromTimeout) { +void enterDeepSleep(bool fromTimeout, bool preserveLastSleepFromReader) { HalPowerManager::Lock powerLock; // Ensure we are at normal CPU frequency for sleep preparation - APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); + // Skipped when called before any activity is pushed (the TRMNL refresh-in-place + // boot path): activityManager.isReaderActivity() would report false regardless of + // what was actually open before this wake, corrupting the reader-resume flag. + if (!preserveLastSleepFromReader) { + APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); + } const bool isQuickResumeSleep = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::QUICK_RESUME || @@ -832,13 +846,31 @@ void setup() { // have to hold the power button across all of the SD reads below. const auto wakeupReason = gpio.getWakeupReason(); LOG_INF("BOOT", "Wake route: %s", wakeupRouteName(wakeupReason)); + // Set when a PowerButton wake is a short press with the TRMNL sleep screen active: + // routes to a refresh-in-place instead of a normal boot, below. Always false in the + // simulator, which can't measure press duration during its synthetic wake (see + // HalGPIO::measurePowerButtonPressWasShort's lib/hal/HalGPIO.h comment). + bool powerButtonWakeIsTrmnlRefresh = false; switch (wakeupReason) { - case HalGPIO::WakeupReason::PowerButton: + case HalGPIO::WakeupReason::PowerButton: { + const bool trmnlSleepScreenActive = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRMNL; + const bool shortPressAllowed = + trmnlSleepScreenActive || SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP; LOG_INF("BOOT", "Power-button wake: verifying duration required=%u shortAllowed=%d", - SETTINGS.getPowerButtonWakeDuration(), SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); - gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonWakeDuration(), - SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); + SETTINGS.getPowerButtonWakeDuration(), shortPressAllowed); + gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonWakeDuration(), shortPressAllowed); +#ifndef SIMULATOR + // The external simulator library (lib_deps in platformio.ini) doesn't implement + // measurePowerButtonPressWasShort -- the simulator's synthetic wake path can't + // measure press duration at all (see docs/simulator.md and HalGPIO.h). Real + // hardware measures it here to decide refresh-in-place vs. a normal wake. + if (trmnlSleepScreenActive) { + powerButtonWakeIsTrmnlRefresh = + gpio.measurePowerButtonPressWasShort(SETTINGS.getPowerButtonLongPressDuration()); + } +#endif break; + } case HalGPIO::WakeupReason::AfterUSBPower: // TEMP: continue booting while diagnosing post-flash/reset behavior. // Normal behavior is to go back to sleep when USB power causes a cold boot. @@ -873,6 +905,21 @@ void setup() { } } + // Short power-button press with the TRMNL sleep screen active: refresh the TRMNL + // image and go straight back to sleep, without booting into Home/Reader. Recovery + // mode (UP + POWER) takes priority if both were somehow detected. + if (powerButtonWakeIsTrmnlRefresh && !recoveryFirmwareMode) { + LOG_INF("BOOT", "Power-button short press with TRMNL sleep screen active: refreshing in place"); + setupDisplayAndFonts(/*seamless=*/true); + showTrmnlRefreshingStatus(); + // preserveLastSleepFromReader=true: no activity is pushed yet, so + // activityManager.isReaderActivity() would wrongly report false and clobber + // whatever this flag was already set to before this wake. + enterDeepSleep(/*fromTimeout=*/false, /*preserveLastSleepFromReader=*/true); + // enterDeepSleep() never returns on real hardware (esp_deep_sleep_start()). + return; + } + // First serial output only here to avoid timing inconsistencies for power button press duration verification LOG_DBG("MAIN", "Starting CrossInk version " CROSSINK_VERSION); From 6fc528ed485b5c92d595936f5a1063055335108d Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Thu, 2 Jul 2026 13:08:55 -0400 Subject: [PATCH 4/8] feat(TRMNL): add settings toggle to gate power-button refresh The short-press-refreshes-TRMNL-in-place behavior from the previous commit was unconditional whenever sleepScreen==TRMNL, with no way to opt out. Add Settings > System > TRMNL Settings > Refresh on Power Button, off by default (matching the Extended Wi-Fi Timeout toggle's convention of preserving prior behavior unless explicitly enabled). - CrossPointSettings: new trmnlRefreshOnPowerButton field, registered in getSettingsList() for JSON persistence (same pattern as trmnlOrientation and trmnlExtendedWifiTimeout). - TrmnlSettingsActivity: new list item, toggled and saved the same way as the other TRMNL settings. - main.cpp: trmnlSleepScreenActive now also requires the toggle, which gates both the wake-gate fast-path (shortPressAllowed) and the press-duration measurement -- off means byte-for-byte the pre-feature wake behavior. Verified: pio run -e simulator builds clean. Confirmed via CROSSPOINT_SIM_WAKE_REASON=power against a local settings file with sleepScreen=TRMNL that shortAllowed logs 0 with the new field absent/0 (default) and 1 once set to 1, matching the gate's intent. Co-Authored-By: Claude Sonnet 5 --- lib/I18n/translations/english.yaml | 1 + src/CrossPointSettings.h | 4 ++++ src/SettingsList.h | 2 ++ src/activities/settings/TrmnlSettingsActivity.cpp | 12 ++++++++++-- src/main.cpp | 13 ++++++++----- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 12cc51ccf8..9afe8d0fb8 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -352,6 +352,7 @@ STR_TRMNL_ORIENTATION: "TRMNL Orientation" STR_TRMNL_HORIZONTAL: "Horizontal" STR_TRMNL_VERTICAL: "Vertical" STR_TRMNL_FETCH_FAILED: "TRMNL fetch failed" +STR_TRMNL_REFRESH_ON_POWER_BUTTON: "Refresh on Power Button" STR_RECENT_BOOKS_VIEW: "Recent Books View" STR_LIST_VIEW: "List" STR_GRID_VIEW: "Grid" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 784132254d..1308c7a05a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -393,6 +393,10 @@ class CrossPointSettings { char trmnlApiKey[128] = ""; char trmnlDeviceId[32] = ""; uint8_t trmnlOrientation = TRMNL_LANDSCAPE; + // Short-pressing the power button while asleep with the TRMNL sleep screen + // active refreshes it in place instead of the default wake behavior. Off by + // default -- opt in per-device. + uint8_t trmnlRefreshOnPowerButton = 0; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press page turn button behavior diff --git a/src/SettingsList.h b/src/SettingsList.h index 4812348235..7b2c31c177 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -599,6 +599,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist add(SettingInfo::Enum(StrId::STR_TRMNL_ORIENTATION, &CrossPointSettings::trmnlOrientation, {StrId::STR_TRMNL_HORIZONTAL, StrId::STR_TRMNL_VERTICAL}, "trmnlOrientation", StrId::STR_TRMNL_SETTINGS)); + add(SettingInfo::Toggle(StrId::STR_TRMNL_REFRESH_ON_POWER_BUTTON, &CrossPointSettings::trmnlRefreshOnPowerButton, + "trmnlRefreshOnPowerButton", StrId::STR_TRMNL_SETTINGS)); // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- add(SettingInfo::DynamicString( diff --git a/src/activities/settings/TrmnlSettingsActivity.cpp b/src/activities/settings/TrmnlSettingsActivity.cpp index 981e06bd16..2f722cab4d 100644 --- a/src/activities/settings/TrmnlSettingsActivity.cpp +++ b/src/activities/settings/TrmnlSettingsActivity.cpp @@ -11,9 +11,10 @@ #include "components/UITheme.h" namespace { -constexpr int MENU_ITEMS = 4; +constexpr int MENU_ITEMS = 5; const StrId menuNames[MENU_ITEMS] = {StrId::STR_TRMNL_SERVER_URL, StrId::STR_TRMNL_API_KEY, - StrId::STR_TRMNL_DEVICE_ID, StrId::STR_TRMNL_ORIENTATION}; + StrId::STR_TRMNL_DEVICE_ID, StrId::STR_TRMNL_ORIENTATION, + StrId::STR_TRMNL_REFRESH_ON_POWER_BUTTON}; void copySetting(char* dest, const size_t destSize, const std::string& value) { strncpy(dest, value.c_str(), destSize - 1); @@ -92,6 +93,10 @@ void TrmnlSettingsActivity::handleSelection() { SETTINGS.trmnlOrientation = (SETTINGS.trmnlOrientation + 1) % CrossPointSettings::TRMNL_ORIENTATION_COUNT; SETTINGS.saveToFile(); requestUpdate(); + } else if (selectedIndex == 4) { + SETTINGS.trmnlRefreshOnPowerButton = !SETTINGS.trmnlRefreshOnPowerButton; + SETTINGS.saveToFile(); + requestUpdate(); } } @@ -114,6 +119,9 @@ void TrmnlSettingsActivity::render(RenderLock&&) { return SETTINGS.trmnlOrientation == CrossPointSettings::TRMNL_PORTRAIT ? std::string(tr(STR_TRMNL_VERTICAL)) : std::string(tr(STR_TRMNL_HORIZONTAL)); } + if (index == 4) { + return SETTINGS.trmnlRefreshOnPowerButton ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF)); + } return std::string(tr(STR_NOT_SET)); }, true); diff --git a/src/main.cpp b/src/main.cpp index d377566524..39f413585f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -846,14 +846,17 @@ void setup() { // have to hold the power button across all of the SD reads below. const auto wakeupReason = gpio.getWakeupReason(); LOG_INF("BOOT", "Wake route: %s", wakeupRouteName(wakeupReason)); - // Set when a PowerButton wake is a short press with the TRMNL sleep screen active: - // routes to a refresh-in-place instead of a normal boot, below. Always false in the - // simulator, which can't measure press duration during its synthetic wake (see - // HalGPIO::measurePowerButtonPressWasShort's lib/hal/HalGPIO.h comment). + // Set when a PowerButton wake is a short press with the TRMNL sleep screen active + // and SETTINGS.trmnlRefreshOnPowerButton is on (Settings > System > TRMNL Settings + // > Refresh on Power Button, off by default): routes to a refresh-in-place instead + // of a normal boot, below. Always false in the simulator, which can't measure press + // duration during its synthetic wake (see HalGPIO::measurePowerButtonPressWasShort's + // lib/hal/HalGPIO.h comment). bool powerButtonWakeIsTrmnlRefresh = false; switch (wakeupReason) { case HalGPIO::WakeupReason::PowerButton: { - const bool trmnlSleepScreenActive = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRMNL; + const bool trmnlSleepScreenActive = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRMNL && + SETTINGS.trmnlRefreshOnPowerButton != 0; const bool shortPressAllowed = trmnlSleepScreenActive || SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP; LOG_INF("BOOT", "Power-button wake: verifying duration required=%u shortAllowed=%d", From a7306c143da793cf8060b4432ae87852719988cf Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Thu, 2 Jul 2026 13:35:00 -0400 Subject: [PATCH 5/8] feat(TRMNL): show refresh-specific status text during power-button refresh Two text tweaks to the refresh-in-place flow, requested after trying it conceptually: the transient full-screen status shown at the very start now reads as a usage hint rather than a bare status word, and the "Going to sleep" popup that SleepActivity always shows on the way down now says "Refreshing" specifically for this path (unchanged for every other sleep trigger/mode). - showTrmnlRefreshingStatus() now shows two lines ("Short press power to refresh" / "Long press power to wake up") instead of just "Refreshing". - SleepActivity gains an isPowerButtonRefresh constructor flag (threaded through ActivityManager::goToSleep() and enterDeepSleep(), replacing the narrower preserveLastSleepFromReader flag added previously -- this is the more accurate name for what that flag actually represents, and now drives two behaviors instead of one). onEnter() swaps its "Going to sleep" popup for "Refreshing" only when this flag is set; every other sleep-screen mode and sleep trigger (timeout, manual sleep, etc.) is unaffected. - STR_REFRESHING moves from the BOOTING-style all-caps splash convention to the ENTERING_SLEEP-style sentence-case popup convention, since it's no longer used for the former. Verified: pio run -e simulator builds clean. Re-ran the same CROSSPOINT_SIM_WAKE_REASON=power + temporarily-forced-refresh-branch check used for the original feature commit; confirmed the same crash-free activity sequence (status screen -> SleepActivity -> TRMNL fetch fallback -> deep sleep) with the new strings compiled in. Exact on-device text rendering (line wrap, popup sizing) still needs a real device flash to see visually -- not verifiable in this simulator per the original commit's limitations. Co-Authored-By: Claude Sonnet 5 --- lib/I18n/translations/english.yaml | 4 +++- src/GlobalActions.h | 2 +- src/activities/ActivityManager.cpp | 6 +++--- src/activities/ActivityManager.h | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 5 +++-- src/activities/boot_sleep/SleepActivity.h | 10 ++++++++-- src/main.cpp | 19 +++++++++++-------- 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 9afe8d0fb8..6eba010bc7 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -4,9 +4,9 @@ _order: "0" STR_CROSSINK: "CrossInk" STR_BOOTING: "BOOTING" -STR_REFRESHING: "REFRESHING" STR_SLEEPING: "SLEEPING" STR_ENTERING_SLEEP: "Going to sleep" +STR_REFRESHING: "Refreshing" STR_BROWSE_FILES: "Browse Files" STR_BROWSE: "Browse" STR_FILE_TRANSFER: "File Transfer" @@ -353,6 +353,8 @@ STR_TRMNL_HORIZONTAL: "Horizontal" STR_TRMNL_VERTICAL: "Vertical" STR_TRMNL_FETCH_FAILED: "TRMNL fetch failed" STR_TRMNL_REFRESH_ON_POWER_BUTTON: "Refresh on Power Button" +STR_TRMNL_REFRESH_HINT_1: "Short press power to refresh" +STR_TRMNL_REFRESH_HINT_2: "Long press power to wake up" STR_RECENT_BOOKS_VIEW: "Recent Books View" STR_LIST_VIEW: "List" STR_GRID_VIEW: "Grid" diff --git a/src/GlobalActions.h b/src/GlobalActions.h index 2f49a62425..87020d7a6f 100644 --- a/src/GlobalActions.h +++ b/src/GlobalActions.h @@ -33,4 +33,4 @@ inline bool isPowerButtonActionAvailableOutsideReader(const CrossPointSettings:: } } -void enterDeepSleep(bool fromTimeout = false, bool preserveLastSleepFromReader = false); +void enterDeepSleep(bool fromTimeout = false, bool isPowerButtonRefresh = false); diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 25c6bc5101..b2c3e325fe 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -237,10 +237,10 @@ void ActivityManager::goToReader(std::string path, const bool suppressBackReleas replaceActivity(std::make_unique(renderer, mappedInput, std::move(path), suppressBackRelease)); } -void ActivityManager::goToSleep(bool fromTimeout) { +void ActivityManager::goToSleep(bool fromTimeout, bool isPowerButtonRefresh) { const bool canSnapshotOverlay = currentActivity && currentActivity->canSnapshotForSleepOverlay(); - replaceActivity( - std::make_unique(renderer, mappedInput, canSnapshotOverlay, getCurrentBookPath(), fromTimeout)); + replaceActivity(std::make_unique(renderer, mappedInput, canSnapshotOverlay, getCurrentBookPath(), + fromTimeout, isPowerButtonRefresh)); loop(); // Important: sleep screen must be rendered immediately, the caller will go to sleep right after this returns } diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 1033737cf0..cb3f99234b 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -99,7 +99,7 @@ class ActivityManager { void goToRecentBooks(); void goToBrowser(); void goToReader(std::string path, bool suppressBackRelease = false); - void goToSleep(bool fromTimeout = false); + void goToSleep(bool fromTimeout = false, bool isPowerButtonRefresh = false); void goToBoot(); void goToFullScreenMessage(std::string message, EpdFontFamily::Style style = EpdFontFamily::REGULAR); void goToCrashReport(); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 7a667b0c5f..71b96bcfe5 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -433,12 +433,13 @@ void SleepActivity::onEnter() { // Show the popup in the reader's orientation when sleep starts from an open book. // Reset to portrait afterwards so the sleep screen renderer keeps its existing layout. + const char* popupText = I18N.get(isPowerButtonRefresh ? StrId::STR_REFRESHING : StrId::STR_ENTERING_SLEEP); if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + GUI.drawPopup(renderer, popupText); renderer.setOrientation(GfxRenderer::Orientation::Portrait); } else { - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + GUI.drawPopup(renderer, popupText); } switch (SETTINGS.sleepScreen) { diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 21e5657301..93018e348b 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -9,11 +9,13 @@ class Bitmap; class SleepActivity final : public Activity { public: explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool canSnapshotOverlayBackground, - std::string currentBookPath = {}, bool fromTimeout = false) + std::string currentBookPath = {}, bool fromTimeout = false, + bool isPowerButtonRefresh = false) : Activity("Sleep", renderer, mappedInput), canSnapshotOverlayBackground(canSnapshotOverlayBackground), currentBookPath(std::move(currentBookPath)), - fromTimeout(fromTimeout) {} + fromTimeout(fromTimeout), + isPowerButtonRefresh(isPowerButtonRefresh) {} void onEnter() override; private: @@ -35,4 +37,8 @@ class SleepActivity final : public Activity { bool overlayBackgroundBufferStored = false; std::string currentBookPath; bool fromTimeout = false; + // True when this sleep-entry is the TRMNL refresh-in-place path (short power-button + // press while already asleep, see main.cpp) rather than a normal sleep. Swaps the + // "Going to sleep" popup for "Refreshing". + bool isPowerButtonRefresh = false; }; diff --git a/src/main.cpp b/src/main.cpp index 39f413585f..ebe3d44fe0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -445,8 +445,10 @@ void waitForPowerRelease() { // screen in place, in lieu of the normal "BOOTING" splash (which this path skips). void showTrmnlRefreshingStatus() { const auto pageHeight = renderer.getScreenHeight(); + const auto lineHeight = renderer.getTextHeight(SMALL_FONT_ID); renderer.clearScreen(); - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2, tr(STR_REFRESHING)); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 - lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_1)); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_2)); renderer.displayBuffer(); } @@ -634,12 +636,12 @@ static bool loadSleepFrameBuffer() { } // Enter deep sleep mode -void enterDeepSleep(bool fromTimeout, bool preserveLastSleepFromReader) { +void enterDeepSleep(bool fromTimeout, bool isPowerButtonRefresh) { HalPowerManager::Lock powerLock; // Ensure we are at normal CPU frequency for sleep preparation // Skipped when called before any activity is pushed (the TRMNL refresh-in-place // boot path): activityManager.isReaderActivity() would report false regardless of // what was actually open before this wake, corrupting the reader-resume flag. - if (!preserveLastSleepFromReader) { + if (!isPowerButtonRefresh) { APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); } @@ -654,7 +656,7 @@ void enterDeepSleep(bool fromTimeout, bool preserveLastSleepFromReader) { // Commit to sleeping before goToSleep() runs the outgoing activity's onExit(): // a WiFi activity would otherwise silentRestart() here and reboot instead. deepSleepInProgress = true; - activityManager.goToSleep(fromTimeout); + activityManager.goToSleep(fromTimeout, isPowerButtonRefresh); if (isQuickResumeSleep) { saveSleepFrameBuffer(); @@ -915,10 +917,11 @@ void setup() { LOG_INF("BOOT", "Power-button short press with TRMNL sleep screen active: refreshing in place"); setupDisplayAndFonts(/*seamless=*/true); showTrmnlRefreshingStatus(); - // preserveLastSleepFromReader=true: no activity is pushed yet, so - // activityManager.isReaderActivity() would wrongly report false and clobber - // whatever this flag was already set to before this wake. - enterDeepSleep(/*fromTimeout=*/false, /*preserveLastSleepFromReader=*/true); + // isPowerButtonRefresh=true: preserves lastSleepFromReader (no activity is pushed + // yet, so activityManager.isReaderActivity() would wrongly report false and + // clobber it) and tells SleepActivity to show "Refreshing" instead of "Going to + // sleep". + enterDeepSleep(/*fromTimeout=*/false, /*isPowerButtonRefresh=*/true); // enterDeepSleep() never returns on real hardware (esp_deep_sleep_start()). return; } From f9e9c4459cb3d6d11d8a38b7899114c0d5b13211 Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Thu, 2 Jul 2026 14:05:40 -0400 Subject: [PATCH 6/8] feat(TRMNL): add arrow pointing to the power button on the refresh hint The refresh-in-place status screen (showTrmnlRefreshingStatus) explains what the buttons do but didn't say where the power button physically is. Add a simple up-pointing chevron-and-shaft arrow in the top-left corner of the screen, matching the X4's physical power button placement (top edge, offset left) as confirmed by the user. Reuses the existing stacked-widening-lines chevron technique already used for BaseTheme's list scroll arrows (src/components/themes/ BaseTheme.cpp) rather than introducing a new drawing primitive or a bitmap asset -- there were no existing button-hint diagrams or arrow assets anywhere in the repo to reuse (confirmed by search). Text casing left unchanged (sentence-style, matching Apple HIG guidance: capitalize only the first word, no title-case/all-caps for generic hardware terms like "power"). Verified: pio run -e simulator builds clean. Re-ran the same forced- refresh-branch trace used for prior commits on this branch to confirm the arrow-drawing code executes without crashing. Pixel-level visual verification (arrow proportions, exact on-screen position) was not possible in this environment -- the simulator binary isn't a registered macOS app bundle, so GUI screenshot tooling can't target its window, and hand-decoding the raw rotated 1bpp framebuffer risked a wrong read more than it was worth for a cosmetic check. Geometric placement was sanity- checked against the known 480x800 logical portrait canvas (arrow head spans roughly x=33-47, y=24-32; shaft to y=52; well clear of the text centered around y=400, no clipping or overlap). Real device flash will be the actual visual confirmation. Co-Authored-By: Claude Sonnet 5 --- src/main.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index ebe3d44fe0..868287e220 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -447,6 +447,23 @@ void showTrmnlRefreshingStatus() { const auto pageHeight = renderer.getScreenHeight(); const auto lineHeight = renderer.getTextHeight(SMALL_FONT_ID); renderer.clearScreen(); + + // Up-pointing arrow toward the top-left corner, where the physical power button is + // on this device -- a chevron head (stacked widening lines, same technique as + // BaseTheme's list scroll arrows) plus a shaft, positioned in the top-left margin. + constexpr int arrowMarginLeft = 40; + constexpr int arrowMarginTop = 24; + constexpr int arrowHeadSize = 8; + constexpr int arrowShaftLength = 20; + const int arrowX = arrowMarginLeft; + for (int i = 0; i < arrowHeadSize; ++i) { + const int lineWidth = 1 + i * 2; + const int startX = arrowX - i; + renderer.drawLine(startX, arrowMarginTop + i, startX + lineWidth - 1, arrowMarginTop + i); + } + renderer.drawLine(arrowX, arrowMarginTop + arrowHeadSize, arrowX, arrowMarginTop + arrowHeadSize + arrowShaftLength, + 2, true); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 - lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_1)); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_2)); renderer.displayBuffer(); From 04e114cd1e6202d2a97306c8779aac97311061bf Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Thu, 2 Jul 2026 14:10:32 -0400 Subject: [PATCH 7/8] fix(TRMNL): correct power button arrow to right edge, ~20% down The previous commit placed the arrow at the top-left corner based on an initial rough description. Corrected per an actual physical measurement: the power button is on the device's right edge, about 20% of the way down from the top (~22mm). Replaces the up-pointing chevron with a right-pointing arrow (shaft plus a two-line diagonal head, mirroring BaseTheme's keyboard backspace arrow rotated 180 degrees) positioned at the right edge, y = pageHeight/5. Verified: pio run -e simulator builds clean. Re-ran the same forced- refresh-branch trace as prior commits to confirm no crash. Geometry sanity-checked against the 480x800 logical portrait canvas: arrow tip at x=456, head spans y=152-168, shaft to x=432 -- within bounds, clear of the text block centered around y=400. Co-Authored-By: Claude Sonnet 5 --- src/main.cpp | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 868287e220..570aad2ef4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -444,25 +444,24 @@ void waitForPowerRelease() { // Status screen shown while a short power-button press refreshes the TRMNL sleep // screen in place, in lieu of the normal "BOOTING" splash (which this path skips). void showTrmnlRefreshingStatus() { + const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); const auto lineHeight = renderer.getTextHeight(SMALL_FONT_ID); renderer.clearScreen(); - // Up-pointing arrow toward the top-left corner, where the physical power button is - // on this device -- a chevron head (stacked widening lines, same technique as - // BaseTheme's list scroll arrows) plus a shaft, positioned in the top-left margin. - constexpr int arrowMarginLeft = 40; - constexpr int arrowMarginTop = 24; + // Right-pointing arrow toward the physical power button, which sits on this + // device's right edge, ~20% of the way down from the top (measured ~22mm). + // Mirrors BaseTheme's keyboard backspace arrow (shaft + two diagonals forming + // the head), rotated 180 degrees to point right instead of left. + constexpr int arrowMarginRight = 24; + constexpr int arrowShaftLength = 24; constexpr int arrowHeadSize = 8; - constexpr int arrowShaftLength = 20; - const int arrowX = arrowMarginLeft; - for (int i = 0; i < arrowHeadSize; ++i) { - const int lineWidth = 1 + i * 2; - const int startX = arrowX - i; - renderer.drawLine(startX, arrowMarginTop + i, startX + lineWidth - 1, arrowMarginTop + i); - } - renderer.drawLine(arrowX, arrowMarginTop + arrowHeadSize, arrowX, arrowMarginTop + arrowHeadSize + arrowShaftLength, - 2, true); + const int arrowTipX = pageWidth - arrowMarginRight; + const int arrowShaftLeftX = arrowTipX - arrowShaftLength; + const int arrowY = pageHeight / 5; // ~20% down from the top + renderer.drawLine(arrowShaftLeftX, arrowY, arrowTipX, arrowY, 2, true); + renderer.drawLine(arrowTipX, arrowY, arrowTipX - arrowHeadSize, arrowY - arrowHeadSize, 2, true); + renderer.drawLine(arrowTipX, arrowY, arrowTipX - arrowHeadSize, arrowY + arrowHeadSize, 2, true); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 - lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_1)); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_2)); From ee40894a3a6d571217e6b6e3e8cf23df003a1808 Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Thu, 2 Jul 2026 14:36:29 -0400 Subject: [PATCH 8/8] docs+cleanup: address code review comments - TrmnlSettingsActivity: replace the hard-coded index 4 with a named MENU_INDEX_REFRESH_ON_POWER_BUTTON constant, matching the review's suggestion. Keeps handleSelection()/render() in sync if items are ever reordered. - HalGPIO::measurePowerButtonPressWasShort is a single generic duration- vs-threshold primitive deliberately reused from two call sites with different thresholds and meanings (noise-filter gate inside verifyPowerButtonWakeup vs. short/long classification in main.cpp's TRMNL routing). Rather than splitting into two identically-implemented wrapper functions, documented this explicitly at the declaration and at both call sites -- the review offered either as an acceptable fix, and the docs option avoids adding two functions whose only difference is their name. Renamed the parameter from longPressDurationMs to the more accurate durationMs since it isn't always a long-press duration. Investigated but did NOT act on the review's third comment (bug_risk on HalGPIO.cpp:252): it claimed verifyPowerButtonWakeup now ignores shortPressAllowed and always forces deep sleep on a short press, breaking the TRMNL refresh path. That's not what the code does -- the `if (shortPressAllowed) { return; }` guard is unchanged from before this feature (confirmed against the original commit's diff, which only touched code after that guard) and still short-circuits before measurePowerButtonPressWasShort/startDeepSleep are ever reached. Since shortPressAllowed is true precisely when the TRMNL refresh path is active, the claimed regression can't occur. Restructuring working code to fix a bug that doesn't exist would add risk for no benefit, so left the logic as-is and instead strengthened the guard's comment (see above) so the same misread doesn't happen again on a future review. Verified: pio run -e simulator builds clean. Re-ran the same forced- refresh-branch trace used for every commit on this branch to confirm behavior is unchanged (doc/naming-only change, no logic touched). Co-Authored-By: Claude Sonnet 5 --- lib/hal/HalGPIO.cpp | 11 +++++---- lib/hal/HalGPIO.h | 24 ++++++++++++------- .../settings/TrmnlSettingsActivity.cpp | 8 +++++-- src/main.cpp | 8 +++++++ 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index a71427d3bc..edcea460a4 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -244,7 +244,11 @@ void HalGPIO::startDeepSleep() { void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) { if (shortPressAllowed) { - // Fast path - no duration check needed + // Fast path: the caller has already decided any press length should continue + // booting (e.g. shortPwrBtn==SLEEP, or the TRMNL-refresh path in main.cpp, which + // does its OWN separate measurePowerButtonPressWasShort() call afterward to + // classify short vs. long -- this early return is exactly what lets that call + // happen instead of being preempted by startDeepSleep() below). return; } // TODO: Intermittent edge case remains: a single tap followed by another single tap @@ -254,11 +258,10 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre } } -bool HalGPIO::measurePowerButtonPressWasShort(uint16_t longPressDurationMs) { +bool HalGPIO::measurePowerButtonPressWasShort(uint16_t durationMs) { // Calibrate: subtract boot time already elapsed, assuming button held since boot const uint16_t calibration = millis(); - const uint16_t calibratedDuration = - (calibration < longPressDurationMs) ? (longPressDurationMs - calibration) : 1; + const uint16_t calibratedDuration = (calibration < durationMs) ? (durationMs - calibration) : 1; const auto start = millis(); inputMgr.update(); diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 6b5acad525..2c2d7606cd 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -75,17 +75,25 @@ class HalGPIO { // Setup wake up GPIO and enter deep sleep void startDeepSleep(); - // Verify power button was held long enough after wakeup. - // If verification fails, enters deep sleep and does not return. + // Verify power button was held long enough after wakeup. If shortPressAllowed is + // true, returns immediately without measuring anything -- the caller is asserting + // that ANY press length should continue booting, so no gate is needed. Otherwise + // measures the press (see measurePowerButtonPressWasShort below) and, if it comes + // back short, rejects the wakeup: enters deep sleep and does not return. // Should only be called when wakeup reason is PowerButton. void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed); - // Measures the power-button press that caused a PowerButton wakeup and reports - // whether it was short (released before longPressDurationMs) or long. Unlike - // verifyPowerButtonWakeup, this never rejects the wakeup -- the caller decides - // what a short vs. long press means. Should only be called when wakeup reason - // is PowerButton. - bool measurePowerButtonPressWasShort(uint16_t longPressDurationMs); + // Generic primitive: polls the power button and reports whether it was released + // before durationMs elapsed (calibrated for boot time already spent), or is still + // held past it. Never rejects the wakeup itself -- the caller decides what to do + // with the short/long verdict. Two call sites use this with deliberately different + // thresholds and meanings, so match the threshold to the question being asked: + // - verifyPowerButtonWakeup (above) passes the WAKE duration (10-200ms) purely as + // a noise filter: "was this even a deliberate press, or a bounce?" + // - main.cpp's TRMNL-refresh routing passes the LONG-PRESS duration (400ms) to + // classify intent: "should this be treated as a short action or a long press?" + // Should only be called when wakeup reason is PowerButton. + bool measurePowerButtonPressWasShort(uint16_t durationMs); // Check if USB is connected bool isUsbConnected() const; diff --git a/src/activities/settings/TrmnlSettingsActivity.cpp b/src/activities/settings/TrmnlSettingsActivity.cpp index 2f722cab4d..cde446ba25 100644 --- a/src/activities/settings/TrmnlSettingsActivity.cpp +++ b/src/activities/settings/TrmnlSettingsActivity.cpp @@ -12,6 +12,10 @@ namespace { constexpr int MENU_ITEMS = 5; +// Named so this item's wiring in handleSelection()/render() doesn't silently break if +// items are reordered or new ones inserted before it -- the other indices (0-3) are +// pre-existing and unchanged by this feature, left as-is to keep this change scoped. +constexpr int MENU_INDEX_REFRESH_ON_POWER_BUTTON = 4; const StrId menuNames[MENU_ITEMS] = {StrId::STR_TRMNL_SERVER_URL, StrId::STR_TRMNL_API_KEY, StrId::STR_TRMNL_DEVICE_ID, StrId::STR_TRMNL_ORIENTATION, StrId::STR_TRMNL_REFRESH_ON_POWER_BUTTON}; @@ -93,7 +97,7 @@ void TrmnlSettingsActivity::handleSelection() { SETTINGS.trmnlOrientation = (SETTINGS.trmnlOrientation + 1) % CrossPointSettings::TRMNL_ORIENTATION_COUNT; SETTINGS.saveToFile(); requestUpdate(); - } else if (selectedIndex == 4) { + } else if (selectedIndex == MENU_INDEX_REFRESH_ON_POWER_BUTTON) { SETTINGS.trmnlRefreshOnPowerButton = !SETTINGS.trmnlRefreshOnPowerButton; SETTINGS.saveToFile(); requestUpdate(); @@ -119,7 +123,7 @@ void TrmnlSettingsActivity::render(RenderLock&&) { return SETTINGS.trmnlOrientation == CrossPointSettings::TRMNL_PORTRAIT ? std::string(tr(STR_TRMNL_VERTICAL)) : std::string(tr(STR_TRMNL_HORIZONTAL)); } - if (index == 4) { + if (index == MENU_INDEX_REFRESH_ON_POWER_BUTTON) { return SETTINGS.trmnlRefreshOnPowerButton ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF)); } return std::string(tr(STR_NOT_SET)); diff --git a/src/main.cpp b/src/main.cpp index 570aad2ef4..7f5864f7b2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -885,6 +885,14 @@ void setup() { // measurePowerButtonPressWasShort -- the simulator's synthetic wake path can't // measure press duration at all (see docs/simulator.md and HalGPIO.h). Real // hardware measures it here to decide refresh-in-place vs. a normal wake. + // + // This is a SEPARATE measurement from the one verifyPowerButtonWakeup() may have + // just done above (it didn't, here, since shortPressAllowed was true for this + // case) -- and deliberately uses a different threshold. That call, when it runs, + // uses the WAKE duration purely as a noise filter (any qualifying press + // continues booting). This one uses the LONG-PRESS duration to classify intent: + // under it means "treat as a short action" (refresh in place); at or over it + // means "treat as a long press" (fall through to a normal wake, below). if (trmnlSleepScreenActive) { powerButtonWakeIsTrmnlRefresh = gpio.measurePowerButtonPressWasShort(SETTINGS.getPowerButtonLongPressDuration());