Compare commits

...

9 Commits

Author SHA1 Message Date
Jesse Vincent
7be1359838
Merge 94b869f6d5 into 78d6e5931c 2026-02-04 09:18:12 +11:00
Jake Kenneally
78d6e5931c
fix: Correct debugging_monitor.py script instructions (#676)
Some checks are pending
CI / build (push) Waiting to run
## Summary

**What is the goal of this PR?**
- Minor correction to the `debugging_monitor.py` script instructions

**What changes are included?**
- `pyserial` should be installed, NOT `serial`, which is a [different
lib](https://pypi.org/project/serial/)
- Added macOS serial port

## Additional Context

- Just a minor docs update. I can confirm the debugging script is
working great on macOS

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< NO >**_
2026-02-04 00:33:20 +03:00
Luke Stein
dac11c3fdd
fix: Correct instruction text to match actual button text (#672)
## Summary

* Instruction text says "Press OK to scan again" but button label is
actually "Connect" (not OK)
* Corrects instruction text

---

### AI Usage

Did you use AI tools to help write this code? **No**
2026-02-04 00:32:52 +03:00
Jesse Vincent
94b869f6d5
Merge branch 'crosspoint-reader:master' into feature/web-settings 2026-02-02 10:19:55 -08:00
Jesse Vincent
3834555a16 style: Apply clang-format to web settings files 2026-02-02 10:09:36 -08:00
Jesse Vincent
76bfe2d35d feat: Add KOReader Sync and OPDS Browser settings to web UI
Web settings now mirror the device UI structure:
- Display, Reader, Controls, System (same as device categories)
- KOReader Sync: username, password, server URL, document matching
- OPDS Browser: server URL, username, password

Adds dynamic getter/setter support to SettingInfo for settings
backed by stores other than CrossPointSettings (KOReaderCredentialStore).
2026-02-01 19:55:14 -08:00
Jesse Vincent
98bd0572e0 refactor: Derive device UI settings from shared SettingsList
Remove duplicated setting definitions from SettingsActivity.cpp.
The device UI now builds its per-category settings from
getSettingsList() at runtime, with Action items appended for
the System category. This eliminates the maintenance hazard of
keeping two copies of every setting definition in sync.
2026-02-01 19:16:34 -08:00
Jesse Vincent
434fc9fb7d feat: Group web settings by category and add missing OPDS credentials
Address code review: settings are now grouped by category (Display,
Reader, Controls, System, Calibre) in both the JSON API and the web
UI. Adds category field to SettingInfo struct. Also adds opdsUsername
and opdsPassword to the Calibre section, which were missing from the
web settings.
2026-02-01 19:16:34 -08:00
Justin Mitchell
84fa55789c feat: Add settings editing to web UI
Reimplements PR #346 on top of current master. Adds a web settings page
at /settings that allows viewing and editing all device settings via the
hotspot, with GET/POST JSON API at /api/settings.
2026-02-01 19:16:34 -08:00
11 changed files with 721 additions and 90 deletions

View File

@ -102,13 +102,18 @@ After flashing the new features, its recommended to capture detailed logs fro
First, make sure all required Python packages are installed:
```python
python3 -m pip install serial colorama matplotlib
python3 -m pip install pyserial colorama matplotlib
```
after that run the script:
```sh
# For Linux
# This was tested on Debian and should work on most Linux systems.
python3 scripts/debugging_monitor.py
# For macOS
python3 scripts/debugging_monitor.py /dev/cu.usbmodem2101
```
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
Minor adjustments may be required for Windows.
## Internals

84
src/SettingsList.h Normal file
View File

@ -0,0 +1,84 @@
#pragma once
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/CategorySettingsActivity.h"
// Returns the list of all settings, used by both the device UI and the web API.
// Categories match the device UI grouping. Settings with categories that don't
// match a device UI category (e.g. "KOReader Sync", "OPDS Browser") are
// web-only — they correspond to device sub-screens accessed via Action items.
inline std::vector<SettingInfo> getSettingsList() {
return {
// Display
SettingInfo::Enum("sleepScreen", "Sleep Screen", "Display", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", "Display",
&CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter", "Display",
&CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}),
SettingInfo::Enum("statusBar", "Status Bar", "Display", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", "Display",
&CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("refreshFrequency", "Refresh Frequency", "Display", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
// Reader
SettingInfo::Enum("fontFamily", "Font Family", "Reader", &CrossPointSettings::fontFamily,
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("fontSize", "Font Size", "Reader", &CrossPointSettings::fontSize,
{"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("lineSpacing", "Line Spacing", "Reader", &CrossPointSettings::lineSpacing,
{"Tight", "Normal", "Wide"}),
SettingInfo::Value("screenMargin", "Screen Margin", "Reader", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", "Reader", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}),
SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", "Reader", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("orientation", "Reading Orientation", "Reader", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing", "Reader",
&CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", "Reader", &CrossPointSettings::textAntiAliasing),
// Controls
SettingInfo::Enum(
"frontButtonLayout", "Front Button Layout", "Controls", &CrossPointSettings::frontButtonLayout,
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", "Controls",
&CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", "Controls",
&CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", "Controls", &CrossPointSettings::shortPwrBtn,
{"Ignore", "Sleep", "Page Turn"}),
// System
SettingInfo::Enum("sleepTimeout", "Time to Sleep", "System", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
// KOReader Sync (device sub-screen accessed via System > KOReader Sync action)
SettingInfo::DynamicString(
"koUsername", "Username", "KOReader Sync", [] { return KOREADER_STORE.getUsername(); },
[](const std::string& v) { KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword()); }, 64),
SettingInfo::DynamicString(
"koPassword", "Password", "KOReader Sync", [] { return KOREADER_STORE.getPassword(); },
[](const std::string& v) { KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v); }, 64),
SettingInfo::DynamicString(
"koServerUrl", "Sync Server URL", "KOReader Sync", [] { return KOREADER_STORE.getServerUrl(); },
[](const std::string& v) { KOREADER_STORE.setServerUrl(v); }, 128),
SettingInfo::DynamicEnum(
"koMatchMethod", "Document Matching", "KOReader Sync",
[] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); },
[](uint8_t v) { KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v)); },
{"Filename", "Binary"}),
// OPDS Browser (device sub-screen accessed via System > OPDS Browser action)
SettingInfo::String("opdsServerUrl", "Server URL", "OPDS Browser", SETTINGS.opdsServerUrl,
sizeof(SETTINGS.opdsServerUrl) - 1),
SettingInfo::String("opdsUsername", "Username", "OPDS Browser", SETTINGS.opdsUsername,
sizeof(SETTINGS.opdsUsername) - 1),
SettingInfo::String("opdsPassword", "Password", "OPDS Browser", SETTINGS.opdsPassword,
sizeof(SETTINGS.opdsPassword) - 1),
};
}

View File

@ -520,7 +520,7 @@ void WifiSelectionActivity::renderNetworkList() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height) / 2;
renderer.drawCenteredText(UI_10_FONT_ID, top, "No networks found");
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again");
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press Connect to scan again");
} else {
// Calculate how many networks we can display
constexpr int startY = 60;

View File

@ -11,12 +11,16 @@
class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
struct SettingInfo {
const char* name;
const char* key; // JSON key for web API (nullptr for ACTION types)
const char* name; // Display name of the setting
const char* category; // Category for grouping in web UI (nullptr = uncategorized)
SettingType type;
uint8_t CrossPointSettings::* valuePtr;
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE)
char* stringPtr; // Pointer to char array (for STRING type)
size_t stringMaxLen; // Max length for STRING type
std::vector<std::string> enumValues;
struct ValueRange {
@ -26,18 +30,61 @@ struct SettingInfo {
};
ValueRange valueRange;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
return {name, SettingType::TOGGLE, ptr};
// Dynamic accessors for settings not in CrossPointSettings
std::function<uint8_t()> valueGetter;
std::function<void(uint8_t)> valueSetter;
std::function<std::string()> stringGetter;
std::function<void(const std::string&)> stringSetter;
static SettingInfo Toggle(const char* key, const char* name, const char* category,
uint8_t CrossPointSettings::* ptr) {
return {key, name, category, SettingType::TOGGLE, ptr, nullptr, 0, {}, {}};
}
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
return {name, SettingType::ENUM, ptr, std::move(values)};
static SettingInfo Enum(const char* key, const char* name, const char* category, uint8_t CrossPointSettings::* ptr,
std::vector<std::string> values) {
return {key, name, category, SettingType::ENUM, ptr, nullptr, 0, std::move(values), {}};
}
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; }
static SettingInfo Action(const char* name) {
return {nullptr, name, nullptr, SettingType::ACTION, nullptr, nullptr, 0, {}, {}};
}
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) {
return {name, SettingType::VALUE, ptr, {}, valueRange};
static SettingInfo Value(const char* key, const char* name, const char* category, uint8_t CrossPointSettings::* ptr,
const ValueRange valueRange) {
return {key, name, category, SettingType::VALUE, ptr, nullptr, 0, {}, valueRange};
}
static SettingInfo String(const char* key, const char* name, const char* category, char* ptr, size_t maxLen) {
return {key, name, category, SettingType::STRING, nullptr, ptr, maxLen, {}, {}};
}
static SettingInfo DynamicEnum(const char* key, const char* name, const char* category,
std::function<uint8_t()> getter, std::function<void(uint8_t)> setter,
std::vector<std::string> values) {
SettingInfo info{};
info.key = key;
info.name = name;
info.category = category;
info.type = SettingType::ENUM;
info.enumValues = std::move(values);
info.valueGetter = std::move(getter);
info.valueSetter = std::move(setter);
return info;
}
static SettingInfo DynamicString(const char* key, const char* name, const char* category,
std::function<std::string()> getter, std::function<void(const std::string&)> setter,
size_t maxLen) {
SettingInfo info{};
info.key = key;
info.name = name;
info.category = category;
info.type = SettingType::STRING;
info.stringMaxLen = maxLen;
info.stringGetter = std::move(getter);
info.stringSetter = std::move(setter);
return info;
}
};

