From b814dc364d5ad2fddf32b202c7d1c8756926eeac Mon Sep 17 00:00:00 2001 From: Dudekahedron <49531350+StanleyDudek@users.noreply.github.com> Date: Fri, 22 May 2026 16:18:34 -0700 Subject: [PATCH 01/17] correctly draw status bar preview while editing in non portrait orientation With CW, CCW, Inverted orientations When in Reader If you open Book menu > Reader Options > Customize Status Bar The preview is drawn in the incorrect location due to a hardcoded offset, this commit sets this to change based on orientation --- .../settings/StatusBarSettingsActivity.cpp | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 7fca845cce..3cf5323508 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; @@ -259,6 +262,10 @@ void StatusBarSettingsActivity::render(RenderLock&&) { } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { title = tr(STR_EXAMPLE_CHAPTER); } + + if (isLandscapeCw || isLandscapeCcw || isInverted) { + verticalPreviewPadding = 0; + } GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding); From 8ef86449dabb07277d162d102fafc4aa76969132 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 09:27:44 -0400 Subject: [PATCH 02/17] fix(reader): persist auto page turn interval --- CHANGELOG.md | 1 + docs/contributing/architecture.md | 1 + docs/file-formats.md | 13 +++++ src/activities/reader/EpubReaderActivity.cpp | 55 ++++++++++++++++++-- src/activities/reader/EpubReaderActivity.h | 1 + 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a9ef7408..9bb75843a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed - Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. +- Fixed Auto Page Turn so each book remembers the last selected interval when it is turned on again. ### Changed 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..7f451dc38f 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -116,6 +116,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/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f93e47443e..a17767ff11 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) { @@ -1324,11 +1370,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 +1383,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; From 08ab39729aac719968e0d5ec00d12837c151577b Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 09:23:38 -0400 Subject: [PATCH 03/17] feat(reader): add adjustable line height setting --- CHANGELOG.md | 1 + src/CrossPointSettings.cpp | 95 ++++++++++--------- src/CrossPointSettings.h | 9 +- src/JsonSettingsIO.cpp | 9 ++ src/SettingsList.h | 7 +- .../reader/EpubReaderMenuActivity.cpp | 12 +-- .../reader/ReaderOptionsActivity.cpp | 36 ++++++- src/activities/reader/ReaderOptionsActivity.h | 1 + src/activities/reader/TxtReaderActivity.cpp | 10 +- src/activities/settings/SettingsActivity.cpp | 47 +++++++-- src/activities/settings/SettingsActivity.h | 1 + .../util/IntervalSelectionActivity.cpp | 4 +- .../util/IntervalSelectionActivity.h | 6 +- 13 files changed, 165 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a9ef7408..90c9fd84b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Added an adjustable reader line-height setting with percent-based spacing for EPUB and TXT books. ### Fixed - Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. 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..7cb15c1d3d 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -315,6 +315,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/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/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/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/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); From 2b1e520592c5aa3bf8495393cd59d07d27542310 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 11:30:52 -0400 Subject: [PATCH 04/17] style: run clang-format --- src/activities/settings/StatusBarSettingsActivity.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 3cf5323508..3c66c392c7 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -209,8 +209,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { 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)); + + 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; @@ -262,7 +262,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) { } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { title = tr(STR_EXAMPLE_CHAPTER); } - + if (isLandscapeCw || isLandscapeCcw || isInverted) { verticalPreviewPadding = 0; } From 944fa3e05283517af22fae3033983bed197213ca Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 12:32:02 -0400 Subject: [PATCH 05/17] fix: improve font download timeout diagnostics --- .../settings/FontDownloadActivity.cpp | 7 ++++++ src/network/HttpDownloader.cpp | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) 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/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 26c7fca258..9de658bbb3 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -25,13 +25,19 @@ constexpr size_t 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; @@ -143,11 +149,11 @@ 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); @@ -161,7 +167,7 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& 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; } @@ -282,6 +288,9 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& 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), DOWNLOAD_BUFFER_SIZE); http.begin(*client, url.c_str()); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); @@ -337,6 +346,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())) { @@ -390,6 +402,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()); } From 247ff23db0f7653c176bc4f3ec496deeaae3e903 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 13:09:52 -0400 Subject: [PATCH 06/17] ci: run firmware build on self-hosted runner --- .github/workflows/ci.yml | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14561a504..3c430c6c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,32 +65,16 @@ 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 run: | From b340133c4f98d4439e44b70102b97bac7fc8f5d1 Mon Sep 17 00:00:00 2001 From: Julia Date: Sat, 23 May 2026 13:11:05 -0400 Subject: [PATCH 07/17] fix: improve OPDS and font download speeds and reliability (#195) * fix(opds): show more specific opds errors for low memory scenario * fix(opds): free sd font before catalog loading * fix: improve font download timeout diagnostics * fix(opds): show more specific opds errors for low memory scenario * fix(opds): free sd font before catalog loading * fix: increase opds transfer buffer back up to 4096 and increase font buffer to 2048kb --- CHANGELOG.md | 3 +++ lib/I18n/translations/english.yaml | 1 + lib/OpdsParser/OpdsParser.cpp | 8 ++++++++ lib/OpdsParser/OpdsParser.h | 4 ++++ .../browser/OpdsBookBrowserActivity.cpp | 8 +++++++- src/network/HttpDownloader.cpp | 15 ++++++++------- src/network/HttpDownloader.h | 8 ++++++-- 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c9fd84b9..e82010da4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ ### 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. ### Changed diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e7270b6263..67a52194e0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -209,6 +209,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: " 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/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/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 9de658bbb3..be350d2ce3 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -21,7 +21,7 @@ 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; @@ -124,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) { @@ -132,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; } @@ -160,7 +160,7 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& 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) { @@ -285,12 +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), DOWNLOAD_BUFFER_SIZE); + static_cast(DOWNLOAD_IDLE_TIMEOUT_MS), bufferSize); http.begin(*client, url.c_str()); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); @@ -376,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)); 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; }; /** From 3684ae9c0d74bfb21e4536aa95f58827073f3168 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 13:21:08 -0400 Subject: [PATCH 08/17] ci: build only tiny firmware in CI --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c430c6c86..a7088088ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,10 +76,10 @@ jobs: - 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: | @@ -92,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 From 628d68c492155ce82b7223d2f9caf85adb6a2264 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 13:27:44 -0400 Subject: [PATCH 09/17] feat(stats): aggregate synced global stats --- CHANGELOG.md | 5 + README.md | 8 + docs/file-formats.md | 32 +++ src/activities/boot_sleep/SleepActivity.cpp | 2 +- src/activities/home/HomeActivity.cpp | 34 +++- src/activities/reader/EpubReaderActivity.cpp | 7 +- src/activities/reader/GlobalReadingStats.cpp | 197 ++++++++++++++----- src/activities/reader/GlobalReadingStats.h | 12 +- 8 files changed, 242 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e82010da4f..cd5e6fe432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added - Added an adjustable reader line-height setting with percent-based spacing for EPUB and TXT books. +- Added a Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. +- Added a Minimal sleep screen option that shows the current book cover and reading progress on a dark background. +- Added aggregate all-time Reading Stats support from peer-synced per-device stats files. +- Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. +- Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. ### Fixed - Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. 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/file-formats.md b/docs/file-formats.md index b6e98d8ea2..9fc274fd90 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 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/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/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f93e47443e..52f37b3657 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -914,9 +914,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: { 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; }; From 9a3af63fa2b1260b034324b716d5eed8e874bab0 Mon Sep 17 00:00:00 2001 From: sabraman Date: Sat, 23 May 2026 14:36:29 -0400 Subject: [PATCH 10/17] feat: add bluetooth stats syncing --- CHANGELOG.md | 1 + docs/webserver.md | 23 +- lib/I18n/translations/english.yaml | 9 + platformio.ini | 1 + scripts/requirements.txt | 1 + src/JsonSettingsIO.cpp | 1 + src/activities/ActivityManager.cpp | 7 + src/activities/ActivityManager.h | 1 + .../network/BleTransferActivity.cpp | 1349 +++++++++++++++++ src/activities/network/BleTransferActivity.h | 114 ++ .../network/CrossPointWebServerActivity.cpp | 8 + .../network/NetworkModeSelectionActivity.cpp | 11 +- .../network/NetworkModeSelectionActivity.h | 3 +- 13 files changed, 1517 insertions(+), 12 deletions(-) create mode 100644 src/activities/network/BleTransferActivity.cpp create mode 100644 src/activities/network/BleTransferActivity.h diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5e6fe432..f710669084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added a Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. - Added a Minimal sleep screen option that shows the current book cover and reading progress on a dark background. - Added aggregate all-time Reading Stats support from peer-synced per-device stats files. +- Added Bluetooth Reading Stats sync between nearby CrossInk readers, including automatic `synced_stats` folder setup from the Bluetooth Stats Sync screen. - Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. - Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. 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 67a52194e0..dbdd2a20dc 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_BLUETOOTH_TRANSFER: "Bluetooth Stats Sync" STR_JOIN_DESC: "Connect to an existing WiFi network" STR_HOTSPOT_DESC: "Create a WiFi network others can join" +STR_BLUETOOTH_TRANSFER_DESC: "Sync 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" @@ -484,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_BLE_TRANSFER_READY: "Ready for stats sync" +STR_BLE_SYNC_STATS: "Sync Stats" +STR_BLE_STATS_SCANNING: "Finding nearby reader" +STR_BLE_STATS_CONNECTING: "Connecting to reader" +STR_BLE_STATS_SYNCING: "Syncing reading stats" +STR_BLE_STATS_SYNCED: "Reading stats synced" +STR_BLE_SIMULATOR_UNAVAILABLE: "Bluetooth sync is not available in simulator" diff --git a/platformio.ini b/platformio.ini index 1dc07b1e46..613d501baa 100644 --- a/platformio.ini +++ b/platformio.ini @@ -66,6 +66,7 @@ lib_deps = ricmoo/QRCode @ 0.0.1 bitbank2/PNGdec @ ^1.0.0 https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b + https://github.com/h2zero/NimBLE-Arduino.git#51d99b378f30cc579126cea523949471a8af5f60 links2004/WebSockets @ 2.7.3 lib_ignore = 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/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 7cb15c1d3d..2cee102ebc 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "CrossPointSettings.h" #include "CrossPointState.h" diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 0eeb20d2a1..ab2c68b9d6 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -15,6 +15,7 @@ #include "home/HomeActivity.h" #include "home/RecentBooksActivity.h" #include "home/RecentBooksGridActivity.h" +#include "network/BleTransferActivity.h" #include "network/CrossPointWebServerActivity.h" #include "reader/ReaderActivity.h" #include "settings/OpdsServerListActivity.h" @@ -188,6 +189,10 @@ void ActivityManager::goToFileTransfer(std::string returnBookPath) { replaceActivity(std::make_unique(renderer, mappedInput, std::move(returnBookPath))); } +void ActivityManager::goToBluetoothTransfer() { + 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 == "BleTransfer") { + 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..be20992f01 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 goToBluetoothTransfer(); void goToSettings(); void goToFileBrowser(std::string path = {}); void goToRecentBooks(); diff --git a/src/activities/network/BleTransferActivity.cpp b/src/activities/network/BleTransferActivity.cpp new file mode 100644 index 0000000000..7b8d32cd93 --- /dev/null +++ b/src/activities/network/BleTransferActivity.cpp @@ -0,0 +1,1349 @@ +#include "BleTransferActivity.h" + +#ifdef SIMULATOR + +#include +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +struct BleTransferRuntime {}; + +BleTransferActivity::BleTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("BleTransfer", renderer, mappedInput) {} + +BleTransferActivity::~BleTransferActivity() = default; + +void BleTransferActivity::onEnter() { + Activity::onEnter(); + setState(State::ERROR); +} + +void BleTransferActivity::onExit() { Activity::onExit(); } + +void BleTransferActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) finish(); +} + +void BleTransferActivity::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_BLUETOOTH_TRANSFER)); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_BLE_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 BleTransferActivity::enqueueBleConnected() {} +void BleTransferActivity::enqueueBleDisconnected() {} +void BleTransferActivity::enqueueControlWrite(const std::string&) {} +void BleTransferActivity::enqueueDataWrite(const std::string&) {} + +void BleTransferActivity::setState(const State state) { + state_ = state; + requestUpdate(); +} + +#else + +#include +#include +#include +#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* BLE_DEVICE_NAME = "CrossInk Stats Sync"; +constexpr const char* BLE_SERVICE_UUID = "6f9f0a00-9b1d-4d1f-9f53-5b6b8b3d0f10"; +constexpr const char* BLE_CONTROL_UUID = "6f9f0a01-9b1d-4d1f-9f53-5b6b8b3d0f10"; +constexpr const char* BLE_DATA_IN_UUID = "6f9f0a02-9b1d-4d1f-9f53-5b6b8b3d0f10"; +constexpr const char* BLE_STATUS_UUID = "6f9f0a03-9b1d-4d1f-9f53-5b6b8b3d0f10"; +constexpr const char* BLE_DATA_OUT_UUID = "6f9f0a04-9b1d-4d1f-9f53-5b6b8b3d0f10"; +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 size_t MIN_BLE_STATS_BYTES = 13; +constexpr size_t MAX_BLE_STATS_BYTES = 17; +constexpr size_t BLE_DOWNLOAD_CHUNK_BYTES = 160; +constexpr size_t BLE_DOWNLOAD_CHUNK_BYTES_MIN = 20; +constexpr size_t BLE_DOWNLOAD_CHUNK_BYTES_MAX = BLE_DOWNLOAD_CHUNK_BYTES; +constexpr size_t MAX_FILENAME_BYTES = 96; +constexpr size_t BLE_PROGRESS_STATUS_INTERVAL_BYTES = 4UL * 1024UL; +constexpr size_t BLE_PROGRESS_DISPLAY_INTERVAL_BYTES = 128UL * 1024UL; +constexpr size_t BLE_UPLOAD_ACK_BYTES_MIN = 20; +constexpr size_t BLE_UPLOAD_ACK_BYTES_MAX = 64UL * 1024UL; +constexpr size_t MAX_QUEUED_BLE_EVENTS = 64; +constexpr size_t MAX_QUEUED_BLE_EVENT_BYTES = 8UL * 1024UL; +constexpr size_t BIN_SUFFIX_LEN = 4; +constexpr uint32_t BLE_PEER_SCAN_MS = 6000; +constexpr uint32_t BLE_PEER_CONNECT_TIMEOUT_MS = 5000; +constexpr uint32_t BLE_PEER_STATUS_TIMEOUT_MS = 8000; +constexpr uint32_t BLE_PEER_DOWNLOAD_TIMEOUT_MS = 10000; + +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 makeDeviceId() { + uint8_t mac[6] = {}; + esp_efuse_mac_get_default(mac); + return bytesToHex(mac, sizeof(mac)); +} + +std::string toLowerAscii(std::string value) { + for (char& c : value) c = static_cast(std::tolower(static_cast(c))); + return value; +} + +bool isHexString(const std::string& value, const size_t length) { + if (value.length() != length) return false; + return std::all_of(value.begin(), value.end(), [](const char c) { + return std::isdigit(static_cast(c)) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + }); +} + +bool isHexSha256(const std::string& value) { return isHexString(value, 64); } + +bool endsWithSuffix(const std::string& value, const char* suffix, const size_t suffixLen) { + if (value.length() < suffixLen) return false; + return toLowerAscii(value.substr(value.length() - suffixLen)) == suffix; +} + +bool isSafeBleFileName(const std::string& value) { + if (value.empty() || value.length() > MAX_FILENAME_BYTES || value[0] == '.') return false; + for (const char c : value) { + const auto uc = static_cast(c); + if (std::isalnum(uc) || c == '.' || c == '_' || c == '-') continue; + return false; + } + return true; +} + +bool isSafeDeviceId(const std::string& value) { return isHexString(value, 12); } + +std::string statsFileNameForDeviceId(const std::string& deviceId) { + if (!isSafeDeviceId(deviceId)) return {}; + return "device_" + toLowerAscii(deviceId) + ".bin"; +} + +bool isSafeBleStatsName(const std::string& value) { + if (!isSafeBleFileName(value) || value.length() != 23 || value.rfind("device_", 0) != 0 || + !endsWithSuffix(value, ".bin", BIN_SUFFIX_LEN)) { + return false; + } + return isSafeDeviceId(value.substr(7, 12)); +} + +std::string syncedStatsPathForDeviceId(const std::string& deviceId) { + const std::string fileName = statsFileNameForDeviceId(deviceId); + if (fileName.empty()) return {}; + return std::string(SYNCED_STATS_DIR) + "/" + fileName; +} + +bool isValidStatsPayload(const std::string& data) { + return (data.size() == MIN_BLE_STATS_BYTES && static_cast(data[0]) == 1) || + (data.size() == MAX_BLE_STATS_BYTES && static_cast(data[0]) == 2); +} + +bool ensureSyncedStatsDirectory() { + return Storage.ensureDirectoryExists(CROSSPOINT_ROOT) && Storage.ensureDirectoryExists(SYNCED_STATS_DIR); +} + +bool readSmallFile(const char* path, std::string& out, const size_t maxBytes) { + out.clear(); + FsFile file; + if (!Storage.openFileForRead("BLE", path, file)) return false; + const size_t fileSize = file.fileSize(); + if (fileSize == 0 || fileSize > maxBytes) { + file.close(); + return false; + } + out.resize(fileSize); + const int read = file.read(reinterpret_cast(&out[0]), fileSize); + file.close(); + return read == static_cast(fileSize); +} + +bool writeSyncedStatsFile(const std::string& path, const std::string& data) { + if (!isValidStatsPayload(data) || !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("BLE", tmpPath, file)) return false; + const size_t written = file.write(reinterpret_cast(data.data()), data.size()); + if (written != data.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; +} + +std::string sha256Hex(const uint8_t* data, const size_t length) { + mbedtls_sha256_context context; + mbedtls_sha256_init(&context); + mbedtls_sha256_starts(&context, 0); + mbedtls_sha256_update(&context, data, length); + uint8_t digest[32] = {}; + mbedtls_sha256_finish(&context, digest); + mbedtls_sha256_free(&context); + return bytesToHex(digest, sizeof(digest)); +} + +std::string sha256ToHex(const uint8_t digest[32]) { return bytesToHex(digest, 32); } + +std::string stateName(const BleTransferActivity::State state) { + switch (state) { + case BleTransferActivity::State::STARTING: + return "starting"; + case BleTransferActivity::State::ADVERTISING: + return "advertising"; + case BleTransferActivity::State::CONNECTED: + return "connected"; + case BleTransferActivity::State::RECEIVING_STATS: + return "receiving_stats"; + case BleTransferActivity::State::VERIFYING_STATS: + return "verifying_stats"; + case BleTransferActivity::State::SAVED_STATS: + return "saved_stats"; + case BleTransferActivity::State::SENDING_STATS: + return "sending_stats"; + case BleTransferActivity::State::SENT_STATS: + return "sent_stats"; + case BleTransferActivity::State::SYNC_SCANNING: + return "sync_scanning"; + case BleTransferActivity::State::SYNC_CONNECTING: + return "sync_connecting"; + case BleTransferActivity::State::SYNCING_STATS: + return "syncing_stats"; + case BleTransferActivity::State::SYNCED_STATS: + return "synced_stats"; + case BleTransferActivity::State::ERROR: + return "error"; + } + return "unknown"; +} + +uint32_t readLe32(const std::string& value) { + const auto* b = reinterpret_cast(value.data()); + return static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); +} + +uint32_t readLe32(const uint8_t* b) { + return static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); +} + +class ServerCallbacks final : public NimBLEServerCallbacks { + public: + explicit ServerCallbacks(BleTransferActivity& activity) : activity_(activity) {} + + void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override { + server->updateConnParams(connInfo.getConnHandle(), 6, 12, 0, 120); + server->setDataLen(connInfo.getConnHandle(), 251); + activity_.enqueueBleConnected(); + } + + void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override { activity_.enqueueBleDisconnected(); } + + private: + BleTransferActivity& activity_; +}; + +class ControlCallbacks final : public NimBLECharacteristicCallbacks { + public: + explicit ControlCallbacks(BleTransferActivity& activity) : activity_(activity) {} + + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo&) override { + activity_.enqueueControlWrite(characteristic->getValue()); + } + + private: + BleTransferActivity& activity_; +}; + +class DataCallbacks final : public NimBLECharacteristicCallbacks { + public: + explicit DataCallbacks(BleTransferActivity& activity) : activity_(activity) {} + + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo&) override { + activity_.enqueueDataWrite(characteristic->getValue()); + } + + private: + BleTransferActivity& activity_; +}; + +struct PeerStatsDownload { + std::string data; + uint32_t expectedSequence = 0; + uint32_t pendingAck = 0; + bool hasPendingAck = false; + bool badFrame = false; +}; + +bool writeJsonControl(NimBLERemoteCharacteristic* control, JsonDocument& doc) { + String output; + serializeJson(doc, output); + return control && control->writeValue(output.c_str(), output.length(), true); +} + +bool readRemoteStatus(NimBLERemoteCharacteristic* status, JsonDocument& doc) { + if (!status) return false; + const std::string value = status->readValue(); + return !deserializeJson(doc, value.data(), value.size()); +} + +bool waitForRemoteState(NimBLERemoteCharacteristic* status, const char* desiredState, std::string& error, + const uint32_t timeoutMs = BLE_PEER_STATUS_TIMEOUT_MS) { + const uint32_t start = millis(); + while (millis() - start < timeoutMs) { + JsonDocument doc; + if (readRemoteStatus(status, doc)) { + const std::string state = doc["state"] | ""; + if (state == desiredState) return true; + if (state == "error") { + error = doc["error"] | "peer error"; + return false; + } + } + delay(100); + } + error = "peer timed out"; + return false; +} + +bool sendDownloadAck(NimBLERemoteCharacteristic* control, const uint32_t sequence) { + JsonDocument doc; + doc["op"] = "get_ack"; + doc["sequence"] = sequence; + return writeJsonControl(control, doc); +} + +bool waitForStatsDownload(NimBLERemoteCharacteristic* control, NimBLERemoteCharacteristic* status, + PeerStatsDownload& download, size_t& expectedSize, std::string& error) { + const uint32_t start = millis(); + expectedSize = 0; + while (millis() - start < BLE_PEER_DOWNLOAD_TIMEOUT_MS) { + if (download.badFrame) { + error = "invalid stats frame"; + return false; + } + if (download.hasPendingAck) { + const uint32_t sequence = download.pendingAck; + download.hasPendingAck = false; + if (!sendDownloadAck(control, sequence)) { + error = "could not ack stats"; + return false; + } + } + + JsonDocument doc; + if (readRemoteStatus(status, doc)) { + const std::string state = doc["state"] | ""; + if (doc["size"].is()) expectedSize = doc["size"].as(); + if (expectedSize > 0 && download.data.size() == expectedSize && isValidStatsPayload(download.data)) return true; + if (state == "sent_stats") return true; + if (state == "error") { + error = doc["error"] | "peer error"; + return false; + } + } + delay(50); + } + + error = "stats download timed out"; + return false; +} + +void writeLe32(std::vector& data, const uint32_t value) { + data.push_back(static_cast(value & 0xFF)); + data.push_back(static_cast((value >> 8) & 0xFF)); + data.push_back(static_cast((value >> 16) & 0xFF)); + data.push_back(static_cast((value >> 24) & 0xFF)); +} + +} // namespace + +struct BleTransferRuntime { + explicit BleTransferRuntime(BleTransferActivity& activity) + : serverCallbacks(activity), controlCallbacks(activity), dataCallbacks(activity) {} + + NimBLEServer* server = nullptr; + NimBLEService* service = nullptr; + NimBLECharacteristic* status = nullptr; + NimBLECharacteristic* dataOut = nullptr; + ServerCallbacks serverCallbacks; + ControlCallbacks controlCallbacks; + DataCallbacks dataCallbacks; + + bool begin(BleTransferActivity& activity) { + NimBLEDevice::init(BLE_DEVICE_NAME); + NimBLEDevice::setMTU(517); + NimBLEDevice::setPower(ESP_PWR_LVL_P9); + + server = NimBLEDevice::createServer(); + if (!server) return false; + server->setCallbacks(&serverCallbacks, false); + + service = server->createService(BLE_SERVICE_UUID); + if (!service) return false; + + auto* control = service->createCharacteristic(BLE_CONTROL_UUID, NIMBLE_PROPERTY::WRITE); + auto* dataIn = service->createCharacteristic(BLE_DATA_IN_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR); + status = service->createCharacteristic(BLE_STATUS_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + dataOut = service->createCharacteristic(BLE_DATA_OUT_UUID, NIMBLE_PROPERTY::NOTIFY); + if (!control || !dataIn || !status || !dataOut) return false; + + control->setCallbacks(&controlCallbacks); + dataIn->setCallbacks(&dataCallbacks); + status->setValue(activity.buildStatusJson()); + + NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising(); + advertising->addServiceUUID(BLE_SERVICE_UUID); + advertising->setName(BLE_DEVICE_NAME); + advertising->start(); + return true; + } + + void publish(const std::string& json) { + if (!status) return; + status->setValue(json); + status->notify(); + } + + void notifyData(const uint8_t* data, const size_t length) { + if (!dataOut) return; + dataOut->setValue(data, length); + dataOut->notify(); + } + + void startAdvertising() { + NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising(); + if (advertising) advertising->start(); + } + + void end() { + NimBLEDevice::stopAdvertising(); + NimBLEDevice::deinit(true); + server = nullptr; + service = nullptr; + status = nullptr; + dataOut = nullptr; + } +}; + +BleTransferActivity::BleTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("BleTransfer", renderer, mappedInput), eventMutex_(xSemaphoreCreateMutex()) {} + +BleTransferActivity::~BleTransferActivity() { + if (eventMutex_) { + vSemaphoreDelete(eventMutex_); + eventMutex_ = nullptr; + } +} + +void BleTransferActivity::onEnter() { + Activity::onEnter(); + deviceId_ = makeDeviceId(); + mbedtls_sha256_init(&shaContext_); + setState(State::STARTING); + + ble_ = std::make_unique(*this); + if (!ble_->begin(*this)) { + setError("Could not start BLE"); + return; + } + + setState(State::ADVERTISING); + publishStatus(); +} + +void BleTransferActivity::onExit() { + Activity::onExit(); + resetTransfer(true); + if (ble_) { + ble_->end(); + ble_.reset(); + } + mbedtls_sha256_free(&shaContext_); +} + +void BleTransferActivity::loop() { + processBleEvents(); + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && state_ == State::ADVERTISING) { + startPeerStatsSync(); + return; + } + if (pendingCommit_) { + pendingCommit_ = false; + processCommit(); + return; + } + if (state_ == State::SENDING_STATS && downloadOpen_) { + if (statusDirty_) publishStatus(); + pumpDownload(); + return; + } + if (statusDirty_) publishStatus(); +} + +void BleTransferActivity::enqueueBleEvent(BleEvent event) { + if (!eventMutex_) return; + const size_t eventBytes = event.value.size(); + xSemaphoreTake(eventMutex_, portMAX_DELAY); + if (bleEventOverflow_ || bleEvents_.size() >= MAX_QUEUED_BLE_EVENTS || + queuedBleEventBytes_ + eventBytes > MAX_QUEUED_BLE_EVENT_BYTES) { + bleEventOverflow_ = true; + queuedBleEventBytes_ = 0; + bleEvents_.clear(); + } else { + queuedBleEventBytes_ += eventBytes; + bleEvents_.push_back(std::move(event)); + } + xSemaphoreGive(eventMutex_); +} + +void BleTransferActivity::enqueueBleConnected() { enqueueBleEvent({BleEventType::CONNECTED, {}}); } + +void BleTransferActivity::enqueueBleDisconnected() { enqueueBleEvent({BleEventType::DISCONNECTED, {}}); } + +void BleTransferActivity::enqueueControlWrite(const std::string& value) { + enqueueBleEvent({BleEventType::CONTROL, value}); +} + +void BleTransferActivity::enqueueDataWrite(const std::string& value) { enqueueBleEvent({BleEventType::DATA, value}); } + +void BleTransferActivity::processBleEvents() { + while (true) { + BleEvent event; + bool hasEvent = false; + bool hasOverflow = false; + if (eventMutex_) { + xSemaphoreTake(eventMutex_, portMAX_DELAY); + if (bleEventOverflow_) { + bleEventOverflow_ = false; + queuedBleEventBytes_ = 0; + bleEvents_.clear(); + hasOverflow = true; + } + if (!bleEvents_.empty()) { + event = std::move(bleEvents_.front()); + queuedBleEventBytes_ -= event.value.size(); + bleEvents_.pop_front(); + hasEvent = true; + } + xSemaphoreGive(eventMutex_); + } + if (hasOverflow) { + resetTransfer(true); + setError("BLE event queue overflow"); + return; + } + if (!hasEvent) return; + + switch (event.type) { + case BleEventType::CONNECTED: + onBleConnected(); + break; + case BleEventType::DISCONNECTED: + onBleDisconnected(); + break; + case BleEventType::CONTROL: + onControlWrite(event.value); + break; + case BleEventType::DATA: + onDataWrite(event.value); + break; + } + } +} + +void BleTransferActivity::onBleConnected() { + helloAccepted_ = false; + setState(State::CONNECTED); +} + +void BleTransferActivity::onBleDisconnected() { + if (state_ == State::SAVED_STATS || state_ == State::SENT_STATS || state_ == State::SYNCED_STATS) { + resetTransfer(false); + helloAccepted_ = false; + setState(State::SYNCED_STATS); + if (ble_) ble_->startAdvertising(); + return; + } + + if (transferOpen_ || downloadOpen_) { + resetTransfer(true); + setError("reader disconnected"); + return; + } + helloAccepted_ = false; + setState(State::ADVERTISING); + if (ble_) ble_->startAdvertising(); +} + +void BleTransferActivity::onControlWrite(const std::string& value) { + JsonDocument doc; + const DeserializationError parseError = deserializeJson(doc, value.data(), value.size()); + if (parseError) { + setError("invalid control JSON"); + return; + } + + const std::string op = doc["op"] | ""; + if (op == "peer_hello") { + const int version = doc["version"] | 0; + const std::string peerDeviceId = toLowerAscii(doc["device_id"] | ""); + if (version != 1 || !isSafeDeviceId(peerDeviceId) || peerDeviceId == deviceId_) { + setError("invalid peer hello"); + return; + } + helloAccepted_ = true; + setState(State::CONNECTED); + return; + } + + if (!helloAccepted_) { + setError("peer hello required"); + return; + } + + if (op == "start_put") { + resetTransfer(true); + + const std::string kind = doc["kind"] | ""; + fileName_ = doc["name"] | ""; + expectedSize_ = doc["size"] | 0; + expectedSha256_ = toLowerAscii(doc["sha256"] | ""); + uploadAckBytes_ = doc["ack_bytes"] | BLE_UPLOAD_ACK_BYTES_MIN; + + if (kind != "stats") { + setError("unsupported transfer kind"); + return; + } + if (!isSafeBleStatsName(fileName_)) { + setError("unsafe stats filename"); + return; + } + if (fileName_ == statsFileNameForDeviceId(deviceId_)) { + setError("refusing local stats overwrite"); + return; + } + if (expectedSize_ < MIN_BLE_STATS_BYTES || expectedSize_ > MAX_BLE_STATS_BYTES) { + setError("invalid stats size"); + return; + } + if (!isHexSha256(expectedSha256_)) { + setError("invalid sha256"); + return; + } + if (uploadAckBytes_ < BLE_UPLOAD_ACK_BYTES_MIN || uploadAckBytes_ > BLE_UPLOAD_ACK_BYTES_MAX) { + setError("invalid ack window"); + return; + } + if (!ensureSyncedStatsDirectory()) { + setError("could not create synced stats directory"); + return; + } + + partPath_ = std::string(SYNCED_STATS_DIR) + "/.ble-" + fileName_ + ".part"; + finalPath_ = std::string(SYNCED_STATS_DIR) + "/" + fileName_; + if (Storage.exists(partPath_.c_str())) Storage.remove(partPath_.c_str()); + if (!Storage.openFileForWrite("BLE", partPath_, uploadFile_)) { + setError("could not open stats file"); + return; + } + + mbedtls_sha256_starts(&shaContext_, 0); + shaActive_ = true; + receivedBytes_ = 0; + expectedSequence_ = 0; + transferOpen_ = true; + removePartOnExit_ = true; + lastProgressStatusBytes_ = 0; + lastDisplayProgressBytes_ = 0; + setState(State::RECEIVING_STATS); + return; + } + + if (op == "start_get") { + resetTransfer(true); + const std::string kind = doc["kind"] | ""; + const int64_t offsetValue = doc["offset"] | 0; + const int64_t chunkSizeValue = doc["chunk_size"] | static_cast(BLE_DOWNLOAD_CHUNK_BYTES); + if (kind != "stats") { + setError("unsupported transfer kind"); + return; + } + if (offsetValue < 0 || + static_cast(offsetValue) > static_cast(std::numeric_limits::max())) { + setError("invalid download offset"); + return; + } + if (chunkSizeValue < static_cast(BLE_DOWNLOAD_CHUNK_BYTES_MIN) || + chunkSizeValue > static_cast(BLE_DOWNLOAD_CHUNK_BYTES_MAX)) { + setError("invalid download chunk size"); + return; + } + startStatsDownload(static_cast(offsetValue), static_cast(chunkSizeValue)); + return; + } + + if (op == "get_ack") { + if (!downloadOpen_ || !downloadAwaitingAck_) { + setError("no download pending"); + return; + } + const uint32_t sequence = doc["sequence"] | UINT32_MAX; + if (sequence != pendingDownloadAck_) { + setError("unexpected download ack"); + return; + } + downloadAwaitingAck_ = false; + statusDirty_ = true; + requestUpdate(); + return; + } + + if (op == "commit") { + if (!transferOpen_) { + setError("no transfer open"); + return; + } + pendingCommit_ = true; + setState(State::VERIFYING_STATS); + return; + } + + if (op == "cancel") { + resetTransfer(true); + setState(State::CONNECTED); + return; + } + + setError("unknown control op"); +} + +void BleTransferActivity::onDataWrite(const std::string& value) { + if (!transferOpen_ || state_ != State::RECEIVING_STATS) return; + if (value.size() <= sizeof(uint32_t)) { + setError("invalid data frame"); + resetTransfer(true); + return; + } + + const uint32_t sequence = readLe32(value); + if (sequence != expectedSequence_) { + setError("unexpected data sequence"); + resetTransfer(true); + return; + } + + const uint8_t* payload = reinterpret_cast(value.data() + sizeof(uint32_t)); + const size_t payloadSize = value.size() - sizeof(uint32_t); + if (receivedBytes_ + payloadSize > expectedSize_) { + setError("transfer too large"); + resetTransfer(true); + return; + } + if (uploadFile_.write(payload, payloadSize) != payloadSize) { + setError("transfer write failed"); + resetTransfer(true); + return; + } + + mbedtls_sha256_update(&shaContext_, payload, payloadSize); + receivedBytes_ += payloadSize; + expectedSequence_++; + if (receivedBytes_ == expectedSize_ || receivedBytes_ - lastProgressStatusBytes_ >= uploadAckBytes_) { + lastProgressStatusBytes_ = receivedBytes_; + statusDirty_ = true; + } + if (receivedBytes_ == expectedSize_ || + receivedBytes_ - lastDisplayProgressBytes_ >= BLE_PROGRESS_DISPLAY_INTERVAL_BYTES) { + lastDisplayProgressBytes_ = receivedBytes_; + requestUpdate(); + } +} + +void BleTransferActivity::processCommit() { + if (!transferOpen_) return; + setState(State::VERIFYING_STATS); + + uploadFile_.flush(); + uploadFile_.close(); + transferOpen_ = false; + + if (receivedBytes_ != expectedSize_) { + setError("size mismatch"); + resetTransfer(true); + return; + } + + uint8_t digest[32] = {}; + mbedtls_sha256_finish(&shaContext_, digest); + shaActive_ = false; + if (sha256ToHex(digest) != expectedSha256_) { + setError("sha256 mismatch"); + resetTransfer(true); + return; + } + + std::string statsData; + if (!readSmallFile(partPath_.c_str(), statsData, MAX_BLE_STATS_BYTES) || !isValidStatsPayload(statsData)) { + setError("invalid stats file"); + resetTransfer(true); + return; + } + if (Storage.exists(finalPath_.c_str()) && !Storage.remove(finalPath_.c_str())) { + setError("could not replace stats file"); + resetTransfer(true); + return; + } + if (!Storage.rename(partPath_.c_str(), finalPath_.c_str())) { + setError("could not finalize stats"); + resetTransfer(true); + return; + } + + removePartOnExit_ = false; + savedPath_ = finalPath_; + setState(State::SAVED_STATS); +} + +void BleTransferActivity::startStatsDownload(const size_t offset, const size_t chunkSize) { + GlobalReadingStats::load().save(); + + if (!Storage.exists(GLOBAL_STATS_PATH)) { + setError("not_found"); + return; + } + if (!Storage.openFileForRead("BLE", GLOBAL_STATS_PATH, downloadFile_)) { + setError("could not open stats"); + return; + } + + const size_t fileSize = downloadFile_.fileSize(); + if (fileSize < MIN_BLE_STATS_BYTES || fileSize > MAX_BLE_STATS_BYTES) { + downloadFile_.close(); + setError("invalid stats size"); + return; + } + if (offset > fileSize) { + downloadFile_.close(); + setError("invalid download offset"); + return; + } + if (offset < fileSize && offset % chunkSize != 0) { + downloadFile_.close(); + setError("unaligned download offset"); + return; + } + if (!downloadFile_.seek(offset)) { + downloadFile_.close(); + setError("could not seek stats"); + return; + } + + fileName_ = statsFileNameForDeviceId(deviceId_); + expectedSize_ = fileSize; + sentBytes_ = offset; + downloadSequence_ = static_cast(offset / chunkSize); + pendingDownloadAck_ = 0; + downloadAwaitingAck_ = false; + downloadChunkSize_ = chunkSize; + lastProgressStatusBytes_ = sentBytes_; + downloadOpen_ = true; + setState(State::SENDING_STATS); +} + +void BleTransferActivity::pumpDownload() { + if (!downloadOpen_ || downloadAwaitingAck_) return; + + std::array frame = {}; + frame[0] = static_cast(downloadSequence_ & 0xFF); + frame[1] = static_cast((downloadSequence_ >> 8) & 0xFF); + frame[2] = static_cast((downloadSequence_ >> 16) & 0xFF); + frame[3] = static_cast((downloadSequence_ >> 24) & 0xFF); + + const int read = downloadFile_.read(frame.data() + sizeof(uint32_t), downloadChunkSize_); + if (read < 0) { + downloadFile_.close(); + downloadOpen_ = false; + setError("download read failed"); + return; + } + if (read == 0) { + downloadFile_.close(); + downloadOpen_ = false; + setState(State::SENT_STATS); + return; + } + + ble_->notifyData(frame.data(), sizeof(uint32_t) + static_cast(read)); + sentBytes_ += static_cast(read); + pendingDownloadAck_ = downloadSequence_; + downloadAwaitingAck_ = true; + downloadSequence_++; + if (sentBytes_ == expectedSize_ || sentBytes_ - lastProgressStatusBytes_ >= BLE_PROGRESS_STATUS_INTERVAL_BYTES) { + lastProgressStatusBytes_ = sentBytes_; + statusDirty_ = true; + requestUpdate(); + } +} + +void BleTransferActivity::startPeerStatsSync() { + resetTransfer(true); + setState(State::SYNC_SCANNING); + publishStatus(); + requestUpdateAndWait(); + + if (!ensureSyncedStatsDirectory()) { + setError("could not create synced stats directory"); + return; + } + + // Creating synced_stats is the opt-in signal; mirror local stats only after + // the user starts this workflow. + GlobalReadingStats::load().save(); + + std::string localStats; + if (!readSmallFile(GLOBAL_STATS_PATH, localStats, MAX_BLE_STATS_BYTES) || !isValidStatsPayload(localStats)) { + setError("local stats unavailable"); + return; + } + + if (ble_) NimBLEDevice::stopAdvertising(); + + NimBLEScan* scan = NimBLEDevice::getScan(); + if (!scan) { + setError("could not scan"); + if (ble_) ble_->startAdvertising(); + return; + } + scan->setActiveScan(true); + scan->setInterval(45); + scan->setWindow(30); + + const NimBLEUUID serviceUuid(BLE_SERVICE_UUID); + NimBLEScanResults results = scan->getResults(BLE_PEER_SCAN_MS, false); + const NimBLEAdvertisedDevice* peer = nullptr; + for (int i = 0; i < results.getCount(); i++) { + const NimBLEAdvertisedDevice* candidate = results.getDevice(i); + if (candidate && candidate->isAdvertisingService(serviceUuid)) { + peer = candidate; + break; + } + } + + if (!peer) { + scan->clearResults(); + if (ble_) ble_->startAdvertising(); + setError("no reader found"); + return; + } + + setState(State::SYNC_CONNECTING); + publishStatus(); + requestUpdateAndWait(); + + std::string error; + NimBLEClient* client = NimBLEDevice::createClient(); + if (!client) { + scan->clearResults(); + if (ble_) ble_->startAdvertising(); + setError("could not create client"); + return; + } + client->setConnectionParams(12, 12, 0, 150); + client->setConnectTimeout(BLE_PEER_CONNECT_TIMEOUT_MS); + + auto finishClient = [&]() { + if (client) { + if (client->isConnected()) client->disconnect(); + NimBLEDevice::deleteClient(client); + client = nullptr; + } + scan->clearResults(); + if (ble_) ble_->startAdvertising(); + }; + + if (!client->connect(peer)) { + finishClient(); + setError("could not connect reader"); + return; + } + client->setDataLen(251); + + NimBLERemoteService* service = client->getService(BLE_SERVICE_UUID); + NimBLERemoteCharacteristic* control = service ? service->getCharacteristic(BLE_CONTROL_UUID) : nullptr; + NimBLERemoteCharacteristic* dataIn = service ? service->getCharacteristic(BLE_DATA_IN_UUID) : nullptr; + NimBLERemoteCharacteristic* status = service ? service->getCharacteristic(BLE_STATUS_UUID) : nullptr; + NimBLERemoteCharacteristic* dataOut = service ? service->getCharacteristic(BLE_DATA_OUT_UUID) : nullptr; + if (!service || !control || !dataIn || !status || !dataOut) { + finishClient(); + setError("reader service incomplete"); + return; + } + + JsonDocument statusDoc; + if (!readRemoteStatus(status, statusDoc)) { + finishClient(); + setError("could not read reader status"); + return; + } + const std::string peerDeviceId = toLowerAscii(statusDoc["device_id"] | ""); + if (!isSafeDeviceId(peerDeviceId) || peerDeviceId == deviceId_) { + finishClient(); + setError("invalid reader id"); + return; + } + + setState(State::SYNCING_STATS); + publishStatus(); + requestUpdateAndWait(); + + JsonDocument hello; + hello["op"] = "peer_hello"; + hello["version"] = 1; + hello["device_id"] = deviceId_.c_str(); + if (!writeJsonControl(control, hello) || !waitForRemoteState(status, "connected", error)) { + finishClient(); + setError(error.empty() ? "reader hello failed" : error); + return; + } + + const std::string localStatsName = statsFileNameForDeviceId(deviceId_); + const std::string localStatsSha = sha256Hex(reinterpret_cast(localStats.data()), localStats.size()); + JsonDocument startPut; + startPut["op"] = "start_put"; + startPut["kind"] = "stats"; + startPut["name"] = localStatsName.c_str(); + startPut["size"] = localStats.size(); + startPut["sha256"] = localStatsSha.c_str(); + startPut["ack_bytes"] = BLE_UPLOAD_ACK_BYTES_MIN; + if (!writeJsonControl(control, startPut) || !waitForRemoteState(status, "receiving_stats", error)) { + finishClient(); + setError(error.empty() ? "stats upload failed" : error); + return; + } + + std::vector frame; + frame.reserve(sizeof(uint32_t) + localStats.size()); + writeLe32(frame, 0); + frame.insert(frame.end(), localStats.begin(), localStats.end()); + if (!dataIn->writeValue(frame.data(), frame.size(), true)) { + finishClient(); + setError("could not send stats"); + return; + } + + JsonDocument commit; + commit["op"] = "commit"; + if (!writeJsonControl(control, commit) || !waitForRemoteState(status, "saved_stats", error)) { + finishClient(); + setError(error.empty() ? "stats upload commit failed" : error); + return; + } + + PeerStatsDownload download; + auto notifyCb = [&download](NimBLERemoteCharacteristic*, uint8_t* data, size_t length, bool) { + if (length <= sizeof(uint32_t)) { + download.badFrame = true; + return; + } + const uint32_t sequence = readLe32(data); + if (sequence != download.expectedSequence || download.hasPendingAck) { + download.badFrame = true; + return; + } + download.data.append(reinterpret_cast(data + sizeof(uint32_t)), length - sizeof(uint32_t)); + download.pendingAck = sequence; + download.hasPendingAck = true; + download.expectedSequence++; + }; + if (!dataOut->subscribe(true, notifyCb)) { + finishClient(); + setError("could not receive stats"); + return; + } + + JsonDocument startGet; + startGet["op"] = "start_get"; + startGet["kind"] = "stats"; + startGet["offset"] = 0; + startGet["chunk_size"] = BLE_DOWNLOAD_CHUNK_BYTES; + if (!writeJsonControl(control, startGet)) { + dataOut->unsubscribe(); + finishClient(); + setError("could not request stats"); + return; + } + + size_t expectedDownloadSize = 0; + if (!waitForStatsDownload(control, status, download, expectedDownloadSize, error)) { + dataOut->unsubscribe(); + finishClient(); + setError(error); + return; + } + dataOut->unsubscribe(); + + if (expectedDownloadSize != download.data.size() || !isValidStatsPayload(download.data)) { + finishClient(); + setError("invalid received stats"); + return; + } + + const std::string peerStatsPath = syncedStatsPathForDeviceId(peerDeviceId); + if (peerStatsPath.empty() || !writeSyncedStatsFile(peerStatsPath, download.data)) { + finishClient(); + setError("could not save stats"); + return; + } + + finishClient(); + setState(State::SYNCED_STATS); +} + +void BleTransferActivity::resetTransfer(const bool removePart) { + if (shaActive_) { + mbedtls_sha256_free(&shaContext_); + mbedtls_sha256_init(&shaContext_); + shaActive_ = false; + } + if (uploadFile_) uploadFile_.close(); + if (downloadFile_) downloadFile_.close(); + if (removePart && removePartOnExit_ && !partPath_.empty() && Storage.exists(partPath_.c_str())) { + Storage.remove(partPath_.c_str()); + } + + fileName_.clear(); + partPath_.clear(); + finalPath_.clear(); + expectedSha256_.clear(); + savedPath_.clear(); + expectedSize_ = 0; + receivedBytes_ = 0; + sentBytes_ = 0; + lastProgressStatusBytes_ = 0; + lastDisplayProgressBytes_ = 0; + uploadAckBytes_ = BLE_UPLOAD_ACK_BYTES_MIN; + downloadChunkSize_ = BLE_DOWNLOAD_CHUNK_BYTES; + expectedSequence_ = 0; + downloadSequence_ = 0; + pendingDownloadAck_ = 0; + transferOpen_ = false; + downloadOpen_ = false; + downloadAwaitingAck_ = false; + pendingCommit_ = false; + removePartOnExit_ = false; +} + +void BleTransferActivity::setState(const State state) { + state_ = state; + statusDirty_ = true; + requestUpdate(); +} + +void BleTransferActivity::setError(const std::string& error) { + errorMessage_ = error; + state_ = State::ERROR; + statusDirty_ = true; + requestUpdate(); +} + +void BleTransferActivity::publishStatus() { + statusDirty_ = false; + if (ble_) ble_->publish(buildStatusJson()); +} + +std::string BleTransferActivity::buildStatusJson() const { + JsonDocument doc; + const std::string state = stateName(state_); + doc["state"] = state.c_str(); + doc["protocol_version"] = 1; + doc["sync_kind"] = "stats"; + doc["device_id"] = deviceId_.c_str(); + JsonArray uploadKinds = doc["upload_kinds"].to(); + uploadKinds.add("stats"); + JsonArray downloadKinds = doc["download_kinds"].to(); + downloadKinds.add("stats"); + if (expectedSize_ > 0 || state_ == State::SENDING_STATS || state_ == State::SENT_STATS) { + doc["kind"] = "stats"; + if (state_ == State::SENDING_STATS || state_ == State::SENT_STATS) { + doc["sent"] = sentBytes_; + } else { + doc["received"] = receivedBytes_; + doc["ack_bytes"] = uploadAckBytes_; + } + doc["size"] = expectedSize_; + } + if (state_ == State::SAVED_STATS && !savedPath_.empty()) { + doc["name"] = fileName_.c_str(); + doc["path"] = savedPath_.c_str(); + } + if (state_ == State::SENT_STATS) doc["name"] = fileName_.c_str(); + if (state_ == State::ERROR && !errorMessage_.empty()) doc["error"] = errorMessage_.c_str(); + + String output; + serializeJson(doc, output); + return output.c_str(); +} + +void BleTransferActivity::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_BLUETOOTH_TRANSFER)); + + 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::ADVERTISING: + primary = tr(STR_BLE_TRANSFER_READY); + secondary = statsFileNameForDeviceId(deviceId_); + break; + case State::CONNECTED: + primary = tr(STR_CONNECTED); + break; + case State::RECEIVING_STATS: + case State::SENDING_STATS: { + primary = tr(STR_BLE_STATS_SYNCING); + char buffer[48]; + const size_t progress = state_ == State::RECEIVING_STATS ? receivedBytes_ : sentBytes_; + snprintf(buffer, sizeof(buffer), "%u / %u bytes", static_cast(progress), + static_cast(expectedSize_)); + secondary = buffer; + break; + } + case State::VERIFYING_STATS: + primary = tr(STR_BLE_STATS_SYNCING); + secondary = fileName_; + break; + case State::SAVED_STATS: + primary = tr(STR_BLE_STATS_SYNCED); + secondary = savedPath_.empty() ? fileName_ : savedPath_; + break; + case State::SENT_STATS: + primary = tr(STR_BLE_STATS_SYNCED); + secondary = fileName_; + break; + case State::SYNC_SCANNING: + primary = tr(STR_BLE_STATS_SCANNING); + break; + case State::SYNC_CONNECTING: + primary = tr(STR_BLE_STATS_CONNECTING); + break; + case State::SYNCING_STATS: + primary = tr(STR_BLE_STATS_SYNCING); + break; + case State::SYNCED_STATS: + primary = tr(STR_BLE_STATS_SYNCED); + break; + case State::ERROR: + primary = tr(STR_ERROR_MSG); + secondary = errorMessage_; + break; + } + + if (state_ == State::ADVERTISING || state_ == State::CONNECTED) { + renderReady(primary, secondary); + const char* syncLabel = state_ == State::ADVERTISING ? tr(STR_BLE_SYNC_STATS) : ""; + const auto labels = mappedInput.mapLabels(tr(STR_BACK), syncLabel, "", ""); + 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 BleTransferActivity::renderReady(const std::string& primary, const std::string& secondary) const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int pageWidth = renderer.getScreenWidth(); + 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; + renderer.drawCenteredText(SMALL_FONT_ID, y, tr(STR_BLE_SYNC_STATS), true); +} + +#endif diff --git a/src/activities/network/BleTransferActivity.h b/src/activities/network/BleTransferActivity.h new file mode 100644 index 0000000000..b053b7b2d9 --- /dev/null +++ b/src/activities/network/BleTransferActivity.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "activities/Activity.h" + +struct BleTransferRuntime; + +class BleTransferActivity final : public Activity { + public: + enum class State { + STARTING, + ADVERTISING, + CONNECTED, + RECEIVING_STATS, + VERIFYING_STATS, + SAVED_STATS, + SENDING_STATS, + SENT_STATS, + SYNC_SCANNING, + SYNC_CONNECTING, + SYNCING_STATS, + SYNCED_STATS, + ERROR + }; + + explicit BleTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput); + ~BleTransferActivity() 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::VERIFYING_STATS || state_ == State::SENDING_STATS || state_ == State::SYNC_SCANNING || + state_ == State::SYNC_CONNECTING || state_ == State::SYNCING_STATS; + } + + void enqueueBleConnected(); + void enqueueBleDisconnected(); + void enqueueControlWrite(const std::string& value); + void enqueueDataWrite(const std::string& value); + + private: + friend struct BleTransferRuntime; + enum class BleEventType { CONNECTED, DISCONNECTED, CONTROL, DATA }; + struct BleEvent { + BleEventType type; + std::string value; + }; + + State state_ = State::STARTING; + std::unique_ptr ble_; + FsFile uploadFile_; + FsFile downloadFile_; + SemaphoreHandle_t eventMutex_ = nullptr; + std::deque bleEvents_; + size_t queuedBleEventBytes_ = 0; + bool bleEventOverflow_ = false; + + std::string fileName_; + std::string partPath_; + std::string finalPath_; + std::string expectedSha256_; + std::string savedPath_; + std::string errorMessage_; + std::string deviceId_; + + size_t expectedSize_ = 0; + size_t receivedBytes_ = 0; + size_t sentBytes_ = 0; + size_t lastProgressStatusBytes_ = 0; + size_t lastDisplayProgressBytes_ = 0; + size_t uploadAckBytes_ = 0; + size_t downloadChunkSize_ = 0; + uint32_t expectedSequence_ = 0; + uint32_t downloadSequence_ = 0; + uint32_t pendingDownloadAck_ = 0; + bool helloAccepted_ = false; + bool transferOpen_ = false; + bool downloadOpen_ = false; + bool downloadAwaitingAck_ = false; + bool pendingCommit_ = false; + bool statusDirty_ = true; + bool removePartOnExit_ = false; + bool shaActive_ = false; + mbedtls_sha256_context shaContext_; + + void enqueueBleEvent(BleEvent event); + void processBleEvents(); + void onBleConnected(); + void onBleDisconnected(); + void onControlWrite(const std::string& value); + void onDataWrite(const std::string& value); + void processCommit(); + void startStatsDownload(size_t offset, size_t chunkSize); + void pumpDownload(); + void startPeerStatsSync(); + void resetTransfer(bool removePart); + void setState(State state); + void setError(const std::string& error); + void publishStatus(); + std::string buildStatusJson() const; + void renderReady(const std::string& primary, const std::string& secondary) const; +}; diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 81d764312d..473779c1a9 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::BLUETOOTH_TRANSFER) { + modeName = "Bluetooth Stats Sync"; } LOG_DBG("WEBACT", "Network mode selected: %s", modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); + if (mode == NetworkMode::BLUETOOTH_TRANSFER) { + activityManager.goToBluetoothTransfer(); + return; + } + if (mode == NetworkMode::CONNECT_CALIBRE) { startActivityForResult( std::make_unique(renderer, mappedInput), [this](const ActivityResult& result) { diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ec8edc904a..445362a558 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::BLUETOOTH_TRANSFER; } 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_BLUETOOTH_TRANSFER}; 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_BLUETOOTH_TRANSFER_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..3a955fa05e 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, BLUETOOTH_TRANSFER }; /** * 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) + * - "Bluetooth Stats Sync" - Sync reading stats with a nearby reader over BLE * * The onModeSelected callback is called with the user's choice. * The onCancel callback is called if the user presses back. From c70ae05088a326d452fe79bf49004ddfb4b47cc2 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 17:15:49 -0400 Subject: [PATCH 11/17] wip --- CHANGELOG.md | 1 + lib/I18n/translations/english.yaml | 4 +- src/activities/network/BleTransferActivity.h | 114 ------------------ ...tivity.cpp => NearbyStatsSyncActivity.cpp} | 55 ++++++++- 4 files changed, 55 insertions(+), 119 deletions(-) delete mode 100644 src/activities/network/BleTransferActivity.h rename src/activities/network/{BleTransferActivity.cpp => NearbyStatsSyncActivity.cpp} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f710669084..881db5f3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. +- Fixed Bluetooth Reading Stats sync completion so the receiving reader shows success after the sender finishes. - 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. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index dbdd2a20dc..817455a9bf 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -487,9 +487,11 @@ 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_BLE_TRANSFER_READY: "Ready for stats sync" -STR_BLE_SYNC_STATS: "Sync Stats" +STR_BLE_SYNC_STATS: "Sync" STR_BLE_STATS_SCANNING: "Finding nearby reader" STR_BLE_STATS_CONNECTING: "Connecting to reader" STR_BLE_STATS_SYNCING: "Syncing reading stats" STR_BLE_STATS_SYNCED: "Reading stats synced" +STR_BLE_STATS_RECEIVED: "All-time stats received" +STR_BLE_STATS_SENT: "All-time stats sent" STR_BLE_SIMULATOR_UNAVAILABLE: "Bluetooth sync is not available in simulator" diff --git a/src/activities/network/BleTransferActivity.h b/src/activities/network/BleTransferActivity.h deleted file mode 100644 index b053b7b2d9..0000000000 --- a/src/activities/network/BleTransferActivity.h +++ /dev/null @@ -1,114 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "activities/Activity.h" - -struct BleTransferRuntime; - -class BleTransferActivity final : public Activity { - public: - enum class State { - STARTING, - ADVERTISING, - CONNECTED, - RECEIVING_STATS, - VERIFYING_STATS, - SAVED_STATS, - SENDING_STATS, - SENT_STATS, - SYNC_SCANNING, - SYNC_CONNECTING, - SYNCING_STATS, - SYNCED_STATS, - ERROR - }; - - explicit BleTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput); - ~BleTransferActivity() 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::VERIFYING_STATS || state_ == State::SENDING_STATS || state_ == State::SYNC_SCANNING || - state_ == State::SYNC_CONNECTING || state_ == State::SYNCING_STATS; - } - - void enqueueBleConnected(); - void enqueueBleDisconnected(); - void enqueueControlWrite(const std::string& value); - void enqueueDataWrite(const std::string& value); - - private: - friend struct BleTransferRuntime; - enum class BleEventType { CONNECTED, DISCONNECTED, CONTROL, DATA }; - struct BleEvent { - BleEventType type; - std::string value; - }; - - State state_ = State::STARTING; - std::unique_ptr ble_; - FsFile uploadFile_; - FsFile downloadFile_; - SemaphoreHandle_t eventMutex_ = nullptr; - std::deque bleEvents_; - size_t queuedBleEventBytes_ = 0; - bool bleEventOverflow_ = false; - - std::string fileName_; - std::string partPath_; - std::string finalPath_; - std::string expectedSha256_; - std::string savedPath_; - std::string errorMessage_; - std::string deviceId_; - - size_t expectedSize_ = 0; - size_t receivedBytes_ = 0; - size_t sentBytes_ = 0; - size_t lastProgressStatusBytes_ = 0; - size_t lastDisplayProgressBytes_ = 0; - size_t uploadAckBytes_ = 0; - size_t downloadChunkSize_ = 0; - uint32_t expectedSequence_ = 0; - uint32_t downloadSequence_ = 0; - uint32_t pendingDownloadAck_ = 0; - bool helloAccepted_ = false; - bool transferOpen_ = false; - bool downloadOpen_ = false; - bool downloadAwaitingAck_ = false; - bool pendingCommit_ = false; - bool statusDirty_ = true; - bool removePartOnExit_ = false; - bool shaActive_ = false; - mbedtls_sha256_context shaContext_; - - void enqueueBleEvent(BleEvent event); - void processBleEvents(); - void onBleConnected(); - void onBleDisconnected(); - void onControlWrite(const std::string& value); - void onDataWrite(const std::string& value); - void processCommit(); - void startStatsDownload(size_t offset, size_t chunkSize); - void pumpDownload(); - void startPeerStatsSync(); - void resetTransfer(bool removePart); - void setState(State state); - void setError(const std::string& error); - void publishStatus(); - std::string buildStatusJson() const; - void renderReady(const std::string& primary, const std::string& secondary) const; -}; diff --git a/src/activities/network/BleTransferActivity.cpp b/src/activities/network/NearbyStatsSyncActivity.cpp similarity index 96% rename from src/activities/network/BleTransferActivity.cpp rename to src/activities/network/NearbyStatsSyncActivity.cpp index 7b8d32cd93..7bab34712d 100644 --- a/src/activities/network/BleTransferActivity.cpp +++ b/src/activities/network/NearbyStatsSyncActivity.cpp @@ -103,6 +103,7 @@ constexpr size_t BIN_SUFFIX_LEN = 4; constexpr uint32_t BLE_PEER_SCAN_MS = 6000; constexpr uint32_t BLE_PEER_CONNECT_TIMEOUT_MS = 5000; constexpr uint32_t BLE_PEER_STATUS_TIMEOUT_MS = 8000; +constexpr uint32_t BLE_PEER_COMPLETE_TIMEOUT_MS = 2000; constexpr uint32_t BLE_PEER_DOWNLOAD_TIMEOUT_MS = 10000; std::string bytesToHex(const uint8_t* data, const size_t length) { @@ -390,6 +391,10 @@ bool waitForStatsDownload(NimBLERemoteCharacteristic* control, NimBLERemoteChara error = "could not ack stats"; return false; } + if (isValidStatsPayload(download.data)) { + if (expectedSize == 0) expectedSize = download.data.size(); + return true; + } } JsonDocument doc; @@ -619,6 +624,7 @@ void BleTransferActivity::processBleEvents() { } void BleTransferActivity::onBleConnected() { + initiatedPeerSync_ = false; helloAccepted_ = false; setState(State::CONNECTED); } @@ -632,6 +638,14 @@ void BleTransferActivity::onBleDisconnected() { return; } + if (downloadOpen_ && expectedSize_ > 0 && sentBytes_ == expectedSize_) { + resetTransfer(false); + helloAccepted_ = false; + setState(State::SYNCED_STATS); + if (ble_) ble_->startAdvertising(); + return; + } + if (transferOpen_ || downloadOpen_) { resetTransfer(true); setError("reader disconnected"); @@ -668,6 +682,13 @@ void BleTransferActivity::onControlWrite(const std::string& value) { return; } + if (op == "sync_complete") { + resetTransfer(false); + setState(State::SYNCED_STATS); + publishStatus(); + return; + } + if (op == "start_put") { resetTransfer(true); @@ -760,6 +781,12 @@ void BleTransferActivity::onControlWrite(const std::string& value) { return; } downloadAwaitingAck_ = false; + if (expectedSize_ > 0 && sentBytes_ == expectedSize_) { + if (downloadFile_) downloadFile_.close(); + downloadOpen_ = false; + setState(State::SENT_STATS); + return; + } statusDirty_ = true; requestUpdate(); return; @@ -953,6 +980,7 @@ void BleTransferActivity::pumpDownload() { } void BleTransferActivity::startPeerStatsSync() { + initiatedPeerSync_ = true; resetTransfer(true); setState(State::SYNC_SCANNING); publishStatus(); @@ -1100,11 +1128,17 @@ void BleTransferActivity::startPeerStatsSync() { JsonDocument commit; commit["op"] = "commit"; - if (!writeJsonControl(control, commit) || !waitForRemoteState(status, "saved_stats", error)) { + if (!writeJsonControl(control, commit)) { + finishClient(); + setError("stats upload commit failed"); + return; + } + if (!waitForRemoteState(status, "saved_stats", error) && error != "peer timed out") { finishClient(); setError(error.empty() ? "stats upload commit failed" : error); return; } + error.clear(); PeerStatsDownload download; auto notifyCb = [&download](NimBLERemoteCharacteristic*, uint8_t* data, size_t length, bool) { @@ -1162,6 +1196,19 @@ void BleTransferActivity::startPeerStatsSync() { return; } + JsonDocument complete; + complete["op"] = "sync_complete"; + if (!writeJsonControl(control, complete)) { + finishClient(); + setError("stats sync completion failed"); + return; + } + if (!waitForRemoteState(status, "synced_stats", error, BLE_PEER_COMPLETE_TIMEOUT_MS) && error != "peer timed out") { + finishClient(); + setError(error.empty() ? "stats sync completion failed" : error); + return; + } + finishClient(); setState(State::SYNCED_STATS); } @@ -1289,11 +1336,11 @@ void BleTransferActivity::render(RenderLock&&) { secondary = fileName_; break; case State::SAVED_STATS: - primary = tr(STR_BLE_STATS_SYNCED); + primary = tr(STR_BLE_STATS_RECEIVED); secondary = savedPath_.empty() ? fileName_ : savedPath_; break; case State::SENT_STATS: - primary = tr(STR_BLE_STATS_SYNCED); + primary = tr(STR_BLE_STATS_SENT); secondary = fileName_; break; case State::SYNC_SCANNING: @@ -1306,7 +1353,7 @@ void BleTransferActivity::render(RenderLock&&) { primary = tr(STR_BLE_STATS_SYNCING); break; case State::SYNCED_STATS: - primary = tr(STR_BLE_STATS_SYNCED); + primary = initiatedPeerSync_ ? tr(STR_BLE_STATS_SENT) : tr(STR_BLE_STATS_RECEIVED); break; case State::ERROR: primary = tr(STR_ERROR_MSG); From 18c9bfd37753a39b50549b97977e14c1cc3c1071 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 17:24:53 -0400 Subject: [PATCH 12/17] feat(stats): sync reading stats over ESP-NOW --- CHANGELOG.md | 3 +- lib/I18n/translations/english.yaml | 19 +- platformio.ini | 1 - src/activities/ActivityManager.cpp | 8 +- src/activities/ActivityManager.h | 2 +- .../network/CrossPointWebServerActivity.cpp | 8 +- .../network/NearbyStatsSyncActivity.cpp | 1385 ++++------------- .../network/NearbyStatsSyncActivity.h | 81 + .../network/NetworkModeSelectionActivity.cpp | 6 +- .../network/NetworkModeSelectionActivity.h | 4 +- 10 files changed, 375 insertions(+), 1142 deletions(-) create mode 100644 src/activities/network/NearbyStatsSyncActivity.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 881db5f3fb..d6e7fc6c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,12 @@ - Added a Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. - Added a Minimal sleep screen option that shows the current book cover and reading progress on a dark background. - Added aggregate all-time Reading Stats support from peer-synced per-device stats files. -- Added Bluetooth Reading Stats sync between nearby CrossInk readers, including automatic `synced_stats` folder setup from the Bluetooth Stats Sync screen. +- Added nearby Reading Stats sync between CrossInk readers using direct ESP-NOW device-to-device messages. - Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. - Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. ### Fixed - Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. -- Fixed Bluetooth Reading Stats sync completion so the receiving reader shows success after the sender finishes. - 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. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 817455a9bf..048d6f845b 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -39,10 +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_BLUETOOTH_TRANSFER: "Bluetooth Stats Sync" +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_BLUETOOTH_TRANSFER_DESC: "Sync reading stats with a nearby reader" +STR_NEARBY_STATS_SYNC_DESC: "Sync 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" @@ -486,12 +486,9 @@ 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_BLE_TRANSFER_READY: "Ready for stats sync" -STR_BLE_SYNC_STATS: "Sync" -STR_BLE_STATS_SCANNING: "Finding nearby reader" -STR_BLE_STATS_CONNECTING: "Connecting to reader" -STR_BLE_STATS_SYNCING: "Syncing reading stats" -STR_BLE_STATS_SYNCED: "Reading stats synced" -STR_BLE_STATS_RECEIVED: "All-time stats received" -STR_BLE_STATS_SENT: "All-time stats sent" -STR_BLE_SIMULATOR_UNAVAILABLE: "Bluetooth sync is not available in simulator" +STR_NEARBY_STATS_READY: "Ready for stats sync" +STR_NEARBY_STATS_SYNC_BUTTON: "Sync" +STR_NEARBY_STATS_SCANNING: "Finding nearby reader" +STR_NEARBY_STATS_SYNCING: "Syncing reading stats" +STR_NEARBY_STATS_SYNCED: "Reading stats synced" +STR_NEARBY_STATS_SIMULATOR_UNAVAILABLE: "Nearby stats sync is not available in simulator" diff --git a/platformio.ini b/platformio.ini index 613d501baa..1dc07b1e46 100644 --- a/platformio.ini +++ b/platformio.ini @@ -66,7 +66,6 @@ lib_deps = ricmoo/QRCode @ 0.0.1 bitbank2/PNGdec @ ^1.0.0 https://github.com/bitbank2/JPEGDEC.git#86282979224c8a32fd51e091ed5a35b0c699a52b - https://github.com/h2zero/NimBLE-Arduino.git#51d99b378f30cc579126cea523949471a8af5f60 links2004/WebSockets @ 2.7.3 lib_ignore = diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index ab2c68b9d6..9688c11d5f 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -15,8 +15,8 @@ #include "home/HomeActivity.h" #include "home/RecentBooksActivity.h" #include "home/RecentBooksGridActivity.h" -#include "network/BleTransferActivity.h" #include "network/CrossPointWebServerActivity.h" +#include "network/NearbyStatsSyncActivity.h" #include "reader/ReaderActivity.h" #include "settings/OpdsServerListActivity.h" #include "settings/SettingsActivity.h" @@ -189,8 +189,8 @@ void ActivityManager::goToFileTransfer(std::string returnBookPath) { replaceActivity(std::make_unique(renderer, mappedInput, std::move(returnBookPath))); } -void ActivityManager::goToBluetoothTransfer() { - replaceActivity(std::make_unique(renderer, mappedInput)); +void ActivityManager::goToNearbyStatsSync() { + replaceActivity(std::make_unique(renderer, mappedInput)); } void ActivityManager::goToSettings() { replaceActivity(std::make_unique(renderer, mappedInput)); } @@ -245,7 +245,7 @@ void ActivityManager::goHome(HomeMenuItem initialMenuItem) { initialMenuItem = HomeMenuItem::OPDS_BROWSER; } else if (activityName == "CrossPointWebServer") { initialMenuItem = HomeMenuItem::FILE_TRANSFER; - } else if (activityName == "BleTransfer") { + } 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 be20992f01..0b3a76cf13 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -90,7 +90,7 @@ class ActivityManager { // goTo... functions are convenient wrapper for replaceActivity() void goToFileTransfer(std::string returnBookPath = {}); - void goToBluetoothTransfer(); + void goToNearbyStatsSync(); void goToSettings(); void goToFileBrowser(std::string path = {}); void goToRecentBooks(); diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 473779c1a9..7e0c7f4bd6 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -108,16 +108,16 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) modeName = "Connect to Calibre"; } else if (mode == NetworkMode::CREATE_HOTSPOT) { modeName = "Create Hotspot"; - } else if (mode == NetworkMode::BLUETOOTH_TRANSFER) { - modeName = "Bluetooth Stats Sync"; + } 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::BLUETOOTH_TRANSFER) { - activityManager.goToBluetoothTransfer(); + if (mode == NetworkMode::NEARBY_STATS_SYNC) { + activityManager.goToNearbyStatsSync(); return; } diff --git a/src/activities/network/NearbyStatsSyncActivity.cpp b/src/activities/network/NearbyStatsSyncActivity.cpp index 7bab34712d..4c35ecbc19 100644 --- a/src/activities/network/NearbyStatsSyncActivity.cpp +++ b/src/activities/network/NearbyStatsSyncActivity.cpp @@ -1,4 +1,4 @@ -#include "BleTransferActivity.h" +#include "NearbyStatsSyncActivity.h" #ifdef SIMULATOR @@ -9,67 +9,59 @@ #include "components/UITheme.h" #include "fontIds.h" -struct BleTransferRuntime {}; +NearbyStatsSyncActivity::NearbyStatsSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("NearbyStatsSync", renderer, mappedInput) {} -BleTransferActivity::BleTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("BleTransfer", renderer, mappedInput) {} +NearbyStatsSyncActivity::~NearbyStatsSyncActivity() = default; -BleTransferActivity::~BleTransferActivity() = default; - -void BleTransferActivity::onEnter() { +void NearbyStatsSyncActivity::onEnter() { Activity::onEnter(); setState(State::ERROR); } -void BleTransferActivity::onExit() { Activity::onExit(); } +void NearbyStatsSyncActivity::onExit() { Activity::onExit(); } -void BleTransferActivity::loop() { +void NearbyStatsSyncActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) finish(); } -void BleTransferActivity::render(RenderLock&&) { +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_BLUETOOTH_TRANSFER)); - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_BLE_SIMULATOR_UNAVAILABLE), true, + 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 BleTransferActivity::enqueueBleConnected() {} -void BleTransferActivity::enqueueBleDisconnected() {} -void BleTransferActivity::enqueueControlWrite(const std::string&) {} -void BleTransferActivity::enqueueDataWrite(const std::string&) {} +void NearbyStatsSyncActivity::enqueueEspNowPacket(const uint8_t*, const uint8_t*, int) {} -void BleTransferActivity::setState(const State state) { +void NearbyStatsSyncActivity::setState(const State state) { state_ = state; requestUpdate(); } #else -#include #include #include #include -#include +#include #include +#include +#include #include #include -#include #include #include #include -#include -#include -#include -#include +#include #include "MappedInputManager.h" #include "activities/reader/GlobalReadingStats.h" @@ -78,33 +70,21 @@ void BleTransferActivity::setState(const State state) { namespace { -constexpr const char* BLE_DEVICE_NAME = "CrossInk Stats Sync"; -constexpr const char* BLE_SERVICE_UUID = "6f9f0a00-9b1d-4d1f-9f53-5b6b8b3d0f10"; -constexpr const char* BLE_CONTROL_UUID = "6f9f0a01-9b1d-4d1f-9f53-5b6b8b3d0f10"; -constexpr const char* BLE_DATA_IN_UUID = "6f9f0a02-9b1d-4d1f-9f53-5b6b8b3d0f10"; -constexpr const char* BLE_STATUS_UUID = "6f9f0a03-9b1d-4d1f-9f53-5b6b8b3d0f10"; -constexpr const char* BLE_DATA_OUT_UUID = "6f9f0a04-9b1d-4d1f-9f53-5b6b8b3d0f10"; +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 size_t MIN_BLE_STATS_BYTES = 13; -constexpr size_t MAX_BLE_STATS_BYTES = 17; -constexpr size_t BLE_DOWNLOAD_CHUNK_BYTES = 160; -constexpr size_t BLE_DOWNLOAD_CHUNK_BYTES_MIN = 20; -constexpr size_t BLE_DOWNLOAD_CHUNK_BYTES_MAX = BLE_DOWNLOAD_CHUNK_BYTES; -constexpr size_t MAX_FILENAME_BYTES = 96; -constexpr size_t BLE_PROGRESS_STATUS_INTERVAL_BYTES = 4UL * 1024UL; -constexpr size_t BLE_PROGRESS_DISPLAY_INTERVAL_BYTES = 128UL * 1024UL; -constexpr size_t BLE_UPLOAD_ACK_BYTES_MIN = 20; -constexpr size_t BLE_UPLOAD_ACK_BYTES_MAX = 64UL * 1024UL; -constexpr size_t MAX_QUEUED_BLE_EVENTS = 64; -constexpr size_t MAX_QUEUED_BLE_EVENT_BYTES = 8UL * 1024UL; -constexpr size_t BIN_SUFFIX_LEN = 4; -constexpr uint32_t BLE_PEER_SCAN_MS = 6000; -constexpr uint32_t BLE_PEER_CONNECT_TIMEOUT_MS = 5000; -constexpr uint32_t BLE_PEER_STATUS_TIMEOUT_MS = 8000; -constexpr uint32_t BLE_PEER_COMPLETE_TIMEOUT_MS = 2000; -constexpr uint32_t BLE_PEER_DOWNLOAD_TIMEOUT_MS = 10000; +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"; @@ -117,96 +97,52 @@ std::string bytesToHex(const uint8_t* data, const size_t length) { return out; } -std::string makeDeviceId() { - uint8_t mac[6] = {}; - esp_efuse_mac_get_default(mac); - return bytesToHex(mac, sizeof(mac)); -} - -std::string toLowerAscii(std::string value) { - for (char& c : value) c = static_cast(std::tolower(static_cast(c))); - return value; -} - -bool isHexString(const std::string& value, const size_t length) { - if (value.length() != length) return false; - return std::all_of(value.begin(), value.end(), [](const char c) { - return std::isdigit(static_cast(c)) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); - }); -} - -bool isHexSha256(const std::string& value) { return isHexString(value, 64); } - -bool endsWithSuffix(const std::string& value, const char* suffix, const size_t suffixLen) { - if (value.length() < suffixLen) return false; - return toLowerAscii(value.substr(value.length() - suffixLen)) == suffix; -} - -bool isSafeBleFileName(const std::string& value) { - if (value.empty() || value.length() > MAX_FILENAME_BYTES || value[0] == '.') return false; - for (const char c : value) { - const auto uc = static_cast(c); - if (std::isalnum(uc) || c == '.' || c == '_' || c == '-') continue; - return false; - } - return true; -} - -bool isSafeDeviceId(const std::string& value) { return isHexString(value, 12); } - -std::string statsFileNameForDeviceId(const std::string& deviceId) { - if (!isSafeDeviceId(deviceId)) return {}; - return "device_" + toLowerAscii(deviceId) + ".bin"; -} - -bool isSafeBleStatsName(const std::string& value) { - if (!isSafeBleFileName(value) || value.length() != 23 || value.rfind("device_", 0) != 0 || - !endsWithSuffix(value, ".bin", BIN_SUFFIX_LEN)) { - return false; - } - return isSafeDeviceId(value.substr(7, 12)); +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 syncedStatsPathForDeviceId(const std::string& deviceId) { - const std::string fileName = statsFileNameForDeviceId(deviceId); - if (fileName.empty()) return {}; - return std::string(SYNCED_STATS_DIR) + "/" + fileName; +std::string syncedStatsPathForDeviceMac(const std::array& mac) { + return std::string(SYNCED_STATS_DIR) + "/" + statsFileNameForDeviceMac(mac); } -bool isValidStatsPayload(const std::string& data) { - return (data.size() == MIN_BLE_STATS_BYTES && static_cast(data[0]) == 1) || - (data.size() == MAX_BLE_STATS_BYTES && static_cast(data[0]) == 2); +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::string& out, const size_t maxBytes) { - out.clear(); +bool readSmallFile(const char* path, std::array& out, uint8_t& outSize) { + outSize = 0; FsFile file; - if (!Storage.openFileForRead("BLE", path, file)) return false; + if (!Storage.openFileForRead(LOG_TAG, path, file)) return false; const size_t fileSize = file.fileSize(); - if (fileSize == 0 || fileSize > maxBytes) { + if (fileSize < MIN_STATS_BYTES || fileSize > MAX_STATS_BYTES) { file.close(); return false; } - out.resize(fileSize); - const int read = file.read(reinterpret_cast(&out[0]), fileSize); + + const int read = file.read(out.data(), fileSize); file.close(); - return read == static_cast(fileSize); + 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 std::string& data) { - if (!isValidStatsPayload(data) || !ensureSyncedStatsDirectory()) return false; +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("BLE", tmpPath, file)) return false; - const size_t written = file.write(reinterpret_cast(data.data()), data.size()); - if (written != data.size()) { + 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; @@ -233,1078 +169,326 @@ bool writeSyncedStatsFile(const std::string& path, const std::string& data) { return true; } -std::string sha256Hex(const uint8_t* data, const size_t length) { - mbedtls_sha256_context context; - mbedtls_sha256_init(&context); - mbedtls_sha256_starts(&context, 0); - mbedtls_sha256_update(&context, data, length); - uint8_t digest[32] = {}; - mbedtls_sha256_finish(&context, digest); - mbedtls_sha256_free(&context); - return bytesToHex(digest, sizeof(digest)); -} - -std::string sha256ToHex(const uint8_t digest[32]) { return bytesToHex(digest, 32); } - -std::string stateName(const BleTransferActivity::State state) { - switch (state) { - case BleTransferActivity::State::STARTING: - return "starting"; - case BleTransferActivity::State::ADVERTISING: - return "advertising"; - case BleTransferActivity::State::CONNECTED: - return "connected"; - case BleTransferActivity::State::RECEIVING_STATS: - return "receiving_stats"; - case BleTransferActivity::State::VERIFYING_STATS: - return "verifying_stats"; - case BleTransferActivity::State::SAVED_STATS: - return "saved_stats"; - case BleTransferActivity::State::SENDING_STATS: - return "sending_stats"; - case BleTransferActivity::State::SENT_STATS: - return "sent_stats"; - case BleTransferActivity::State::SYNC_SCANNING: - return "sync_scanning"; - case BleTransferActivity::State::SYNC_CONNECTING: - return "sync_connecting"; - case BleTransferActivity::State::SYNCING_STATS: - return "syncing_stats"; - case BleTransferActivity::State::SYNCED_STATS: - return "synced_stats"; - case BleTransferActivity::State::ERROR: - return "error"; - } - return "unknown"; -} - -uint32_t readLe32(const std::string& value) { - const auto* b = reinterpret_cast(value.data()); - return static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | - (static_cast(b[3]) << 24); -} - -uint32_t readLe32(const uint8_t* b) { - return static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | - (static_cast(b[3]) << 24); +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); } -class ServerCallbacks final : public NimBLEServerCallbacks { - public: - explicit ServerCallbacks(BleTransferActivity& activity) : activity_(activity) {} - - void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override { - server->updateConnParams(connInfo.getConnHandle(), 6, 12, 0, 120); - server->setDataLen(connInfo.getConnHandle(), 251); - activity_.enqueueBleConnected(); - } - - void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override { activity_.enqueueBleDisconnected(); } - - private: - BleTransferActivity& activity_; -}; - -class ControlCallbacks final : public NimBLECharacteristicCallbacks { - public: - explicit ControlCallbacks(BleTransferActivity& activity) : activity_(activity) {} - - void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo&) override { - activity_.enqueueControlWrite(characteristic->getValue()); - } - - private: - BleTransferActivity& activity_; -}; +} // namespace -class DataCallbacks final : public NimBLECharacteristicCallbacks { - public: - explicit DataCallbacks(BleTransferActivity& activity) : activity_(activity) {} +NearbyStatsSyncActivity::NearbyStatsSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("NearbyStatsSync", renderer, mappedInput), eventMutex_(xSemaphoreCreateMutex()) {} - void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo&) override { - activity_.enqueueDataWrite(characteristic->getValue()); +NearbyStatsSyncActivity::~NearbyStatsSyncActivity() { + if (eventMutex_) { + vSemaphoreDelete(eventMutex_); + eventMutex_ = nullptr; } - - private: - BleTransferActivity& activity_; -}; - -struct PeerStatsDownload { - std::string data; - uint32_t expectedSequence = 0; - uint32_t pendingAck = 0; - bool hasPendingAck = false; - bool badFrame = false; -}; - -bool writeJsonControl(NimBLERemoteCharacteristic* control, JsonDocument& doc) { - String output; - serializeJson(doc, output); - return control && control->writeValue(output.c_str(), output.length(), true); } -bool readRemoteStatus(NimBLERemoteCharacteristic* status, JsonDocument& doc) { - if (!status) return false; - const std::string value = status->readValue(); - return !deserializeJson(doc, value.data(), value.size()); -} +void NearbyStatsSyncActivity::onEnter() { + Activity::onEnter(); + setState(State::STARTING); -bool waitForRemoteState(NimBLERemoteCharacteristic* status, const char* desiredState, std::string& error, - const uint32_t timeoutMs = BLE_PEER_STATUS_TIMEOUT_MS) { - const uint32_t start = millis(); - while (millis() - start < timeoutMs) { - JsonDocument doc; - if (readRemoteStatus(status, doc)) { - const std::string state = doc["state"] | ""; - if (state == desiredState) return true; - if (state == "error") { - error = doc["error"] | "peer error"; - return false; - } - } - delay(100); + if (esp_efuse_mac_get_default(localDeviceMac_.data()) != ESP_OK) { + setError("Could not read device id"); + return; } - error = "peer timed out"; - return false; -} - -bool sendDownloadAck(NimBLERemoteCharacteristic* control, const uint32_t sequence) { - JsonDocument doc; - doc["op"] = "get_ack"; - doc["sequence"] = sequence; - return writeJsonControl(control, doc); -} -bool waitForStatsDownload(NimBLERemoteCharacteristic* control, NimBLERemoteCharacteristic* status, - PeerStatsDownload& download, size_t& expectedSize, std::string& error) { - const uint32_t start = millis(); - expectedSize = 0; - while (millis() - start < BLE_PEER_DOWNLOAD_TIMEOUT_MS) { - if (download.badFrame) { - error = "invalid stats frame"; - return false; - } - if (download.hasPendingAck) { - const uint32_t sequence = download.pendingAck; - download.hasPendingAck = false; - if (!sendDownloadAck(control, sequence)) { - error = "could not ack stats"; - return false; - } - if (isValidStatsPayload(download.data)) { - if (expectedSize == 0) expectedSize = download.data.size(); - return true; - } - } - - JsonDocument doc; - if (readRemoteStatus(status, doc)) { - const std::string state = doc["state"] | ""; - if (doc["size"].is()) expectedSize = doc["size"].as(); - if (expectedSize > 0 && download.data.size() == expectedSize && isValidStatsPayload(download.data)) return true; - if (state == "sent_stats") return true; - if (state == "error") { - error = doc["error"] | "peer error"; - return false; - } - } - delay(50); + if (!beginEspNow()) { + setError("Could not start nearby sync"); + return; } - error = "stats download timed out"; - return false; + setState(State::READY); } -void writeLe32(std::vector& data, const uint32_t value) { - data.push_back(static_cast(value & 0xFF)); - data.push_back(static_cast((value >> 8) & 0xFF)); - data.push_back(static_cast((value >> 16) & 0xFF)); - data.push_back(static_cast((value >> 24) & 0xFF)); +void NearbyStatsSyncActivity::onExit() { + Activity::onExit(); + endEspNow(); } -} // namespace +void NearbyStatsSyncActivity::loop() { + processEvents(); -struct BleTransferRuntime { - explicit BleTransferRuntime(BleTransferActivity& activity) - : serverCallbacks(activity), controlCallbacks(activity), dataCallbacks(activity) {} - - NimBLEServer* server = nullptr; - NimBLEService* service = nullptr; - NimBLECharacteristic* status = nullptr; - NimBLECharacteristic* dataOut = nullptr; - ServerCallbacks serverCallbacks; - ControlCallbacks controlCallbacks; - DataCallbacks dataCallbacks; - - bool begin(BleTransferActivity& activity) { - NimBLEDevice::init(BLE_DEVICE_NAME); - NimBLEDevice::setMTU(517); - NimBLEDevice::setPower(ESP_PWR_LVL_P9); - - server = NimBLEDevice::createServer(); - if (!server) return false; - server->setCallbacks(&serverCallbacks, false); - - service = server->createService(BLE_SERVICE_UUID); - if (!service) return false; - - auto* control = service->createCharacteristic(BLE_CONTROL_UUID, NIMBLE_PROPERTY::WRITE); - auto* dataIn = service->createCharacteristic(BLE_DATA_IN_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR); - status = service->createCharacteristic(BLE_STATUS_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); - dataOut = service->createCharacteristic(BLE_DATA_OUT_UUID, NIMBLE_PROPERTY::NOTIFY); - if (!control || !dataIn || !status || !dataOut) return false; - - control->setCallbacks(&controlCallbacks); - dataIn->setCallbacks(&dataCallbacks); - status->setValue(activity.buildStatusJson()); - - NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising(); - advertising->addServiceUUID(BLE_SERVICE_UUID); - advertising->setName(BLE_DEVICE_NAME); - advertising->start(); - return true; + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; } - void publish(const std::string& json) { - if (!status) return; - status->setValue(json); - status->notify(); + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && + (state_ == State::READY || state_ == State::SYNCED || state_ == State::ERROR)) { + startSync(); + return; } - void notifyData(const uint8_t* data, const size_t length) { - if (!dataOut) return; - dataOut->setValue(data, length); - dataOut->notify(); - } + updateSyncProgress(); +} - void startAdvertising() { - NimBLEAdvertising* advertising = NimBLEDevice::getAdvertising(); - if (advertising) advertising->start(); - } +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); - void end() { - NimBLEDevice::stopAdvertising(); - NimBLEDevice::deinit(true); - server = nullptr; - service = nullptr; - status = nullptr; - dataOut = nullptr; - } -}; + if (esp_now_init() != ESP_OK) return false; + espNowStarted_ = true; -BleTransferActivity::BleTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("BleTransfer", renderer, mappedInput), eventMutex_(xSemaphoreCreateMutex()) {} + if (esp_now_register_recv_cb(onEspNowReceive) != ESP_OK) return false; + if (!addPeer(BROADCAST_MAC)) return false; + activeActivity = this; + return true; +} -BleTransferActivity::~BleTransferActivity() { - if (eventMutex_) { - vSemaphoreDelete(eventMutex_); - eventMutex_ = nullptr; +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); } -void BleTransferActivity::onEnter() { - Activity::onEnter(); - deviceId_ = makeDeviceId(); - mbedtls_sha256_init(&shaContext_); - setState(State::STARTING); - - ble_ = std::make_unique(*this); - if (!ble_->begin(*this)) { - setError("Could not start BLE"); - return; +bool NearbyStatsSyncActivity::prepareLocalStats() { + localStatsReady_ = false; + if (!ensureSyncedStatsDirectory()) { + setError("could not create synced stats directory"); + return false; } - setState(State::ADVERTISING); - publishStatus(); -} + // Creating synced_stats is the opt-in signal; mirror local stats only after + // the user starts or accepts this workflow. + GlobalReadingStats::load().save(); -void BleTransferActivity::onExit() { - Activity::onExit(); - resetTransfer(true); - if (ble_) { - ble_->end(); - ble_.reset(); + if (!readSmallFile(GLOBAL_STATS_PATH, localStats_, localStatsSize_)) { + setError("local stats unavailable"); + return false; } - mbedtls_sha256_free(&shaContext_); -} -void BleTransferActivity::loop() { - processBleEvents(); + localStatsReady_ = true; + return true; +} - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - finish(); - return; - } - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm) && state_ == State::ADVERTISING) { - startPeerStatsSync(); - return; - } - if (pendingCommit_) { - pendingCommit_ = false; - processCommit(); - return; - } - if (state_ == State::SENDING_STATS && downloadOpen_) { - if (statusDirty_) publishStatus(); - pumpDownload(); +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 (statusDirty_) publishStatus(); -} -void BleTransferActivity::enqueueBleEvent(BleEvent event) { - if (!eventMutex_) return; - const size_t eventBytes = event.value.size(); - xSemaphoreTake(eventMutex_, portMAX_DELAY); - if (bleEventOverflow_ || bleEvents_.size() >= MAX_QUEUED_BLE_EVENTS || - queuedBleEventBytes_ + eventBytes > MAX_QUEUED_BLE_EVENT_BYTES) { - bleEventOverflow_ = true; - queuedBleEventBytes_ = 0; - bleEvents_.clear(); + if (xSemaphoreTake(eventMutex_, 0) != pdTRUE) return; + if (eventOverflow_ || eventCount_ >= MAX_SYNC_EVENTS) { + eventOverflow_ = true; + eventHead_ = 0; + eventCount_ = 0; } else { - queuedBleEventBytes_ += eventBytes; - bleEvents_.push_back(std::move(event)); + const uint8_t eventTail = static_cast((eventHead_ + eventCount_) % MAX_SYNC_EVENTS); + events_[eventTail] = event; + eventCount_++; } xSemaphoreGive(eventMutex_); } -void BleTransferActivity::enqueueBleConnected() { enqueueBleEvent({BleEventType::CONNECTED, {}}); } - -void BleTransferActivity::enqueueBleDisconnected() { enqueueBleEvent({BleEventType::DISCONNECTED, {}}); } - -void BleTransferActivity::enqueueControlWrite(const std::string& value) { - enqueueBleEvent({BleEventType::CONTROL, value}); -} - -void BleTransferActivity::enqueueDataWrite(const std::string& value) { enqueueBleEvent({BleEventType::DATA, value}); } - -void BleTransferActivity::processBleEvents() { +void NearbyStatsSyncActivity::processEvents() { while (true) { - BleEvent event; + SyncEvent event; bool hasEvent = false; bool hasOverflow = false; if (eventMutex_) { xSemaphoreTake(eventMutex_, portMAX_DELAY); - if (bleEventOverflow_) { - bleEventOverflow_ = false; - queuedBleEventBytes_ = 0; - bleEvents_.clear(); + if (eventOverflow_) { + eventOverflow_ = false; + eventHead_ = 0; + eventCount_ = 0; hasOverflow = true; } - if (!bleEvents_.empty()) { - event = std::move(bleEvents_.front()); - queuedBleEventBytes_ -= event.value.size(); - bleEvents_.pop_front(); + if (eventCount_ > 0) { + event = events_[eventHead_]; + eventHead_ = static_cast((eventHead_ + 1) % MAX_SYNC_EVENTS); + eventCount_--; hasEvent = true; } xSemaphoreGive(eventMutex_); } + if (hasOverflow) { - resetTransfer(true); - setError("BLE event queue overflow"); + setError("sync event queue overflow"); return; } if (!hasEvent) return; - - switch (event.type) { - case BleEventType::CONNECTED: - onBleConnected(); - break; - case BleEventType::DISCONNECTED: - onBleDisconnected(); - break; - case BleEventType::CONTROL: - onControlWrite(event.value); - break; - case BleEventType::DATA: - onDataWrite(event.value); - break; - } + handleEvent(event); } } -void BleTransferActivity::onBleConnected() { - initiatedPeerSync_ = false; - helloAccepted_ = false; - setState(State::CONNECTED); -} +void NearbyStatsSyncActivity::handleEvent(const SyncEvent& event) { + if (state_ == State::ERROR) return; -void BleTransferActivity::onBleDisconnected() { - if (state_ == State::SAVED_STATS || state_ == State::SENT_STATS || state_ == State::SYNCED_STATS) { - resetTransfer(false); - helloAccepted_ = false; - setState(State::SYNCED_STATS); - if (ble_) ble_->startAdvertising(); - return; - } - - if (downloadOpen_ && expectedSize_ > 0 && sentBytes_ == expectedSize_) { - resetTransfer(false); - helloAccepted_ = false; - setState(State::SYNCED_STATS); - if (ble_) ble_->startAdvertising(); - return; - } + peerSeen_ = true; + peerSourceMac_ = event.sourceMac; + peerDeviceMac_ = event.deviceMac; + peerId_ = bytesToHex(peerDeviceMac_.data(), peerDeviceMac_.size()); + addPeer(peerSourceMac_.data()); - if (transferOpen_ || downloadOpen_) { - resetTransfer(true); - setError("reader disconnected"); - return; - } - helloAccepted_ = false; - setState(State::ADVERTISING); - if (ble_) ble_->startAdvertising(); -} + if (!localStatsReady_ && !prepareLocalStats()) return; + if (state_ == State::READY || state_ == State::DISCOVERING) setState(State::SYNCING); -void BleTransferActivity::onControlWrite(const std::string& value) { - JsonDocument doc; - const DeserializationError parseError = deserializeJson(doc, value.data(), value.size()); - if (parseError) { - setError("invalid control JSON"); + if (event.type == PacketType::HELLO) { + sendLocalStats(); return; } - const std::string op = doc["op"] | ""; - if (op == "peer_hello") { - const int version = doc["version"] | 0; - const std::string peerDeviceId = toLowerAscii(doc["device_id"] | ""); - if (version != 1 || !isSafeDeviceId(peerDeviceId) || peerDeviceId == deviceId_) { - setError("invalid peer hello"); + if (event.type == PacketType::STATS) { + if (!writeSyncedStatsFile(syncedStatsPathForDeviceMac(peerDeviceMac_), event.stats.data(), event.statsSize)) { + setError("could not save stats"); return; } - helloAccepted_ = true; - setState(State::CONNECTED); - return; - } - - if (!helloAccepted_) { - setError("peer hello required"); + peerStatsSaved_ = true; + sendAck(peerSourceMac_.data()); + if (!localStatsSent_ || !localStatsAcked_) sendLocalStats(); return; } - if (op == "sync_complete") { - resetTransfer(false); - setState(State::SYNCED_STATS); - publishStatus(); - return; - } - - if (op == "start_put") { - resetTransfer(true); - - const std::string kind = doc["kind"] | ""; - fileName_ = doc["name"] | ""; - expectedSize_ = doc["size"] | 0; - expectedSha256_ = toLowerAscii(doc["sha256"] | ""); - uploadAckBytes_ = doc["ack_bytes"] | BLE_UPLOAD_ACK_BYTES_MIN; - - if (kind != "stats") { - setError("unsupported transfer kind"); - return; - } - if (!isSafeBleStatsName(fileName_)) { - setError("unsafe stats filename"); - return; - } - if (fileName_ == statsFileNameForDeviceId(deviceId_)) { - setError("refusing local stats overwrite"); - return; - } - if (expectedSize_ < MIN_BLE_STATS_BYTES || expectedSize_ > MAX_BLE_STATS_BYTES) { - setError("invalid stats size"); - return; - } - if (!isHexSha256(expectedSha256_)) { - setError("invalid sha256"); - return; - } - if (uploadAckBytes_ < BLE_UPLOAD_ACK_BYTES_MIN || uploadAckBytes_ > BLE_UPLOAD_ACK_BYTES_MAX) { - setError("invalid ack window"); - return; - } - if (!ensureSyncedStatsDirectory()) { - setError("could not create synced stats directory"); - return; - } - - partPath_ = std::string(SYNCED_STATS_DIR) + "/.ble-" + fileName_ + ".part"; - finalPath_ = std::string(SYNCED_STATS_DIR) + "/" + fileName_; - if (Storage.exists(partPath_.c_str())) Storage.remove(partPath_.c_str()); - if (!Storage.openFileForWrite("BLE", partPath_, uploadFile_)) { - setError("could not open stats file"); - return; - } - - mbedtls_sha256_starts(&shaContext_, 0); - shaActive_ = true; - receivedBytes_ = 0; - expectedSequence_ = 0; - transferOpen_ = true; - removePartOnExit_ = true; - lastProgressStatusBytes_ = 0; - lastDisplayProgressBytes_ = 0; - setState(State::RECEIVING_STATS); - return; + if (event.type == PacketType::ACK) { + localStatsAcked_ = true; } - - if (op == "start_get") { - resetTransfer(true); - const std::string kind = doc["kind"] | ""; - const int64_t offsetValue = doc["offset"] | 0; - const int64_t chunkSizeValue = doc["chunk_size"] | static_cast(BLE_DOWNLOAD_CHUNK_BYTES); - if (kind != "stats") { - setError("unsupported transfer kind"); - return; - } - if (offsetValue < 0 || - static_cast(offsetValue) > static_cast(std::numeric_limits::max())) { - setError("invalid download offset"); - return; - } - if (chunkSizeValue < static_cast(BLE_DOWNLOAD_CHUNK_BYTES_MIN) || - chunkSizeValue > static_cast(BLE_DOWNLOAD_CHUNK_BYTES_MAX)) { - setError("invalid download chunk size"); - return; - } - startStatsDownload(static_cast(offsetValue), static_cast(chunkSizeValue)); - return; - } - - if (op == "get_ack") { - if (!downloadOpen_ || !downloadAwaitingAck_) { - setError("no download pending"); - return; - } - const uint32_t sequence = doc["sequence"] | UINT32_MAX; - if (sequence != pendingDownloadAck_) { - setError("unexpected download ack"); - return; - } - downloadAwaitingAck_ = false; - if (expectedSize_ > 0 && sentBytes_ == expectedSize_) { - if (downloadFile_) downloadFile_.close(); - downloadOpen_ = false; - setState(State::SENT_STATS); - return; - } - statusDirty_ = true; - requestUpdate(); - return; - } - - if (op == "commit") { - if (!transferOpen_) { - setError("no transfer open"); - return; - } - pendingCommit_ = true; - setState(State::VERIFYING_STATS); - return; - } - - if (op == "cancel") { - resetTransfer(true); - setState(State::CONNECTED); - return; - } - - setError("unknown control op"); } -void BleTransferActivity::onDataWrite(const std::string& value) { - if (!transferOpen_ || state_ != State::RECEIVING_STATS) return; - if (value.size() <= sizeof(uint32_t)) { - setError("invalid data frame"); - resetTransfer(true); - return; - } - - const uint32_t sequence = readLe32(value); - if (sequence != expectedSequence_) { - setError("unexpected data sequence"); - resetTransfer(true); - return; - } +bool NearbyStatsSyncActivity::addPeer(const uint8_t* peerMac) { + if (!peerMac) return false; - const uint8_t* payload = reinterpret_cast(value.data() + sizeof(uint32_t)); - const size_t payloadSize = value.size() - sizeof(uint32_t); - if (receivedBytes_ + payloadSize > expectedSize_) { - setError("transfer too large"); - resetTransfer(true); - return; - } - if (uploadFile_.write(payload, payloadSize) != payloadSize) { - setError("transfer write failed"); - resetTransfer(true); - return; - } + 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; - mbedtls_sha256_update(&shaContext_, payload, payloadSize); - receivedBytes_ += payloadSize; - expectedSequence_++; - if (receivedBytes_ == expectedSize_ || receivedBytes_ - lastProgressStatusBytes_ >= uploadAckBytes_) { - lastProgressStatusBytes_ = receivedBytes_; - statusDirty_ = true; - } - if (receivedBytes_ == expectedSize_ || - receivedBytes_ - lastDisplayProgressBytes_ >= BLE_PROGRESS_DISPLAY_INTERVAL_BYTES) { - lastDisplayProgressBytes_ = receivedBytes_; - requestUpdate(); - } + const esp_err_t result = esp_now_add_peer(&peer); + return result == ESP_OK || result == ESP_ERR_ESPNOW_EXIST; } -void BleTransferActivity::processCommit() { - if (!transferOpen_) return; - setState(State::VERIFYING_STATS); +bool NearbyStatsSyncActivity::sendPacket(const PacketType type, const uint8_t* peerMac) { + if (!peerMac || !espNowStarted_) return false; + if (!addPeer(peerMac)) return false; - uploadFile_.flush(); - uploadFile_.close(); - transferOpen_ = 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); - if (receivedBytes_ != expectedSize_) { - setError("size mismatch"); - resetTransfer(true); - return; - } - - uint8_t digest[32] = {}; - mbedtls_sha256_finish(&shaContext_, digest); - shaActive_ = false; - if (sha256ToHex(digest) != expectedSha256_) { - setError("sha256 mismatch"); - resetTransfer(true); - return; + 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_; } - std::string statsData; - if (!readSmallFile(partPath_.c_str(), statsData, MAX_BLE_STATS_BYTES) || !isValidStatsPayload(statsData)) { - setError("invalid stats file"); - resetTransfer(true); - return; - } - if (Storage.exists(finalPath_.c_str()) && !Storage.remove(finalPath_.c_str())) { - setError("could not replace stats file"); - resetTransfer(true); - return; - } - if (!Storage.rename(partPath_.c_str(), finalPath_.c_str())) { - setError("could not finalize stats"); - resetTransfer(true); - return; + 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; } - - removePartOnExit_ = false; - savedPath_ = finalPath_; - setState(State::SAVED_STATS); + return true; } -void BleTransferActivity::startStatsDownload(const size_t offset, const size_t chunkSize) { - GlobalReadingStats::load().save(); - - if (!Storage.exists(GLOBAL_STATS_PATH)) { - setError("not_found"); - return; - } - if (!Storage.openFileForRead("BLE", GLOBAL_STATS_PATH, downloadFile_)) { - setError("could not open stats"); - return; - } - - const size_t fileSize = downloadFile_.fileSize(); - if (fileSize < MIN_BLE_STATS_BYTES || fileSize > MAX_BLE_STATS_BYTES) { - downloadFile_.close(); - setError("invalid stats size"); - return; - } - if (offset > fileSize) { - downloadFile_.close(); - setError("invalid download offset"); - return; - } - if (offset < fileSize && offset % chunkSize != 0) { - downloadFile_.close(); - setError("unaligned download offset"); - return; - } - if (!downloadFile_.seek(offset)) { - downloadFile_.close(); - setError("could not seek stats"); - return; - } - - fileName_ = statsFileNameForDeviceId(deviceId_); - expectedSize_ = fileSize; - sentBytes_ = offset; - downloadSequence_ = static_cast(offset / chunkSize); - pendingDownloadAck_ = 0; - downloadAwaitingAck_ = false; - downloadChunkSize_ = chunkSize; - lastProgressStatusBytes_ = sentBytes_; - downloadOpen_ = true; - setState(State::SENDING_STATS); +bool NearbyStatsSyncActivity::sendHello() { + lastHelloMs_ = millis(); + return sendPacket(PacketType::HELLO, BROADCAST_MAC); } -void BleTransferActivity::pumpDownload() { - if (!downloadOpen_ || downloadAwaitingAck_) return; - - std::array frame = {}; - frame[0] = static_cast(downloadSequence_ & 0xFF); - frame[1] = static_cast((downloadSequence_ >> 8) & 0xFF); - frame[2] = static_cast((downloadSequence_ >> 16) & 0xFF); - frame[3] = static_cast((downloadSequence_ >> 24) & 0xFF); - - const int read = downloadFile_.read(frame.data() + sizeof(uint32_t), downloadChunkSize_); - if (read < 0) { - downloadFile_.close(); - downloadOpen_ = false; - setError("download read failed"); - return; - } - if (read == 0) { - downloadFile_.close(); - downloadOpen_ = false; - setState(State::SENT_STATS); - return; - } - - ble_->notifyData(frame.data(), sizeof(uint32_t) + static_cast(read)); - sentBytes_ += static_cast(read); - pendingDownloadAck_ = downloadSequence_; - downloadAwaitingAck_ = true; - downloadSequence_++; - if (sentBytes_ == expectedSize_ || sentBytes_ - lastProgressStatusBytes_ >= BLE_PROGRESS_STATUS_INTERVAL_BYTES) { - lastProgressStatusBytes_ = sentBytes_; - statusDirty_ = true; - requestUpdate(); - } +bool NearbyStatsSyncActivity::sendLocalStats() { + if (!peerSeen_) return false; + lastStatsSendMs_ = millis(); + localStatsSent_ = sendPacket(PacketType::STATS, peerSourceMac_.data()); + return localStatsSent_; } -void BleTransferActivity::startPeerStatsSync() { - initiatedPeerSync_ = true; - resetTransfer(true); - setState(State::SYNC_SCANNING); - publishStatus(); - requestUpdateAndWait(); - - if (!ensureSyncedStatsDirectory()) { - setError("could not create synced stats directory"); - return; - } - - // Creating synced_stats is the opt-in signal; mirror local stats only after - // the user starts this workflow. - GlobalReadingStats::load().save(); - - std::string localStats; - if (!readSmallFile(GLOBAL_STATS_PATH, localStats, MAX_BLE_STATS_BYTES) || !isValidStatsPayload(localStats)) { - setError("local stats unavailable"); - return; - } - - if (ble_) NimBLEDevice::stopAdvertising(); - - NimBLEScan* scan = NimBLEDevice::getScan(); - if (!scan) { - setError("could not scan"); - if (ble_) ble_->startAdvertising(); - return; - } - scan->setActiveScan(true); - scan->setInterval(45); - scan->setWindow(30); - - const NimBLEUUID serviceUuid(BLE_SERVICE_UUID); - NimBLEScanResults results = scan->getResults(BLE_PEER_SCAN_MS, false); - const NimBLEAdvertisedDevice* peer = nullptr; - for (int i = 0; i < results.getCount(); i++) { - const NimBLEAdvertisedDevice* candidate = results.getDevice(i); - if (candidate && candidate->isAdvertisingService(serviceUuid)) { - peer = candidate; - break; - } - } - - if (!peer) { - scan->clearResults(); - if (ble_) ble_->startAdvertising(); - setError("no reader found"); - return; - } - - setState(State::SYNC_CONNECTING); - publishStatus(); - requestUpdateAndWait(); - - std::string error; - NimBLEClient* client = NimBLEDevice::createClient(); - if (!client) { - scan->clearResults(); - if (ble_) ble_->startAdvertising(); - setError("could not create client"); - return; - } - client->setConnectionParams(12, 12, 0, 150); - client->setConnectTimeout(BLE_PEER_CONNECT_TIMEOUT_MS); - - auto finishClient = [&]() { - if (client) { - if (client->isConnected()) client->disconnect(); - NimBLEDevice::deleteClient(client); - client = nullptr; - } - scan->clearResults(); - if (ble_) ble_->startAdvertising(); - }; - - if (!client->connect(peer)) { - finishClient(); - setError("could not connect reader"); - return; - } - client->setDataLen(251); - - NimBLERemoteService* service = client->getService(BLE_SERVICE_UUID); - NimBLERemoteCharacteristic* control = service ? service->getCharacteristic(BLE_CONTROL_UUID) : nullptr; - NimBLERemoteCharacteristic* dataIn = service ? service->getCharacteristic(BLE_DATA_IN_UUID) : nullptr; - NimBLERemoteCharacteristic* status = service ? service->getCharacteristic(BLE_STATUS_UUID) : nullptr; - NimBLERemoteCharacteristic* dataOut = service ? service->getCharacteristic(BLE_DATA_OUT_UUID) : nullptr; - if (!service || !control || !dataIn || !status || !dataOut) { - finishClient(); - setError("reader service incomplete"); - return; - } - - JsonDocument statusDoc; - if (!readRemoteStatus(status, statusDoc)) { - finishClient(); - setError("could not read reader status"); - return; - } - const std::string peerDeviceId = toLowerAscii(statusDoc["device_id"] | ""); - if (!isSafeDeviceId(peerDeviceId) || peerDeviceId == deviceId_) { - finishClient(); - setError("invalid reader id"); - return; - } - - setState(State::SYNCING_STATS); - publishStatus(); - requestUpdateAndWait(); - - JsonDocument hello; - hello["op"] = "peer_hello"; - hello["version"] = 1; - hello["device_id"] = deviceId_.c_str(); - if (!writeJsonControl(control, hello) || !waitForRemoteState(status, "connected", error)) { - finishClient(); - setError(error.empty() ? "reader hello failed" : error); - return; - } - - const std::string localStatsName = statsFileNameForDeviceId(deviceId_); - const std::string localStatsSha = sha256Hex(reinterpret_cast(localStats.data()), localStats.size()); - JsonDocument startPut; - startPut["op"] = "start_put"; - startPut["kind"] = "stats"; - startPut["name"] = localStatsName.c_str(); - startPut["size"] = localStats.size(); - startPut["sha256"] = localStatsSha.c_str(); - startPut["ack_bytes"] = BLE_UPLOAD_ACK_BYTES_MIN; - if (!writeJsonControl(control, startPut) || !waitForRemoteState(status, "receiving_stats", error)) { - finishClient(); - setError(error.empty() ? "stats upload failed" : error); - return; - } - - std::vector frame; - frame.reserve(sizeof(uint32_t) + localStats.size()); - writeLe32(frame, 0); - frame.insert(frame.end(), localStats.begin(), localStats.end()); - if (!dataIn->writeValue(frame.data(), frame.size(), true)) { - finishClient(); - setError("could not send stats"); - return; - } - - JsonDocument commit; - commit["op"] = "commit"; - if (!writeJsonControl(control, commit)) { - finishClient(); - setError("stats upload commit failed"); - return; - } - if (!waitForRemoteState(status, "saved_stats", error) && error != "peer timed out") { - finishClient(); - setError(error.empty() ? "stats upload commit failed" : error); - return; - } - error.clear(); - - PeerStatsDownload download; - auto notifyCb = [&download](NimBLERemoteCharacteristic*, uint8_t* data, size_t length, bool) { - if (length <= sizeof(uint32_t)) { - download.badFrame = true; - return; - } - const uint32_t sequence = readLe32(data); - if (sequence != download.expectedSequence || download.hasPendingAck) { - download.badFrame = true; - return; - } - download.data.append(reinterpret_cast(data + sizeof(uint32_t)), length - sizeof(uint32_t)); - download.pendingAck = sequence; - download.hasPendingAck = true; - download.expectedSequence++; - }; - if (!dataOut->subscribe(true, notifyCb)) { - finishClient(); - setError("could not receive stats"); - return; - } +bool NearbyStatsSyncActivity::sendAck(const uint8_t* peerMac) { return sendPacket(PacketType::ACK, peerMac); } - JsonDocument startGet; - startGet["op"] = "start_get"; - startGet["kind"] = "stats"; - startGet["offset"] = 0; - startGet["chunk_size"] = BLE_DOWNLOAD_CHUNK_BYTES; - if (!writeJsonControl(control, startGet)) { - dataOut->unsubscribe(); - finishClient(); - setError("could not request stats"); - return; - } - - size_t expectedDownloadSize = 0; - if (!waitForStatsDownload(control, status, download, expectedDownloadSize, error)) { - dataOut->unsubscribe(); - finishClient(); - setError(error); - return; - } - dataOut->unsubscribe(); +void NearbyStatsSyncActivity::updateSyncProgress() { + if (state_ != State::DISCOVERING && state_ != State::SYNCING) return; - if (expectedDownloadSize != download.data.size() || !isValidStatsPayload(download.data)) { - finishClient(); - setError("invalid received stats"); + const uint32_t now = millis(); + if (now - syncStartedMs_ > SYNC_TIMEOUT_MS) { + setError(peerSeen_ ? "stats sync timed out" : "no reader found"); return; } - const std::string peerStatsPath = syncedStatsPathForDeviceId(peerDeviceId); - if (peerStatsPath.empty() || !writeSyncedStatsFile(peerStatsPath, download.data)) { - finishClient(); - setError("could not save stats"); + if (peerStatsSaved_ && localStatsAcked_) { + setState(State::SYNCED); return; } - JsonDocument complete; - complete["op"] = "sync_complete"; - if (!writeJsonControl(control, complete)) { - finishClient(); - setError("stats sync completion failed"); + if (!peerSeen_ && now - lastHelloMs_ >= HELLO_INTERVAL_MS) { + sendHello(); return; } - if (!waitForRemoteState(status, "synced_stats", error, BLE_PEER_COMPLETE_TIMEOUT_MS) && error != "peer timed out") { - finishClient(); - setError(error.empty() ? "stats sync completion failed" : error); - return; - } - - finishClient(); - setState(State::SYNCED_STATS); -} -void BleTransferActivity::resetTransfer(const bool removePart) { - if (shaActive_) { - mbedtls_sha256_free(&shaContext_); - mbedtls_sha256_init(&shaContext_); - shaActive_ = false; + if (peerSeen_ && localStatsReady_ && !localStatsAcked_ && now - lastStatsSendMs_ >= STATS_RETRY_INTERVAL_MS) { + sendLocalStats(); } - if (uploadFile_) uploadFile_.close(); - if (downloadFile_) downloadFile_.close(); - if (removePart && removePartOnExit_ && !partPath_.empty() && Storage.exists(partPath_.c_str())) { - Storage.remove(partPath_.c_str()); - } - - fileName_.clear(); - partPath_.clear(); - finalPath_.clear(); - expectedSha256_.clear(); - savedPath_.clear(); - expectedSize_ = 0; - receivedBytes_ = 0; - sentBytes_ = 0; - lastProgressStatusBytes_ = 0; - lastDisplayProgressBytes_ = 0; - uploadAckBytes_ = BLE_UPLOAD_ACK_BYTES_MIN; - downloadChunkSize_ = BLE_DOWNLOAD_CHUNK_BYTES; - expectedSequence_ = 0; - downloadSequence_ = 0; - pendingDownloadAck_ = 0; - transferOpen_ = false; - downloadOpen_ = false; - downloadAwaitingAck_ = false; - pendingCommit_ = false; - removePartOnExit_ = false; } -void BleTransferActivity::setState(const State state) { +void NearbyStatsSyncActivity::setState(const State state) { + if (state_ == state) return; state_ = state; - statusDirty_ = true; requestUpdate(); } -void BleTransferActivity::setError(const std::string& error) { +void NearbyStatsSyncActivity::setError(const std::string& error) { + LOG_ERR(LOG_TAG, "%s", error.c_str()); errorMessage_ = error; - state_ = State::ERROR; - statusDirty_ = true; - requestUpdate(); -} - -void BleTransferActivity::publishStatus() { - statusDirty_ = false; - if (ble_) ble_->publish(buildStatusJson()); -} - -std::string BleTransferActivity::buildStatusJson() const { - JsonDocument doc; - const std::string state = stateName(state_); - doc["state"] = state.c_str(); - doc["protocol_version"] = 1; - doc["sync_kind"] = "stats"; - doc["device_id"] = deviceId_.c_str(); - JsonArray uploadKinds = doc["upload_kinds"].to(); - uploadKinds.add("stats"); - JsonArray downloadKinds = doc["download_kinds"].to(); - downloadKinds.add("stats"); - if (expectedSize_ > 0 || state_ == State::SENDING_STATS || state_ == State::SENT_STATS) { - doc["kind"] = "stats"; - if (state_ == State::SENDING_STATS || state_ == State::SENT_STATS) { - doc["sent"] = sentBytes_; - } else { - doc["received"] = receivedBytes_; - doc["ack_bytes"] = uploadAckBytes_; - } - doc["size"] = expectedSize_; - } - if (state_ == State::SAVED_STATS && !savedPath_.empty()) { - doc["name"] = fileName_.c_str(); - doc["path"] = savedPath_.c_str(); - } - if (state_ == State::SENT_STATS) doc["name"] = fileName_.c_str(); - if (state_ == State::ERROR && !errorMessage_.empty()) doc["error"] = errorMessage_.c_str(); - - String output; - serializeJson(doc, output); - return output.c_str(); + setState(State::ERROR); } -void BleTransferActivity::render(RenderLock&&) { +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_BLUETOOTH_TRANSFER)); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_NEARBY_STATS_SYNC)); const int centerY = pageHeight / 2 - 20; std::string primary; @@ -1314,46 +498,20 @@ void BleTransferActivity::render(RenderLock&&) { case State::STARTING: primary = tr(STR_LOADING_POPUP); break; - case State::ADVERTISING: - primary = tr(STR_BLE_TRANSFER_READY); - secondary = statsFileNameForDeviceId(deviceId_); - break; - case State::CONNECTED: - primary = tr(STR_CONNECTED); - break; - case State::RECEIVING_STATS: - case State::SENDING_STATS: { - primary = tr(STR_BLE_STATS_SYNCING); - char buffer[48]; - const size_t progress = state_ == State::RECEIVING_STATS ? receivedBytes_ : sentBytes_; - snprintf(buffer, sizeof(buffer), "%u / %u bytes", static_cast(progress), - static_cast(expectedSize_)); - secondary = buffer; - break; - } - case State::VERIFYING_STATS: - primary = tr(STR_BLE_STATS_SYNCING); - secondary = fileName_; - break; - case State::SAVED_STATS: - primary = tr(STR_BLE_STATS_RECEIVED); - secondary = savedPath_.empty() ? fileName_ : savedPath_; - break; - case State::SENT_STATS: - primary = tr(STR_BLE_STATS_SENT); - secondary = fileName_; - break; - case State::SYNC_SCANNING: - primary = tr(STR_BLE_STATS_SCANNING); + case State::READY: + primary = tr(STR_NEARBY_STATS_READY); + secondary = statsFileNameForDeviceMac(localDeviceMac_); break; - case State::SYNC_CONNECTING: - primary = tr(STR_BLE_STATS_CONNECTING); + case State::DISCOVERING: + primary = tr(STR_NEARBY_STATS_SCANNING); break; - case State::SYNCING_STATS: - primary = tr(STR_BLE_STATS_SYNCING); + case State::SYNCING: + primary = tr(STR_NEARBY_STATS_SYNCING); + secondary = peerId_; break; - case State::SYNCED_STATS: - primary = initiatedPeerSync_ ? tr(STR_BLE_STATS_SENT) : tr(STR_BLE_STATS_RECEIVED); + case State::SYNCED: + primary = tr(STR_NEARBY_STATS_SYNCED); + secondary = peerId_; break; case State::ERROR: primary = tr(STR_ERROR_MSG); @@ -1361,10 +519,9 @@ void BleTransferActivity::render(RenderLock&&) { break; } - if (state_ == State::ADVERTISING || state_ == State::CONNECTED) { + if (state_ == State::READY || state_ == State::SYNCED || state_ == State::ERROR) { renderReady(primary, secondary); - const char* syncLabel = state_ == State::ADVERTISING ? tr(STR_BLE_SYNC_STATS) : ""; - const auto labels = mappedInput.mapLabels(tr(STR_BACK), syncLabel, "", ""); + 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; @@ -1379,7 +536,7 @@ void BleTransferActivity::render(RenderLock&&) { renderer.displayBuffer(); } -void BleTransferActivity::renderReady(const std::string& primary, const std::string& secondary) const { +void NearbyStatsSyncActivity::renderReady(const std::string& primary, const std::string& secondary) const { const auto& metrics = UITheme::getInstance().getMetrics(); const int pageWidth = renderer.getScreenWidth(); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; @@ -1390,7 +547,7 @@ void BleTransferActivity::renderReady(const std::string& primary, const std::str y += lineHeight + metrics.verticalSpacing; renderer.drawCenteredText(SMALL_FONT_ID, y, secondary.c_str(), true); y += renderer.getLineHeight(SMALL_FONT_ID) + metrics.verticalSpacing; - renderer.drawCenteredText(SMALL_FONT_ID, y, tr(STR_BLE_SYNC_STATS), true); + 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 445362a558..6486598ccb 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -38,7 +38,7 @@ void NetworkModeSelectionActivity::loop() { } else if (selectedIndex == 2) { mode = NetworkMode::CREATE_HOTSPOT; } else if (selectedIndex == 3) { - mode = NetworkMode::BLUETOOTH_TRANSFER; + mode = NetworkMode::NEARBY_STATS_SYNC; } onModeSelected(mode); return; @@ -69,9 +69,9 @@ 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_BLUETOOTH_TRANSFER}; + 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, StrId::STR_BLUETOOTH_TRANSFER_DESC}; + 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}; diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index 3a955fa05e..447da71f56 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -5,14 +5,14 @@ #include "activities/Activity.h" #include "util/ButtonNavigator.h" -enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT, BLUETOOTH_TRANSFER }; +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) - * - "Bluetooth Stats Sync" - Sync reading stats with a nearby reader over BLE + * - "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. From 3edf03bd2d169428565a504eab5f27eef98b1c52 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 17:42:24 -0400 Subject: [PATCH 13/17] fix(stats): initialize passive ESP-NOW sync sessions --- src/activities/network/NearbyStatsSyncActivity.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/activities/network/NearbyStatsSyncActivity.cpp b/src/activities/network/NearbyStatsSyncActivity.cpp index 4c35ecbc19..ef7d11e197 100644 --- a/src/activities/network/NearbyStatsSyncActivity.cpp +++ b/src/activities/network/NearbyStatsSyncActivity.cpp @@ -359,6 +359,18 @@ void NearbyStatsSyncActivity::processEvents() { 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; @@ -366,7 +378,7 @@ void NearbyStatsSyncActivity::handleEvent(const SyncEvent& event) { addPeer(peerSourceMac_.data()); if (!localStatsReady_ && !prepareLocalStats()) return; - if (state_ == State::READY || state_ == State::DISCOVERING) setState(State::SYNCING); + if (state_ == State::READY || state_ == State::DISCOVERING || state_ == State::SYNCED) setState(State::SYNCING); if (event.type == PacketType::HELLO) { sendLocalStats(); From 14dc0ee45796517f437031a3d39ceae51e04e82c Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sat, 23 May 2026 17:53:14 -0400 Subject: [PATCH 14/17] feat: improve syncing help text --- lib/I18n/translations/english.yaml | 11 ++++++----- src/activities/network/NearbyStatsSyncActivity.cpp | 4 ++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 048d6f845b..e216dcb026 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -42,7 +42,7 @@ 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: "Sync reading stats with a nearby reader" +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" @@ -486,9 +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 for stats sync" +STR_NEARBY_STATS_READY: "Ready to sync both readers" STR_NEARBY_STATS_SYNC_BUTTON: "Sync" -STR_NEARBY_STATS_SCANNING: "Finding nearby reader" -STR_NEARBY_STATS_SYNCING: "Syncing reading stats" -STR_NEARBY_STATS_SYNCED: "Reading stats synced" +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/src/activities/network/NearbyStatsSyncActivity.cpp b/src/activities/network/NearbyStatsSyncActivity.cpp index ef7d11e197..09532eba96 100644 --- a/src/activities/network/NearbyStatsSyncActivity.cpp +++ b/src/activities/network/NearbyStatsSyncActivity.cpp @@ -559,6 +559,10 @@ void NearbyStatsSyncActivity::renderReady(const std::string& primary, const std: 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); } From 3f5b06adfca4d111cb800c179fa7ce69d0908801 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sun, 24 May 2026 09:46:02 -0400 Subject: [PATCH 15/17] fix(reader): persist auto page turn interval --- CHANGELOG.md | 2 + docs/contributing/architecture.md | 1 + docs/file-formats.md | 13 +++++ src/activities/reader/EpubReaderActivity.cpp | 55 ++++++++++++++++++-- src/activities/reader/EpubReaderActivity.h | 1 + 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e7fc6c04..b6cbb8870f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added nearby Reading Stats sync between CrossInk readers using direct ESP-NOW device-to-device messages. - Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. - Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. +- 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. @@ -17,6 +18,7 @@ - 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. + ### Changed ## [v1.3.0] - 2026-05-21 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 9fc274fd90..cb641704e9 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -148,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/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 52f37b3657..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) { @@ -1325,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) { @@ -1339,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; From f13075611b4fe0bcaa29df3599bdf23f69b74031 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sun, 24 May 2026 09:48:43 -0400 Subject: [PATCH 16/17] docs: update changelog --- CHANGELOG.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cbb8870f..1616304bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,7 @@ ### Added - Added an adjustable reader line-height setting with percent-based spacing for EPUB and TXT books. -- Added a Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. -- Added a Minimal sleep screen option that shows the current book cover and reading progress on a dark background. -- Added aggregate all-time Reading Stats support from peer-synced per-device stats files. - Added nearby Reading Stats sync between CrossInk readers using direct ESP-NOW device-to-device messages. -- Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. -- Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. - Auto Page Turn interval now remembers the last selected interval per book when it is turned on again. ### Fixed @@ -18,9 +13,9 @@ - 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. - ### Changed + ## [v1.3.0] - 2026-05-21 ### Added From c8c6f6a431b9e8057f1f96fb4a683e7468c245f9 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Sun, 24 May 2026 10:28:30 -0400 Subject: [PATCH 17/17] style: remove unused variable --- src/activities/network/NearbyStatsSyncActivity.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/activities/network/NearbyStatsSyncActivity.cpp b/src/activities/network/NearbyStatsSyncActivity.cpp index 09532eba96..80c791ae3b 100644 --- a/src/activities/network/NearbyStatsSyncActivity.cpp +++ b/src/activities/network/NearbyStatsSyncActivity.cpp @@ -550,7 +550,6 @@ void NearbyStatsSyncActivity::render(RenderLock&&) { void NearbyStatsSyncActivity::renderReady(const std::string& primary, const std::string& secondary) const { const auto& metrics = UITheme::getInstance().getMetrics(); - const int pageWidth = renderer.getScreenWidth(); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); int y = contentTop + 70;