Merge pull request #133 from jpirnay/feat-wifedit

feat: edit wifi networks in webui (upstream #1743 by osteotek)
This commit is contained in:
jpirnay
2026-04-25 13:37:51 +02:00
committed by GitHub
4 changed files with 281 additions and 4 deletions
+19 -2
View File
@@ -21,7 +21,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.3 Controls](#363-controls)
- [3.6.4 System](#364-system)
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
- [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup)
- [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds)
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
- [3.7 Sleep Screen](#37-sleep-screen)
- [4. Reading Mode](#4-reading-mode)
- [Page Turning](#page-turning)
@@ -222,8 +223,24 @@ You can also manage OPDS servers from the web interface while in File Transfer m
1. Connect to the device web UI.
2. Open `http://<device-ip>/settings`.
3. Use the **OPDS Servers** card to add, edit, or delete entries.
For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds).
#### 3.6.6 KOReader Sync Quick Setup
#### 3.6.6 Web Settings (WiFi + OPDS)
While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**.
1. On device: open **File Transfer** and connect to WiFi.
1. In a browser, open `http://<device-ip>/settings` or `http://crosspoint.local`.
1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password).
1. In **OPDS Servers**, add, edit, or delete OPDS catalogs.
Behavior notes:
- Passwords are never shown back in the web UI after saving.
- Leaving Password blank while editing keeps the existing saved password unchanged.
- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow.
#### 3.6.7 KOReader Sync Quick Setup
CrossPoint can sync reading progress with KOReader-compatible sync servers.
It also interoperates with KOReader apps/devices when they use the same server and credentials.
+140
View File
@@ -15,6 +15,7 @@
#include "SettingsList.h"
#include "SystemStatus.h"
#include "WebDAVHandler.h"
#include "WifiCredentialStore.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
@@ -199,6 +200,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());
@@ -1375,6 +1381,140 @@ void CrossPointWebServer::handlePostSettings() {
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
}
// ---- 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");
}
// ---- OPDS Server API ----
void CrossPointWebServer::handleGetOpdsServers() const {
+5
View File
@@ -114,4 +114,9 @@ class CrossPointWebServer {
void handleGetOpdsServers() const;
void handlePostOpdsServer();
void handleDeleteOpdsServer();
// Wi-Fi credential handlers
void handleGetWifiNetworks() const;
void handlePostWifiNetwork();
void handleDeleteWifiNetwork();
};
+117 -2
View File
@@ -314,7 +314,8 @@
<div class="save-container" id="save-container" style="display:none;">
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
</div>
<div id="wifi-container"></div>
<div id="opds-container"></div>
<div class="card">
@@ -504,6 +505,119 @@
loadSettings();
// --- Wi-Fi Network Management ---
// Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints.
// Password fields are never pre-filled; when left blank during edit, existing
// passwords remain unchanged server-side.
let wifiNetworks = [];
function renderWifiNetwork(net, idx) {
const isNew = idx === -1;
const id = isNew ? 'new' : idx;
const lastConnected = net.isLastConnected
? '<div style="margin-top:8px;color:var(--label-color);font-size:0.9em;">Last connected network</div>'
: '';
return '<div class="opds-server" id="wifi-' + id + '">' +
'<div class="setting-row">' +
'<span class="setting-name">SSID</span>' +
'<span class="setting-control"><input type="text" id="wifi-ssid-' + id + '" value="' + escapeHtml(net.ssid || '') + '"></span>' +
'</div>' +
'<div class="setting-row">' +
'<span class="setting-name">Password</span>' +
'<span class="setting-control"><input type="password" id="wifi-pass-' + id + '" placeholder="' + (net.hasPassword ? '(unchanged)' : '') + '"></span>' +
'</div>' +
lastConnected +
'<div class="opds-actions">' +
'<button class="btn-small btn-save-server" onclick="saveWifiNetwork(' + idx + ')">Save</button>' +
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteWifiNetwork(' + idx + ')">Delete</button>') +
'</div>' +
'</div>';
}
function renderWifiSection() {
const container = document.getElementById('wifi-container');
let html = '<div class="card"><h2>Wi-Fi Networks</h2>';
if (wifiNetworks.length === 0) {
html += '<p style="color:var(--label-color);text-align:center;">No Wi-Fi networks saved</p>';
} else {
wifiNetworks.forEach(function(net, idx) {
html += renderWifiNetwork(net, idx);
});
}
html += '<div style="margin-top:12px;text-align:center;">' +
'<button class="btn-small btn-add" onclick="addWifiNetwork()">+ Add Network</button>' +
'</div></div>';
container.innerHTML = html;
}
async function loadWifiNetworks() {
try {
const resp = await fetch('/api/wifi');
if (!resp.ok) throw new Error('Failed to load');
wifiNetworks = await resp.json();
renderWifiSection();
} catch (e) {
console.error('Wi-Fi load error:', e);
}
}
function addWifiNetwork() {
const container = document.getElementById('wifi-container');
const card = container.querySelector('.card');
const addBtn = card.querySelector('.btn-add').parentElement;
// Prevent multiple unsaved new-network forms at once (idx -1 -> id "new")
if (document.getElementById('wifi-new')) return;
addBtn.insertAdjacentHTML('beforebegin', renderWifiNetwork({ssid:'',hasPassword:false,isLastConnected:false}, -1));
}
async function saveWifiNetwork(idx) {
const id = idx === -1 ? 'new' : idx;
const ssid = document.getElementById('wifi-ssid-' + id).value.trim();
if (!ssid) {
showMessage('SSID is required.', true);
return;
}
const data = { ssid: ssid };
// Only include password when the user actually typed something; omitting it
// tells the server to preserve an existing password.
const pass = document.getElementById('wifi-pass-' + id).value;
if (pass) data.password = pass;
if (idx >= 0) data.index = idx;
try {
const resp = await fetch('/api/wifi', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('Wi-Fi network saved!', false);
await loadWifiNetworks();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
async function deleteWifiNetwork(idx) {
if (!confirm('Delete this Wi-Fi network?')) return;
try {
const resp = await fetch('/api/wifi/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({index: idx})
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('Wi-Fi network deleted', false);
await loadWifiNetworks();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
// --- OPDS Server Management ---
// Dynamically renders an editable list of OPDS servers, communicating with the
// /api/opds REST endpoints. Password fields are never pre-filled for security;
@@ -622,7 +736,8 @@
showMessage('Error: ' + e.message, true);
}
}
loadWifiNetworks();
loadOpdsServers();
</script>
</body>