Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
- File Browser long-press folder action for choosing a custom sleep-image folder instead of only `/.sleep` or `/sleep`.
- Expanded X3 Reading Stats, including streaks, time charts, editable dates, all-time backups, reset controls, an idle-time threshold, and the `Minimal Stats` sleep screen.
- `Reset Reading Pace` in the EPUB reader menu when Time Left is enabled, for clearing only the time-left pace estimate while keeping book reading stats.
- Added TRMNL as a sleep-screen wallpaper source with configurable server, API key, device ID, and orientation.

### Changed
- Display, Reader, and Controls settings now open list menus instead of cycling through options one by one.
Expand Down
10 changes: 10 additions & 0 deletions lib/I18n/translations/english.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ STR_THEME_MINIMAL: "Minimal"
STR_THEME_DASHBOARD: "Dashboard"
STR_THEME_MINIMAL_STATS: "Minimal Stats"
STR_QUICK_RESUME: "Quick Resume"
STR_TRMNL: "TRMNL"
STR_TRMNL_SETTINGS: "TRMNL Settings"
STR_TRMNL_SERVER_URL: "TRMNL Server URL"
STR_TRMNL_API_KEY: "TRMNL API Key"
STR_TRMNL_DEVICE_ID: "TRMNL Device ID"
STR_TRMNL_ORIENTATION: "TRMNL Orientation"
STR_TRMNL_HORIZONTAL: "Horizontal"
STR_TRMNL_VERTICAL: "Vertical"
STR_TRMNL_FETCH_FAILED: "TRMNL fetch failed"
STR_TRMNL_EXTENDED_WIFI_TIMEOUT: "Extended Wi-Fi Timeout"
STR_RECENT_BOOKS_VIEW: "Recent Books View"
STR_LIST_VIEW: "List"
STR_GRID_VIEW: "Grid"
Expand Down
88 changes: 88 additions & 0 deletions lib/JsonParser/TrmnlDisplayJsonParser.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#include "TrmnlDisplayJsonParser.h"

#include <cstdlib>
#include <cstring>

namespace {
void safeCopy(char* dst, const size_t dstSize, const char* src, const size_t srcLen) {
const size_t n = srcLen < dstSize - 1 ? srcLen : dstSize - 1;
memcpy(dst, src, n);
dst[n] = '\0';
}
} // namespace

TrmnlDisplayJsonParser::TrmnlDisplayJsonParser()
: parser(JsonCallbacks{this, sOnKey, sOnString, sOnNumber, sOnBool, sOnNull, sOnObjectStart, sOnObjectEnd,
sOnArrayStart, sOnArrayEnd}) {
reset();
}

void TrmnlDisplayJsonParser::reset() {
parser.reset();
lastKey = LastKey::NONE;
depth = 0;
imageUrl[0] = '\0';
filename[0] = '\0';
refreshRateSeconds = 0;
imageUrlFound = false;
}

void TrmnlDisplayJsonParser::feed(const char* data, const size_t len) { parser.feed(data, len); }
bool TrmnlDisplayJsonParser::foundImageUrl() const { return imageUrlFound; }
bool TrmnlDisplayJsonParser::hasError() const { return parser.hasError(); }
const char* TrmnlDisplayJsonParser::getImageUrl() const { return imageUrl; }
const char* TrmnlDisplayJsonParser::getFilename() const { return filename; }
uint32_t TrmnlDisplayJsonParser::getRefreshRateSeconds() const { return refreshRateSeconds; }

void TrmnlDisplayJsonParser::sOnKey(void* ctx, const char* key, const size_t len) {
auto* self = static_cast<TrmnlDisplayJsonParser*>(ctx);
if (self->depth != 1) {
self->lastKey = LastKey::NONE;
return;
}
if (len == 9 && memcmp(key, "image_url", 9) == 0) {
self->lastKey = LastKey::IMAGE_URL;
} else if ((len == 8 && memcmp(key, "filename", 8) == 0) || (len == 10 && memcmp(key, "image_name", 10) == 0)) {
self->lastKey = LastKey::FILENAME;
} else if (len == 12 && memcmp(key, "refresh_rate", 12) == 0) {
self->lastKey = LastKey::REFRESH_RATE;
} else {
self->lastKey = LastKey::NONE;
}
}

