Merge pull request #133 from jpirnay/feat-wifedit
feat: edit wifi networks in webui (upstream #1743 by osteotek)
This commit is contained in:
+19
-2
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -114,4 +114,9 @@ class CrossPointWebServer {
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
|
||||
// Wi-Fi credential handlers
|
||||
void handleGetWifiNetworks() const;
|
||||
void handlePostWifiNetwork();
|
||||
void handleDeleteWifiNetwork();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user