From cda0a3f898289b8a8f86c68d6585eb4f92c8ac9b Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Sun, 8 Feb 2026 12:46:14 -0800 Subject: [PATCH] feat: A web editor for settings (#667) ## Summary This is an updated version of @itsthisjustin's #346 that builds on current master and also deduplicates the settings list so we don't have two copies of the settings. In the Web UI, it should organize the settings a little closer to what you see on device. ## Additional Context I tested this live on device and it seems to play nicely for me. It's re-based on master since master's settings stuff has moved somewhat since the original PR and addresses the sole review comment #346 - it also means that I don't need to manually key in the URL for my OPDS server. :) --- ### AI Usage My changes were implemented with Claude Opus 4.5 and Claude Code 2.1.25. I don't know if @itsthisjustin's original work used AI assistance. Co-authored-by: Dave Allie --- src/SettingsList.h | 101 +++++ src/activities/settings/SettingsActivity.cpp | 123 +++--- src/activities/settings/SettingsActivity.h | 108 ++++- src/network/CrossPointWebServer.cpp | 170 ++++++++ src/network/CrossPointWebServer.h | 5 + src/network/html/FilesPage.html | 1 + src/network/html/HomePage.html | 1 + src/network/html/SettingsPage.html | 414 +++++++++++++++++++ 8 files changed, 839 insertions(+), 84 deletions(-) create mode 100644 src/SettingsList.h create mode 100644 src/network/html/SettingsPage.html diff --git a/src/SettingsList.h b/src/SettingsList.h new file mode 100644 index 00000000..e493f40f --- /dev/null +++ b/src/SettingsList.h @@ -0,0 +1,101 @@ +#pragma once + +#include + +#include "CrossPointSettings.h" +#include "KOReaderCredentialStore.h" +#include "activities/settings/SettingsActivity.h" + +// Shared settings list used by both the device settings UI and the web settings API. +// Each entry has a key (for JSON API) and category (for grouping). +// ACTION-type entries and entries without a key are device-only. +inline std::vector getSettingsList() { + return { + // --- Display --- + SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, + {"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}, "sleepScreen", "Display"), + SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}, + "sleepScreenCoverMode", "Display"), + SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, + {"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"), + SettingInfo::Enum( + "Status Bar", &CrossPointSettings::statusBar, + {"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}, + "statusBar", "Display"), + SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}, + "hideBatteryPercentage", "Display"), + SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}, "refreshFrequency", "Display"), + SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}, "uiTheme", "Display"), + SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"), + + // --- Reader --- + SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}, + "fontFamily", "Reader"), + SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize", + "Reader"), + SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing", + "Reader"), + SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin", "Reader"), + SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment, + {"Justify", "Left", "Center", "Right", "Book's Style"}, "paragraphAlignment", "Reader"), + SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle, "embeddedStyle", "Reader"), + SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", "Reader"), + SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}, "orientation", "Reader"), + SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing, + "extraParagraphSpacing", "Reader"), + SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing, "textAntiAliasing", "Reader"), + + // --- Controls --- + SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, + {"Prev, Next", "Next, Prev"}, "sideButtonLayout", "Controls"), + SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip", + "Controls"), + SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}, + "shortPwrBtn", "Controls"), + + // --- System --- + SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, + {"1 min", "5 min", "10 min", "15 min", "30 min"}, "sleepTimeout", "System"), + + // --- KOReader Sync (web-only, uses KOReaderCredentialStore) --- + SettingInfo::DynamicString( + "KOReader Username", [] { return KOREADER_STORE.getUsername(); }, + [](const std::string& v) { + KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword()); + KOREADER_STORE.saveToFile(); + }, + "koUsername", "KOReader Sync"), + SettingInfo::DynamicString( + "KOReader Password", [] { return KOREADER_STORE.getPassword(); }, + [](const std::string& v) { + KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v); + KOREADER_STORE.saveToFile(); + }, + "koPassword", "KOReader Sync"), + SettingInfo::DynamicString( + "Sync Server URL", [] { return KOREADER_STORE.getServerUrl(); }, + [](const std::string& v) { + KOREADER_STORE.setServerUrl(v); + KOREADER_STORE.saveToFile(); + }, + "koServerUrl", "KOReader Sync"), + SettingInfo::DynamicEnum( + "Document Matching", {"Filename", "Binary"}, + [] { return static_cast(KOREADER_STORE.getMatchMethod()); }, + [](uint8_t v) { + KOREADER_STORE.setMatchMethod(static_cast(v)); + KOREADER_STORE.saveToFile(); + }, + "koMatchMethod", "KOReader Sync"), + + // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- + SettingInfo::String("OPDS Server URL", SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), "opdsServerUrl", + "OPDS Browser"), + SettingInfo::String("OPDS Username", SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername", + "OPDS Browser"), + SettingInfo::String("OPDS Password", SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword", + "OPDS Browser"), + }; +} diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7a8fc261..967a8342 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -10,6 +10,7 @@ #include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "SettingsList.h" #include "components/UITheme.h" #include "fontIds.h" @@ -17,54 +18,6 @@ const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader namespace { constexpr int changeTabsMs = 700; -constexpr int displaySettingsCount = 8; -const SettingInfo displaySettings[displaySettingsCount] = { - // Should match with SLEEP_SCREEN_MODE - SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, - {"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}), - 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/ Book Bar", "Book Bar Only", "Full w/ Chapter 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"}), - SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}), - SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix), -}; - -constexpr int readerSettingsCount = 10; -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", "Book's Style"}), - SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle), - 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] = { - // Launches the remap wizard for front buttons. - SettingInfo::Action("Remap Front Buttons"), - 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) { @@ -76,13 +29,40 @@ void SettingsActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); + // Build per-category vectors from the shared settings list + displaySettings.clear(); + readerSettings.clear(); + controlsSettings.clear(); + systemSettings.clear(); + + for (auto& setting : getSettingsList()) { + if (!setting.category) continue; + if (strcmp(setting.category, "Display") == 0) { + displaySettings.push_back(std::move(setting)); + } else if (strcmp(setting.category, "Reader") == 0) { + readerSettings.push_back(std::move(setting)); + } else if (strcmp(setting.category, "Controls") == 0) { + controlsSettings.push_back(std::move(setting)); + } else if (strcmp(setting.category, "System") == 0) { + systemSettings.push_back(std::move(setting)); + } + // Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI + } + + // Append device-only ACTION items + controlsSettings.insert(controlsSettings.begin(), SettingInfo::Action("Remap Front Buttons")); + systemSettings.push_back(SettingInfo::Action("KOReader Sync")); + systemSettings.push_back(SettingInfo::Action("OPDS Browser")); + systemSettings.push_back(SettingInfo::Action("Clear Cache")); + systemSettings.push_back(SettingInfo::Action("Check for updates")); + // Reset selection to first category selectedCategoryIndex = 0; selectedSettingIndex = 0; // Initialize with first category (Display) - settingsList = displaySettings; - settingsCount = displaySettingsCount; + currentSettings = &displaySettings; + settingsCount = static_cast(displaySettings.size()); // Trigger first update updateRequired = true; @@ -162,23 +142,20 @@ void SettingsActivity::loop() { if (hasChangedCategory) { selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; switch (selectedCategoryIndex) { - case 0: // Display - settingsList = displaySettings; - settingsCount = displaySettingsCount; + case 0: + currentSettings = &displaySettings; break; - case 1: // Reader - settingsList = readerSettings; - settingsCount = readerSettingsCount; + case 1: + currentSettings = &readerSettings; break; - case 2: // Controls - settingsList = controlsSettings; - settingsCount = controlsSettingsCount; + case 2: + currentSettings = &controlsSettings; break; - case 3: // System - settingsList = systemSettings; - settingsCount = systemSettingsCount; + case 3: + currentSettings = &systemSettings; break; } + settingsCount = static_cast(currentSettings->size()); } } @@ -188,7 +165,7 @@ void SettingsActivity::toggleCurrentSetting() { return; } - const auto& setting = settingsList[selectedSetting]; + const auto& setting = (*currentSettings)[selectedSetting]; if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { // Toggle the boolean value using the member pointer @@ -283,24 +260,24 @@ void SettingsActivity::render() const { GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs, selectedSettingIndex == 0); + const auto& settings = *currentSettings; GUI.drawList( renderer, Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + metrics.verticalSpacing * 2)}, - settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); }, + settingsCount, selectedSettingIndex - 1, [&settings](int index) { return std::string(settings[index].name); }, nullptr, nullptr, - [this](int i) { - const auto& setting = settingsList[i]; + [&settings](int i) { std::string valueText = ""; - if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { - const bool value = SETTINGS.*(settingsList[i].valuePtr); + if (settings[i].type == SettingType::TOGGLE && settings[i].valuePtr != nullptr) { + const bool value = SETTINGS.*(settings[i].valuePtr); valueText = value ? "ON" : "OFF"; - } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { - const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); - valueText = settingsList[i].enumValues[value]; - } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { - valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) { + const uint8_t value = SETTINGS.*(settings[i].valuePtr); + valueText = settings[i].enumValues[value]; + } else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) { + valueText = std::to_string(SETTINGS.*(settings[i].valuePtr)); } return valueText; }); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 54eb8ba4..70248bb0 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -11,12 +11,12 @@ class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; +enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; struct SettingInfo { const char* name; SettingType type; - uint8_t CrossPointSettings::* valuePtr; + uint8_t CrossPointSettings::* valuePtr = nullptr; std::vector enumValues; struct ValueRange { @@ -24,20 +24,100 @@ struct SettingInfo { uint8_t max; uint8_t step; }; - ValueRange valueRange; + ValueRange valueRange = {}; - static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { - return {name, SettingType::TOGGLE, ptr}; + const char* key = nullptr; // JSON API key (nullptr for ACTION types) + const char* category = nullptr; // Category for web UI grouping + + // Direct char[] string fields (for settings stored in CrossPointSettings) + char* stringPtr = nullptr; + size_t stringMaxLen = 0; + + // Dynamic accessors (for settings stored outside CrossPointSettings, e.g. KOReaderCredentialStore) + std::function valueGetter; + std::function valueSetter; + std::function stringGetter; + std::function stringSetter; + + static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr, const char* key = nullptr, + const char* category = nullptr) { + SettingInfo s; + s.name = name; + s.type = SettingType::TOGGLE; + s.valuePtr = ptr; + s.key = key; + s.category = category; + return s; } - static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { - return {name, SettingType::ENUM, ptr, std::move(values)}; + static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values, + const char* key = nullptr, const char* category = nullptr) { + SettingInfo s; + s.name = name; + s.type = SettingType::ENUM; + s.valuePtr = ptr; + s.enumValues = std::move(values); + s.key = key; + s.category = category; + return s; } - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } + static SettingInfo Action(const char* name) { + SettingInfo s; + s.name = name; + s.type = SettingType::ACTION; + return s; + } - static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { - return {name, SettingType::VALUE, ptr, {}, valueRange}; + static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange, + const char* key = nullptr, const char* category = nullptr) { + SettingInfo s; + s.name = name; + s.type = SettingType::VALUE; + s.valuePtr = ptr; + s.valueRange = valueRange; + s.key = key; + s.category = category; + return s; + } + + static SettingInfo String(const char* name, char* ptr, size_t maxLen, const char* key = nullptr, + const char* category = nullptr) { + SettingInfo s; + s.name = name; + s.type = SettingType::STRING; + s.stringPtr = ptr; + s.stringMaxLen = maxLen; + s.key = key; + s.category = category; + return s; + } + + static SettingInfo DynamicEnum(const char* name, std::vector values, std::function getter, + std::function setter, const char* key = nullptr, + const char* category = nullptr) { + SettingInfo s; + s.name = name; + s.type = SettingType::ENUM; + s.enumValues = std::move(values); + s.valueGetter = std::move(getter); + s.valueSetter = std::move(setter); + s.key = key; + s.category = category; + return s; + } + + static SettingInfo DynamicString(const char* name, std::function getter, + std::function setter, const char* key = nullptr, + const char* category = nullptr) { + SettingInfo s; + s.name = name; + s.type = SettingType::STRING; + s.stringGetter = std::move(getter); + s.stringSetter = std::move(setter); + s.key = key; + s.category = category; + return s; } }; @@ -48,7 +128,13 @@ class SettingsActivity final : public ActivityWithSubactivity { int selectedCategoryIndex = 0; // Currently selected category int selectedSettingIndex = 0; int settingsCount = 0; - const SettingInfo* settingsList = nullptr; + + // Per-category settings derived from shared list + device-only actions + std::vector displaySettings; + std::vector readerSettings; + std::vector controlsSettings; + std::vector systemSettings; + const std::vector* currentSettings = nullptr; const std::function onGoHome; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index d29edb5a..70658413 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -9,8 +9,11 @@ #include +#include "CrossPointSettings.h" +#include "SettingsList.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" +#include "html/SettingsPageHtml.generated.h" #include "util/StringUtils.h" namespace { @@ -148,6 +151,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()); @@ -983,6 +991,168 @@ void CrossPointWebServer::handleDelete() const { } } +void CrossPointWebServer::handleSettingsPage() const { + server->send(200, "text/html", SettingsPageHtml); + Serial.printf("[%lu] [WEB] Served settings page\n", millis()); +} + +void CrossPointWebServer::handleGetSettings() const { + auto settings = getSettingsList(); + + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + + char output[512]; + constexpr size_t outputSize = sizeof(output); + bool seenFirst = false; + JsonDocument doc; + + for (const auto& s : settings) { + if (!s.key) continue; // Skip ACTION-only entries + + doc.clear(); + doc["key"] = s.key; + doc["name"] = s.name; + doc["category"] = s.category; + + switch (s.type) { + case SettingType::TOGGLE: { + doc["type"] = "toggle"; + if (s.valuePtr) { + doc["value"] = static_cast(SETTINGS.*(s.valuePtr)); + } + break; + } + case SettingType::ENUM: { + doc["type"] = "enum"; + if (s.valuePtr) { + doc["value"] = static_cast(SETTINGS.*(s.valuePtr)); + } else if (s.valueGetter) { + doc["value"] = static_cast(s.valueGetter()); + } + JsonArray options = doc["options"].to(); + for (const auto& opt : s.enumValues) { + options.add(opt); + } + break; + } + case SettingType::VALUE: { + doc["type"] = "value"; + if (s.valuePtr) { + doc["value"] = static_cast(SETTINGS.*(s.valuePtr)); + } + doc["min"] = s.valueRange.min; + doc["max"] = s.valueRange.max; + doc["step"] = s.valueRange.step; + break; + } + case SettingType::STRING: { + doc["type"] = "string"; + if (s.stringGetter) { + doc["value"] = s.stringGetter(); + } else if (s.stringPtr) { + doc["value"] = s.stringPtr; + } + break; + } + default: + continue; + } + + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) { + Serial.printf("[%lu] [WEB] Skipping oversized setting JSON for: %s\n", millis(), s.key); + continue; + } + + if (seenFirst) { + server->sendContent(","); + } else { + seenFirst = true; + } + server->sendContent(output); + } + + server->sendContent("]"); + server->sendContent(""); + Serial.printf("[%lu] [WEB] Served settings API\n", millis()); +} + +void CrossPointWebServer::handlePostSettings() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + auto settings = getSettingsList(); + int applied = 0; + + for (auto& s : settings) { + if (!s.key) continue; + if (!doc[s.key].is()) continue; + + switch (s.type) { + case SettingType::TOGGLE: { + const int val = doc[s.key].as() ? 1 : 0; + if (s.valuePtr) { + SETTINGS.*(s.valuePtr) = val; + } + applied++; + break; + } + case SettingType::ENUM: { + const int val = doc[s.key].as(); + if (val >= 0 && val < static_cast(s.enumValues.size())) { + if (s.valuePtr) { + SETTINGS.*(s.valuePtr) = static_cast(val); + } else if (s.valueSetter) { + s.valueSetter(static_cast(val)); + } + applied++; + } + break; + } + case SettingType::VALUE: { + const int val = doc[s.key].as(); + if (val >= s.valueRange.min && val <= s.valueRange.max) { + if (s.valuePtr) { + SETTINGS.*(s.valuePtr) = static_cast(val); + } + applied++; + } + break; + } + case SettingType::STRING: { + const std::string val = doc[s.key].as(); + if (s.stringSetter) { + s.stringSetter(val); + } else if (s.stringPtr && s.stringMaxLen > 0) { + strncpy(s.stringPtr, val.c_str(), s.stringMaxLen - 1); + s.stringPtr[s.stringMaxLen - 1] = '\0'; + } + applied++; + break; + } + default: + break; + } + } + + SETTINGS.saveToFile(); + + Serial.printf("[%lu] [WEB] Applied %d setting(s)\n", millis(), applied); + server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index db318d0a..bb2063cb 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -100,4 +100,9 @@ class CrossPointWebServer { void handleRename() const; void handleMove() const; void handleDelete() const; + + // Settings handlers + void handleSettingsPage() const; + void handleGetSettings() const; + void handlePostSettings(); }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 19dd0300..ff114969 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -628,6 +628,7 @@