void TrmnlDisplayJsonParser::sOnString(void* ctx, const char* value, const size_t len) {
auto* self = static_cast<TrmnlDisplayJsonParser*>(ctx);
if (self->depth == 1 && self->lastKey == LastKey::IMAGE_URL) {
safeCopy(self->imageUrl, sizeof(self->imageUrl), value, len);
self->imageUrlFound = self->imageUrl[0] != '\0';
} else if (self->depth == 1 && self->lastKey == LastKey::FILENAME) {
safeCopy(self->filename, sizeof(self->filename), value, len);
}
self->lastKey = LastKey::NONE;
}

void TrmnlDisplayJsonParser::sOnNumber(void* ctx, const char* value, const size_t /*len*/) {
auto* self = static_cast<TrmnlDisplayJsonParser*>(ctx);
if (self->depth == 1 && self->lastKey == LastKey::REFRESH_RATE) {
self->refreshRateSeconds = static_cast<uint32_t>(strtoul(value, nullptr, 10));
}
self->lastKey = LastKey::NONE;
}

void TrmnlDisplayJsonParser::sOnBool(void* ctx, bool /*value*/) {
static_cast<TrmnlDisplayJsonParser*>(ctx)->lastKey = LastKey::NONE;
}
void TrmnlDisplayJsonParser::sOnNull(void* ctx) { static_cast<TrmnlDisplayJsonParser*>(ctx)->lastKey = LastKey::NONE; }
void TrmnlDisplayJsonParser::sOnObjectStart(void* ctx) {
auto* self = static_cast<TrmnlDisplayJsonParser*>(ctx);
self->depth++;
self->lastKey = LastKey::NONE;
}
void TrmnlDisplayJsonParser::sOnObjectEnd(void* ctx) {
auto* self = static_cast<TrmnlDisplayJsonParser*>(ctx);
if (self->depth > 0) self->depth--;
self->lastKey = LastKey::NONE;
}
void TrmnlDisplayJsonParser::sOnArrayStart(void* ctx) { sOnObjectStart(ctx); }
void TrmnlDisplayJsonParser::sOnArrayEnd(void* ctx) { sOnObjectEnd(ctx); }
40 changes: 40 additions & 0 deletions lib/JsonParser/TrmnlDisplayJsonParser.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#pragma once

#include <cstddef>
#include <cstdint>

#include "StreamingJsonParser.h"

class TrmnlDisplayJsonParser {
public:
TrmnlDisplayJsonParser();
void reset();
void feed(const char* data, size_t len);

bool foundImageUrl() const;
bool hasError() const;
const char* getImageUrl() const;
const char* getFilename() const;
uint32_t getRefreshRateSeconds() const;

private:
enum class LastKey : uint8_t { NONE, IMAGE_URL, FILENAME, REFRESH_RATE };

static void sOnKey(void* ctx, const char* key, size_t len);
static void sOnString(void* ctx, const char* value, size_t len);
static void sOnNumber(void* ctx, const char* value, size_t len);
static void sOnBool(void* ctx, bool value);
static void sOnNull(void* ctx);
static void sOnObjectStart(void* ctx);
static void sOnObjectEnd(void* ctx);
static void sOnArrayStart(void* ctx);
static void sOnArrayEnd(void* ctx);

StreamingJsonParser parser;
LastKey lastKey = LastKey::NONE;
uint16_t depth = 0;
char imageUrl[512] = "";
char filename[128] = "";
uint32_t refreshRateSeconds = 0;
bool imageUrlFound = false;
};
1 change: 1 addition & 0 deletions src/CrossPointSettings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ constexpr uint8_t SLEEP_SCREEN_STORAGE_ORDER[] = {
static_cast<uint8_t>(CrossPointSettings::QUICK_RESUME),
static_cast<uint8_t>(CrossPointSettings::MINIMAL_STATS_SLEEP),
static_cast<uint8_t>(CrossPointSettings::DASHBOARD_SLEEP),
static_cast<uint8_t>(CrossPointSettings::TRMNL),
};
constexpr uint8_t SLEEP_SCREEN_STORAGE_ORDER_COUNT =
sizeof(SLEEP_SCREEN_STORAGE_ORDER) / sizeof(SLEEP_SCREEN_STORAGE_ORDER[0]);
Expand Down
10 changes: 10 additions & 0 deletions src/CrossPointSettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class CrossPointSettings {
QUICK_RESUME = 9,
MINIMAL_STATS_SLEEP = 10,
DASHBOARD_SLEEP = 11,
TRMNL = 12,
SLEEP_SCREEN_MODE_COUNT
};
enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT };
Expand All @@ -40,6 +41,7 @@ class CrossPointSettings {
INVERTED_BLACK_AND_WHITE = 2,
SLEEP_SCREEN_COVER_FILTER_COUNT
};
enum TRMNL_ORIENTATION { TRMNL_LANDSCAPE = 0, TRMNL_PORTRAIT = 1, TRMNL_ORIENTATION_COUNT };

