Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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