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/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 5f81b1fc1d..db90b68f42 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -106,10 +107,24 @@ int requiredPngInternalBufferBytes(int srcWidth, int pixelType) { // Convert entire source line to grayscale with alpha blending to white background. // For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards. // Processing the whole line at once improves cache locality and reduces per-pixel overhead. -void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) { +void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha, + int bpp) { switch (pixelType) { case PNG_PIXEL_GRAYSCALE: - memcpy(grayLine, pPixels, width); + if (bpp == 8) { + // Fast bulk path for the common case -- see the file-level comment above + // on why processing the whole line at once matters here. + memcpy(grayLine, pPixels, width); + } else { + // Sub-8-bit grayscale is packed MSB-first (8/4/2 samples per byte); 16-bit + // grayscale is 2 bytes/sample, big-endian. pngUnpackGraySample() unpacks + // and scales any valid PNG grayscale depth; shared with SleepActivity.cpp's + // pngOverlayDraw() so the two PNG consumers can't drift apart. The old + // straight memcpy assumed 8-bit and sheared other depths horizontally. + for (int x = 0; x < width; x++) { + grayLine[x] = pngUnpackGraySample(pPixels, x, bpp); + } + } break; case PNG_PIXEL_TRUECOLOR: @@ -185,7 +200,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { // Convert entire source line to grayscale (improves cache locality) convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette, - pDraw->iHasAlpha); + pDraw->iHasAlpha, pDraw->iBpp); // Render scaled row using Bresenham-style integer stepping (no floating-point division) int dstWidth = ctx->dstWidth; 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/lib/PngGraySample/PngGraySample.h b/lib/PngGraySample/PngGraySample.h new file mode 100644 index 0000000000..9a095e38fe --- /dev/null +++ b/lib/PngGraySample/PngGraySample.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +// Reads a single grayscale sample from a PNGdec scanline at column `x` and +// returns it scaled to a full 0..255 intensity. +// +// PNG grayscale images are encoded at 1, 2, 4, 8, or 16 bits per sample (PNG +// spec, IHDR bit depth for color type 0). PNGdec delivers each scanline +// packed at that native bit depth: +// - 1/2/4-bit: samples are packed MSB-first, multiple samples per byte. +// - 8-bit: one byte per sample. +// - 16-bit: two bytes per sample, big-endian per spec; we use the high +// byte as the 8-bit intensity, matching how the rest of the rendering +// pipeline treats channel data. +// Any other bpp is not a valid PNG grayscale bit depth and should not occur +// from PNGdec; rather than trust that invariant and read out of bounds or +// misinterpret the buffer, fall back to a neutral mid-gray. +inline uint8_t pngUnpackGraySample(const uint8_t* pixels, const int x, const int bpp) { + switch (bpp) { + case 8: + return pixels[x]; + case 16: + return pixels[x * 2]; + case 1: + case 2: + case 4: { + const int ppb = 8 / bpp; // samples per byte + const int mask = (1 << bpp) - 1; // max sample value + const uint8_t byte = pixels[x / ppb]; + const int shift = (ppb - 1 - (x % ppb)) * bpp; // MSB-first within the byte + const int sample = (byte >> shift) & mask; + return static_cast(sample * 255 / mask); // scale sample to 0..255 + } + default: + return 128; // unexpected bit depth; avoid reading out of bounds or misinterpreting data + } +} 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..d730898e12 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -30,6 +31,7 @@ #include "fontIds.h" #include "images/Logo120.h" #include "images/MoonIcon.h" +#include "trmnl/TrmnlSleepClient.h" namespace { @@ -170,13 +172,18 @@ int pngOverlayDraw(PNGDRAW* pDraw) { } break; } - case PNG_PIXEL_GRAYSCALE: - gray = pixels[srcX]; + case PNG_PIXEL_GRAYSCALE: { + // TRMNL serves 2-bit grayscale, which the old 8-bit-only read (pixels[srcX]) + // sheared horizontally by ~4x. pngUnpackGraySample() handles every valid PNG + // grayscale bit depth (1/2/4/8/16); shared with PngToFramebufferConverter.cpp + // so the two PNG consumers can't drift apart. + gray = pngUnpackGraySample(pixels, srcX, pDraw->iBpp); // tRNS color-key: transparent gray value stored in low byte if (ctx->transparentColor >= 0 && gray == (uint8_t)(ctx->transparentColor & 0xFF)) { alpha = 0; } break; + } case PNG_PIXEL_INDEXED: if (pDraw->pPalette) { const uint8_t idx = pixels[srcX]; @@ -459,6 +466,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 +616,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/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; 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; +}