diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14561a504..a7088088ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,37 +65,21 @@ jobs: run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high build: - runs-on: ubuntu-latest + # Never run untrusted fork PR code on the self-hosted runner. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: [self-hosted, linux, crossink-build] steps: - uses: actions/checkout@v6 with: submodules: recursive - - uses: actions/setup-python@v6 - with: - python-version: "3.14" - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: "latest" - enable-cache: true - - - name: Cache PlatformIO packages - uses: actions/cache@v4 - with: - path: ~/.platformio - key: ${{ runner.os }}-platformio-${{ hashFiles('platformio.ini', 'platformio.local.example.ini') }} - restore-keys: | - ${{ runner.os }}-platformio- - - - name: Install PlatformIO Core - run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Verify PlatformIO Core + run: pio --version - - name: Build CrossInk + - name: Build CrossInk tiny run: | set -euo pipefail - pio run | tee pio.log + pio run -e tiny | tee pio.log - name: Extract firmware stats run: | @@ -108,12 +92,9 @@ jobs: - name: Upload firmware artifacts uses: actions/upload-artifact@v7 with: - name: firmware + name: firmware-tiny path: | - .pio/build/teensy/firmware-teensy.bin .pio/build/tiny/firmware-tiny.bin - .pio/build/xlarge/firmware-xlarge.bin - .pio/build/no_emoji/firmware-no_emoji.bin if-no-files-found: error # This job is used as the PR required actions check, allows for changes to other steps in the future without breaking diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a9ef7408..709fb46f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,20 @@ ## [Unreleased] ### Added +- Added an adjustable reader line-height setting with percent-based spacing for EPUB and TXT books. +- Added nearby Reading Stats sync between CrossInk readers using direct ESP-NOW device-to-device messages. +- Auto Page Turn interval now remembers the last selected interval per book when it is turned on again. ### Fixed - Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. +- Improved OPDS book download throughput by using a larger transfer buffer while keeping SD-card font downloads on the lower-memory path. +- Fixed OPDS feed errors so low-memory parser-buffer failures show the specific memory message instead of the generic parse error. +- Free the active SD-card reader font before opening OPDS catalogs so WiFi/feed parsing has more memory available. +- Fixed Auto Page Turn so each book remembers the last selected interval when it is turned on again. ### Changed + ## [v1.3.0] - 2026-05-21 ### Added diff --git a/README.md b/README.md index 66f9b51082..fc1ae5b56d 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,13 @@ Some simple per-book reading stats are tracked automatically and displayed in tw - Average session time - All time reading stats including total number of books read +To include all-time totals from other CrossInk devices, create or sync a +`.crosspoint/synced_stats/` folder between devices. When that folder exists, +each reader writes its own `device_.bin` contribution file and ignores that +file while summing the folder, so any device can display the aggregate total +without becoming the main device. If the folder is not present, the reader only +uses its local `global_stats.bin`. + **Home screen book card (Lyra theme only):** - Total reading time @@ -372,6 +379,7 @@ The structure is roughly: .crosspoint/ ├── global_stats.bin # All-time reading stats, including total books read ├── global_stats.bin.bak # Backup used if the main global stats file is corrupt +├── synced_stats/ # One per-device stats contribution file for aggregate all-time totals ├── settings.bin # Device settings ├── state.bin # Last-opened book and sleep/session state ├── recent.bin # Recent books list diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index 5dfe007b7a..bc615e5c60 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -146,6 +146,7 @@ Typical persisted areas on SD: epub_/ book.bin progress.bin + reader_settings.bin stats.bin cover.bmp sections/*.bin diff --git a/docs/file-formats.md b/docs/file-formats.md index b6e98d8ea2..cb641704e9 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -5,6 +5,38 @@ nav_order: 8 # File Formats +## `global_stats.bin` + +`/.crosspoint/global_stats.bin` stores this device's all-time reading counters. +If `/.crosspoint/synced_stats/` already exists, saves also mirror the same +counters to `/.crosspoint/synced_stats/device_.bin`, where `` is the +device's hardware MAC address without separators. The reader does not create +this folder on its own. + +The `/.crosspoint/synced_stats/` directory is designed for peer-to-peer folder +sync: each device owns one contribution file, and display-only Reading Stats +views add every other device's contribution to this device's local +`global_stats.bin`. This device's own contribution file is skipped while +aggregating so mirroring the folder back to the same device does not double +count its local stats. + +### Version 2 + +Adds `completedBooks` after the original counters. + +```text +[0] version (= 2) +[1-4] totalSessions uint32 little-endian +[5-8] totalReadingSeconds uint32 little-endian +[9-12] totalPagesTurned uint32 little-endian +[13-16] completedBooks uint32 little-endian +``` + +### Version 1 + +Version 1 files are still readable. They are 13 bytes long and do not include +`completedBooks`, so the reader treats that value as zero. + ## `book.bin` ### Version 6 @@ -116,6 +148,19 @@ if (parsedSize != fileSize) { } ``` +## `reader_settings.bin` + +### Version 1 + +Stores per-book reader preferences that should survive reopening a book without changing EPUB layout caches. + +Binary layout: + +```text +[0] version (= 1) +[1-2] lastAutoPageTurnIntervalSeconds uint16_t LE +``` + ## `section.bin` ### Version 36 diff --git a/docs/webserver.md b/docs/webserver.md index 2f34bc2c60..95ccfcec7b 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -107,15 +107,24 @@ The web interface includes APIs for managing saved OPDS servers and saved WiFi c You can manage files directly from a terminal with `curl` while File Transfer is running. See [Webserver Endpoints](./webserver-endpoints.md). -## Security Notes +## Bluetooth Reading Stats Sync + +CrossPoint Reader supports reader-to-reader stats sync from **File Transfer > Bluetooth Stats Sync**. Bluetooth is +advertised only while that screen is open. Press **Sync Stats** on one nearby reader; both readers exchange their +`/.crosspoint/global_stats.bin` contribution and save the peer copy under `/.crosspoint/synced_stats/`. -- The web server runs on HTTP port 80. -- The fast upload WebSocket runs on port 81. -- No authentication is required. -- Anyone on the same network, or connected to the CrossInk hotspot, can access the interface while File Transfer is running. -- The web server stops and WiFi disconnects when you exit File Transfer. +The `synced_stats` folder is created automatically when the user starts the Bluetooth stats-sync workflow. Peer files +are named with the durable device MAC fallback, for example `device_aabbccddeeff.bin`. + +## Security Notes -Use File Transfer only on trusted networks. +- The web server runs on port 80 (standard HTTP) +- **No authentication is required** - anyone on the same network can access the interface +- The web server is only accessible while the WiFi screen shows "Connected" +- The web server automatically stops when you exit the WiFi screen +- Bluetooth stats sync only accepts stats payloads from nearby CrossPoint readers +- Bluetooth stats sync is only available while the Bluetooth Stats Sync screen is open +- For security, only use on trusted private networks --- diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e7270b6263..e216dcb026 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -39,8 +39,10 @@ STR_SAVE_PASSWORD: "Save password for next time?" STR_PRESS_OK_SCAN: "Press OK to scan again" STR_JOIN_NETWORK: "Join a Network" STR_CREATE_HOTSPOT: "Create Hotspot" +STR_NEARBY_STATS_SYNC: "Nearby Stats Sync" STR_JOIN_DESC: "Connect to an existing WiFi network" STR_HOTSPOT_DESC: "Create a WiFi network others can join" +STR_NEARBY_STATS_SYNC_DESC: "Exchange reading stats with a nearby reader" STR_STARTING_HOTSPOT: "Starting Hotspot..." STR_HOTSPOT_MODE: "Hotspot Mode" STR_CONNECT_WIFI_HINT: "Connect your device to this WiFi network" @@ -209,6 +211,7 @@ STR_UNNAMED: "Unnamed" STR_NO_SERVER_URL: "No server URL configured" STR_FETCH_FEED_FAILED: "Failed to fetch feed" STR_PARSE_FEED_FAILED: "Failed to parse feed" +STR_OPDS_FEED_BUFFER_MEMORY_ERROR: "Couldn't allocate memory for buffer" STR_NEXT_PAGE: "Next Page »" STR_PREV_PAGE: "« Previous Page" STR_NETWORK_PREFIX: "Network: " @@ -483,3 +486,10 @@ STR_FIRMWARE_WRITE_FAILED: "Firmware write failed" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Do not power off!" STR_RECOVERY_MODE: "Recovery Mode" STR_RECOVERY_MODE_HINT: "Place firmware.bin on SD card root and select it" +STR_NEARBY_STATS_READY: "Ready to sync both readers" +STR_NEARBY_STATS_SYNC_BUTTON: "Sync" +STR_NEARBY_STATS_READY_HINT: "Press Sync on one reader only" +STR_NEARBY_STATS_SCANNING: "Finding reader to exchange stats" +STR_NEARBY_STATS_SYNCING: "Sending and receiving stats" +STR_NEARBY_STATS_SYNCED: "Both readers synced" +STR_NEARBY_STATS_SIMULATOR_UNAVAILABLE: "Nearby stats sync is not available in simulator" diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index b2737b0ce6..70073417d8 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -10,6 +10,7 @@ OpdsParser::OpdsParser(OpdsEntry* entries, const size_t entryCapacity) : entries(entries), entryCapacity(entryCapacity) { if (!entries || entryCapacity == 0) { errorOccured = true; + errorReason = OpdsParserError::NO_ENTRY_BUFFER; LOG_DBG("OPDS", "No entry buffer supplied"); } @@ -27,6 +28,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { } if (!xmlData && length > 0) { errorOccured = true; + errorReason = OpdsParserError::INVALID_INPUT; return length; } @@ -38,6 +40,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { void* const buf = XML_GetBuffer(parser, chunkSize); if (!buf) { errorOccured = true; + errorReason = OpdsParserError::BUFFER_MEMORY; LOG_DBG("OPDS", "Couldn't allocate memory for buffer"); destroyXmlParser(parser); parser = nullptr; @@ -49,6 +52,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { if (XML_ParseBuffer(parser, static_cast(toRead), 0) == XML_STATUS_ERROR) { errorOccured = true; + errorReason = OpdsParserError::XML_PARSE; LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); destroyXmlParser(parser); @@ -65,6 +69,7 @@ void OpdsParser::flush() { if (!parser) return; if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) { errorOccured = true; + errorReason = OpdsParserError::XML_PARSE; destroyXmlParser(parser); parser = nullptr; } @@ -74,6 +79,7 @@ bool OpdsParser::parse(const uint8_t* xmlData, const size_t length) { clear(); if (!xmlData && length > 0) { errorOccured = true; + errorReason = OpdsParserError::INVALID_INPUT; return false; } @@ -96,6 +102,7 @@ void OpdsParser::clear() { currentText.clear(); inEntry = inTitle = inAuthor = inAuthorName = inId = false; errorOccured = !entries || entryCapacity == 0; + errorReason = errorOccured ? OpdsParserError::NO_ENTRY_BUFFER : OpdsParserError::NONE; resetXmlParser(); } @@ -110,6 +117,7 @@ bool OpdsParser::resetXmlParser() { parser = XML_ParserCreate(nullptr); if (!parser) { errorOccured = true; + errorReason = OpdsParserError::PARSER_MEMORY; LOG_DBG("OPDS", "Couldn't allocate memory for parser"); return false; } diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index a6bc680540..76d1b1d2be 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -15,6 +15,8 @@ enum class OpdsEntryType { BOOK // Downloadable book }; +enum class OpdsParserError { NONE, NO_ENTRY_BUFFER, INVALID_INPUT, PARSER_MEMORY, BUFFER_MEMORY, XML_PARSE }; + /** * Represents an entry from an OPDS feed (either a navigation link or a book). */ @@ -144,6 +146,7 @@ class OpdsParser final : public Print { bool parse(const char* xmlData, size_t length) { return parse(reinterpret_cast(xmlData), length); } bool error() const; + OpdsParserError getErrorReason() const { return errorReason; } operator bool() const { return !error(); } @@ -189,5 +192,6 @@ class OpdsParser final : public Print { bool inId = false; bool errorOccured = false; + OpdsParserError errorReason = OpdsParserError::NONE; bool truncated = false; }; diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 79692e9dbe..5a6a177643 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -3,3 +3,4 @@ cairosvg>=2.9.0 matplotlib>=3.10.9 pyserial>=3.5 colorama>=0.4.6 +bleak>=1.1.1 diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 8ca8ea507f..78bae7937f 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -297,6 +297,52 @@ uint8_t CrossPointSettings::sleepScreenModeToStorage(const uint8_t mode) { return 0; } +uint8_t CrossPointSettings::legacyLineSpacingToPercent(const uint8_t legacyValue, const uint8_t fontFamily, + const bool sdFontSelected) { + if (sdFontSelected) { + switch (legacyValue) { + case TIGHT: + return 95; + case WIDE: + return 110; + case NORMAL: + default: + return 100; + } + } + + switch (fontFamily) { + case CHAREINK: + case BITTER: + switch (legacyValue) { + case TIGHT: + return 95; + case WIDE: + return 130; + case NORMAL: + default: + return 110; + } + case LEXENDDECA: + default: + switch (legacyValue) { + case TIGHT: + return 90; + case WIDE: + return 120; + case NORMAL: + default: + return 100; + } + } +} + +uint8_t CrossPointSettings::clampedLineHeightPercent(const uint8_t value) { + if (value < MIN_LINE_HEIGHT_PERCENT) return MIN_LINE_HEIGHT_PERCENT; + if (value > MAX_LINE_HEIGHT_PERCENT) return MAX_LINE_HEIGHT_PERCENT; + return value; +} + bool CrossPointSettings::saveToFile() const { Storage.mkdir("/.crosspoint"); return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON); @@ -479,57 +525,14 @@ bool CrossPointSettings::loadFromBinaryFile() { applyLegacyFrontButtonLayout(*this); } + lineHeightPercent = legacyLineSpacingToPercent(lineSpacing, fontFamily, sdFontFamilyName[0] != '\0'); + LOG_DBG("CPS", "Settings loaded from binary file"); return true; } float CrossPointSettings::getReaderLineCompression() const { - // SD card fonts use same compression as Bookerly (the most neutral values) - if (sdFontFamilyName[0] != '\0') { - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.0f; - case WIDE: - return 1.1f; - } - } - - switch (fontFamily) { - case LEXENDDECA: - default: - switch (lineSpacing) { - case TIGHT: - return 0.90f; - case NORMAL: - default: - return 1.0f; - case WIDE: - return 1.2f; - } - case CHAREINK: - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.1f; - case WIDE: - return 1.3f; - } - case BITTER: - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.1f; - case WIDE: - return 1.3f; - } - } + return static_cast(clampedLineHeightPercent(lineHeightPercent)) / 100.0f; } unsigned long CrossPointSettings::getSleepTimeoutMs() const { diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 963a5f8662..021c18935b 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -140,6 +140,7 @@ class CrossPointSettings { SD_FONT_RANGE_ALL = 4, SD_FONT_SIZE_RANGE_COUNT }; + // Legacy persisted values for the old Tight / Normal / Wide line-spacing setting. enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT }; enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, @@ -320,7 +321,8 @@ class CrossPointSettings { #else uint8_t sdFontSizeRange = SD_FONT_RANGE_TINY; #endif - uint8_t lineSpacing = NORMAL; + uint8_t lineSpacing = NORMAL; // migration only; new saves use lineHeightPercent + uint8_t lineHeightPercent = 100; uint8_t paragraphAlignment = JUSTIFIED; // Auto-sleep timeout setting (default 10 minutes). Legacy sleepTimeout enum values are migration-only. uint8_t sleepTimeoutMinutes = 10; @@ -379,6 +381,9 @@ class CrossPointSettings { static constexpr uint8_t MIN_SLEEP_TIMEOUT_MINUTES = 1; static constexpr uint8_t MAX_SLEEP_TIMEOUT_MINUTES = 30; static constexpr uint8_t SD_FONT_MAX_SIZE_STEPS = 8; + static constexpr uint8_t MIN_LINE_HEIGHT_PERCENT = 70; + static constexpr uint8_t MAX_LINE_HEIGHT_PERCENT = 200; + static constexpr uint8_t LINE_HEIGHT_PERCENT_STEP = 1; uint16_t getPowerButtonWakeDuration() const { return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? POWER_BUTTON_WAKE_SHORT_MS @@ -414,6 +419,8 @@ class CrossPointSettings { static uint8_t sleepTimeoutEnumToMinutes(uint8_t legacyValue); static uint8_t sleepScreenStorageToMode(uint8_t storedValue); static uint8_t sleepScreenModeToStorage(uint8_t mode); + static uint8_t legacyLineSpacingToPercent(uint8_t legacyValue, uint8_t fontFamily, bool sdFontSelected); + static uint8_t clampedLineHeightPercent(uint8_t value); #ifdef SIMULATOR static bool verifySleepTimeoutMigrationContract(); static bool verifySleepScreenMigrationContract(); diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index fa7694c03b..2cee102ebc 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "CrossPointSettings.h" #include "CrossPointState.h" @@ -315,6 +316,15 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1); s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0'; + if (doc["lineHeightPercent"].isNull() && !doc["lineSpacing"].isNull()) { + const uint8_t legacyLineSpacing = clamp(doc["lineSpacing"] | static_cast(CrossPointSettings::NORMAL), + static_cast(CrossPointSettings::LINE_COMPRESSION_COUNT), + static_cast(CrossPointSettings::NORMAL)); + s.lineHeightPercent = + CrossPointSettings::legacyLineSpacingToPercent(legacyLineSpacing, s.fontFamily, s.sdFontFamilyName[0] != '\0'); + if (needsResave) *needsResave = true; + } + // Language -- stored as code string for stability across enum reorders. if (doc["language"].is()) { s.language = static_cast(I18n::languageFromCode(doc["language"].as())); diff --git a/src/SettingsList.h b/src/SettingsList.h index 806bc0fbdf..e0374496ac 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -305,9 +305,10 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist {StrId::STR_FONT_RANGE_TEENSY, StrId::STR_FONT_RANGE_TINY, StrId::STR_FONT_RANGE_XLARGE, StrId::STR_FONT_RANGE_NO_EMOJI, StrId::STR_FONT_RANGE_ALL}, "sdFontSizeRange", StrId::STR_CAT_READER)); - add(SettingInfo::Enum(StrId::STR_LINE_SPACING, &CrossPointSettings::lineSpacing, - {StrId::STR_TIGHT, StrId::STR_NORMAL, StrId::STR_WIDE}, "lineSpacing", - StrId::STR_CAT_READER)); + add(SettingInfo::Value(StrId::STR_LINE_SPACING, &CrossPointSettings::lineHeightPercent, + {CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, CrossPointSettings::MAX_LINE_HEIGHT_PERCENT, + CrossPointSettings::LINE_HEIGHT_PERCENT_STEP}, + "lineHeightPercent", StrId::STR_CAT_READER)); add(SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation, {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}, "orientation", StrId::STR_CAT_READER)); diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 0eeb20d2a1..9688c11d5f 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -16,6 +16,7 @@ #include "home/RecentBooksActivity.h" #include "home/RecentBooksGridActivity.h" #include "network/CrossPointWebServerActivity.h" +#include "network/NearbyStatsSyncActivity.h" #include "reader/ReaderActivity.h" #include "settings/OpdsServerListActivity.h" #include "settings/SettingsActivity.h" @@ -188,6 +189,10 @@ void ActivityManager::goToFileTransfer(std::string returnBookPath) { replaceActivity(std::make_unique(renderer, mappedInput, std::move(returnBookPath))); } +void ActivityManager::goToNearbyStatsSync() { + replaceActivity(std::make_unique(renderer, mappedInput)); +} + void ActivityManager::goToSettings() { replaceActivity(std::make_unique(renderer, mappedInput)); } void ActivityManager::goToFileBrowser(std::string path) { @@ -240,6 +245,8 @@ void ActivityManager::goHome(HomeMenuItem initialMenuItem) { initialMenuItem = HomeMenuItem::OPDS_BROWSER; } else if (activityName == "CrossPointWebServer") { initialMenuItem = HomeMenuItem::FILE_TRANSFER; + } else if (activityName == "NearbyStatsSync") { + initialMenuItem = HomeMenuItem::FILE_TRANSFER; } else if (activityName == "Settings") { initialMenuItem = HomeMenuItem::SETTINGS_MENU; } diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 333c14a25f..0b3a76cf13 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -90,6 +90,7 @@ class ActivityManager { // goTo... functions are convenient wrapper for replaceActivity() void goToFileTransfer(std::string returnBookPath = {}); + void goToNearbyStatsSync(); void goToSettings(); void goToFileBrowser(std::string path = {}); void goToRecentBooks(); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 40aa325592..dff02938de 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -586,7 +586,7 @@ void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderReadingStatsSleepScreen() const { BookReadingStats bookStats; - GlobalReadingStats globalStats = GlobalReadingStats::load(); + GlobalReadingStats globalStats = GlobalReadingStats::loadAggregated(); std::string bookTitle = tr(STR_READING_STATS); const std::string& path = APP_STATE.openEpubPath; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 1a9f7bc250..3c61cce688 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -8,6 +8,7 @@ #include #include "MappedInputManager.h" +#include "SdCardFontSystem.h" #include "SilentRestart.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" @@ -21,11 +22,14 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr size_t OPDS_BROWSER_ENTRY_CAPACITY = MAX_OPDS_FEED_ENTRIES + 2; +constexpr size_t OPDS_DOWNLOAD_BUFFER_SIZE = 4096; } // namespace void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); + sdFontSystem.releaseLoadedFont(renderer); + state = BrowserState::CHECK_WIFI; entryCount = 0; navigationHistory.clear(); @@ -250,7 +254,8 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { if (!parser) { state = BrowserState::ERROR; - errorMessage = tr(STR_PARSE_FEED_FAILED); + errorMessage = parser.getErrorReason() == OpdsParserError::BUFFER_MEMORY ? tr(STR_OPDS_FEED_BUFFER_MEMORY_ERROR) + : tr(STR_PARSE_FEED_FAILED); requestUpdate(); return; } @@ -350,6 +355,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { }; HttpDownloader::DownloadOptions downloadOptions; downloadOptions.shouldCancel = pollCancel; + downloadOptions.bufferSize = OPDS_DOWNLOAD_BUFFER_SIZE; const auto result = HttpDownloader::downloadToFile( downloadUrl, filename, diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 03a111c3d8..2f3ef3ae8b 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -316,6 +316,37 @@ void appendCarouselCoverStateToKey(std::string& key, const RecentBook& book) { } } +void appendSyncedStatsStateToKey(std::string& key) { + FsFile dir = Storage.open("/.crosspoint/synced_stats"); + if (!dir) { + key += "no-synced-stats"; + key += '\0'; + return; + } + + if (!dir.isDirectory()) { + dir.close(); + key += "synced-stats-not-dir"; + key += '\0'; + return; + } + + char name[128]; + for (FsFile file = dir.openNextFile(); file; file = dir.openNextFile()) { + const bool isDirectory = file.isDirectory(); + const size_t nameLen = file.getName(name, sizeof(name)); + if (!isDirectory && nameLen > 0) { + key += name; + key += '\0'; + file.close(); + appendHashedFileStateToKey(key, std::string("/.crosspoint/synced_stats/") + name); + continue; + } + file.close(); + } + dir.close(); +} + void buildCarouselCacheKey(const std::vector& recentBooks, std::string& key, uint64_t& keyHash) { key.clear(); key.reserve(512); @@ -323,6 +354,7 @@ void buildCarouselCacheKey(const std::vector& recentBooks, std::stri appendCarouselCoverStateToKey(key, book); } appendHashedFileStateToKey(key, "/.crosspoint/global_stats.bin"); + appendSyncedStatsStateToKey(key); keyHash = fnvHash64(key); } @@ -693,7 +725,7 @@ void HomeActivity::onEnter() { } } - globalStats = GlobalReadingStats::load(); + globalStats = GlobalReadingStats::loadAggregated(); if (isCarouselTheme) { loadAllBookStats(); } diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 81d764312d..7e0c7f4bd6 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -13,6 +13,7 @@ #include "NetworkModeSelectionActivity.h" #include "SilentRestart.h" #include "WifiSelectionActivity.h" +#include "activities/ActivityManager.h" #include "activities/network/CalibreConnectActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -107,12 +108,19 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) modeName = "Connect to Calibre"; } else if (mode == NetworkMode::CREATE_HOTSPOT) { modeName = "Create Hotspot"; + } else if (mode == NetworkMode::NEARBY_STATS_SYNC) { + modeName = "Nearby Stats Sync"; } LOG_DBG("WEBACT", "Network mode selected: %s", modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); + if (mode == NetworkMode::NEARBY_STATS_SYNC) { + activityManager.goToNearbyStatsSync(); + return; + } + if (mode == NetworkMode::CONNECT_CALIBRE) { startActivityForResult( std::make_unique(renderer, mappedInput), [this](const ActivityResult& result) { diff --git a/src/activities/network/NearbyStatsSyncActivity.cpp b/src/activities/network/NearbyStatsSyncActivity.cpp new file mode 100644 index 0000000000..80c791ae3b --- /dev/null +++ b/src/activities/network/NearbyStatsSyncActivity.cpp @@ -0,0 +1,568 @@ +#include "NearbyStatsSyncActivity.h" + +#ifdef SIMULATOR + +#include +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +NearbyStatsSyncActivity::NearbyStatsSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("NearbyStatsSync", renderer, mappedInput) {} + +NearbyStatsSyncActivity::~NearbyStatsSyncActivity() = default; + +void NearbyStatsSyncActivity::onEnter() { + Activity::onEnter(); + setState(State::ERROR); +} + +void NearbyStatsSyncActivity::onExit() { Activity::onExit(); } + +void NearbyStatsSyncActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) finish(); +} + +void NearbyStatsSyncActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_NEARBY_STATS_SYNC)); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NEARBY_STATS_SIMULATOR_UNAVAILABLE), true, + EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} + +void NearbyStatsSyncActivity::enqueueEspNowPacket(const uint8_t*, const uint8_t*, int) {} + +void NearbyStatsSyncActivity::setState(const State state) { + state_ = state; + requestUpdate(); +} + +#else + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "activities/reader/GlobalReadingStats.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { + +constexpr const char* LOG_TAG = "NSYNC"; +constexpr const char* CROSSPOINT_ROOT = "/.crosspoint"; +constexpr const char* GLOBAL_STATS_PATH = "/.crosspoint/global_stats.bin"; +constexpr const char* SYNCED_STATS_DIR = "/.crosspoint/synced_stats"; +constexpr uint8_t ESPNOW_CHANNEL = 1; +constexpr uint8_t PROTOCOL_VERSION = 1; +constexpr uint8_t MIN_STATS_BYTES = 13; +constexpr uint8_t MAX_STATS_BYTES = 17; +constexpr uint8_t PACKET_HEADER_BYTES = 14; +constexpr uint32_t HELLO_INTERVAL_MS = 750; +constexpr uint32_t STATS_RETRY_INTERVAL_MS = 750; +constexpr uint32_t SYNC_TIMEOUT_MS = 12000; +constexpr uint8_t BROADCAST_MAC[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + +NearbyStatsSyncActivity* activeActivity = nullptr; + +std::string bytesToHex(const uint8_t* data, const size_t length) { + static constexpr char hex[] = "0123456789abcdef"; + std::string out; + out.resize(length * 2); + for (size_t i = 0; i < length; i++) { + out[i * 2] = hex[data[i] >> 4]; + out[i * 2 + 1] = hex[data[i] & 0x0F]; + } + return out; +} + +std::string statsFileNameForDeviceMac(const std::array& mac) { + char name[32]; + snprintf(name, sizeof(name), "device_%02x%02x%02x%02x%02x%02x.bin", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return name; +} + +std::string syncedStatsPathForDeviceMac(const std::array& mac) { + return std::string(SYNCED_STATS_DIR) + "/" + statsFileNameForDeviceMac(mac); +} + +bool isValidStatsPayload(const uint8_t* data, const uint8_t size) { + return (size == MIN_STATS_BYTES && data[0] == 1) || (size == MAX_STATS_BYTES && data[0] == 2); +} + +bool ensureSyncedStatsDirectory() { + return Storage.ensureDirectoryExists(CROSSPOINT_ROOT) && Storage.ensureDirectoryExists(SYNCED_STATS_DIR); +} + +bool readSmallFile(const char* path, std::array& out, uint8_t& outSize) { + outSize = 0; + FsFile file; + if (!Storage.openFileForRead(LOG_TAG, path, file)) return false; + const size_t fileSize = file.fileSize(); + if (fileSize < MIN_STATS_BYTES || fileSize > MAX_STATS_BYTES) { + file.close(); + return false; + } + + const int read = file.read(out.data(), fileSize); + file.close(); + if (read != static_cast(fileSize) || !isValidStatsPayload(out.data(), static_cast(fileSize))) + return false; + outSize = static_cast(fileSize); + return true; +} + +bool writeSyncedStatsFile(const std::string& path, const uint8_t* data, const uint8_t size) { + if (!isValidStatsPayload(data, size) || !ensureSyncedStatsDirectory()) return false; + + const std::string tmpPath = path + ".part"; + if (Storage.exists(tmpPath.c_str())) Storage.remove(tmpPath.c_str()); + + FsFile file; + if (!Storage.openFileForWrite(LOG_TAG, tmpPath, file)) return false; + const size_t written = file.write(data, size); + if (written != size) { + file.close(); + Storage.remove(tmpPath.c_str()); + return false; + } + file.flush(); + if (!file.sync()) { + file.close(); + Storage.remove(tmpPath.c_str()); + return false; + } + if (!file.close()) { + Storage.remove(tmpPath.c_str()); + return false; + } + + if (Storage.exists(path.c_str()) && !Storage.remove(path.c_str())) { + Storage.remove(tmpPath.c_str()); + return false; + } + if (!Storage.rename(tmpPath.c_str(), path.c_str())) { + Storage.remove(tmpPath.c_str()); + return false; + } + return true; +} + +void onEspNowReceive(const esp_now_recv_info_t* info, const uint8_t* data, int length) { + if (!activeActivity || !info || !info->src_addr) return; + activeActivity->enqueueEspNowPacket(info->src_addr, data, length); +} + +} // namespace + +NearbyStatsSyncActivity::NearbyStatsSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("NearbyStatsSync", renderer, mappedInput), eventMutex_(xSemaphoreCreateMutex()) {} + +NearbyStatsSyncActivity::~NearbyStatsSyncActivity() { + if (eventMutex_) { + vSemaphoreDelete(eventMutex_); + eventMutex_ = nullptr; + } +} + +void NearbyStatsSyncActivity::onEnter() { + Activity::onEnter(); + setState(State::STARTING); + + if (esp_efuse_mac_get_default(localDeviceMac_.data()) != ESP_OK) { + setError("Could not read device id"); + return; + } + + if (!beginEspNow()) { + setError("Could not start nearby sync"); + return; + } + + setState(State::READY); +} + +void NearbyStatsSyncActivity::onExit() { + Activity::onExit(); + endEspNow(); +} + +void NearbyStatsSyncActivity::loop() { + processEvents(); + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && + (state_ == State::READY || state_ == State::SYNCED || state_ == State::ERROR)) { + startSync(); + return; + } + + updateSyncProgress(); +} + +bool NearbyStatsSyncActivity::beginEspNow() { + WiFi.mode(WIFI_STA); + WiFi.disconnect(false); + WiFi.setSleep(false); + if (esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE) != ESP_OK) return false; + esp_wifi_set_ps(WIFI_PS_NONE); + + if (esp_now_init() != ESP_OK) return false; + espNowStarted_ = true; + + if (esp_now_register_recv_cb(onEspNowReceive) != ESP_OK) return false; + if (!addPeer(BROADCAST_MAC)) return false; + activeActivity = this; + return true; +} + +void NearbyStatsSyncActivity::endEspNow() { + if (activeActivity == this) activeActivity = nullptr; + if (espNowStarted_) { + esp_now_unregister_recv_cb(); + esp_now_deinit(); + espNowStarted_ = false; + } + WiFi.disconnect(false); + WiFi.mode(WIFI_OFF); +} + +bool NearbyStatsSyncActivity::prepareLocalStats() { + localStatsReady_ = false; + if (!ensureSyncedStatsDirectory()) { + setError("could not create synced stats directory"); + return false; + } + + // Creating synced_stats is the opt-in signal; mirror local stats only after + // the user starts or accepts this workflow. + GlobalReadingStats::load().save(); + + if (!readSmallFile(GLOBAL_STATS_PATH, localStats_, localStatsSize_)) { + setError("local stats unavailable"); + return false; + } + + localStatsReady_ = true; + return true; +} + +void NearbyStatsSyncActivity::startSync() { + errorMessage_.clear(); + peerSeen_ = false; + peerStatsSaved_ = false; + localStatsSent_ = false; + localStatsAcked_ = false; + peerId_.clear(); + syncStartedMs_ = millis(); + lastHelloMs_ = 0; + lastStatsSendMs_ = 0; + + if (!prepareLocalStats()) return; + + setState(State::DISCOVERING); + sendHello(); +} + +void NearbyStatsSyncActivity::enqueueEspNowPacket(const uint8_t* sourceMac, const uint8_t* data, const int length) { + if (!eventMutex_ || !sourceMac || !data || length < PACKET_HEADER_BYTES) return; + if (data[0] != 'C' || data[1] != 'I' || data[2] != 'S' || data[3] != 'S') return; + if (data[4] != PROTOCOL_VERSION) return; + + SyncEvent event; + event.type = static_cast(data[5]); + event.statsSize = data[6]; + std::copy(sourceMac, sourceMac + event.sourceMac.size(), event.sourceMac.begin()); + std::copy(data + 8, data + 14, event.deviceMac.begin()); + + const int expectedLength = PACKET_HEADER_BYTES + (event.type == PacketType::STATS ? event.statsSize : 0); + if (length != expectedLength) return; + if (event.type != PacketType::HELLO && event.type != PacketType::STATS && event.type != PacketType::ACK) return; + if (event.deviceMac == localDeviceMac_) return; + if (event.type == PacketType::STATS) { + if (event.statsSize > event.stats.size() || !isValidStatsPayload(data + PACKET_HEADER_BYTES, event.statsSize)) + return; + std::copy(data + PACKET_HEADER_BYTES, data + PACKET_HEADER_BYTES + event.statsSize, event.stats.begin()); + } else if (event.statsSize != 0) { + return; + } + + if (xSemaphoreTake(eventMutex_, 0) != pdTRUE) return; + if (eventOverflow_ || eventCount_ >= MAX_SYNC_EVENTS) { + eventOverflow_ = true; + eventHead_ = 0; + eventCount_ = 0; + } else { + const uint8_t eventTail = static_cast((eventHead_ + eventCount_) % MAX_SYNC_EVENTS); + events_[eventTail] = event; + eventCount_++; + } + xSemaphoreGive(eventMutex_); +} + +void NearbyStatsSyncActivity::processEvents() { + while (true) { + SyncEvent event; + bool hasEvent = false; + bool hasOverflow = false; + if (eventMutex_) { + xSemaphoreTake(eventMutex_, portMAX_DELAY); + if (eventOverflow_) { + eventOverflow_ = false; + eventHead_ = 0; + eventCount_ = 0; + hasOverflow = true; + } + if (eventCount_ > 0) { + event = events_[eventHead_]; + eventHead_ = static_cast((eventHead_ + 1) % MAX_SYNC_EVENTS); + eventCount_--; + hasEvent = true; + } + xSemaphoreGive(eventMutex_); + } + + if (hasOverflow) { + setError("sync event queue overflow"); + return; + } + if (!hasEvent) return; + handleEvent(event); + } +} + +void NearbyStatsSyncActivity::handleEvent(const SyncEvent& event) { + if (state_ == State::ERROR) return; + + const bool startingPassiveSync = state_ != State::DISCOVERING && state_ != State::SYNCING; + if (startingPassiveSync) { + errorMessage_.clear(); + peerStatsSaved_ = false; + localStatsSent_ = false; + localStatsAcked_ = false; + localStatsReady_ = false; + syncStartedMs_ = millis(); + lastHelloMs_ = syncStartedMs_; + lastStatsSendMs_ = 0; + } + + peerSeen_ = true; + peerSourceMac_ = event.sourceMac; + peerDeviceMac_ = event.deviceMac; + peerId_ = bytesToHex(peerDeviceMac_.data(), peerDeviceMac_.size()); + addPeer(peerSourceMac_.data()); + + if (!localStatsReady_ && !prepareLocalStats()) return; + if (state_ == State::READY || state_ == State::DISCOVERING || state_ == State::SYNCED) setState(State::SYNCING); + + if (event.type == PacketType::HELLO) { + sendLocalStats(); + return; + } + + if (event.type == PacketType::STATS) { + if (!writeSyncedStatsFile(syncedStatsPathForDeviceMac(peerDeviceMac_), event.stats.data(), event.statsSize)) { + setError("could not save stats"); + return; + } + peerStatsSaved_ = true; + sendAck(peerSourceMac_.data()); + if (!localStatsSent_ || !localStatsAcked_) sendLocalStats(); + return; + } + + if (event.type == PacketType::ACK) { + localStatsAcked_ = true; + } +} + +bool NearbyStatsSyncActivity::addPeer(const uint8_t* peerMac) { + if (!peerMac) return false; + + esp_now_peer_info_t peer = {}; + memcpy(peer.peer_addr, peerMac, ESP_NOW_ETH_ALEN); + peer.channel = ESPNOW_CHANNEL; + peer.ifidx = WIFI_IF_STA; + peer.encrypt = false; + + const esp_err_t result = esp_now_add_peer(&peer); + return result == ESP_OK || result == ESP_ERR_ESPNOW_EXIST; +} + +bool NearbyStatsSyncActivity::sendPacket(const PacketType type, const uint8_t* peerMac) { + if (!peerMac || !espNowStarted_) return false; + if (!addPeer(peerMac)) return false; + + std::array packet = {}; + packet[0] = 'C'; + packet[1] = 'I'; + packet[2] = 'S'; + packet[3] = 'S'; + packet[4] = PROTOCOL_VERSION; + packet[5] = static_cast(type); + packet[6] = type == PacketType::STATS ? localStatsSize_ : 0; + packet[7] = 0; + std::copy(localDeviceMac_.begin(), localDeviceMac_.end(), packet.begin() + 8); + + size_t length = PACKET_HEADER_BYTES; + if (type == PacketType::STATS) { + if (!localStatsReady_ || !isValidStatsPayload(localStats_.data(), localStatsSize_)) return false; + std::copy(localStats_.begin(), localStats_.begin() + localStatsSize_, packet.begin() + PACKET_HEADER_BYTES); + length += localStatsSize_; + } + + const esp_err_t result = esp_now_send(peerMac, packet.data(), length); + if (result != ESP_OK) { + LOG_ERR(LOG_TAG, "esp_now_send failed: %d", static_cast(result)); + return false; + } + return true; +} + +bool NearbyStatsSyncActivity::sendHello() { + lastHelloMs_ = millis(); + return sendPacket(PacketType::HELLO, BROADCAST_MAC); +} + +bool NearbyStatsSyncActivity::sendLocalStats() { + if (!peerSeen_) return false; + lastStatsSendMs_ = millis(); + localStatsSent_ = sendPacket(PacketType::STATS, peerSourceMac_.data()); + return localStatsSent_; +} + +bool NearbyStatsSyncActivity::sendAck(const uint8_t* peerMac) { return sendPacket(PacketType::ACK, peerMac); } + +void NearbyStatsSyncActivity::updateSyncProgress() { + if (state_ != State::DISCOVERING && state_ != State::SYNCING) return; + + const uint32_t now = millis(); + if (now - syncStartedMs_ > SYNC_TIMEOUT_MS) { + setError(peerSeen_ ? "stats sync timed out" : "no reader found"); + return; + } + + if (peerStatsSaved_ && localStatsAcked_) { + setState(State::SYNCED); + return; + } + + if (!peerSeen_ && now - lastHelloMs_ >= HELLO_INTERVAL_MS) { + sendHello(); + return; + } + + if (peerSeen_ && localStatsReady_ && !localStatsAcked_ && now - lastStatsSendMs_ >= STATS_RETRY_INTERVAL_MS) { + sendLocalStats(); + } +} + +void NearbyStatsSyncActivity::setState(const State state) { + if (state_ == state) return; + state_ = state; + requestUpdate(); +} + +void NearbyStatsSyncActivity::setError(const std::string& error) { + LOG_ERR(LOG_TAG, "%s", error.c_str()); + errorMessage_ = error; + setState(State::ERROR); +} + +void NearbyStatsSyncActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_NEARBY_STATS_SYNC)); + + const int centerY = pageHeight / 2 - 20; + std::string primary; + std::string secondary; + + switch (state_) { + case State::STARTING: + primary = tr(STR_LOADING_POPUP); + break; + case State::READY: + primary = tr(STR_NEARBY_STATS_READY); + secondary = statsFileNameForDeviceMac(localDeviceMac_); + break; + case State::DISCOVERING: + primary = tr(STR_NEARBY_STATS_SCANNING); + break; + case State::SYNCING: + primary = tr(STR_NEARBY_STATS_SYNCING); + secondary = peerId_; + break; + case State::SYNCED: + primary = tr(STR_NEARBY_STATS_SYNCED); + secondary = peerId_; + break; + case State::ERROR: + primary = tr(STR_ERROR_MSG); + secondary = errorMessage_; + break; + } + + if (state_ == State::READY || state_ == State::SYNCED || state_ == State::ERROR) { + renderReady(primary, secondary); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_NEARBY_STATS_SYNC_BUTTON), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + renderer.drawCenteredText(UI_10_FONT_ID, centerY, primary.c_str(), true, EpdFontFamily::BOLD); + if (!secondary.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, centerY + renderer.getLineHeight(UI_10_FONT_ID) + 8, secondary.c_str()); + } + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} + +void NearbyStatsSyncActivity::renderReady(const std::string& primary, const std::string& secondary) const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + int y = contentTop + 70; + + renderer.drawCenteredText(UI_10_FONT_ID, y, primary.c_str(), true, EpdFontFamily::BOLD); + y += lineHeight + metrics.verticalSpacing; + renderer.drawCenteredText(SMALL_FONT_ID, y, secondary.c_str(), true); + y += renderer.getLineHeight(SMALL_FONT_ID) + metrics.verticalSpacing; + if (state_ == State::READY) { + renderer.drawCenteredText(SMALL_FONT_ID, y, tr(STR_NEARBY_STATS_READY_HINT), true); + y += renderer.getLineHeight(SMALL_FONT_ID) + metrics.verticalSpacing; + } + renderer.drawCenteredText(SMALL_FONT_ID, y, tr(STR_NEARBY_STATS_SYNC_BUTTON), true); +} + +#endif diff --git a/src/activities/network/NearbyStatsSyncActivity.h b/src/activities/network/NearbyStatsSyncActivity.h new file mode 100644 index 0000000000..37e4d650e0 --- /dev/null +++ b/src/activities/network/NearbyStatsSyncActivity.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "activities/Activity.h" + +class NearbyStatsSyncActivity final : public Activity { + public: + enum class State { STARTING, READY, DISCOVERING, SYNCING, SYNCED, ERROR }; + + explicit NearbyStatsSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput); + ~NearbyStatsSyncActivity() override; + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool preventAutoSleep() override { return true; } + bool skipLoopDelay() override { return state_ == State::DISCOVERING || state_ == State::SYNCING; } + + void enqueueEspNowPacket(const uint8_t* sourceMac, const uint8_t* data, int length); + + private: + enum class PacketType : uint8_t { HELLO = 1, STATS = 2, ACK = 3 }; + + struct SyncEvent { + PacketType type = PacketType::HELLO; + std::array sourceMac = {}; + std::array deviceMac = {}; + std::array stats = {}; + uint8_t statsSize = 0; + }; + static constexpr size_t MAX_SYNC_EVENTS = 8; + + State state_ = State::STARTING; + SemaphoreHandle_t eventMutex_ = nullptr; + std::array events_ = {}; + uint8_t eventHead_ = 0; + uint8_t eventCount_ = 0; + bool eventOverflow_ = false; + bool espNowStarted_ = false; + bool localStatsReady_ = false; + bool peerSeen_ = false; + bool peerStatsSaved_ = false; + bool localStatsSent_ = false; + bool localStatsAcked_ = false; + + std::array localDeviceMac_ = {}; + std::array peerSourceMac_ = {}; + std::array peerDeviceMac_ = {}; + std::array localStats_ = {}; + uint8_t localStatsSize_ = 0; + + uint32_t syncStartedMs_ = 0; + uint32_t lastHelloMs_ = 0; + uint32_t lastStatsSendMs_ = 0; + std::string peerId_; + std::string errorMessage_; + + bool beginEspNow(); + void endEspNow(); + bool prepareLocalStats(); + void startSync(); + void processEvents(); + void handleEvent(const SyncEvent& event); + bool sendPacket(PacketType type, const uint8_t* peerMac); + bool sendHello(); + bool sendLocalStats(); + bool sendAck(const uint8_t* peerMac); + bool addPeer(const uint8_t* peerMac); + void updateSyncProgress(); + void setState(State state); + void setError(const std::string& error); + void renderReady(const std::string& primary, const std::string& secondary) const; +}; diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ec8edc904a..6486598ccb 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -8,7 +8,7 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEM_COUNT = 3; +constexpr int MENU_ITEM_COUNT = 4; } // namespace void NetworkModeSelectionActivity::onEnter() { @@ -37,6 +37,8 @@ void NetworkModeSelectionActivity::loop() { mode = NetworkMode::CONNECT_CALIBRE; } else if (selectedIndex == 2) { mode = NetworkMode::CREATE_HOTSPOT; + } else if (selectedIndex == 3) { + mode = NetworkMode::NEARBY_STATS_SYNC; } onModeSelected(mode); return; @@ -67,10 +69,11 @@ void NetworkModeSelectionActivity::render(RenderLock&&) { const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; // Menu items and descriptions static constexpr StrId menuItems[MENU_ITEM_COUNT] = {StrId::STR_JOIN_NETWORK, StrId::STR_CALIBRE_WIRELESS, - StrId::STR_CREATE_HOTSPOT}; + StrId::STR_CREATE_HOTSPOT, StrId::STR_NEARBY_STATS_SYNC}; static constexpr StrId menuDescs[MENU_ITEM_COUNT] = {StrId::STR_JOIN_DESC, StrId::STR_CALIBRE_DESC, - StrId::STR_HOTSPOT_DESC}; - static constexpr UIIcon menuIcons[MENU_ITEM_COUNT] = {UIIcon::Wifi, UIIcon::Library, UIIcon::Hotspot}; + StrId::STR_HOTSPOT_DESC, StrId::STR_NEARBY_STATS_SYNC_DESC}; + static constexpr UIIcon menuIcons[MENU_ITEM_COUNT] = {UIIcon::Wifi, UIIcon::Library, UIIcon::Hotspot, + UIIcon::Transfer}; GUI.drawList( renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEM_COUNT), selectedIndex, diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index e9524c650d..447da71f56 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -5,13 +5,14 @@ #include "activities/Activity.h" #include "util/ButtonNavigator.h" -enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; +enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT, NEARBY_STATS_SYNC }; /** * NetworkModeSelectionActivity presents the user with a choice: * - "Join a Network" - Connect to an existing WiFi network (STA mode) * - "Connect to Calibre" - Use Calibre wireless device transfers * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) + * - "Nearby Stats Sync" - Sync reading stats directly with a nearby reader * * The onModeSelected callback is called with the user's choice. * The onCancel callback is called if the user presses back. diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f93e47443e..d9d0001a49 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -49,6 +49,8 @@ constexpr uint16_t DEFAULT_AUTO_PAGE_TURN_INTERVAL_S = 30; constexpr uint16_t MIN_AUTO_PAGE_TURN_INTERVAL_S = 5; constexpr uint16_t MAX_AUTO_PAGE_TURN_INTERVAL_S = 120; constexpr int MAX_PAGE_LOAD_RETRIES = 3; +constexpr uint8_t READER_SETTINGS_FILE_VERSION = 1; +constexpr char READER_SETTINGS_FILE_NAME[] = "/reader_settings.bin"; void drawToastBuffer(const GfxRenderer& renderer, const char* msg) { constexpr int toastPadX = 20; @@ -82,6 +84,49 @@ uint16_t clampAutoPageTurnIntervalSeconds(const uint16_t seconds) { return std::clamp(seconds, MIN_AUTO_PAGE_TURN_INTERVAL_S, MAX_AUTO_PAGE_TURN_INTERVAL_S); } +uint16_t loadAutoPageTurnIntervalSeconds(const std::string& cachePath) { + FsFile f; + if (!Storage.openFileForRead("ERS", cachePath + READER_SETTINGS_FILE_NAME, f)) { + return DEFAULT_AUTO_PAGE_TURN_INTERVAL_S; + } + + uint8_t data[3] = {}; + const int n = f.read(data, sizeof(data)); + f.close(); + + if (n != static_cast(sizeof(data)) || data[0] != READER_SETTINGS_FILE_VERSION) { + LOG_DBG("ERS", "Reader settings missing or version mismatch, using defaults"); + return DEFAULT_AUTO_PAGE_TURN_INTERVAL_S; + } + + const uint16_t seconds = static_cast(data[1]) | (static_cast(data[2]) << 8); + if (seconds == 0) { + return DEFAULT_AUTO_PAGE_TURN_INTERVAL_S; + } + return clampAutoPageTurnIntervalSeconds(seconds); +} + +bool saveAutoPageTurnIntervalSeconds(const std::string& cachePath, const uint16_t seconds) { + FsFile f; + if (!Storage.openFileForWrite("ERS", cachePath + READER_SETTINGS_FILE_NAME, f)) { + LOG_ERR("ERS", "Could not open reader settings file for write"); + return false; + } + + const uint16_t clampedSeconds = clampAutoPageTurnIntervalSeconds(seconds); + uint8_t data[3]; + data[0] = READER_SETTINGS_FILE_VERSION; + data[1] = clampedSeconds & 0xFF; + data[2] = (clampedSeconds >> 8) & 0xFF; + const size_t written = f.write(data, sizeof(data)); + f.close(); + if (written != sizeof(data)) { + LOG_ERR("ERS", "Short write saving reader settings: %u/%u bytes", (unsigned)written, (unsigned)sizeof(data)); + return false; + } + return true; +} + // SD card folder finished books are moved into. Single source of truth for the path. constexpr char READ_FOLDER[] = "/Read"; @@ -257,6 +302,7 @@ void EpubReaderActivity::onEnter() { mappedInput.setReaderMode(true); epub->setupCacheDir(); + lastAutoPageTurnIntervalSeconds = loadAutoPageTurnIntervalSeconds(epub->getCachePath()); BOOKMARKS.loadForBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), "epub"); if (APP_STATE.pendingBookmarkSpine != UINT16_MAX && APP_STATE.pendingBookmarkProgress >= 0.0f) { @@ -914,9 +960,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction // Include elapsed time from the current session in the display stats. BookReadingStats displayStats = stats; displayStats.totalReadingSeconds += static_cast((millis() - sessionStartMs) / 1000UL); - startActivityForResult( - std::make_unique(renderer, mappedInput, epub->getTitle(), displayStats, globalStats), - [this](const ActivityResult&) { requestUpdate(); }); + GlobalReadingStats displayGlobalStats = GlobalReadingStats::loadAggregated(globalStats); + startActivityForResult(std::make_unique(renderer, mappedInput, epub->getTitle(), displayStats, + displayGlobalStats), + [this](const ActivityResult&) { requestUpdate(); }); break; } case EpubReaderMenuActivity::MenuAction::TOGGLE_COMPLETED: { @@ -1324,11 +1371,10 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { } uint16_t EpubReaderActivity::getAutoPageTurnIntervalSeconds() const { - const uint16_t seconds = static_cast(pageTurnDuration / 1000UL); - if (seconds == 0) { + if (lastAutoPageTurnIntervalSeconds == 0) { return DEFAULT_AUTO_PAGE_TURN_INTERVAL_S; } - return clampAutoPageTurnIntervalSeconds(seconds); + return clampAutoPageTurnIntervalSeconds(lastAutoPageTurnIntervalSeconds); } void EpubReaderActivity::setAutoPageTurnIntervalSeconds(uint16_t seconds) { @@ -1338,6 +1384,10 @@ void EpubReaderActivity::setAutoPageTurnIntervalSeconds(uint16_t seconds) { } seconds = clampAutoPageTurnIntervalSeconds(seconds); + lastAutoPageTurnIntervalSeconds = seconds; + if (epub) { + saveAutoPageTurnIntervalSeconds(epub->getCachePath(), seconds); + } lastPageTurnTime = millis(); pageTurnDuration = static_cast(seconds) * 1000UL; automaticPageTurnActive = true; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 131112f0ef..e2afa69214 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -28,6 +28,7 @@ class EpubReaderActivity final : public Activity { int cachedChapterTotalPageCount = 0; unsigned long lastPageTurnTime = 0UL; unsigned long pageTurnDuration = 0UL; + uint16_t lastAutoPageTurnIntervalSeconds = 0; BookReadingStats stats; GlobalReadingStats globalStats; unsigned long sessionStartMs = 0UL; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 69dce9e331..ba0e20f956 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -16,7 +16,7 @@ struct ReaderLayoutSettingsSnapshot { uint8_t fontFamily; uint8_t fontSize; uint8_t sdFontSizeRange; - uint8_t lineSpacing; + uint8_t lineHeightPercent; uint8_t orientation; uint8_t screenMargin; uint8_t paragraphAlignment; @@ -31,10 +31,10 @@ struct ReaderLayoutSettingsSnapshot { bool operator==(const ReaderLayoutSettingsSnapshot& other) const { return fontFamily == other.fontFamily && fontSize == other.fontSize && sdFontSizeRange == other.sdFontSizeRange && - lineSpacing == other.lineSpacing && orientation == other.orientation && screenMargin == other.screenMargin && - paragraphAlignment == other.paragraphAlignment && embeddedStyle == other.embeddedStyle && - hyphenationEnabled == other.hyphenationEnabled && imageRendering == other.imageRendering && - extraParagraphSpacing == other.extraParagraphSpacing && + lineHeightPercent == other.lineHeightPercent && orientation == other.orientation && + screenMargin == other.screenMargin && paragraphAlignment == other.paragraphAlignment && + embeddedStyle == other.embeddedStyle && hyphenationEnabled == other.hyphenationEnabled && + imageRendering == other.imageRendering && extraParagraphSpacing == other.extraParagraphSpacing && forceParagraphIndents == other.forceParagraphIndents && bionicReadingEnabled == other.bionicReadingEnabled && guideReadingEnabled == other.guideReadingEnabled && std::strncmp(sdFontFamilyName, other.sdFontFamilyName, sizeof(sdFontFamilyName)) == 0; @@ -47,7 +47,7 @@ ReaderLayoutSettingsSnapshot captureReaderLayoutSettings() { SETTINGS.fontFamily, SETTINGS.fontSize, SETTINGS.sdFontSizeRange, - SETTINGS.lineSpacing, + SETTINGS.lineHeightPercent, SETTINGS.orientation, SETTINGS.screenMargin, SETTINGS.paragraphAlignment, diff --git a/src/activities/reader/GlobalReadingStats.cpp b/src/activities/reader/GlobalReadingStats.cpp index cc7b30896d..bfd613ab19 100644 --- a/src/activities/reader/GlobalReadingStats.cpp +++ b/src/activities/reader/GlobalReadingStats.cpp @@ -2,6 +2,11 @@ #include #include +#include + +#include +#include +#include namespace { // Binary layout v1 (13 bytes): @@ -22,6 +27,7 @@ static constexpr int GLOBAL_STATS_FILE_SIZE_V1 = 13; static constexpr int GLOBAL_STATS_FILE_SIZE = 17; static constexpr char GLOBAL_STATS_PATH[] = "/.crosspoint/global_stats.bin"; static constexpr char GLOBAL_STATS_BAK_PATH[] = "/.crosspoint/global_stats.bin.bak"; +static constexpr char SYNCED_STATS_DIR[] = "/.crosspoint/synced_stats"; uint32_t readLe32(const uint8_t* data, const int offset) { return static_cast(data[offset]) | (static_cast(data[offset + 1]) << 8) | @@ -33,14 +39,42 @@ void loadCommonFields(const uint8_t* data, GlobalReadingStats& out) { out.totalReadingSeconds = readLe32(data, 5); out.totalPagesTurned = readLe32(data, 9); } -} // namespace -static bool loadFromFile(const char* path, GlobalReadingStats& out) { - FsFile f; - if (!Storage.openFileForRead("GSTATS", path, f)) return false; +uint32_t addSaturated(const uint32_t a, const uint32_t b) { + const uint32_t max = std::numeric_limits::max(); + return max - a < b ? max : a + b; +} + +void addStats(GlobalReadingStats& target, const GlobalReadingStats& source) { + target.totalSessions = addSaturated(target.totalSessions, source.totalSessions); + target.totalReadingSeconds = addSaturated(target.totalReadingSeconds, source.totalReadingSeconds); + target.totalPagesTurned = addSaturated(target.totalPagesTurned, source.totalPagesTurned); + target.completedBooks = addSaturated(target.completedBooks, source.completedBooks); +} + +void serializeStats(const GlobalReadingStats& stats, uint8_t* data) { + data[0] = GLOBAL_STATS_VERSION; + data[1] = stats.totalSessions & 0xFF; + data[2] = (stats.totalSessions >> 8) & 0xFF; + data[3] = (stats.totalSessions >> 16) & 0xFF; + data[4] = (stats.totalSessions >> 24) & 0xFF; + data[5] = stats.totalReadingSeconds & 0xFF; + data[6] = (stats.totalReadingSeconds >> 8) & 0xFF; + data[7] = (stats.totalReadingSeconds >> 16) & 0xFF; + data[8] = (stats.totalReadingSeconds >> 24) & 0xFF; + data[9] = stats.totalPagesTurned & 0xFF; + data[10] = (stats.totalPagesTurned >> 8) & 0xFF; + data[11] = (stats.totalPagesTurned >> 16) & 0xFF; + data[12] = (stats.totalPagesTurned >> 24) & 0xFF; + data[13] = stats.completedBooks & 0xFF; + data[14] = (stats.completedBooks >> 8) & 0xFF; + data[15] = (stats.completedBooks >> 16) & 0xFF; + data[16] = (stats.completedBooks >> 24) & 0xFF; +} + +bool loadFromOpenFile(FsFile& f, GlobalReadingStats& out) { uint8_t data[GLOBAL_STATS_FILE_SIZE] = {}; const int n = f.read(data, GLOBAL_STATS_FILE_SIZE); - f.close(); if (n == GLOBAL_STATS_FILE_SIZE_V1 && data[0] == GLOBAL_STATS_VERSION_V1) { loadCommonFields(data, out); @@ -54,68 +88,133 @@ static bool loadFromFile(const char* path, GlobalReadingStats& out) { return true; } -GlobalReadingStats GlobalReadingStats::load() { - GlobalReadingStats stats; - if (loadFromFile(GLOBAL_STATS_PATH, stats)) return stats; - if (loadFromFile(GLOBAL_STATS_BAK_PATH, stats)) { - LOG_DBG("GSTATS", "Recovered global stats from backup"); - return stats; - } - LOG_DBG("GSTATS", "Global stats missing or corrupt, starting fresh"); - return stats; +std::string localSyncedStatsFileName() { + uint8_t mac[6] = {}; + if (esp_efuse_mac_get_default(mac) != 0) return {}; + + char name[32]; + snprintf(name, sizeof(name), "device_%02x%02x%02x%02x%02x%02x.bin", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return name; } -void GlobalReadingStats::save() const { - // Preserve previous file as .bak before truncating — openFileForWrite uses - // O_TRUNC, so a power failure mid-write would corrupt the primary file - // without this fallback. - if (Storage.exists(GLOBAL_STATS_PATH)) { - Storage.remove(GLOBAL_STATS_BAK_PATH); - Storage.rename(GLOBAL_STATS_PATH, GLOBAL_STATS_BAK_PATH); +std::string localSyncedStatsPath() { + const std::string fileName = localSyncedStatsFileName(); + if (fileName.empty()) return {}; + return std::string(SYNCED_STATS_DIR) + "/" + fileName; +} + +bool saveToFile(const GlobalReadingStats& stats, const char* path, const char* backupPath) { + if (backupPath != nullptr && Storage.exists(path)) { + Storage.remove(backupPath); + Storage.rename(path, backupPath); } FsFile f; - if (!Storage.openFileForWrite("GSTATS", GLOBAL_STATS_PATH, f)) { - LOG_ERR("GSTATS", "Could not write global_stats.bin"); - return; + if (!Storage.openFileForWrite("GSTATS", path, f)) { + LOG_ERR("GSTATS", "Could not write stats file: %s", path); + return false; } + uint8_t data[GLOBAL_STATS_FILE_SIZE]; - data[0] = GLOBAL_STATS_VERSION; - data[1] = totalSessions & 0xFF; - data[2] = (totalSessions >> 8) & 0xFF; - data[3] = (totalSessions >> 16) & 0xFF; - data[4] = (totalSessions >> 24) & 0xFF; - data[5] = totalReadingSeconds & 0xFF; - data[6] = (totalReadingSeconds >> 8) & 0xFF; - data[7] = (totalReadingSeconds >> 16) & 0xFF; - data[8] = (totalReadingSeconds >> 24) & 0xFF; - data[9] = totalPagesTurned & 0xFF; - data[10] = (totalPagesTurned >> 8) & 0xFF; - data[11] = (totalPagesTurned >> 16) & 0xFF; - data[12] = (totalPagesTurned >> 24) & 0xFF; - data[13] = completedBooks & 0xFF; - data[14] = (completedBooks >> 8) & 0xFF; - data[15] = (completedBooks >> 16) & 0xFF; - data[16] = (completedBooks >> 24) & 0xFF; + serializeStats(stats, data); const size_t bytesWritten = f.write(data, GLOBAL_STATS_FILE_SIZE); if (bytesWritten != GLOBAL_STATS_FILE_SIZE) { - LOG_ERR("GSTATS", "Short write for global stats: %u/%u bytes", static_cast(bytesWritten), + LOG_ERR("GSTATS", "Short write for stats file %s: %u/%u bytes", path, static_cast(bytesWritten), static_cast(GLOBAL_STATS_FILE_SIZE)); f.close(); - Storage.remove(GLOBAL_STATS_PATH); - return; + Storage.remove(path); + return false; } f.flush(); if (!f.sync()) { - LOG_ERR("GSTATS", "Failed to sync global_stats.bin"); + LOG_ERR("GSTATS", "Failed to sync stats file: %s", path); f.close(); - Storage.remove(GLOBAL_STATS_PATH); - return; + Storage.remove(path); + return false; } if (!f.close()) { - LOG_ERR("GSTATS", "Failed to close global_stats.bin after save"); - Storage.remove(GLOBAL_STATS_PATH); + LOG_ERR("GSTATS", "Failed to close stats file after save: %s", path); + Storage.remove(path); + return false; + } + + return true; +} +} // namespace + +static bool loadFromFile(const char* path, GlobalReadingStats& out) { + FsFile f; + if (!Storage.openFileForRead("GSTATS", path, f)) return false; + const bool ok = loadFromOpenFile(f, out); + f.close(); + return ok; +} + +GlobalReadingStats GlobalReadingStats::load() { + GlobalReadingStats stats; + if (loadFromFile(GLOBAL_STATS_PATH, stats)) return stats; + if (loadFromFile(GLOBAL_STATS_BAK_PATH, stats)) { + LOG_DBG("GSTATS", "Recovered global stats from backup"); + return stats; + } + LOG_DBG("GSTATS", "Global stats missing or corrupt, starting fresh"); + return stats; +} + +GlobalReadingStats GlobalReadingStats::loadAggregated() { return loadAggregated(load()); } + +GlobalReadingStats GlobalReadingStats::loadAggregated(const GlobalReadingStats& localStats) { + GlobalReadingStats stats = localStats; + FsFile dir = Storage.open(SYNCED_STATS_DIR); + if (!dir) return stats; + + if (!dir.isDirectory()) { + dir.close(); + return stats; + } + + char name[128]; + const std::string localFileName = localSyncedStatsFileName(); + uint16_t loadedCount = 0; + uint16_t skippedCount = 0; + for (FsFile file = dir.openNextFile(); file; file = dir.openNextFile()) { + const bool isDirectory = file.isDirectory(); + const size_t nameLen = file.getName(name, sizeof(name)); + + if (!isDirectory && nameLen > 0 && (localFileName.empty() || strcmp(name, localFileName.c_str()) != 0)) { + GlobalReadingStats syncedStats; + if (loadFromOpenFile(file, syncedStats)) { + addStats(stats, syncedStats); + loadedCount++; + } else { + skippedCount++; + LOG_DBG("GSTATS", "Skipping invalid synced stats file: %s", name); + } + } + + file.close(); + } + dir.close(); + + if (loadedCount > 0 || skippedCount > 0) { + LOG_DBG("GSTATS", "Aggregated %u synced stats file(s), skipped %u", static_cast(loadedCount), + static_cast(skippedCount)); + } + return stats; +} + +void GlobalReadingStats::save() const { + // Preserve previous file as .bak before truncating — openFileForWrite uses + // O_TRUNC, so a power failure mid-write would corrupt the primary file + // without this fallback. + if (!saveToFile(*this, GLOBAL_STATS_PATH, GLOBAL_STATS_BAK_PATH)) return; + + if (!Storage.exists(SYNCED_STATS_DIR)) return; + + const std::string contributionPath = localSyncedStatsPath(); + if (!contributionPath.empty()) { + saveToFile(*this, contributionPath.c_str(), nullptr); } } diff --git a/src/activities/reader/GlobalReadingStats.h b/src/activities/reader/GlobalReadingStats.h index ec78671909..c8cb0890fe 100644 --- a/src/activities/reader/GlobalReadingStats.h +++ b/src/activities/reader/GlobalReadingStats.h @@ -13,6 +13,16 @@ struct GlobalReadingStats { // stats if the file is missing or the version byte does not match. static GlobalReadingStats load(); - // Saves stats to /.crosspoint/global_stats.bin. + // Loads this device's local stats plus one synced stats file per other device + // from /.crosspoint/synced_stats/. This device's own contribution file is + // skipped to avoid double counting. + static GlobalReadingStats loadAggregated(); + + // Adds synced device stats to an already-loaded local stats snapshot. Use this + // when the local stats may include in-memory changes that are not saved yet. + static GlobalReadingStats loadAggregated(const GlobalReadingStats& localStats); + + // Saves stats to /.crosspoint/global_stats.bin. If /.crosspoint/synced_stats/ + // already exists, also mirrors this device's contribution there. void save() const; }; diff --git a/src/activities/reader/ReaderOptionsActivity.cpp b/src/activities/reader/ReaderOptionsActivity.cpp index 07ca0a5975..51fd5dd110 100644 --- a/src/activities/reader/ReaderOptionsActivity.cpp +++ b/src/activities/reader/ReaderOptionsActivity.cpp @@ -13,6 +13,7 @@ #include "activities/settings/FontDownloadActivity.h" #include "activities/settings/FontSelectionActivity.h" #include "activities/settings/StatusBarSettingsActivity.h" +#include "activities/util/IntervalSelectionActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -38,6 +39,13 @@ uint8_t enumRawValueForDisplayIndex(const SettingInfo& setting, uint8_t displayI } return setting.enumRawValues[displayIndex]; } + +std::string formatSettingValue(const SettingInfo& setting) { + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + return std::to_string(SETTINGS.*(setting.valuePtr)) + "%"; + } + return std::to_string(SETTINGS.*(setting.valuePtr)); +} } // namespace void ReaderOptionsActivity::onEnter() { @@ -98,6 +106,10 @@ void ReaderOptionsActivity::toggleCurrentSetting() { const uint8_t cur = setting.valueGetter(); setting.valueSetter((cur + 1) % totalValues); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + openLineHeightPicker(); + return; + } const int8_t cur = SETTINGS.*(setting.valuePtr); if (cur + setting.valueRange.step > setting.valueRange.max) { SETTINGS.*(setting.valuePtr) = setting.valueRange.min; @@ -123,6 +135,23 @@ void ReaderOptionsActivity::toggleCurrentSetting() { } } +void ReaderOptionsActivity::openLineHeightPicker() { + startActivityForResult( + std::make_unique( + renderer, mappedInput, "ReaderOptionsLineHeightInterval", StrId::STR_LINE_SPACING, + StrId::STR_PERCENT_STEP_HINT, SETTINGS.lineHeightPercent, CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, + CrossPointSettings::MAX_LINE_HEIGHT_PERCENT, 1, 10, StrId::STR_NONE_OPT, /*readerActivity=*/true, + /*allowPowerAsConfirm=*/true, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/true), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + SETTINGS.lineHeightPercent = CrossPointSettings::clampedLineHeightPercent( + static_cast(std::get(result.data).value)); + SETTINGS.saveToFile(); + } + requestUpdate(); + }); +} + void ReaderOptionsActivity::loop() { buttonNavigator.onNextRelease([this] { selectedIndex = ButtonNavigator::nextIndex(selectedIndex, settingsCount); @@ -186,13 +215,16 @@ void ReaderOptionsActivity::render(RenderLock&&) { const uint8_t value = setting.valueGetter(); valueText = settingEnumOptionLabel(setting, value); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { - valueText = std::to_string(SETTINGS.*(setting.valuePtr)); + valueText = formatSettingValue(setting); } return valueText; }, true); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_TOGGLE), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const bool selectedLineHeight = selectedIndex >= 0 && selectedIndex < settingsCount && + settings[selectedIndex].valuePtr == &CrossPointSettings::lineHeightPercent; + const auto labels = mappedInput.mapLabels(tr(STR_BACK), selectedLineHeight ? tr(STR_SELECT) : tr(STR_TOGGLE), + tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); renderer.displayBuffer(); diff --git a/src/activities/reader/ReaderOptionsActivity.h b/src/activities/reader/ReaderOptionsActivity.h index 8f0d5c1f0f..1bc94341eb 100644 --- a/src/activities/reader/ReaderOptionsActivity.h +++ b/src/activities/reader/ReaderOptionsActivity.h @@ -15,6 +15,7 @@ class ReaderOptionsActivity final : public Activity { void rebuildSettingsList(); void toggleCurrentSetting(); + void openLineHeightPicker(); public: explicit ReaderOptionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 189a7f504c..62830ed773 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -85,6 +85,10 @@ size_t parseAndWrapLines(const uint8_t* buffer, size_t chunkSize, size_t fileOff } return pos; } + +int getReaderLineHeight(const GfxRenderer& renderer, const int fontId) { + return std::max(1, static_cast(renderer.getLineHeight(fontId) * SETTINGS.getReaderLineCompression() + 0.5f)); +} } // namespace void TxtReaderActivity::onEnter() { @@ -242,7 +246,7 @@ void TxtReaderActivity::initializeReader() { viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight; const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; - const int lineHeight = renderer.getLineHeight(cachedFontId); + const int lineHeight = getReaderLineHeight(renderer, cachedFontId); linesPerPage = viewportHeight / lineHeight; if (linesPerPage < 1) linesPerPage = 1; @@ -378,7 +382,7 @@ void TxtReaderActivity::render(RenderLock&&) { } void TxtReaderActivity::renderPage() { - const int lineHeight = renderer.getLineHeight(cachedFontId); + const int lineHeight = getReaderLineHeight(renderer, cachedFontId); const int contentWidth = viewportWidth; // Render text lines with alignment @@ -644,7 +648,7 @@ bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx const int vw = renderer.getScreenWidth() - marginLeft - marginRight; const int vh = renderer.getScreenHeight() - marginTop - marginBottom; - const int lineHeight = renderer.getLineHeight(fontId); + const int lineHeight = getReaderLineHeight(renderer, fontId); const int linesPerPage = std::max(1, vh / lineHeight); // Step 1: Try to read the saved page and its file offset from progress.bin. diff --git a/src/activities/settings/FontDownloadActivity.cpp b/src/activities/settings/FontDownloadActivity.cpp index 98d2b6a2b6..5ff3e151ab 100644 --- a/src/activities/settings/FontDownloadActivity.cpp +++ b/src/activities/settings/FontDownloadActivity.cpp @@ -497,6 +497,8 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { if (attempt > 1) { LOG_DBG("FONT", "Retrying %s (%d/%d)", file.name.c_str(), attempt, FONT_DOWNLOAD_MAX_ATTEMPTS); } + LOG_DBG("FONT", "Download attempt %d/%d: %s (%zu bytes)", attempt, FONT_DOWNLOAD_MAX_ATTEMPTS, file.name.c_str(), + file.size); requestUpdateAndWait(); if (attempt > 1) delay(FONT_DOWNLOAD_RETRY_DELAY_MS); @@ -514,6 +516,7 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { }, &cancelRequested_, "", "", downloadOptions); if (result == HttpDownloader::ABORTED) { + LOG_INF("FONT", "Download cancelled: %s", file.name.c_str()); Storage.remove(tempPath); { RenderLock lock(*this); @@ -525,8 +528,12 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { return; } if (result == HttpDownloader::OK) { + LOG_DBG("FONT", "Download attempt succeeded: %s (%d/%d)", file.name.c_str(), attempt, + FONT_DOWNLOAD_MAX_ATTEMPTS); break; } + LOG_ERR("FONT", "Download attempt failed: %s (%d/%d, error=%d)", file.name.c_str(), attempt, + FONT_DOWNLOAD_MAX_ATTEMPTS, result); } if (result != HttpDownloader::OK) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 3b7c19f90d..34d155985e 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -103,6 +103,19 @@ void drawSystemVersionFooter(const GfxRenderer& renderer, const int pageWidth, c drawCenteredTextLine(renderer, pageWidth, bottomLineY - lineHeight, firstLine); drawCenteredTextLine(renderer, pageWidth, bottomLineY, secondLine); } + +std::string formatSettingValue(const SettingInfo& setting) { + if (setting.nameId == StrId::STR_TIME_TO_SLEEP) { + char valueBuffer[32]; + snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT), + static_cast(SETTINGS.*(setting.valuePtr))); + return valueBuffer; + } + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + return std::to_string(SETTINGS.*(setting.valuePtr)) + "%"; + } + return std::to_string(SETTINGS.*(setting.valuePtr)); +} } // namespace void SettingsActivity::rebuildSettingsLists() { @@ -336,6 +349,10 @@ void SettingsActivity::toggleCurrentSetting() { openSleepTimeoutPicker(); return; } + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + openLineHeightPicker(); + return; + } if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { // Toggle the boolean value using the member pointer @@ -462,6 +479,23 @@ void SettingsActivity::openSleepTimeoutPicker() { }); } +void SettingsActivity::openLineHeightPicker() { + startActivityForResult( + std::make_unique( + renderer, mappedInput, "LineHeightInterval", StrId::STR_LINE_SPACING, StrId::STR_PERCENT_STEP_HINT, + SETTINGS.lineHeightPercent, CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, + CrossPointSettings::MAX_LINE_HEIGHT_PERCENT, 1, 10, StrId::STR_NONE_OPT, /*readerActivity=*/false, + /*allowPowerAsConfirm=*/false, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/true), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + SETTINGS.lineHeightPercent = CrossPointSettings::clampedLineHeightPercent( + static_cast(std::get(result.data).value)); + SETTINGS.saveToFile(); + } + requestUpdate(); + }); +} + void SettingsActivity::render(RenderLock&&) { renderer.clearScreen(); @@ -504,14 +538,7 @@ void SettingsActivity::render(RenderLock&&) { const uint8_t value = setting.valueGetter(); valueText = settingEnumOptionLabel(setting, value); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { - if (setting.nameId == StrId::STR_TIME_TO_SLEEP) { - char valueBuffer[32]; - snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT), - static_cast(SETTINGS.*(setting.valuePtr))); - valueText = valueBuffer; - } else { - valueText = std::to_string(SETTINGS.*(setting.valuePtr)); - } + valueText = formatSettingValue(setting); } return valueText; }, @@ -526,7 +553,9 @@ void SettingsActivity::render(RenderLock&&) { const auto confirmLabel = (selectedSettingIndex == 0) ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount]) - : (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP + : (selectedSettingIndex > 0 && + ((*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP || + (*currentSettings)[selectedSettingIndex - 1].valuePtr == &CrossPointSettings::lineHeightPercent) ? tr(STR_SELECT) : tr(STR_TOGGLE)); const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 8a2cf0fec1..99f28583a9 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -191,6 +191,7 @@ class SettingsActivity final : public Activity { void enterCategory(int categoryIndex); void toggleCurrentSetting(); void openSleepTimeoutPicker(); + void openLineHeightPicker(); void rebuildSettingsLists(); void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged); diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 7fca845cce..3c66c392c7 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -77,8 +77,6 @@ const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrI constexpr int XTC_STATUS_BAR_ITEMS = 3; const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; -const int verticalPreviewPadding = 50; -const int verticalPreviewTextPadding = 40; } // namespace void StatusBarSettingsActivity::onEnter() { @@ -202,17 +200,24 @@ void StatusBarSettingsActivity::render(RenderLock&&) { const auto pageHeight = renderer.getScreenHeight(); const auto orientation = renderer.getOrientation(); + const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted; const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.buttonHintsHeight : 0; const int contentX = isLandscapeCw ? hintGutterWidth : 0; const int contentWidth = pageWidth - hintGutterWidth; + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_TOGGLE), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + + int verticalPreviewPadding = 50; + int verticalPreviewTextPadding = 40; + GUI.drawHeader(renderer, Rect{contentX, metrics.topPadding, contentWidth, metrics.headerHeight}, tr(STR_CUSTOMISE_STATUS_BAR)); - const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; GUI.drawList( renderer, Rect{contentX, contentTop, contentWidth, contentHeight}, visibleItemCount, static_cast(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, @@ -248,9 +253,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) { } }, true); - // Draw button hints - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_TOGGLE), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); std::string title; @@ -260,6 +263,10 @@ void StatusBarSettingsActivity::render(RenderLock&&) { title = tr(STR_EXAMPLE_CHAPTER); } + if (isLandscapeCw || isLandscapeCcw || isInverted) { + verticalPreviewPadding = 0; + } + GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, diff --git a/src/activities/util/IntervalSelectionActivity.cpp b/src/activities/util/IntervalSelectionActivity.cpp index 9a1bf81b53..f7bb5007ba 100644 --- a/src/activities/util/IntervalSelectionActivity.cpp +++ b/src/activities/util/IntervalSelectionActivity.cpp @@ -62,7 +62,9 @@ void IntervalSelectionActivity::render(RenderLock&&) { renderer.drawCenteredText(UI_12_FONT_ID, 15, I18N.get(titleId), true, EpdFontFamily::BOLD); char formattedValue[32]; - if (valueFormatId != StrId::STR_NONE_OPT) { + if (showPercentValue) { + snprintf(formattedValue, sizeof(formattedValue), "%d%%", value); + } else if (valueFormatId != StrId::STR_NONE_OPT) { snprintf(formattedValue, sizeof(formattedValue), I18N.get(valueFormatId), static_cast(value)); } else { snprintf(formattedValue, sizeof(formattedValue), "%d", value); diff --git a/src/activities/util/IntervalSelectionActivity.h b/src/activities/util/IntervalSelectionActivity.h index 1e7bbeecfd..781a34a7c7 100644 --- a/src/activities/util/IntervalSelectionActivity.h +++ b/src/activities/util/IntervalSelectionActivity.h @@ -14,7 +14,7 @@ class IntervalSelectionActivity final : public Activity { StrId titleId, StrId stepHintId, int initialValue, int minValue, int maxValue, int smallStep, int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT, bool readerActivity = false, bool allowPowerAsConfirm = false, - bool ignoreInitialConfirmRelease = false) + bool ignoreInitialConfirmRelease = false, bool showPercentValue = false) : Activity(activityName, renderer, mappedInput), titleId(titleId), stepHintId(stepHintId), @@ -26,7 +26,8 @@ class IntervalSelectionActivity final : public Activity { largeStep(largeStep), readerActivity(readerActivity), allowPowerAsConfirm(allowPowerAsConfirm), - ignoreConfirmRelease(ignoreInitialConfirmRelease) {} + ignoreConfirmRelease(ignoreInitialConfirmRelease), + showPercentValue(showPercentValue) {} void onEnter() override; void loop() override; @@ -46,6 +47,7 @@ class IntervalSelectionActivity final : public Activity { bool readerActivity; bool allowPowerAsConfirm; bool ignoreConfirmRelease; + bool showPercentValue; ButtonNavigator buttonNavigator; void adjustValue(int delta); diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 26c7fca258..be350d2ce3 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -21,17 +21,23 @@ namespace { constexpr size_t PROGRESS_UPDATE_BYTES = 64 * 1024; constexpr uint32_t PROGRESS_UPDATE_MS = 250; -constexpr size_t DOWNLOAD_BUFFER_SIZE = 1024; +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 = 15000; +constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 30000; void logNetworkState(const char* phase) { LOG_DBG("HTTP", "%s: heap free=%u maxAlloc=%u wifi=%d rssi=%d", phase, ESP.getFreeHeap(), ESP.getMaxAllocHeap(), static_cast(WiFi.status()), WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0); } +void logDownloadState(const char* phase, const size_t downloaded, const size_t total, const uint32_t idleMs) { + LOG_ERR("HTTP", "%s after %zu/%zu bytes (idle=%lu ms, timeout=%lu ms)", phase, downloaded, total, + static_cast(idleMs), static_cast(DOWNLOAD_IDLE_TIMEOUT_MS)); + logNetworkState(phase); +} + bool isCancelRequested(bool* cancelFlag, const HttpDownloader::CancelCallback& shouldCancel) { if (cancelFlag && *cancelFlag) { return true; @@ -118,7 +124,7 @@ class FileWriteStream final : public Stream { HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& file, const size_t contentLength, HttpDownloader::ProgressCallback progress, size_t& downloaded, - bool* cancelFlag, + bool* cancelFlag, const size_t bufferSize, const HttpDownloader::CancelCallback& shouldCancel) { auto* stream = http.getStreamPtr(); if (!stream) { @@ -126,9 +132,9 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& return HttpDownloader::HTTP_ERROR; } - std::unique_ptr buffer(new (std::nothrow) uint8_t[DOWNLOAD_BUFFER_SIZE]); + std::unique_ptr buffer(new (std::nothrow) uint8_t[bufferSize]); if (!buffer) { - LOG_ERR("HTTP", "Failed to allocate %zu byte download buffer", DOWNLOAD_BUFFER_SIZE); + LOG_ERR("HTTP", "Failed to allocate %zu byte download buffer", bufferSize); return HttpDownloader::HTTP_ERROR; } @@ -143,25 +149,25 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& int available = stream->available(); if (available <= 0) { if (!http.connected()) { - LOG_ERR("HTTP", "Connection closed after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Connection closed", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } if (millis() - lastProgressMs >= DOWNLOAD_IDLE_TIMEOUT_MS) { - LOG_ERR("HTTP", "Read timed out after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Read timed out", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } delay(1); continue; } - const size_t toRead = std::min({DOWNLOAD_BUFFER_SIZE, remaining, static_cast(available)}); + 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; } - LOG_ERR("HTTP", "Read timed out after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Read timed out", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } @@ -171,7 +177,7 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& progressNotifier.notify(downloaded, false); if (accepted != bytesRead) { - LOG_ERR("HTTP", "Write failed after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Write failed", downloaded, contentLength, 0); return HttpDownloader::FILE_ERROR; } @@ -279,9 +285,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& 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); @@ -337,6 +347,9 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& } 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())) { @@ -364,7 +377,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& if (contentLength > 0) { transferError = downloadKnownLengthBody(http, file, contentLength, std::move(progress), downloaded, cancelFlag, - options.shouldCancel); + 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)); @@ -390,6 +403,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& 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(transferError), downloaded, contentLength, options.preservePartial, options.resumePartial); if (transferError == ABORTED || !options.preservePartial) { Storage.remove(destPath.c_str()); } diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index 4d4f4644d6..b09fcb2332 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -24,12 +24,16 @@ class HttpDownloader { struct DownloadOptions { explicit DownloadOptions(bool preservePartial = false, bool resumePartial = false, - CancelCallback shouldCancel = nullptr) - : preservePartial(preservePartial), resumePartial(resumePartial), shouldCancel(std::move(shouldCancel)) {} + CancelCallback shouldCancel = nullptr, size_t bufferSize = 1024) + : preservePartial(preservePartial), + resumePartial(resumePartial), + shouldCancel(std::move(shouldCancel)), + bufferSize(bufferSize) {} bool preservePartial; bool resumePartial; CancelCallback shouldCancel; + size_t bufferSize; }; /**