// Status bar enum - legacy
enum STATUS_BAR_MODE {
Expand Down Expand Up @@ -387,6 +389,14 @@ class CrossPointSettings {
char opdsServerUrl[128] = "";
char opdsUsername[64] = "";
char opdsPassword[64] = "";
char trmnlServerUrl[160] = "";
char trmnlApiKey[128] = "";
char trmnlDeviceId[32] = "";
uint8_t trmnlOrientation = TRMNL_LANDSCAPE;
// Widens the post-wake Wi-Fi connect window for the TRMNL fetch from 10s to 20s.
// Off by default; enable on networks where a cold-wake association/handshake
// routinely needs more than 10s (e.g. WPA2/WPA3 mixed-mode APs).
uint8_t trmnlExtendedWifiTimeout = 0;
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press page turn button behavior
Expand Down
19 changes: 17 additions & 2 deletions src/SettingsList.h
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ inline SettingInfo buildSleepScreenSetting() {
StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
{StrId::STR_NONE_OPT, StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
StrId::STR_COVER_CUSTOM, StrId::STR_PAGE_OVERLAY, StrId::STR_READING_STATS, StrId::STR_THEME_MINIMAL,
StrId::STR_THEME_MINIMAL_STATS, StrId::STR_THEME_DASHBOARD, StrId::STR_QUICK_RESUME},
StrId::STR_THEME_MINIMAL_STATS, StrId::STR_THEME_DASHBOARD, StrId::STR_TRMNL, StrId::STR_QUICK_RESUME},
"sleepScreen", StrId::STR_CAT_DISPLAY);
s.withEnumRawValues({
static_cast<uint8_t>(CrossPointSettings::BLANK),
Expand All @@ -254,7 +254,9 @@ inline SettingInfo buildSleepScreenSetting() {
static_cast<uint8_t>(CrossPointSettings::MINIMAL_SLEEP),
static_cast<uint8_t>(CrossPointSettings::MINIMAL_STATS_SLEEP),
static_cast<uint8_t>(CrossPointSettings::DASHBOARD_SLEEP),
static_cast<uint8_t>(CrossPointSettings::TRMNL),
static_cast<uint8_t>(CrossPointSettings::QUICK_RESUME),
static_cast<uint8_t>(CrossPointSettings::TRMNL),
});
return s;
}
Expand Down Expand Up @@ -587,6 +589,18 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
add(SettingInfo::Toggle(StrId::STR_TRACK_READING_STATS, &CrossPointSettings::trackReadingStats, "trackReadingStats",
StrId::STR_CAT_SYSTEM));
#endif
add(SettingInfo::String(StrId::STR_TRMNL_SERVER_URL, SETTINGS.trmnlServerUrl, sizeof(SETTINGS.trmnlServerUrl),
"trmnlServerUrl", StrId::STR_TRMNL_SETTINGS));
add(SettingInfo::String(StrId::STR_TRMNL_API_KEY, SETTINGS.trmnlApiKey, sizeof(SETTINGS.trmnlApiKey),
"trmnlApiKey", StrId::STR_TRMNL_SETTINGS)
.withObfuscated());
add(SettingInfo::String(StrId::STR_TRMNL_DEVICE_ID, SETTINGS.trmnlDeviceId, sizeof(SETTINGS.trmnlDeviceId),
"trmnlDeviceId", StrId::STR_TRMNL_SETTINGS));
add(SettingInfo::Enum(StrId::STR_TRMNL_ORIENTATION, &CrossPointSettings::trmnlOrientation,
{StrId::STR_TRMNL_HORIZONTAL, StrId::STR_TRMNL_VERTICAL}, "trmnlOrientation",
StrId::STR_TRMNL_SETTINGS));
add(SettingInfo::Toggle(StrId::STR_TRMNL_EXTENDED_WIFI_TIMEOUT, &CrossPointSettings::trmnlExtendedWifiTimeout,
"trmnlExtendedWifiTimeout", StrId::STR_TRMNL_SETTINGS));

// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
add(SettingInfo::DynamicString(
Expand Down Expand Up @@ -914,11 +928,12 @@ inline std::vector<SettingInfo> buildDisplaySleepSettingsList(const std::vector<

inline std::vector<SettingInfo> buildSystemSettingsParentList(const std::vector<SettingInfo>& allSettings) {
std::vector<SettingInfo> systemSettings;
systemSettings.reserve(8);
systemSettings.reserve(9);
systemSettings.push_back(SettingInfo::Submenu(StrId::STR_SYSTEM_DEVICE, SettingAction::SystemDevice));
systemSettings.push_back(SettingInfo::Submenu(StrId::STR_SYSTEM_FILES_CACHE, SettingAction::SystemFilesCache));
systemSettings.push_back(SettingInfo::Submenu(StrId::STR_READING_STATS, SettingAction::SystemReadingStats));
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
systemSettings.push_back(SettingInfo::Action(StrId::STR_TRMNL_SETTINGS, SettingAction::TrmnlSettings));
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
Expand Down
83 changes: 83 additions & 0 deletions src/activities/boot_sleep/SleepActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#include "fontIds.h"
#include "images/Logo120.h"
#include "images/MoonIcon.h"
#include "trmnl/TrmnlSleepClient.h"

namespace {

Expand Down Expand Up @@ -459,6 +460,8 @@ void SleepActivity::onEnter() {
return renderReadingStatsSleepScreen();
case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_SLEEP):
return renderMinimalSleepScreen();
case (CrossPointSettings::SLEEP_SCREEN_MODE::TRMNL):
return renderTrmnlSleepScreen();
case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_STATS_SLEEP):
return renderMinimalStatsSleepScreen();
case (CrossPointSettings::SLEEP_SCREEN_MODE::DASHBOARD_SLEEP):
Expand Down Expand Up @@ -607,6 +610,86 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
}
}

bool SleepActivity::renderPngSleepScreen(const std::string& path) const {
if (!Storage.exists(path.c_str())) return false;
constexpr size_t MIN_FREE_HEAP = 60 * 1024;
if (ESP.getFreeHeap() < MIN_FREE_HEAP) {
LOG_ERR("SLP", "Not enough heap for PNG sleep decoder: %u free", ESP.getFreeHeap());
return false;
}
PNG* png = new (std::nothrow) PNG();
if (!png) return false;
int rc = png->open(path.c_str(), pngSleepOpen, pngSleepClose, pngSleepRead, pngSleepSeek, pngOverlayDraw);
if (rc != PNG_SUCCESS) {
delete png;
LOG_ERR("SLP", "PNG sleep open failed for %s: %d", path.c_str(), rc);
return false;
}

const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
const int srcW = png->getWidth();
const int srcH = png->getHeight();
float yScale = 1.0f;
int dstW = srcW;
int dstH = srcH;
if (srcW > pageWidth || srcH > pageHeight) {
const float scaleX = static_cast<float>(pageWidth) / srcW;
const float scaleY = static_cast<float>(pageHeight) / srcH;
const float scale = (scaleX < scaleY) ? scaleX : scaleY;
dstW = static_cast<int>(srcW * scale);
dstH = static_cast<int>(srcH * scale);
yScale = static_cast<float>(dstH) / srcH;
}

renderer.clearScreen();
PngOverlayCtx ctx{&renderer, pageWidth, pageHeight, srcW, dstW, (pageWidth - dstW) / 2, (pageHeight - dstH) / 2,
yScale, -1, -2, png};
rc = png->decode(&ctx, 0);
png->close();
delete png;
if (rc != PNG_SUCCESS) {
LOG_ERR("SLP", "PNG sleep decode failed for %s: %d", path.c_str(), rc);
return false;
}
renderer.displayBuffer(HalDisplay::HALF_REFRESH, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH);
return true;
}

bool SleepActivity::renderTrmnlCachedImage() const {
if (renderPngSleepScreen(TrmnlSleepClient::CACHE_PNG)) return true;
FsFile file;
if (Storage.openFileForRead("TRM", TrmnlSleepClient::CACHE_BMP, file)) {
Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap);
file.close();
return true;
}
file.close();
}
return false;
}

void SleepActivity::renderTrmnlSleepScreen() const {
const auto trmnlOrientation = SETTINGS.trmnlOrientation == CrossPointSettings::TRMNL_PORTRAIT
? trmnl::Orientation::Portrait
: trmnl::Orientation::Landscape;
renderer.setOrientation(trmnlOrientation == trmnl::Orientation::Portrait
? GfxRenderer::Orientation::Portrait
: GfxRenderer::Orientation::LandscapeCounterClockwise);
const TrmnlSleepClient::Config config{SETTINGS.trmnlServerUrl,
SETTINGS.trmnlApiKey,
SETTINGS.trmnlDeviceId,
trmnl::displaySizeFor(trmnlOrientation),
trmnl::modelFor(trmnlOrientation),
SETTINGS.trmnlExtendedWifiTimeout != 0};
TrmnlSleepClient::fetchLatest(config);
if (!renderTrmnlCachedImage()) {
renderDefaultSleepScreen();
}
}

void SleepActivity::renderCoverSleepScreen() const {
void (SleepActivity::*renderNoCoverSleepScreen)() const;
switch (SETTINGS.sleepScreen) {
Expand Down
3 changes: 3 additions & 0 deletions src/activities/boot_sleep/SleepActivity.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class SleepActivity final : public Activity {
void renderMinimalStatsSleepScreen() const;
void renderDashboardSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
bool renderPngSleepScreen(const std::string& path) const;
bool renderTrmnlCachedImage() const;
void renderTrmnlSleepScreen() const;
void renderLastScreenSleepScreen() const;
void renderBlankSleepScreen() const;
void renderOverlaySleepScreen() const;
Expand Down
4 changes: 3 additions & 1 deletion src/activities/browser/OpdsBookBrowserActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,10 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
// Build full download URL relative to the current feed, not the root server URL
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
const std::string serverDir = "/" + StringUtils::sanitizeFilename(server.name);
Storage.mkdir(serverDir.c_str());
std::string filename =
"/" + StringUtils::sanitizeFilename(buildBookFilenameBase(book, server.filenameFormat)) + ".epub";
serverDir + "/" + StringUtils::sanitizeFilename(buildBookFilenameBase(book, server.filenameFormat)) + ".epub";
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());

bool cancelRequested = false;
Expand Down
4 changes: 1 addition & 3 deletions src/activities/network/WifiSelectionActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,7 @@ void WifiSelectionActivity::attemptConnection() {
// Abort any in-progress SDK auto-connect before our explicit begin().
// Do not erase the AP config or power-cycle the radio; some routers fail the
// next WPA handshake after that heavier reset.
if (!WiFi.disconnect(false, false, 1000)) {
LOG_DBG("WIFI", "Disconnect before begin timed out; continuing with explicit begin");
}
WiFi.disconnect(false, false);
delay(100);
#ifndef SIMULATOR
sLastStaDisconnectReason = 0;
Expand Down
Loading