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
9 changes: 9 additions & 0 deletions lib/I18n/translations/english.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,15 @@ 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_RECENT_BOOKS_VIEW: "Recent Books View"
STR_LIST_VIEW: "List"
STR_GRID_VIEW: "Grid"
Expand Down
90 changes: 86 additions & 4 deletions lib/JsonParser/StreamingJsonParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ void StreamingJsonParser::reset() {
nestingDepth = 0;
literalLen = 0;
literalPos = 0;
unicodeDigitsRemaining = 0;
unicodeValue = 0;
pendingHighSurrogate = 0;
}

void StreamingJsonParser::feed(const char* data, size_t len) {
Expand Down Expand Up @@ -45,6 +48,8 @@ void StreamingJsonParser::handleScanning(char c) {
case '"':
tokenLen = 0;
tokenOverflow = false;
unicodeDigitsRemaining = 0;
pendingHighSurrogate = 0;
if (expectingValue || inArray()) {
state = State::IN_STRING_VALUE;
} else {
Expand Down Expand Up @@ -123,6 +128,25 @@ void StreamingJsonParser::handleScanning(char c) {
}

void StreamingJsonParser::handleStringChar(char c) {
if (unicodeDigitsRemaining > 0) {
const int digit = hexDigitValue(c);
if (digit < 0) {
// error is terminal in normal use (feed()'s loop stops on it), but reset the
// in-progress escape state too so the parser stays internally consistent for
// any caller that inspects/reuses it after an error rather than discarding it.
error = true;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
unicodeDigitsRemaining = 0;
unicodeValue = 0;
return;
}
unicodeValue = static_cast<uint16_t>((unicodeValue << 4) | static_cast<uint16_t>(digit));
--unicodeDigitsRemaining;
if (unicodeDigitsRemaining == 0) {
finishUnicodeEscape();
}
return;
}

if (escaped) {
escaped = false;
switch (c) {
Expand All @@ -147,10 +171,8 @@ void StreamingJsonParser::handleStringChar(char c) {
appendToken('\t');
break;
case 'u':
// Pass \uXXXX through as literal characters -- we don't decode
// Unicode escapes since our use case only needs ASCII field matching.
appendToken('\\');
appendToken('u');
unicodeDigitsRemaining = 4;
unicodeValue = 0;
break;
default:
appendToken('\\');
Expand All @@ -166,13 +188,73 @@ void StreamingJsonParser::handleStringChar(char c) {
}

if (c == '"') {
if (pendingHighSurrogate != 0) {
// String closed with an unpaired UTF-16 high surrogate -- malformed input.
error = true;
return;
}
emitToken();
return;
}

appendToken(c);
}

void StreamingJsonParser::finishUnicodeEscape() {
if (pendingHighSurrogate != 0) {
if (unicodeValue >= 0xDC00 && unicodeValue <= 0xDFFF) {
// Valid low surrogate: combine per the UTF-16 surrogate pair formula.
const uint32_t codepoint = 0x10000 + ((static_cast<uint32_t>(pendingHighSurrogate) - 0xD800) << 10) +
(static_cast<uint32_t>(unicodeValue) - 0xDC00);
pendingHighSurrogate = 0;
appendUtf8CodePoint(codepoint);
} else {
// High surrogate not followed by a valid low surrogate -- malformed input.
error = true;
}
return;
}

if (unicodeValue >= 0xD800 && unicodeValue <= 0xDBFF) {
// High surrogate: hold it and wait for the low surrogate that must follow.
pendingHighSurrogate = unicodeValue;
return;
}

if (unicodeValue >= 0xDC00 && unicodeValue <= 0xDFFF) {
// Low surrogate with no preceding high surrogate -- malformed input.
error = true;
return;
}

appendUtf8CodePoint(unicodeValue);
}

void StreamingJsonParser::appendUtf8CodePoint(const uint32_t codepoint) {
if (codepoint <= 0x7F) {
appendToken(static_cast<char>(codepoint));
} else if (codepoint <= 0x7FF) {
appendToken(static_cast<char>(0xC0 | (codepoint >> 6)));
appendToken(static_cast<char>(0x80 | (codepoint & 0x3F)));
} else if (codepoint <= 0xFFFF) {
appendToken(static_cast<char>(0xE0 | (codepoint >> 12)));
appendToken(static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F)));
appendToken(static_cast<char>(0x80 | (codepoint & 0x3F)));
} else {
appendToken(static_cast<char>(0xF0 | (codepoint >> 18)));
appendToken(static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F)));
appendToken(static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F)));
appendToken(static_cast<char>(0x80 | (codepoint & 0x3F)));
}
}

int StreamingJsonParser::hexDigitValue(const char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}

void StreamingJsonParser::handleNumber(char c) {
if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E') {
appendToken(c);
Expand Down
18 changes: 17 additions & 1 deletion lib/JsonParser/StreamingJsonParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ struct JsonCallbacks {

class StreamingJsonParser {
public:
static constexpr size_t TOKEN_BUF_SIZE = 512;
// \uXXXX escapes are decoded to real UTF-8 bytes (see handleStringChar()'s
// unicodeDigitsRemaining handling), so most escaped characters cost 1-4 bytes here,
// not the 6 raw source bytes of "&" etc. TRMNL's /api/display responses measured
// ~486-521 bytes for image_url alone depending on how the server chose to escape it;
// sized with real headroom for that plus future longer URLs, not tuned to one case.
static constexpr size_t TOKEN_BUF_SIZE = 1024;
static constexpr size_t MAX_NESTING = 32;

explicit StreamingJsonParser(const JsonCallbacks& callbacks);
Expand Down Expand Up @@ -53,6 +58,13 @@ class StreamingJsonParser {
void appendToken(char c);
void emitToken();

// \uXXXX decoding. A code unit spans up to 4 characters and can arrive split across
// separate feed() calls, so the in-progress digits/value must be member state, not
// locals -- mirrors how literalPos/literalExpected track a split true/false/null.
void finishUnicodeEscape();
void appendUtf8CodePoint(uint32_t codepoint);
static int hexDigitValue(char c);

bool inArray() const { return nestingDepth > 0 && nestingStack[nestingDepth - 1] == Container::ARRAY; }

JsonCallbacks cb;
Expand All @@ -64,6 +76,10 @@ class StreamingJsonParser {
bool tokenOverflow;
bool error;

uint8_t unicodeDigitsRemaining; // 0 = not mid-\uXXXX; else counts down 4..1
uint16_t unicodeValue; // hex digits accumulated so far for the current \uXXXX
uint16_t pendingHighSurrogate; // 0 = none; else a UTF-16 high surrogate awaiting its low pair

Container nestingStack[MAX_NESTING];
uint8_t nestingDepth;

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); }
42 changes: 42 additions & 0 deletions lib/JsonParser/TrmnlDisplayJsonParser.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#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;
// Matches StreamingJsonParser::TOKEN_BUF_SIZE -- see its comment for why this needs
// real headroom beyond a "typical" image_url length.
char imageUrl[1024] = "";
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
6 changes: 6 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,10 @@ 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;
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press page turn button behavior
Expand Down
Loading