View File

@ -3,59 +3,15 @@
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include "CategorySettingsActivity.h"
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SettingsList.h"
#include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace {
constexpr int displaySettingsCount = 6;
const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}),
SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
constexpr int readerSettingsCount = 9;
const SettingInfo readerSettings[readerSettingsCount] = {
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
constexpr int controlsSettingsCount = 4;
const SettingInfo controlsSettings[controlsSettingsCount] = {
SettingInfo::Enum(
"Front Button Layout", &CrossPointSettings::frontButtonLayout,
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
constexpr int systemSettingsCount = 5;
const SettingInfo systemSettings[systemSettingsCount] = {
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
SettingInfo::Action("Check for updates")};
} // namespace
void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop();
@ -68,6 +24,23 @@ void SettingsActivity::onEnter() {
// Reset selection to first category
selectedCategoryIndex = 0;
// Build per-category settings from the single source of truth
for (auto& cat : categorySettings) cat.clear();
for (const auto& setting : getSettingsList()) {
if (!setting.category) continue;
for (int i = 0; i < categoryCount; i++) {
if (strcmp(setting.category, categoryNames[i]) == 0) {
categorySettings[i].push_back(setting);
break;
}
}
}
// Append device-only Action items to System (index 3)
categorySettings[3].push_back(SettingInfo::Action("KOReader Sync"));
categorySettings[3].push_back(SettingInfo::Action("OPDS Browser"));
categorySettings[3].push_back(SettingInfo::Action("Clear Cache"));
categorySettings[3].push_back(SettingInfo::Action("Check for updates"));
// Trigger first update
updateRequired = true;
@ -132,30 +105,9 @@ void SettingsActivity::enterCategory(int categoryIndex) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
const SettingInfo* settingsList = nullptr;
int settingsCount = 0;
switch (categoryIndex) {
case 0: // Display
settingsList = displaySettings;
settingsCount = displaySettingsCount;
break;
case 1: // Reader
settingsList = readerSettings;
settingsCount = readerSettingsCount;
break;
case 2: // Controls
settingsList = controlsSettings;
settingsCount = controlsSettingsCount;
break;
case 3: // System
settingsList = systemSettings;
settingsCount = systemSettingsCount;
break;
}
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList,
settingsCount, [this] {
const auto& settings = categorySettings[categoryIndex];
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settings.data(),
static_cast<int>(settings.size()), [this] {
exitActivity();
updateRequired = true;
}));

View File

@ -7,20 +7,19 @@
#include <string>
#include <vector>
#include "CategorySettingsActivity.h"
#include "activities/ActivityWithSubactivity.h"
class CrossPointSettings;
struct SettingInfo;
class SettingsActivity final : public ActivityWithSubactivity {
static constexpr int categoryCount = 4;
static const char* categoryNames[categoryCount];
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category
const std::function<void()> onGoHome;
static constexpr int categoryCount = 4;
static const char* categoryNames[categoryCount];
std::vector<SettingInfo> categorySettings[categoryCount];
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();

View File

@ -8,9 +8,12 @@
#include <esp_task_wdt.h>
#include <algorithm>
#include <cstring>
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
@ -112,6 +115,11 @@ void CrossPointWebServer::begin() {
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Settings endpoints
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
@ -936,3 +944,168 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
break;
}
}
void CrossPointWebServer::handleSettingsPage() const {
server->send(200, "text/html", SettingsPageHtml);
Serial.printf("[%lu] [WEB] Served settings page\n", millis());
}
void CrossPointWebServer::handleGetSettings() const {
const auto settings = getSettingsList();
JsonDocument doc;
JsonArray settingsArray = doc["settings"].to<JsonArray>();
for (const auto& setting : settings) {
// Skip ACTION types - they don't have web-editable values
if (setting.type == SettingType::ACTION) {
continue;
}
JsonObject obj = settingsArray.add<JsonObject>();
obj["key"] = setting.key;
obj["name"] = setting.name;
if (setting.category) {
obj["category"] = setting.category;
}
switch (setting.type) {
case SettingType::TOGGLE:
obj["type"] = "toggle";
obj["value"] = (setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr)) ? 1 : 0;
break;
case SettingType::ENUM: {
obj["type"] = "enum";
obj["value"] = setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr);
JsonArray opts = obj["options"].to<JsonArray>();
for (const auto& opt : setting.enumValues) {
opts.add(opt);
}
break;
}
case SettingType::VALUE:
obj["type"] = "value";
obj["value"] = setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr);
obj["min"] = setting.valueRange.min;
obj["max"] = setting.valueRange.max;
obj["step"] = setting.valueRange.step;
break;
case SettingType::STRING:
obj["type"] = "string";
if (setting.stringGetter) {
obj["value"] = setting.stringGetter();
} else {
obj["value"] = setting.stringPtr;
}
obj["maxLength"] = setting.stringMaxLen;
break;
case SettingType::ACTION:
break;
}
}
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
Serial.printf("[%lu] [WEB] Served settings JSON\n", millis());
}
void CrossPointWebServer::handlePostSettings() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing request body");
return;
}
const String body = server->arg("plain");
Serial.printf("[%lu] [WEB] Received settings update: %s\n", millis(), body.c_str());
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, body);
if (error) {
Serial.printf("[%lu] [WEB] JSON parse error: %s\n", millis(), error.c_str());
server->send(400, "text/plain", "Invalid JSON");
return;
}
const auto settings = getSettingsList();
int updatedCount = 0;
for (const auto& setting : settings) {
if (setting.type == SettingType::ACTION || setting.key == nullptr) {
continue;
}
if (doc[setting.key].isNull()) {
continue;
}
switch (setting.type) {
case SettingType::TOGGLE: {
const int value = doc[setting.key].as<int>();
if (setting.valueSetter) {
setting.valueSetter(value ? 1 : 0);
} else {
SETTINGS.*(setting.valuePtr) = value ? 1 : 0;
}
updatedCount++;
break;
}
case SettingType::ENUM: {
const int value = doc[setting.key].as<int>();
if (value >= 0 && value < static_cast<int>(setting.enumValues.size())) {
if (setting.valueSetter) {
setting.valueSetter(static_cast<uint8_t>(value));
} else {
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(value);
}
updatedCount++;
}
break;
}
case SettingType::VALUE: {
const int value = doc[setting.key].as<int>();
if (value >= setting.valueRange.min && value <= setting.valueRange.max) {
if (setting.valueSetter) {
setting.valueSetter(static_cast<uint8_t>(value));
} else {
SETTINGS.*(setting.valuePtr) = static_cast<uint8_t>(value);
}
updatedCount++;
}
break;
}
case SettingType::STRING: {
const char* value = doc[setting.key].as<const char*>();
if (value != nullptr) {
if (setting.stringSetter) {
setting.stringSetter(value);
} else {
strncpy(setting.stringPtr, value, setting.stringMaxLen);
setting.stringPtr[setting.stringMaxLen] = '\0';
}
updatedCount++;
}
break;
}
case SettingType::ACTION:
break;
}
}
if (updatedCount > 0) {
SETTINGS.saveToFile();
KOREADER_STORE.saveToFile();
Serial.printf("[%lu] [WEB] Updated %d settings and saved to file\n", millis(), updatedCount);
}
server->send(200, "text/plain", "Settings updated: " + String(updatedCount));
}

