Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
85 changes: 81 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,20 @@ void StreamingJsonParser::handleScanning(char c) {
}

void StreamingJsonParser::handleStringChar(char c) {
if (unicodeDigitsRemaining > 0) {
const int digit = hexDigitValue(c);
if (digit < 0) {
error = true;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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 +166,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 +183,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