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/5] 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/5] 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 05bbe27b2e576721cac1847695c9e55862d105ea Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Wed, 1 Jul 2026 11:53:54 -0400 Subject: [PATCH 3/5] fix(TRMNL): widen JSON token buffer to stop silent image_url drops TRMNL's /api/display response JSON-escapes ampersands in the presigned S3 image_url inconsistently across responses -- sometimes a literal '&' (1 byte), sometimes & (6 bytes; both are valid JSON). StreamingJsonParser intentionally passes \uXXXX escapes through as their literal 6-character source text rather than decoding them (see the 'u' case in onEscapedChar()), so a URL with ~6-7 escaped ampersands can run ~30-35 bytes longer in the token buffer than the same URL with literal '&'. TOKEN_BUF_SIZE was 512. A real image_url measured 486 bytes with literal '&' -- comfortably under the limit -- but 521 bytes with & escaping for the identical response, over it. When a token overflows the buffer, the parser's tokenOverflow guard silently skips the onString() callback entirely: no error, no log. TrmnlDisplayJsonParser's imageUrlFound simply stays false, TrmnlSleepClient::fetchLatest() logs a bare "TRMNL fetch failed (fetch=0 validate=0)" with nothing pointing at the real cause, and the device falls back to a stale cached image or the default sleep screen -- indistinguishable on-device from a genuine network failure. Raise TOKEN_BUF_SIZE and the matching TrmnlDisplayJsonParser::imageUrl buffer from 512 to 1024 bytes, with real headroom rather than tuned to one observed case. Added a regression test (testHandlesLongEscapedImageUrl) using a real measured TRMNL response with &-escaped ampersands substituted in. Verified it fails at exactly parser.foundImageUrl() under the old 512-byte size (reproducing the silent failure) and passes at 1024. Full existing suite (test/run_trmnl_display_json_parser_test.sh) and a `pio run -e simulator` firmware build both pass. Not fixed here: \uXXXX escapes are still passed through literally rather than decoded, which is the deeper correctness issue (a passed-through & in a URL is not itself valid for use in an HTTP request if it were ever needed downstream, independent of buffer size). That requires adding real escape-sequence state to StreamingJsonParser (hex digit consumption, surrogate pairs, UTF-8 encoding) and is scoped as a separate, more involved fix. Co-Authored-By: Claude Sonnet 5 --- lib/JsonParser/StreamingJsonParser.h | 10 ++++- lib/JsonParser/TrmnlDisplayJsonParser.h | 4 +- .../TrmnlDisplayJsonParserTest.cpp | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/JsonParser/StreamingJsonParser.h b/lib/JsonParser/StreamingJsonParser.h index 6c36034293..f2db6a38ec 100644 --- a/lib/JsonParser/StreamingJsonParser.h +++ b/lib/JsonParser/StreamingJsonParser.h @@ -18,7 +18,15 @@ struct JsonCallbacks { class StreamingJsonParser { public: - static constexpr size_t TOKEN_BUF_SIZE = 512; + // \uXXXX escapes are passed through as the literal 6-character source text (see + // onEscapedChar()'s 'u' case) rather than decoded, so a string value with several + // escaped ampersands (&, 6 bytes each vs. 1 for a literal '&') can be several + // times longer in the buffer than its logical length. TRMNL's /api/display responses + // measured ~486-516 bytes for image_url alone depending on whether the server escaped + // ampersands for that response, right at the edge of the previous 512-byte limit -- + // silently truncating the callback (see the tokenOverflow check below) with no error. + // Sized with real headroom rather than tuned to one observed case. + static constexpr size_t TOKEN_BUF_SIZE = 1024; static constexpr size_t MAX_NESTING = 32; explicit StreamingJsonParser(const JsonCallbacks& callbacks); diff --git a/lib/JsonParser/TrmnlDisplayJsonParser.h b/lib/JsonParser/TrmnlDisplayJsonParser.h index a5edd31d3c..faa8ce5f04 100644 --- a/lib/JsonParser/TrmnlDisplayJsonParser.h +++ b/lib/JsonParser/TrmnlDisplayJsonParser.h @@ -33,7 +33,9 @@ class TrmnlDisplayJsonParser { StreamingJsonParser parser; LastKey lastKey = LastKey::NONE; uint16_t depth = 0; - char imageUrl[512] = ""; + // Matches StreamingJsonParser::TOKEN_BUF_SIZE -- see its comment for why this needs + // real headroom beyond a "typical" image_url length. + char imageUrl[1024] = ""; char filename[128] = ""; uint32_t refreshRateSeconds = 0; bool imageUrlFound = false; diff --git a/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp b/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp index 1413545a14..6d69aef239 100644 --- a/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp +++ b/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "lib/JsonParser/TrmnlDisplayJsonParser.h" #include "src/trmnl/TrmnlDisplayConfig.h" @@ -126,6 +127,42 @@ void testMissingImageUrl() { PASS(); } +// Regression test for a real TRMNL /api/display response: a presigned S3 URL with +// several &-escaped ampersands (valid JSON -- TRMNL's server was observed emitting +// both literal '&' and & for the same field across different responses). Each +// & is stored as 6 raw bytes by this parser (it intentionally doesn't decode +// \uXXXX escapes -- see StreamingJsonParser's onEscapedChar()), so this 486-byte URL +// (with literal '&') becomes 521 bytes with & substituted in -- over the old +// 512-byte TOKEN_BUF_SIZE, which silently dropped the image_url callback with no +// error (tokenOverflow just skips onString()). This must fit in the current buffer. +void testHandlesLongEscapedImageUrl() { + printf("testHandlesLongEscapedImageUrl...\n"); + + const std::string url = + "https://trmnl.s3.us-east-2.amazonaws.com/m8b1cjzs3zidyc0lexm460vy3t5j?" + "response-content-disposition=inline%3B%20filename%3D%22plugin-10a5b0%22%3B%20" + "filename%2A%3DUTF-8%27%27plugin-10a5b0\\u0026" + "response-content-type=image%2Fpng\\u0026" + "X-Amz-Algorithm=AWS4-HMAC-SHA256\\u0026" + "X-Amz-Credential=AKIA47CRUQUU4VKBBMOF%2F20260701%2Fus-east-2%2Fs3%2Faws4_request\\u0026" + "X-Amz-Date=20260701T154633Z\\u0026" + "X-Amz-Expires=300\\u0026" + "X-Amz-SignedHeaders=host\\u0026" + "X-Amz-Signature=8e8f45ef38cbc565dc18b46574cafb1f8d117b513200d8c795729ef629a41545"; + const std::string json = R"({"status":0,"image_url":")" + url + + R"(","filename":"plugin-10a5b0-1782914788","refresh_rate":907})"; + + TrmnlDisplayJsonParser parser; + parser.feed(json.c_str(), json.length()); + + ASSERT_FALSE(parser.hasError()); + ASSERT_TRUE(parser.foundImageUrl()); + ASSERT_STREQ(parser.getImageUrl(), url.c_str()); + + printf(" passed\n"); + PASS(); +} + void testDisplaySizeForOrientation() { printf("testDisplaySizeForOrientation...\n"); @@ -147,6 +184,7 @@ int main() { testParsesChunkedMinifiedResponse(); testIgnoresNestedImageUrl(); testMissingImageUrl(); + testHandlesLongEscapedImageUrl(); testDisplaySizeForOrientation(); printf("\n%d passed, %d failed\n", testsPassed, testsFailed); From a51e76dbcde3f7b231f9aa5b0e8d4e067f2604b8 Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Wed, 1 Jul 2026 12:22:43 -0400 Subject: [PATCH 4/5] fix(TRMNL): decode \uXXXX escapes instead of passing them through raw Follow-up to the previous commit on this branch, which mitigated the silent image_url drop by widening TOKEN_BUF_SIZE. This addresses the deeper cause it deliberately left open: StreamingJsonParser passed \uXXXX escapes through as their literal 6-character source text (see the old 'u' case in handleStringChar()) rather than decoding them, on the assumption that "our use case only needs ASCII field matching." That assumption doesn't hold for image_url -- its *value*, not just its key, is used downstream to build the actual HTTP request for the image, so a literal & sitting where an HTTP client needs a real '&' silently breaks that request rather than merely wasting buffer space. Implements real \uXXXX decoding to UTF-8: - Hex digits are consumed via new member state (unicodeDigitsRemaining, unicodeValue), not locals, since a single escape can arrive split across separate feed() calls -- same reason literalPos/literalExpected already exist for split true/false/null literals. - UTF-16 surrogate pairs (codepoints beyond the BMP, e.g. emoji) are combined into one codepoint before encoding, via pendingHighSurrogate. - Encodes to 1-4 byte UTF-8 depending on codepoint range, via a new appendUtf8CodePoint() shared by both the surrogate-pair and direct-codepoint paths. - A lone high surrogate, a lone low surrogate, or a non-hex-digit inside \uXXXX now sets the existing error flag (hasError()) instead of producing corrupt output -- consistent with how this parser already treats other malformed input (bad literals, nesting overflow). TOKEN_BUF_SIZE stays at 1024 from the previous commit -- decoding removes the escape-inflation problem for the common ASCII-punctuation case (a decoded '&' is back to costing 1 byte, not 6), but there's no reason to shrink it back down; the extra headroom is cheap (this device has 200+KB free heap) and guards against genuinely long tokens regardless of cause. Tests: - StreamingJsonParserTest.cpp: replaced the old UnicodeEscapePassthrough test (which asserted the raw-passthrough behavior this commit removes) with coverage for 1/2/3/4-byte UTF-8 output, the real & case that motivated this, all three malformed-escape error cases, and a chunked-feed split landing mid-hex-digit (proving the state survives across feed() boundaries). 38/38 pass via the existing CMake+gtest target (test/streaming_json_parser). - TrmnlDisplayJsonParserTest.cpp: updated testHandlesLongEscapedImageUrl to assert against the correctly decoded URL instead of the old passthrough text, building the escaped wire-format input from it programmatically. 6/6 pass via test/run_trmnl_display_json_parser_test.sh. - Full `pio run -e simulator` firmware build passes. Co-Authored-By: Claude Sonnet 5 --- lib/JsonParser/StreamingJsonParser.cpp | 85 +++++++++++++++- lib/JsonParser/StreamingJsonParser.h | 24 +++-- .../StreamingJsonParserTest.cpp | 96 ++++++++++++++++++- .../TrmnlDisplayJsonParserTest.cpp | 43 +++++---- 4 files changed, 215 insertions(+), 33 deletions(-) diff --git a/lib/JsonParser/StreamingJsonParser.cpp b/lib/JsonParser/StreamingJsonParser.cpp index 45d9b435bd..0fd387c52e 100644 --- a/lib/JsonParser/StreamingJsonParser.cpp +++ b/lib/JsonParser/StreamingJsonParser.cpp @@ -14,6 +14,9 @@ void StreamingJsonParser::reset() { nestingDepth = 0; literalLen = 0; literalPos = 0; + unicodeDigitsRemaining = 0; + unicodeValue = 0; + pendingHighSurrogate = 0; } void StreamingJsonParser::feed(const char* data, size_t len) { @@ -45,6 +48,8 @@ void StreamingJsonParser::handleScanning(char c) { case '"': tokenLen = 0; tokenOverflow = false; + unicodeDigitsRemaining = 0; + pendingHighSurrogate = 0; if (expectingValue || inArray()) { state = State::IN_STRING_VALUE; } else { @@ -123,6 +128,20 @@ void StreamingJsonParser::handleScanning(char c) { } void StreamingJsonParser::handleStringChar(char c) { + if (unicodeDigitsRemaining > 0) { + const int digit = hexDigitValue(c); + if (digit < 0) { + error = true; + return; + } + unicodeValue = static_cast((unicodeValue << 4) | static_cast(digit)); + --unicodeDigitsRemaining; + if (unicodeDigitsRemaining == 0) { + finishUnicodeEscape(); + } + return; + } + if (escaped) { escaped = false; switch (c) { @@ -147,10 +166,8 @@ void StreamingJsonParser::handleStringChar(char c) { appendToken('\t'); break; case 'u': - // Pass \uXXXX through as literal characters -- we don't decode - // Unicode escapes since our use case only needs ASCII field matching. - appendToken('\\'); - appendToken('u'); + unicodeDigitsRemaining = 4; + unicodeValue = 0; break; default: appendToken('\\'); @@ -166,6 +183,11 @@ void StreamingJsonParser::handleStringChar(char c) { } if (c == '"') { + if (pendingHighSurrogate != 0) { + // String closed with an unpaired UTF-16 high surrogate -- malformed input. + error = true; + return; + } emitToken(); return; } @@ -173,6 +195,61 @@ void StreamingJsonParser::handleStringChar(char c) { appendToken(c); } +void StreamingJsonParser::finishUnicodeEscape() { + if (pendingHighSurrogate != 0) { + if (unicodeValue >= 0xDC00 && unicodeValue <= 0xDFFF) { + // Valid low surrogate: combine per the UTF-16 surrogate pair formula. + const uint32_t codepoint = 0x10000 + ((static_cast(pendingHighSurrogate) - 0xD800) << 10) + + (static_cast(unicodeValue) - 0xDC00); + pendingHighSurrogate = 0; + appendUtf8CodePoint(codepoint); + } else { + // High surrogate not followed by a valid low surrogate -- malformed input. + error = true; + } + return; + } + + if (unicodeValue >= 0xD800 && unicodeValue <= 0xDBFF) { + // High surrogate: hold it and wait for the low surrogate that must follow. + pendingHighSurrogate = unicodeValue; + return; + } + + if (unicodeValue >= 0xDC00 && unicodeValue <= 0xDFFF) { + // Low surrogate with no preceding high surrogate -- malformed input. + error = true; + return; + } + + appendUtf8CodePoint(unicodeValue); +} + +void StreamingJsonParser::appendUtf8CodePoint(const uint32_t codepoint) { + if (codepoint <= 0x7F) { + appendToken(static_cast(codepoint)); + } else if (codepoint <= 0x7FF) { + appendToken(static_cast(0xC0 | (codepoint >> 6))); + appendToken(static_cast(0x80 | (codepoint & 0x3F))); + } else if (codepoint <= 0xFFFF) { + appendToken(static_cast(0xE0 | (codepoint >> 12))); + appendToken(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + appendToken(static_cast(0x80 | (codepoint & 0x3F))); + } else { + appendToken(static_cast(0xF0 | (codepoint >> 18))); + appendToken(static_cast(0x80 | ((codepoint >> 12) & 0x3F))); + appendToken(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + appendToken(static_cast(0x80 | (codepoint & 0x3F))); + } +} + +int StreamingJsonParser::hexDigitValue(const char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + void StreamingJsonParser::handleNumber(char c) { if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E') { appendToken(c); diff --git a/lib/JsonParser/StreamingJsonParser.h b/lib/JsonParser/StreamingJsonParser.h index f2db6a38ec..d07a03c36e 100644 --- a/lib/JsonParser/StreamingJsonParser.h +++ b/lib/JsonParser/StreamingJsonParser.h @@ -18,14 +18,11 @@ struct JsonCallbacks { class StreamingJsonParser { public: - // \uXXXX escapes are passed through as the literal 6-character source text (see - // onEscapedChar()'s 'u' case) rather than decoded, so a string value with several - // escaped ampersands (&, 6 bytes each vs. 1 for a literal '&') can be several - // times longer in the buffer than its logical length. TRMNL's /api/display responses - // measured ~486-516 bytes for image_url alone depending on whether the server escaped - // ampersands for that response, right at the edge of the previous 512-byte limit -- - // silently truncating the callback (see the tokenOverflow check below) with no error. - // Sized with real headroom rather than tuned to one observed case. + // \uXXXX escapes are decoded to real UTF-8 bytes (see handleStringChar()'s + // unicodeDigitsRemaining handling), so most escaped characters cost 1-4 bytes here, + // not the 6 raw source bytes of "&" etc. TRMNL's /api/display responses measured + // ~486-521 bytes for image_url alone depending on how the server chose to escape it; + // sized with real headroom for that plus future longer URLs, not tuned to one case. static constexpr size_t TOKEN_BUF_SIZE = 1024; static constexpr size_t MAX_NESTING = 32; @@ -61,6 +58,13 @@ class StreamingJsonParser { void appendToken(char c); void emitToken(); + // \uXXXX decoding. A code unit spans up to 4 characters and can arrive split across + // separate feed() calls, so the in-progress digits/value must be member state, not + // locals -- mirrors how literalPos/literalExpected track a split true/false/null. + void finishUnicodeEscape(); + void appendUtf8CodePoint(uint32_t codepoint); + static int hexDigitValue(char c); + bool inArray() const { return nestingDepth > 0 && nestingStack[nestingDepth - 1] == Container::ARRAY; } JsonCallbacks cb; @@ -72,6 +76,10 @@ class StreamingJsonParser { bool tokenOverflow; bool error; + uint8_t unicodeDigitsRemaining; // 0 = not mid-\uXXXX; else counts down 4..1 + uint16_t unicodeValue; // hex digits accumulated so far for the current \uXXXX + uint16_t pendingHighSurrogate; // 0 = none; else a UTF-16 high surrogate awaiting its low pair + Container nestingStack[MAX_NESTING]; uint8_t nestingDepth; diff --git a/test/streaming_json_parser/StreamingJsonParserTest.cpp b/test/streaming_json_parser/StreamingJsonParserTest.cpp index bf9832951a..90436e1103 100644 --- a/test/streaming_json_parser/StreamingJsonParserTest.cpp +++ b/test/streaming_json_parser/StreamingJsonParserTest.cpp @@ -150,13 +150,101 @@ TEST(StreamingJsonParser, StringEscapes) { EXPECT_EQ(events[2].value, std::string("a\"b\\c/d\ne\tf")); } -TEST(StreamingJsonParser, UnicodeEscapePassthrough) { - auto events = parse(R"({"u": "\u0041\u0042"})"); +TEST(StreamingJsonParser, UnicodeEscapeBasicAscii) { + // AB -- two BMP code points in the 1-byte-UTF-8 range -- decode to "AB". + auto events = parse(R"({"u": "AB"})"); ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); - // \uXXXX passed through as literal \u followed by the hex digits - EXPECT_EQ(events[2].value, "\\u0041\\u0042"); + EXPECT_EQ(events[2].value, "AB"); +} + +TEST(StreamingJsonParser, UnicodeEscapeAmpersand) { + // The real-world case that motivated this: TRMNL's /api/display sometimes escapes + // '&' as & within image_url. This must decode back to a literal '&', not the + // 6-byte source text, or the resulting URL is unusable for the actual image request. + auto events = parse(R"({"u": "a&b&c"})"); + + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "a&b&c"); +} + +TEST(StreamingJsonParser, UnicodeEscapeTwoByteUtf8) { + // e-acute -- first code point requiring 2-byte UTF-8 (0xC3 0xA9). + auto events = parse(R"({"u": "café"})"); + + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "caf\xc3\xa9"); +} + +TEST(StreamingJsonParser, UnicodeEscapeThreeByteUtf8) { + // SNOWMAN (U+2603) -- requires 3-byte UTF-8 (0xE2 0x98 0x83). + auto events = parse(R"({"u": "☃"})"); + + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "\xe2\x98\x83"); +} + +TEST(StreamingJsonParser, UnicodeEscapeSurrogatePairFourByteUtf8) { + // U+1F600 GRINNING FACE, beyond the BMP -- JSON encodes it as a UTF-16 surrogate + // pair (😀) that must combine into a single 4-byte UTF-8 sequence + // (0xF0 0x9F 0x98 0x80), not two separate (invalid) 3-byte sequences. + auto events = parse(R"({"u": "😀"})"); + + ASSERT_EQ(events.size(), 4u); + EXPECT_EQ(events[2].type, EventType::STRING); + EXPECT_EQ(events[2].value, "\xf0\x9f\x98\x80"); +} + +TEST(StreamingJsonParser, UnicodeEscapeLoneHighSurrogateIsError) { + // A high surrogate with no low surrogate pair is malformed JSON. + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + const char* json = R"({"u": "\ud83d"})"; + parser.feed(json, strlen(json)); + EXPECT_TRUE(parser.hasError()); +} + +TEST(StreamingJsonParser, UnicodeEscapeLoneLowSurrogateIsError) { + // A low surrogate with no preceding high surrogate is malformed JSON. + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + const char* json = R"({"u": "\ude00"})"; + parser.feed(json, strlen(json)); + EXPECT_TRUE(parser.hasError()); +} + +TEST(StreamingJsonParser, UnicodeEscapeInvalidHexDigitIsError) { + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + const char* json = R"({"u": "\u00zz"})"; + parser.feed(json, strlen(json)); + EXPECT_TRUE(parser.hasError()); +} + +TEST(StreamingJsonParser, ChunkedSplitInsideUnicodeEscape) { + // & split mid-hex-digits across two feed() calls must still decode correctly -- + // the digit accumulator has to be member state, not a local, to survive the split. + const char* json = R"({"k": "a\u0026b"})"; + auto reference = parse(json); + + const char* u = strchr(json, 'u'); + ASSERT_NE(u, nullptr); + size_t splitAt = static_cast(u - json) + 2; // land inside the 4 hex digits + + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(json, splitAt); + parser.feed(json + splitAt, strlen(json) - splitAt); + + ASSERT_EQ(ctx.events.size(), reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + EXPECT_EQ(ctx.events[i].type, reference[i].type); + EXPECT_EQ(ctx.events[i].value, reference[i].value); + } } TEST(StreamingJsonParser, Numbers) { diff --git a/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp b/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp index 6d69aef239..39ddca8262 100644 --- a/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp +++ b/test/trmnl_display_json_parser/TrmnlDisplayJsonParserTest.cpp @@ -128,28 +128,37 @@ void testMissingImageUrl() { } // Regression test for a real TRMNL /api/display response: a presigned S3 URL with -// several &-escaped ampersands (valid JSON -- TRMNL's server was observed emitting -// both literal '&' and & for the same field across different responses). Each -// & is stored as 6 raw bytes by this parser (it intentionally doesn't decode -// \uXXXX escapes -- see StreamingJsonParser's onEscapedChar()), so this 486-byte URL -// (with literal '&') becomes 521 bytes with & substituted in -- over the old -// 512-byte TOKEN_BUF_SIZE, which silently dropped the image_url callback with no -// error (tokenOverflow just skips onString()). This must fit in the current buffer. +// several ampersands that TRMNL's server was observed escaping as \uXXXX in some +// responses and leaving as a literal '&' in others (both are valid JSON). Builds the +// escaped wire-format JSON from the correctly-decoded URL below, then asserts that +// parsing recovers the original -- proving both that \u0026 decodes to a real '&' +// (not passed through as 6 literal bytes) and that the result still fits the token +// buffer end to end through the full TrmnlDisplayJsonParser wrapper. void testHandlesLongEscapedImageUrl() { printf("testHandlesLongEscapedImageUrl...\n"); - const std::string url = + const std::string expectedUrl = "https://trmnl.s3.us-east-2.amazonaws.com/m8b1cjzs3zidyc0lexm460vy3t5j?" "response-content-disposition=inline%3B%20filename%3D%22plugin-10a5b0%22%3B%20" - "filename%2A%3DUTF-8%27%27plugin-10a5b0\\u0026" - "response-content-type=image%2Fpng\\u0026" - "X-Amz-Algorithm=AWS4-HMAC-SHA256\\u0026" - "X-Amz-Credential=AKIA47CRUQUU4VKBBMOF%2F20260701%2Fus-east-2%2Fs3%2Faws4_request\\u0026" - "X-Amz-Date=20260701T154633Z\\u0026" - "X-Amz-Expires=300\\u0026" - "X-Amz-SignedHeaders=host\\u0026" + "filename%2A%3DUTF-8%27%27plugin-10a5b0&" + "response-content-type=image%2Fpng&" + "X-Amz-Algorithm=AWS4-HMAC-SHA256&" + "X-Amz-Credential=AKIA47CRUQUU4VKBBMOF%2F20260701%2Fus-east-2%2Fs3%2Faws4_request&" + "X-Amz-Date=20260701T154633Z&" + "X-Amz-Expires=300&" + "X-Amz-SignedHeaders=host&" "X-Amz-Signature=8e8f45ef38cbc565dc18b46574cafb1f8d117b513200d8c795729ef629a41545"; - const std::string json = R"({"status":0,"image_url":")" + url + + + // Rebuild the same escaping TRMNL's server used for this response: every '&' as + // the 6-character source sequence \u0026, exactly as it arrives over the wire. + const std::string unicodeAmp = std::string(1, '\\') + "u0026"; + std::string rawJsonUrl = expectedUrl; + for (size_t pos = 0; (pos = rawJsonUrl.find('&', pos)) != std::string::npos;) { + rawJsonUrl.replace(pos, 1, unicodeAmp); + pos += unicodeAmp.size(); + } + + const std::string json = R"({"status":0,"image_url":")" + rawJsonUrl + R"(","filename":"plugin-10a5b0-1782914788","refresh_rate":907})"; TrmnlDisplayJsonParser parser; @@ -157,7 +166,7 @@ void testHandlesLongEscapedImageUrl() { ASSERT_FALSE(parser.hasError()); ASSERT_TRUE(parser.foundImageUrl()); - ASSERT_STREQ(parser.getImageUrl(), url.c_str()); + ASSERT_STREQ(parser.getImageUrl(), expectedUrl.c_str()); printf(" passed\n"); PASS(); From 32bb3505caac72f68f8788844c3f1bb8417d2574 Mon Sep 17 00:00:00 2001 From: "Michael C. Mancini" Date: Wed, 1 Jul 2026 13:12:58 -0400 Subject: [PATCH 5/5] fix: address sourcery review -- reset unicode state on error, fix broken tests Comment 1: handleStringChar() set error=true on an invalid hex digit inside \uXXXX but left unicodeDigitsRemaining/unicodeValue unchanged. error is terminal in normal use (feed()'s loop stops on it), but reset both fields anyway so the parser's internal state stays self-consistent for any caller that inspects or reuses it after an error rather than discarding it. Comments 2-5: five of the new StreamingJsonParserTest.cpp tests (UnicodeEscapeBasicAscii, UnicodeEscapeAmpersand, UnicodeEscapeTwoByteUtf8, UnicodeEscapeThreeByteUtf8, UnicodeEscapeSurrogatePairFourByteUtf8) turned out to contain pre-decoded literal text instead of \uXXXX escape sequences in their JSON input -- e.g. UnicodeEscapeAmpersand's input was literally "a&b&c", not "a&b&c". Verified byte-for-byte with od(1): every one of the five had the same defect. These tests exercised nothing new -- they'd have passed identically against the parser before this branch's first two commits, since a parser that never decodes \uXXXX still passes already-decoded text through unchanged. Rewrote all five to contain genuine \uXXXX escapes in their input and assert against the correctly decoded output, so they actually exercise finishUnicodeEscape() and appendUtf8CodePoint() as intended. Also added UnicodeEscapeSurrogatePairSplitAcrossFeed per the reviewer's suggestion: feeds the high surrogate and low surrogate of the same pair in separate feed() calls, proving pendingHighSurrogate survives as member state across a feed() boundary landing between the two halves of a pair -- a different and more important edge case than the existing mid-hex-digit split test. All 39 tests in test/streaming_json_parser pass (was 38; added 1). All 6 tests in test/trmnl_display_json_parser pass. Full `pio run -e simulator` build passes. Co-Authored-By: Claude Sonnet 5 --- lib/JsonParser/StreamingJsonParser.cpp | 5 +++ .../StreamingJsonParserTest.cpp | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/JsonParser/StreamingJsonParser.cpp b/lib/JsonParser/StreamingJsonParser.cpp index 0fd387c52e..3860a34b8f 100644 --- a/lib/JsonParser/StreamingJsonParser.cpp +++ b/lib/JsonParser/StreamingJsonParser.cpp @@ -131,7 +131,12 @@ void StreamingJsonParser::handleStringChar(char c) { if (unicodeDigitsRemaining > 0) { const int digit = hexDigitValue(c); if (digit < 0) { + // error is terminal in normal use (feed()'s loop stops on it), but reset the + // in-progress escape state too so the parser stays internally consistent for + // any caller that inspects/reuses it after an error rather than discarding it. error = true; + unicodeDigitsRemaining = 0; + unicodeValue = 0; return; } unicodeValue = static_cast((unicodeValue << 4) | static_cast(digit)); diff --git a/test/streaming_json_parser/StreamingJsonParserTest.cpp b/test/streaming_json_parser/StreamingJsonParserTest.cpp index 90436e1103..d7a43498cb 100644 --- a/test/streaming_json_parser/StreamingJsonParserTest.cpp +++ b/test/streaming_json_parser/StreamingJsonParserTest.cpp @@ -152,7 +152,7 @@ TEST(StreamingJsonParser, StringEscapes) { TEST(StreamingJsonParser, UnicodeEscapeBasicAscii) { // AB -- two BMP code points in the 1-byte-UTF-8 range -- decode to "AB". - auto events = parse(R"({"u": "AB"})"); + auto events = parse(R"({"u": "\u0041\u0042"})"); ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); @@ -163,7 +163,7 @@ TEST(StreamingJsonParser, UnicodeEscapeAmpersand) { // The real-world case that motivated this: TRMNL's /api/display sometimes escapes // '&' as & within image_url. This must decode back to a literal '&', not the // 6-byte source text, or the resulting URL is unusable for the actual image request. - auto events = parse(R"({"u": "a&b&c"})"); + auto events = parse(R"({"u": "a\u0026b\u0026c"})"); ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); @@ -172,7 +172,7 @@ TEST(StreamingJsonParser, UnicodeEscapeAmpersand) { TEST(StreamingJsonParser, UnicodeEscapeTwoByteUtf8) { // e-acute -- first code point requiring 2-byte UTF-8 (0xC3 0xA9). - auto events = parse(R"({"u": "café"})"); + auto events = parse(R"({"u": "caf\u00E9"})"); ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); @@ -181,7 +181,7 @@ TEST(StreamingJsonParser, UnicodeEscapeTwoByteUtf8) { TEST(StreamingJsonParser, UnicodeEscapeThreeByteUtf8) { // SNOWMAN (U+2603) -- requires 3-byte UTF-8 (0xE2 0x98 0x83). - auto events = parse(R"({"u": "☃"})"); + auto events = parse(R"({"u": "\u2603"})"); ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); @@ -192,13 +192,34 @@ TEST(StreamingJsonParser, UnicodeEscapeSurrogatePairFourByteUtf8) { // U+1F600 GRINNING FACE, beyond the BMP -- JSON encodes it as a UTF-16 surrogate // pair (😀) that must combine into a single 4-byte UTF-8 sequence // (0xF0 0x9F 0x98 0x80), not two separate (invalid) 3-byte sequences. - auto events = parse(R"({"u": "😀"})"); + auto events = parse(R"({"u": "\uD83D\uDE00"})"); ASSERT_EQ(events.size(), 4u); EXPECT_EQ(events[2].type, EventType::STRING); EXPECT_EQ(events[2].value, "\xf0\x9f\x98\x80"); } +TEST(StreamingJsonParser, UnicodeEscapeSurrogatePairSplitAcrossFeed) { + // Same surrogate pair as UnicodeEscapeSurrogatePairFourByteUtf8, but split so + // the high surrogate arrives in one feed() call and the low surrogate in the + // next -- pendingHighSurrogate has to survive as member state across the call + // boundary, not just across single characters within one call. + const char* highSurrogate = R"(\uD83D)"; + const char* lowSurrogateAndClose = R"JSON(\uDE00"})JSON"; + const char* prefix = R"({"u": ")"; + + TestContext ctx; + StreamingJsonParser parser(makeCallbacks(&ctx)); + parser.feed(prefix, strlen(prefix)); + parser.feed(highSurrogate, strlen(highSurrogate)); + parser.feed(lowSurrogateAndClose, strlen(lowSurrogateAndClose)); + + EXPECT_FALSE(parser.hasError()); + ASSERT_EQ(ctx.events.size(), 4u); + EXPECT_EQ(ctx.events[2].type, EventType::STRING); + EXPECT_EQ(ctx.events[2].value, "\xf0\x9f\x98\x80"); +} + TEST(StreamingJsonParser, UnicodeEscapeLoneHighSurrogateIsError) { // A high surrogate with no low surrogate pair is malformed JSON. TestContext ctx;