View File

@ -78,4 +78,9 @@ class CrossPointWebServer {
void handleUploadPost() const;
void handleCreateFolder() const;
void handleDelete() const;
// Settings handlers
void handleSettingsPage() const;
void handleGetSettings() const;
void handlePostSettings();
};

View File

@ -575,6 +575,7 @@
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
</div>
<div class="page-header">

View File

@ -77,6 +77,7 @@
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
</div>
<div class="card">

View File

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - CrossPoint Reader</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #eee;
}
.setting-row:last-child {
border-bottom: none;
}
.setting-label {
font-weight: 500;
color: #2c3e50;
}
.setting-control {
min-width: 150px;
text-align: right;
}
select, input[type="number"], input[type="text"] {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 120px;
}
select:focus, input:focus {
outline: none;
border-color: #3498db;
}
input[type="text"] {
min-width: 200px;
}
.toggle-switch {
position: relative;
width: 50px;
height: 26px;
display: inline-block;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: #27ae60;
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.save-btn {
display: block;
width: 100%;
padding: 14px;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 20px;
}
.save-btn:hover {
background-color: #219a52;
}
.save-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.status-message {
padding: 12px;
border-radius: 4px;
margin-top: 15px;
display: none;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
display: block;
}
.status-message.error {
background-color: #f8d7da;
color: #721c24;
display: block;
}
.loading {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
</style>
</head>
<body>
<h1>Settings</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
<a href="/settings">Settings</a>
</div>
<div id="settings-container">
<div class="card"><div class="loading">Loading settings...</div></div>
</div>
<button id="save-btn" class="save-btn" style="display: none;">Save Settings</button>
<div id="status-message" class="status-message"></div>
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0">
CrossPoint E-Reader
</p>
</div>
<script>
let originalSettings = {};
let currentSettings = {};
async function fetchSettings() {
try {
const response = await fetch('/api/settings');
if (!response.ok) {
throw new Error('Failed to fetch settings');
}
const data = await response.json();
renderSettings(data.settings);
} catch (error) {
console.error('Error fetching settings:', error);
document.getElementById('settings-container').innerHTML =
'<div class="loading" style="color: #e74c3c;">Failed to load settings. Please refresh the page.</div>';
}
}
function renderSettings(settings) {
const container = document.getElementById('settings-container');
container.innerHTML = '';
// Group settings by category
const categories = new Map();
settings.forEach(setting => {
const cat = setting.category || 'Other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat).push(setting);
});
categories.forEach((catSettings, categoryName) => {
const card = document.createElement('div');
card.className = 'card';
const heading = document.createElement('h2');
heading.textContent = categoryName;
card.appendChild(heading);
catSettings.forEach(setting => {
originalSettings[setting.key] = setting.value;
currentSettings[setting.key] = setting.value;
const row = document.createElement('div');
row.className = 'setting-row';
const label = document.createElement('span');
label.className = 'setting-label';
label.textContent = setting.name;
const control = document.createElement('div');
control.className = 'setting-control';
switch (setting.type) {
case 'toggle':
control.innerHTML = `
<label class="toggle-switch">
<input type="checkbox" id="${setting.key}" ${setting.value ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
`;
break;
case 'enum':
let options = setting.options.map((opt, idx) =>
`<option value="${idx}" ${setting.value === idx ? 'selected' : ''}>${opt}</option>`
).join('');
control.innerHTML = `<select id="${setting.key}">${options}</select>`;
break;
case 'value':
control.innerHTML = `
<input type="number" id="${setting.key}"
value="${setting.value}"
min="${setting.min}"
max="${setting.max}"
step="${setting.step}">
`;
break;
case 'string':
control.innerHTML = `
<input type="text" id="${setting.key}"
value="${setting.value || ''}"
maxlength="${setting.maxLength}"
placeholder="Enter value...">
`;
break;
}
row.appendChild(label);
row.appendChild(control);
card.appendChild(row);
// Add change listener
const input = control.querySelector('input, select');
if (input) {
input.addEventListener('change', () => updateCurrentValue(setting));
input.addEventListener('input', () => updateCurrentValue(setting));
}
});
container.appendChild(card);
});
document.getElementById('save-btn').style.display = 'block';
}
function updateCurrentValue(setting) {
const element = document.getElementById(setting.key);
if (!element) return;
switch (setting.type) {
case 'toggle':
currentSettings[setting.key] = element.checked ? 1 : 0;
break;
case 'enum':
case 'value':
currentSettings[setting.key] = parseInt(element.value, 10);
break;
case 'string':
currentSettings[setting.key] = element.value;
break;
}
}
async function saveSettings() {
const saveBtn = document.getElementById('save-btn');
const statusMsg = document.getElementById('status-message');
// Find changed settings
const changes = {};
for (const key in currentSettings) {
if (currentSettings[key] !== originalSettings[key]) {
changes[key] = currentSettings[key];
}
}
if (Object.keys(changes).length === 0) {
statusMsg.className = 'status-message success';
statusMsg.textContent = 'No changes to save.';
setTimeout(() => { statusMsg.className = 'status-message'; }, 3000);
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes)
});
if (!response.ok) {
throw new Error('Failed to save settings');
}
// Update original settings to match current
for (const key in changes) {
originalSettings[key] = changes[key];
}
statusMsg.className = 'status-message success';
statusMsg.textContent = 'Settings saved successfully!';
} catch (error) {
console.error('Error saving settings:', error);
statusMsg.className = 'status-message error';
statusMsg.textContent = 'Failed to save settings. Please try again.';
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Settings';
setTimeout(() => { statusMsg.className = 'status-message'; }, 3000);
}
}
document.getElementById('save-btn').addEventListener('click', saveSettings);
window.onload = fetchSettings;
</script>
</body>
</html>