diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8cc3ad00..827e85ea 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -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) @@ -223,7 +224,24 @@ You can also manage OPDS servers from the web interface while in File Transfer m 2. Open `http:///settings`. 3. Use the **OPDS Servers** card to add, edit, or delete entries. -#### 3.6.6 KOReader Sync Quick Setup +For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds). + +#### 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:///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. diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 713981ea..7c795503 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -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() || doc["password"].is(); + std::string password = doc["password"] | std::string(""); + + if (doc["index"].is()) { + int idx = doc["index"].as(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (idx < 0 || idx >= static_cast(credentials.size())) { + server->send(400, "text/plain", "Invalid network index"); + return; + } + + const std::string oldSsid = credentials[static_cast(idx)].ssid; + if (!hasPasswordField) { + password = credentials[static_cast(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()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + int idx = doc["index"].as(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (idx < 0 || idx >= static_cast(credentials.size())) { + server->send(400, "text/plain", "Invalid network index"); + return; + } + + const std::string ssid = credentials[static_cast(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) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index aac60e19..6850b687 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -112,4 +112,9 @@ class CrossPointWebServer { void handleGetOpdsServers() const; void handlePostOpdsServer(); void handleDeleteOpdsServer(); + + // Wi-Fi credential handlers + void handleGetWifiNetworks() const; + void handlePostWifiNetwork(); + void handleDeleteWifiNetwork(); }; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 9b9ac8ec..47d846f2 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -299,6 +299,7 @@ +
@@ -480,6 +481,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 + ? '
Last connected network
' + : ''; + + return '
' + + '
' + + 'SSID' + + '' + + '
' + + '
' + + 'Password' + + '' + + '
' + + lastConnected + + '
' + + '' + + (isNew ? '' : '') + + '
' + + '
'; + } + + function renderWifiSection() { + const container = document.getElementById('wifi-container'); + let html = '

Wi-Fi Networks

'; + + if (wifiNetworks.length === 0) { + html += '

No Wi-Fi networks saved

'; + } else { + wifiNetworks.forEach(function(net, idx) { + html += renderWifiNetwork(net, idx); + }); + } + + html += '
' + + '' + + '
'; + 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; @@ -594,6 +708,7 @@ } } + loadWifiNetworks(); loadOpdsServers();