feat: edit wifi networks in webui (#1743)
## Summary **What is the goal of this PR?** Add Wi-Fi network management to the Web UI settings page, similar to existing OPDS server management, so users can view, add, edit, and delete saved Wi-Fi credentials from the browser. Closes https://github.com/crosspoint-reader/crosspoint-reader/issues/1544 and https://github.com/crosspoint-reader/crosspoint-reader/discussions/607 **What changes are included?** - Added Wi-Fi API endpoints: - GET /api/wifi Listing saved networks (without exposing plaintext passwords) - POST /api/wifi Creating/updating networks - POST /api/wifi/delete Deleting networks by index - Added a new Wi-Fi Networks management section web UI settings page <img width="921" height="712" alt="image" src="https://github.com/user-attachments/assets/42c20a63-f335-4389-8246-d38065d6b65a" /> --- ### 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? _**< YES >**_ --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
co-authored by
Copilot
parent
1e2f6e2d67
commit
e8d7153d7f
@@ -14,6 +14,7 @@
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "WebDAVHandler.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "html/SettingsPageHtml.generated.h"
|
||||
@@ -168,6 +169,11 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||
|
||||
// Wi-Fi credential endpoints
|
||||
server->on("/api/wifi", HTTP_GET, [this] { handleGetWifiNetworks(); });
|
||||
server->on("/api/wifi", HTTP_POST, [this] { handlePostWifiNetwork(); });
|
||||
server->on("/api/wifi/delete", HTTP_POST, [this] { handleDeleteWifiNetwork(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -1368,6 +1374,140 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// ---- Wi-Fi Credentials API ----
|
||||
|
||||
void CrossPointWebServer::handleGetWifiNetworks() const {
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
const std::string& lastConnectedSsid = WIFI_STORE.getLastConnectedSsid();
|
||||
|
||||
// Stream JSON array incrementally to avoid allocating the full response in memory
|
||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server->send(200, "application/json", "");
|
||||
server->sendContent("[");
|
||||
|
||||
char output[320];
|
||||
constexpr size_t outputSize = sizeof(output);
|
||||
JsonDocument doc;
|
||||
|
||||
for (size_t i = 0; i < credentials.size(); i++) {
|
||||
doc.clear();
|
||||
doc["index"] = i;
|
||||
doc["ssid"] = credentials[i].ssid;
|
||||
// Never expose Wi-Fi passwords over the API — only indicate whether one is set
|
||||
doc["hasPassword"] = !credentials[i].password.empty();
|
||||
doc["isLastConnected"] = credentials[i].ssid == lastConnectedSsid;
|
||||
|
||||
const size_t written = serializeJson(doc, output, outputSize);
|
||||
if (written >= outputSize) continue;
|
||||
|
||||
if (i > 0) server->sendContent(",");
|
||||
server->sendContent(output);
|
||||
}
|
||||
|
||||
server->sendContent("]");
|
||||
server->sendContent("");
|
||||
LOG_DBG("WEB", "Served Wi-Fi credentials API (%zu network(s))", credentials.size());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handlePostWifiNetwork() {
|
||||
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;
|
||||
}
|
||||
|
||||
std::string ssid = doc["ssid"] | std::string("");
|
||||
if (ssid.empty()) {
|
||||
server->send(400, "text/plain", "SSID is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// The password field is optional in the JSON payload. When absent (vs. present but empty),
|
||||
// preserve the existing password for updates. Empty passwords are valid for open networks.
|
||||
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
|
||||
std::string password = doc["password"] | std::string("");
|
||||
|
||||
if (doc["index"].is<int>()) {
|
||||
int idx = doc["index"].as<int>();
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
|
||||
server->send(400, "text/plain", "Invalid network index");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string oldSsid = credentials[static_cast<size_t>(idx)].ssid;
|
||||
if (!hasPasswordField) {
|
||||
password = credentials[static_cast<size_t>(idx)].password;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
if (oldSsid != ssid) {
|
||||
ok = WIFI_STORE.removeCredential(oldSsid) && WIFI_STORE.addCredential(ssid, password);
|
||||
} else {
|
||||
ok = WIFI_STORE.addCredential(ssid, password);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
server->send(400, "text/plain", "Failed to update Wi-Fi network");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Updated Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
|
||||
} else {
|
||||
if (!WIFI_STORE.addCredential(ssid, password)) {
|
||||
server->send(400, "text/plain", "Cannot add network (limit reached)");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Added Wi-Fi network: %s", ssid.c_str());
|
||||
}
|
||||
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
|
||||
void CrossPointWebServer::handleDeleteWifiNetwork() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!doc["index"].is<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = doc["index"].as<int>();
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
|
||||
server->send(400, "text/plain", "Invalid network index");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string ssid = credentials[static_cast<size_t>(idx)].ssid;
|
||||
if (!WIFI_STORE.removeCredential(ssid)) {
|
||||
server->send(400, "text/plain", "Failed to delete Wi-Fi network");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("WEB", "Deleted Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// 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