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 <dave@daveallie.com>
This commit is contained in:
@@ -9,8 +9,11 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<int>(SETTINGS.*(s.valuePtr));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SettingType::ENUM: {
|
||||
doc["type"] = "enum";
|
||||
if (s.valuePtr) {
|
||||
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
|
||||
} else if (s.valueGetter) {
|
||||
doc["value"] = static_cast<int>(s.valueGetter());
|
||||
}
|
||||
JsonArray options = doc["options"].to<JsonArray>();
|
||||
for (const auto& opt : s.enumValues) {
|
||||
options.add(opt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SettingType::VALUE: {
|
||||
doc["type"] = "value";
|
||||
if (s.valuePtr) {
|
||||
doc["value"] = static_cast<int>(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<JsonVariant>()) continue;
|
||||
|
||||
switch (s.type) {
|
||||
case SettingType::TOGGLE: {
|
||||
const int val = doc[s.key].as<int>() ? 1 : 0;
|
||||
if (s.valuePtr) {
|
||||
SETTINGS.*(s.valuePtr) = val;
|
||||
}
|
||||
applied++;
|
||||
break;
|
||||
}
|
||||
case SettingType::ENUM: {
|
||||
const int val = doc[s.key].as<int>();
|
||||
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
|
||||
if (s.valuePtr) {
|
||||
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
|
||||
} else if (s.valueSetter) {
|
||||
s.valueSetter(static_cast<uint8_t>(val));
|
||||
}
|
||||
applied++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SettingType::VALUE: {
|
||||
const int val = doc[s.key].as<int>();
|
||||
if (val >= s.valueRange.min && val <= s.valueRange.max) {
|
||||
if (s.valuePtr) {
|
||||
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
|
||||
}
|
||||
applied++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SettingType::STRING: {
|
||||
const std::string val = doc[s.key].as<std::string>();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user