From 79dc134b78659ff24441a9f9ab99a3404c22a031 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 13 Jan 2026 02:30:37 -0500 Subject: [PATCH] feat: Add settings editing to web UI Add a new Settings page to the crosspoint.local web interface that allows viewing and editing all device settings via the hotspot. Settings page that auto-generates form from settings metadata New settings added to SettingsList.h automatically appear in both the device UI and web UI - Supports all setting types: toggle, enum, value (range), and string - Settings are saved immediately and persist to SD card API endpoints: - GET /settings - Settings page - GET /api/settings - Returns all settings with metadata as JSON - POST /api/settings - Updates settings (accepts JSON key-value pairs) --- src/SettingsList.h | 47 +++ src/activities/settings/SettingsActivity.cpp | 43 +-- src/activities/settings/SettingsActivity.h | 27 +- src/network/CrossPointWebServer.cpp | 157 +++++++++ src/network/CrossPointWebServer.h | 5 + src/network/html/FilesPage.html | 1 + src/network/html/HomePage.html | 1 + src/network/html/SettingsPage.html | 347 +++++++++++++++++++ 8 files changed, 589 insertions(+), 39 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 0000000..a6b15fe --- /dev/null +++ b/src/SettingsList.h @@ -0,0 +1,47 @@ +#pragma once +#include + +#include "CrossPointSettings.h" +#include "activities/settings/SettingsActivity.h" + +// Returns the list of all settings +// This is the single source of truth used by both the device UI and web API +inline std::vector getSettingsList() { + return { + SettingInfo::Enum("sleepScreen", "Sleep Screen", &CrossPointSettings::sleepScreen, + {"Dark", "Light", "Custom", "Cover", "None"}), + SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, + {"Fit", "Crop"}), + SettingInfo::Enum("statusBar", "Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), + SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", &CrossPointSettings::hideBatteryPercentage, + {"Never", "In Reader", "Always"}), + SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing", + &CrossPointSettings::extraParagraphSpacing), + SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), + SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", &CrossPointSettings::shortPwrBtn, + {"Ignore", "Sleep", "Page Turn"}), + SettingInfo::Enum("orientation", "Reading Orientation", &CrossPointSettings::orientation, + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), + SettingInfo::Enum("frontButtonLayout", "Front Button Layout", &CrossPointSettings::frontButtonLayout, + {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), + SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, + {"Prev, Next", "Next, Prev"}), + SettingInfo::Enum("fontFamily", "Reader Font Family", &CrossPointSettings::fontFamily, + {"Bookerly", "Noto Sans", "Open Dyslexic"}), + SettingInfo::Enum("fontSize", "Reader Font Size", &CrossPointSettings::fontSize, + {"Small", "Medium", "Large", "X Large"}), + SettingInfo::Enum("lineSpacing", "Reader Line Spacing", &CrossPointSettings::lineSpacing, + {"Tight", "Normal", "Wide"}), + SettingInfo::Value("screenMargin", "Reader Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), + SettingInfo::Enum("paragraphAlignment", "Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment, + {"Justify", "Left", "Center", "Right"}), + SettingInfo::Enum("sleepTimeout", "Time to Sleep", &CrossPointSettings::sleepTimeout, + {"1 min", "5 min", "10 min", "15 min", "30 min"}), + SettingInfo::Enum("refreshFrequency", "Refresh Frequency", &CrossPointSettings::refreshFrequency, + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::String("opdsServerUrl", "Calibre Web URL", SETTINGS.opdsServerUrl, + sizeof(SETTINGS.opdsServerUrl) - 1), + SettingInfo::Action("Calibre Settings"), + SettingInfo::Action("Check for updates"), + }; +} diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index f22850a..42e5b10 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,41 +9,19 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "SettingsList.h" #include "fontIds.h" -// Define the static settings list +// Get settings list from shared source (lazily initialized to avoid static init issues) namespace { -constexpr int settingsCount = 19; -const SettingInfo settingsList[settingsCount] = { - // 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("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), - SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), - SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), - SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), - SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}), - SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, - {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), - SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), - SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, - {"Prev, Next", "Next, Prev"}), - SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily, - {"Bookerly", "Noto Sans", "Open Dyslexic"}), - SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), - SettingInfo::Enum("Reader Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}), - SettingInfo::Value("Reader Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), - SettingInfo::Enum("Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment, - {"Justify", "Left", "Center", "Right"}), - SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, - {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), - SettingInfo::Action("Calibre Settings"), - SettingInfo::Action("Check for updates")}; +const std::vector& getSettings() { + static const std::vector settingsList = getSettingsList(); + return settingsList; +} } // namespace +#define settingsList getSettings() + void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -103,11 +81,13 @@ void SettingsActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { // Move selection up (with wrap-around) + const int settingsCount = static_cast(settingsList.size()); selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { // Move selection down (with wrap around) + const int settingsCount = static_cast(settingsList.size()); selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; updateRequired = true; } @@ -115,7 +95,7 @@ void SettingsActivity::loop() { void SettingsActivity::toggleCurrentSetting() { // Validate index - if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { + if (selectedSettingIndex < 0 || selectedSettingIndex >= static_cast(settingsList.size())) { return; } @@ -189,6 +169,7 @@ void SettingsActivity::render() const { renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); // Draw all settings + const int settingsCount = static_cast(settingsList.size()); for (int i = 0; i < settingsCount; i++) { const int settingY = 60 + i * 30; // 30 pixels between settings diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 157689e..66b88d9 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -11,13 +11,16 @@ class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; +enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; // Structure to hold setting information struct SettingInfo { + const char* key; // JSON key for web API (nullptr for ACTION types) const char* name; // Display name of the setting SettingType type; // Type of setting 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 enumValues; struct ValueRange { @@ -29,18 +32,26 @@ struct SettingInfo { ValueRange valueRange; // Static constructors - static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { - return {name, SettingType::TOGGLE, ptr}; + static SettingInfo Toggle(const char* key, const char* name, uint8_t CrossPointSettings::* ptr) { + return {key, name, SettingType::TOGGLE, ptr, nullptr, 0, {}, {}}; } - 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* key, const char* name, uint8_t CrossPointSettings::* ptr, + std::vector values) { + return {key, name, 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, 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, uint8_t CrossPointSettings::* ptr, + const ValueRange valueRange) { + return {key, name, SettingType::VALUE, ptr, nullptr, 0, {}, valueRange}; + } + + static SettingInfo String(const char* key, const char* name, char* ptr, size_t maxLen) { + return {key, name, SettingType::STRING, nullptr, ptr, maxLen, {}, {}}; } }; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 8703c2a..3b2a820 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -6,9 +6,12 @@ #include #include +#include +#include "SettingsList.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" +#include "html/SettingsPageHtml.generated.h" namespace { // Folders/files to hide from the web interface file browser @@ -82,6 +85,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()); @@ -555,3 +563,152 @@ void CrossPointWebServer::handleDelete() const { server->send(500, "text/plain", "Failed to delete item"); } } + +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(); + + 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(); + obj["key"] = setting.key; + obj["name"] = setting.name; + + switch (setting.type) { + case SettingType::TOGGLE: + obj["type"] = "toggle"; + obj["value"] = SETTINGS.*(setting.valuePtr) ? 1 : 0; + break; + + case SettingType::ENUM: { + obj["type"] = "enum"; + obj["value"] = SETTINGS.*(setting.valuePtr); + JsonArray opts = obj["options"].to(); + for (const auto& opt : setting.enumValues) { + opts.add(opt); + } + break; + } + + case SettingType::VALUE: + obj["type"] = "value"; + obj["value"] = 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"; + obj["value"] = setting.stringPtr; + obj["maxLength"] = setting.stringMaxLen; + break; + + case SettingType::ACTION: + // Already filtered above + break; + } + } + + String json; + serializeJson(doc, json); + server->send(200, "application/json", json); + Serial.printf("[%lu] [WEB] Served settings JSON\n", millis()); +} + +void CrossPointWebServer::handlePostSettings() { + // Check if we have a body + 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; + + // Iterate through each setting and check if it's in the request + for (const auto& setting : settings) { + // Skip ACTION types and settings without keys + 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(); + SETTINGS.*(setting.valuePtr) = value ? 1 : 0; + updatedCount++; + break; + } + + case SettingType::ENUM: { + const int value = doc[setting.key].as(); + // Validate value is within range + if (value >= 0 && value < static_cast(setting.enumValues.size())) { + SETTINGS.*(setting.valuePtr) = static_cast(value); + updatedCount++; + } + break; + } + + case SettingType::VALUE: { + const int value = doc[setting.key].as(); + // Validate value is within range + if (value >= setting.valueRange.min && value <= setting.valueRange.max) { + SETTINGS.*(setting.valuePtr) = static_cast(value); + updatedCount++; + } + break; + } + + case SettingType::STRING: { + const char* value = doc[setting.key].as(); + if (value != nullptr) { + strncpy(setting.stringPtr, value, setting.stringMaxLen); + setting.stringPtr[setting.stringMaxLen] = '\0'; + updatedCount++; + } + break; + } + + case SettingType::ACTION: + // Already filtered above + break; + } + } + + // Save settings to file + if (updatedCount > 0) { + SETTINGS.saveToFile(); + Serial.printf("[%lu] [WEB] Updated %d settings and saved to file\n", millis(), updatedCount); + } + + server->send(200, "text/plain", "Settings updated: " + String(updatedCount)); +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1be07b4..bff1d1a 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -53,4 +53,9 @@ class CrossPointWebServer { void handleUploadPost() const; void handleCreateFolder() 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 08c0a0b..1c8ad9d 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -575,6 +575,7 @@