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..6eba010bc7 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -6,6 +6,7 @@ STR_CROSSINK: "CrossInk" STR_BOOTING: "BOOTING" STR_SLEEPING: "SLEEPING" STR_ENTERING_SLEEP: "Going to sleep" +STR_REFRESHING: "Refreshing" STR_BROWSE_FILES: "Browse Files" STR_BROWSE: "Browse" STR_FILE_TRANSFER: "File Transfer" @@ -342,6 +343,18 @@ 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_TRMNL_REFRESH_ON_POWER_BUTTON: "Refresh on Power Button" +STR_TRMNL_REFRESH_HINT_1: "Short press power to refresh" +STR_TRMNL_REFRESH_HINT_2: "Long press power to wake up" STR_RECENT_BOOKS_VIEW: "Recent Books View" STR_LIST_VIEW: "List" STR_GRID_VIEW: "Grid" diff --git a/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/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index eec802721a..edcea460a4 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -244,15 +244,24 @@ void HalGPIO::startDeepSleep() { void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) { if (shortPressAllowed) { - // Fast path - no duration check needed + // Fast path: the caller has already decided any press length should continue + // booting (e.g. shortPwrBtn==SLEEP, or the TRMNL-refresh path in main.cpp, which + // does its OWN separate measurePowerButtonPressWasShort() call afterward to + // classify short vs. long -- this early return is exactly what lets that call + // happen instead of being preempted by startDeepSleep() below). return; } // TODO: Intermittent edge case remains: a single tap followed by another single tap // can still power on the device. Tighten wake debounce/state handling here. + if (measurePowerButtonPressWasShort(requiredDurationMs)) { + startDeepSleep(); + } +} +bool HalGPIO::measurePowerButtonPressWasShort(uint16_t durationMs) { // Calibrate: subtract boot time already elapsed, assuming button held since boot const uint16_t calibration = millis(); - const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1; + const uint16_t calibratedDuration = (calibration < durationMs) ? (durationMs - calibration) : 1; const auto start = millis(); inputMgr.update(); @@ -261,17 +270,15 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre delay(10); inputMgr.update(); } - if (inputMgr.isPressed(BTN_POWER)) { - do { - delay(10); - inputMgr.update(); - } while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration); - if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) { - startDeepSleep(); - } - } else { - startDeepSleep(); + if (!inputMgr.isPressed(BTN_POWER)) { + // Never registered as pressed (already released) -- treat as short. + return true; } + do { + delay(10); + inputMgr.update(); + } while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration); + return inputMgr.getPowerButtonHeldTime() < calibratedDuration; } bool HalGPIO::isUsbConnected() const { diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 94e9bcd711..2c2d7606cd 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -75,11 +75,26 @@ class HalGPIO { // Setup wake up GPIO and enter deep sleep void startDeepSleep(); - // Verify power button was held long enough after wakeup. - // If verification fails, enters deep sleep and does not return. + // Verify power button was held long enough after wakeup. If shortPressAllowed is + // true, returns immediately without measuring anything -- the caller is asserting + // that ANY press length should continue booting, so no gate is needed. Otherwise + // measures the press (see measurePowerButtonPressWasShort below) and, if it comes + // back short, rejects the wakeup: enters deep sleep and does not return. // Should only be called when wakeup reason is PowerButton. void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed); + // Generic primitive: polls the power button and reports whether it was released + // before durationMs elapsed (calibrated for boot time already spent), or is still + // held past it. Never rejects the wakeup itself -- the caller decides what to do + // with the short/long verdict. Two call sites use this with deliberately different + // thresholds and meanings, so match the threshold to the question being asked: + // - verifyPowerButtonWakeup (above) passes the WAKE duration (10-200ms) purely as + // a noise filter: "was this even a deliberate press, or a bounce?" + // - main.cpp's TRMNL-refresh routing passes the LONG-PRESS duration (400ms) to + // classify intent: "should this be treated as a short action or a long press?" + // Should only be called when wakeup reason is PowerButton. + bool measurePowerButtonPressWasShort(uint16_t durationMs); + // Check if USB is connected bool isUsbConnected() const; diff --git a/src/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..1308c7a05a 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,14 @@ 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; + // Short-pressing the power button while asleep with the TRMNL sleep screen + // active refreshes it in place instead of the default wake behavior. Off by + // default -- opt in per-device. + uint8_t trmnlRefreshOnPowerButton = 0; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press page turn button behavior diff --git a/src/GlobalActions.h b/src/GlobalActions.h index a860eddd93..87020d7a6f 100644 --- a/src/GlobalActions.h +++ b/src/GlobalActions.h @@ -33,4 +33,4 @@ inline bool isPowerButtonActionAvailableOutsideReader(const CrossPointSettings:: } } -void enterDeepSleep(bool fromTimeout = false); +void enterDeepSleep(bool fromTimeout = false, bool isPowerButtonRefresh = false); diff --git a/src/SettingsList.h b/src/SettingsList.h index ade130cd3d..7b2c31c177 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,18 @@ 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)); + add(SettingInfo::Toggle(StrId::STR_TRMNL_REFRESH_ON_POWER_BUTTON, &CrossPointSettings::trmnlRefreshOnPowerButton, + "trmnlRefreshOnPowerButton", StrId::STR_TRMNL_SETTINGS)); // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- add(SettingInfo::DynamicString( @@ -914,11 +928,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/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 25c6bc5101..b2c3e325fe 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -237,10 +237,10 @@ void ActivityManager::goToReader(std::string path, const bool suppressBackReleas replaceActivity(std::make_unique(renderer, mappedInput, std::move(path), suppressBackRelease)); } -void ActivityManager::goToSleep(bool fromTimeout) { +void ActivityManager::goToSleep(bool fromTimeout, bool isPowerButtonRefresh) { const bool canSnapshotOverlay = currentActivity && currentActivity->canSnapshotForSleepOverlay(); - replaceActivity( - std::make_unique(renderer, mappedInput, canSnapshotOverlay, getCurrentBookPath(), fromTimeout)); + replaceActivity(std::make_unique(renderer, mappedInput, canSnapshotOverlay, getCurrentBookPath(), + fromTimeout, isPowerButtonRefresh)); loop(); // Important: sleep screen must be rendered immediately, the caller will go to sleep right after this returns } diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 1033737cf0..cb3f99234b 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -99,7 +99,7 @@ class ActivityManager { void goToRecentBooks(); void goToBrowser(); void goToReader(std::string path, bool suppressBackRelease = false); - void goToSleep(bool fromTimeout = false); + void goToSleep(bool fromTimeout = false, bool isPowerButtonRefresh = false); void goToBoot(); void goToFullScreenMessage(std::string message, EpdFontFamily::Style style = EpdFontFamily::REGULAR); void goToCrashReport(); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index cac7298a00..71b96bcfe5 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 { @@ -432,12 +433,13 @@ void SleepActivity::onEnter() { // Show the popup in the reader's orientation when sleep starts from an open book. // Reset to portrait afterwards so the sleep screen renderer keeps its existing layout. + const char* popupText = I18N.get(isPowerButtonRefresh ? StrId::STR_REFRESHING : StrId::STR_ENTERING_SLEEP); if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + GUI.drawPopup(renderer, popupText); renderer.setOrientation(GfxRenderer::Orientation::Portrait); } else { - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + GUI.drawPopup(renderer, popupText); } switch (SETTINGS.sleepScreen) { @@ -459,6 +461,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 +611,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..93018e348b 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -9,11 +9,13 @@ class Bitmap; class SleepActivity final : public Activity { public: explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool canSnapshotOverlayBackground, - std::string currentBookPath = {}, bool fromTimeout = false) + std::string currentBookPath = {}, bool fromTimeout = false, + bool isPowerButtonRefresh = false) : Activity("Sleep", renderer, mappedInput), canSnapshotOverlayBackground(canSnapshotOverlayBackground), currentBookPath(std::move(currentBookPath)), - fromTimeout(fromTimeout) {} + fromTimeout(fromTimeout), + isPowerButtonRefresh(isPowerButtonRefresh) {} void onEnter() override; private: @@ -25,6 +27,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; @@ -32,4 +37,8 @@ class SleepActivity final : public Activity { bool overlayBackgroundBufferStored = false; std::string currentBookPath; bool fromTimeout = false; + // True when this sleep-entry is the TRMNL refresh-in-place path (short power-button + // press while already asleep, see main.cpp) rather than a normal sleep. Swaps the + // "Going to sleep" popup for "Refreshing". + bool isPowerButtonRefresh = false; }; diff --git a/src/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..cde446ba25 --- /dev/null +++ b/src/activities/settings/TrmnlSettingsActivity.cpp @@ -0,0 +1,135 @@ +#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 = 5; +// Named so this item's wiring in handleSelection()/render() doesn't silently break if +// items are reordered or new ones inserted before it -- the other indices (0-3) are +// pre-existing and unchanged by this feature, left as-is to keep this change scoped. +constexpr int MENU_INDEX_REFRESH_ON_POWER_BUTTON = 4; +const StrId menuNames[MENU_ITEMS] = {StrId::STR_TRMNL_SERVER_URL, StrId::STR_TRMNL_API_KEY, + StrId::STR_TRMNL_DEVICE_ID, StrId::STR_TRMNL_ORIENTATION, + StrId::STR_TRMNL_REFRESH_ON_POWER_BUTTON}; + +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(); + } else if (selectedIndex == MENU_INDEX_REFRESH_ON_POWER_BUTTON) { + SETTINGS.trmnlRefreshOnPowerButton = !SETTINGS.trmnlRefreshOnPowerButton; + 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)); + } + if (index == MENU_INDEX_REFRESH_ON_POWER_BUTTON) { + return SETTINGS.trmnlRefreshOnPowerButton ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF)); + } + return std::string(tr(STR_NOT_SET)); + }, + 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..7f5864f7b2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -441,6 +441,33 @@ void waitForPowerRelease() { } } +// Status screen shown while a short power-button press refreshes the TRMNL sleep +// screen in place, in lieu of the normal "BOOTING" splash (which this path skips). +void showTrmnlRefreshingStatus() { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const auto lineHeight = renderer.getTextHeight(SMALL_FONT_ID); + renderer.clearScreen(); + + // Right-pointing arrow toward the physical power button, which sits on this + // device's right edge, ~20% of the way down from the top (measured ~22mm). + // Mirrors BaseTheme's keyboard backspace arrow (shaft + two diagonals forming + // the head), rotated 180 degrees to point right instead of left. + constexpr int arrowMarginRight = 24; + constexpr int arrowShaftLength = 24; + constexpr int arrowHeadSize = 8; + const int arrowTipX = pageWidth - arrowMarginRight; + const int arrowShaftLeftX = arrowTipX - arrowShaftLength; + const int arrowY = pageHeight / 5; // ~20% down from the top + renderer.drawLine(arrowShaftLeftX, arrowY, arrowTipX, arrowY, 2, true); + renderer.drawLine(arrowTipX, arrowY, arrowTipX - arrowHeadSize, arrowY - arrowHeadSize, 2, true); + renderer.drawLine(arrowTipX, arrowY, arrowTipX - arrowHeadSize, arrowY + arrowHeadSize, 2, true); + + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 - lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_1)); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + lineHeight / 2, tr(STR_TRMNL_REFRESH_HINT_2)); + renderer.displayBuffer(); +} + bool isGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action) { return isPowerButtonActionAvailableOutsideReader(action); } @@ -625,9 +652,14 @@ static bool loadSleepFrameBuffer() { } // Enter deep sleep mode -void enterDeepSleep(bool fromTimeout) { +void enterDeepSleep(bool fromTimeout, bool isPowerButtonRefresh) { HalPowerManager::Lock powerLock; // Ensure we are at normal CPU frequency for sleep preparation - APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); + // Skipped when called before any activity is pushed (the TRMNL refresh-in-place + // boot path): activityManager.isReaderActivity() would report false regardless of + // what was actually open before this wake, corrupting the reader-resume flag. + if (!isPowerButtonRefresh) { + APP_STATE.lastSleepFromReader = activityManager.isReaderActivity(); + } const bool isQuickResumeSleep = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::QUICK_RESUME || @@ -640,7 +672,7 @@ void enterDeepSleep(bool fromTimeout) { // Commit to sleeping before goToSleep() runs the outgoing activity's onExit(): // a WiFi activity would otherwise silentRestart() here and reboot instead. deepSleepInProgress = true; - activityManager.goToSleep(fromTimeout); + activityManager.goToSleep(fromTimeout, isPowerButtonRefresh); if (isQuickResumeSleep) { saveSleepFrameBuffer(); @@ -817,7 +849,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)); @@ -830,13 +864,42 @@ void setup() { // have to hold the power button across all of the SD reads below. const auto wakeupReason = gpio.getWakeupReason(); LOG_INF("BOOT", "Wake route: %s", wakeupRouteName(wakeupReason)); + // Set when a PowerButton wake is a short press with the TRMNL sleep screen active + // and SETTINGS.trmnlRefreshOnPowerButton is on (Settings > System > TRMNL Settings + // > Refresh on Power Button, off by default): routes to a refresh-in-place instead + // of a normal boot, below. Always false in the simulator, which can't measure press + // duration during its synthetic wake (see HalGPIO::measurePowerButtonPressWasShort's + // lib/hal/HalGPIO.h comment). + bool powerButtonWakeIsTrmnlRefresh = false; switch (wakeupReason) { - case HalGPIO::WakeupReason::PowerButton: + case HalGPIO::WakeupReason::PowerButton: { + const bool trmnlSleepScreenActive = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRMNL && + SETTINGS.trmnlRefreshOnPowerButton != 0; + const bool shortPressAllowed = + trmnlSleepScreenActive || SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP; LOG_INF("BOOT", "Power-button wake: verifying duration required=%u shortAllowed=%d", - SETTINGS.getPowerButtonWakeDuration(), SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); - gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonWakeDuration(), - SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); + SETTINGS.getPowerButtonWakeDuration(), shortPressAllowed); + gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonWakeDuration(), shortPressAllowed); +#ifndef SIMULATOR + // The external simulator library (lib_deps in platformio.ini) doesn't implement + // measurePowerButtonPressWasShort -- the simulator's synthetic wake path can't + // measure press duration at all (see docs/simulator.md and HalGPIO.h). Real + // hardware measures it here to decide refresh-in-place vs. a normal wake. + // + // This is a SEPARATE measurement from the one verifyPowerButtonWakeup() may have + // just done above (it didn't, here, since shortPressAllowed was true for this + // case) -- and deliberately uses a different threshold. That call, when it runs, + // uses the WAKE duration purely as a noise filter (any qualifying press + // continues booting). This one uses the LONG-PRESS duration to classify intent: + // under it means "treat as a short action" (refresh in place); at or over it + // means "treat as a long press" (fall through to a normal wake, below). + if (trmnlSleepScreenActive) { + powerButtonWakeIsTrmnlRefresh = + gpio.measurePowerButtonPressWasShort(SETTINGS.getPowerButtonLongPressDuration()); + } +#endif break; + } case HalGPIO::WakeupReason::AfterUSBPower: // TEMP: continue booting while diagnosing post-flash/reset behavior. // Normal behavior is to go back to sleep when USB power causes a cold boot. @@ -871,6 +934,22 @@ void setup() { } } + // Short power-button press with the TRMNL sleep screen active: refresh the TRMNL + // image and go straight back to sleep, without booting into Home/Reader. Recovery + // mode (UP + POWER) takes priority if both were somehow detected. + if (powerButtonWakeIsTrmnlRefresh && !recoveryFirmwareMode) { + LOG_INF("BOOT", "Power-button short press with TRMNL sleep screen active: refreshing in place"); + setupDisplayAndFonts(/*seamless=*/true); + showTrmnlRefreshingStatus(); + // isPowerButtonRefresh=true: preserves lastSleepFromReader (no activity is pushed + // yet, so activityManager.isReaderActivity() would wrongly report false and + // clobber it) and tells SleepActivity to show "Refreshing" instead of "Going to + // sleep". + enterDeepSleep(/*fromTimeout=*/false, /*isPowerButtonRefresh=*/true); + // enterDeepSleep() never returns on real hardware (esp_deep_sleep_start()). + return; + } + // First serial output only here to avoid timing inconsistencies for power button press duration verification LOG_DBG("MAIN", "Starting CrossInk version " CROSSINK_VERSION); 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; +}