From 1a2a0ce49015900792d06c5fad035772d04c5a14 Mon Sep 17 00:00:00 2001 From: lucas_nz <606314+lucasnz@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:15:00 +1200 Subject: [PATCH 1/2] Beginning of WiFi Manager replacement --- data/www/espa.js | 125 ++++++++++++++++++++++++++++++++++++++++ data/www/index.htm | 37 +++++++++++- lib/WebUI/WebUI.cpp | 136 +++++++++++++++++++++++++++++++++++++++++++- lib/WebUI/WebUI.h | 15 +++++ src/main.cpp | 6 +- 5 files changed, 316 insertions(+), 3 deletions(-) diff --git a/data/www/espa.js b/data/www/espa.js index b3f1a3f..3cf5b14 100644 --- a/data/www/espa.js +++ b/data/www/espa.js @@ -252,6 +252,131 @@ $(document).ready(function () { }); }); +// Wi-Fi modal +$(document).ready(function () { + // configuration settings modal + $('#wifiLink').click(function (event) { + event.preventDefault(); + loadWifiModal(); + $('#wifiModal').modal('show'); + }); + + function loadWifiModal() { + const select = document.getElementById('wifiNetworks'); + select.innerHTML = ``; + select.disabled = true; // Disable the select element while scanning + document.getElementById('wifiPassword').value = ''; + document.getElementById('wifiErrorAlert').style.display = 'none'; + document.getElementById('wifiPassword').disabled = true; + document.getElementById('connectToWifi').disabled = true; + + startWifiScan(); + } + + function startWifiScan() { + fetch('/scan') + .then(res => res.json()) + .then(data => { + if (data.status === 'scan_started' || data.status === 'scan_in_progress') { + setTimeout(startWifiScan, 500); // poll again + } else if (Array.isArray(data)) { + populateScanResults(data); + } else { + document.getElementById('wifiErrorAlert').innerText = data.status || 'Unknown error during Wi-Fi scan'; + document.getElementById('wifiErrorAlert').style.display = 'block'; + } + }) + .catch(err => { + console.error('Scan error', err); + }); + } + + function populateScanResults(data) { + const select = document.getElementById('wifiNetworks'); + select.innerHTML = ''; // Clear existing options + if (data.length === 0) { + select.innerHTML = ``; + } else { + // Enable the select element + select.disabled = false; + data.forEach(network => { + const option = document.createElement('option'); + option.value = network.ssid; + option.text = `${network.ssid} (${network.rssi} dBm)` + (network.secure ? ' [SECURE]' : ''); + option.dataset.secure = network.secure; + select.appendChild(option); + }); + select.selectedIndex = 0; // Select the first option by default + // Trigger change event to update password field and connect button state + select.dispatchEvent(new Event('change')); + } + + // Hide the error alert + document.getElementById('wifiErrorAlert').style.display = 'none'; + } + + $('#wifiNetworks').on('change', async function () { + const select = document.getElementById('wifiNetworks'); + const passwordField = document.getElementById('wifiPassword'); + const connectToWifi = document.getElementById('connectToWifi'); + + const selectedOption = select.options[select.selectedIndex]; + const requiresPassword = selectedOption?.dataset.secure === "true"; + + if (selectedOption && selectedOption.value) { + passwordField.disabled = !requiresPassword; + connectToWifi.disabled = false; + } else { + passwordField.disabled = true; + connectToWifi.disabled = true; + } + passwordField.value = ''; + }); + + // Toggle password visibility + $('#togglePassword').click(function () { + const passwordInput = document.getElementById('wifiPassword'); + const icon = document.getElementById('togglePasswordIcon'); + + if (passwordInput.type === 'password') { + passwordInput.type = 'text'; + icon.classList.remove('bi-eye'); + icon.classList.add('bi-eye-slash'); + } else { + passwordInput.type = 'password'; + icon.classList.remove('bi-eye-slash'); + icon.classList.add('bi-eye'); + } + }); + + // Handle local connect button click + $('#connectToWifi').click(async function () { + const ssid = document.getElementById('wifiNetworks').value; + const password = document.getElementById('wifiPassword').value; + + fetch('/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `ssid=${encodeURIComponent(ssid)}&password=${encodeURIComponent(password)}` + }) + .then(res => res.json()) + .then(result => { + if (result.success) { + document.getElementById('wifiErrorAlert').style.display = 'none'; + alert("Connected to SSID: " + (ssid || 'unknown')); + $('#wifiModal').modal('hide'); + } else { + document.getElementById('wifiErrorAlert').innerText = result.reason || 'Connection failed'; + document.getElementById('wifiErrorAlert').style.display = 'block'; + } + }) + .catch(err => { + document.getElementById('wifiErrorAlert').innerText = "Error: " + err; + document.getElementById('wifiErrorAlert').style.display = 'block'; + }); + }); +}); + /************************************************************************************************ * diff --git a/data/www/index.htm b/data/www/index.htm index 5c7c8ea..836de56 100644 --- a/data/www/index.htm +++ b/data/www/index.htm @@ -6,6 +6,7 @@ + eSpa @@ -33,7 +34,7 @@ + + + diff --git a/lib/WebUI/WebUI.cpp b/lib/WebUI/WebUI.cpp index 3fb4651..233728c 100644 --- a/lib/WebUI/WebUI.cpp +++ b/lib/WebUI/WebUI.cpp @@ -162,10 +162,144 @@ void WebUI::begin() { request->send(response); }); + server.on("/scan", HTTP_GET, [this](AsyncWebServerRequest *request) { + debugD("uri: %s", request->url().c_str()); + if (!wifiScanInProgress) { + if (WiFi.status() != WL_CONNECTED) { + debugD("WiFi not connected, disable STA to start scan"); + WiFi.disconnect(); // Ensure WiFi is disconnected before scanning + } + WiFi.scanNetworks(true, false); // async = true, show hidden = false + wifiScanInProgress = true; + wifiScanStartTime = millis(); + debugD("Starting WiFi scan..."); + request->send(202, "application/json", "{\"status\":\"scan_started\"}"); + } else { + int scanComplete = WiFi.scanComplete(); + if (scanComplete == WIFI_SCAN_RUNNING) { + debugD("WiFi scan already in progress"); + request->send(202, "application/json", "{\"status\":\"scan_in_progress\"}"); + } else if (scanComplete >= 0) { + std::vector& networkMap = *(new std::vector()); + RemoveDuplicateWiFiNetworks(networkMap); + bool first = true; + String json = "["; + for (const auto& entry : networkMap) { + if (!first) json += ","; + else first = false; + json += "{"; + json += "\"ssid\":\"" + entry.ssid + "\","; + json += "\"rssi\":" + String(entry.rssi) + ","; + json += "\"secure\":" + String(entry.encryptionType != WIFI_AUTH_OPEN ? "true" : "false"); + json += "}"; + } + json += "]"; + WiFi.scanDelete(); // clear results for next scan + wifiScanInProgress = false; + debugD("WiFi scan completed successfully"); + request->send(200, "application/json", json); + } else if (millis() - wifiScanStartTime > 30000) { + // If scan is still running after 30 seconds, assume it failed + wifiScanInProgress = false; + WiFi.scanDelete(); // Clear previous scan results + debugD("WiFi scan timed out or failed"); + request->send(500, "application/json", "{\"error\":\"scan timeout\"}"); + } else { + debugD("WiFi scan already in progress"); + request->send(202, "application/json", "{\"status\":\"scan_in_progress\"}"); + } + } + }); + + server.on("/connect", HTTP_POST, [this](AsyncWebServerRequest *request){ + debugD("uri: %s", request->url().c_str()); + String ssid, password; + + if (wifiConnect) { + debugD("WiFi connection already in progress, ignoring new request"); + request->send(429, "application/json", "{\"error\":\"Connection already in progress\"}"); + return; + } + if (request->hasParam("ssid", true)) { + ssid = request->getParam("ssid", true)->value(); + ssid.trim(); // Remove leading/trailing whitespace + debugD("ssid: %s", ssid.c_str()); + } + if (request->hasParam("password", true)) { + password = request->getParam("password", true)->value(); + password.trim(); // Remove leading/trailing whitespace + debugD("password: %s", password.c_str()); + } + + if (ssid.length() == 0) { + debugD("SSID not provided"); + request->send(400, "application/json", "{\"error\":\"SSID required\"}"); + return; + } + + wifiConnect = true; + debugD("Cleaning up previous WiFi connections..."); + WiFi.disconnect(false, true); // Clear previous connections + debugD("Connecting to WiFi SSID: %s with password: %s", ssid.c_str(), password.c_str()); + if (password.length() == 0) { + WiFi.begin(ssid.c_str()); + } else { + WiFi.begin(ssid.c_str(), password.c_str()); + } + + // Optional: wait for connection (blocking, or use task/timer for async) + unsigned long startAttemptTime = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) { + delay(250); + debugD("Waiting for WiFi connection..."); + } + + wifiConnect = false; // Reset connection flag + if (WiFi.status() == WL_CONNECTED) { + debugD("Connected to WiFi SSID: %s", ssid.c_str()); + request->send(200, "application/json", "{\"success\":true}"); + } else { + debugD("Failed to connect to WiFi SSID: %s", ssid.c_str()); + request->send(500, "application/json", "{\"success\":false,\"reason\":\"Connection failed\"}"); + } + }); + + // As a fallback we try to load from /www any requested URL server.serveStatic("/", SPIFFS, "/www/"); server.begin(); initialised = true; -} \ No newline at end of file +} + +void WebUI::RemoveDuplicateWiFiNetworks(std::vector& networkList) { + int n = WiFi.scanComplete(); + + for (int i = 0; i < n; ++i) { + String ssid = WiFi.SSID(i); + int32_t rssi = WiFi.RSSI(i); + wifi_auth_mode_t enc = WiFi.encryptionType(i); + + if (ssid.length() == 0) continue; // Skip hidden or empty SSIDs + + // Search for existing entry with the same SSID + auto it = std::find_if(networkList.begin(), networkList.end(), + [&](const NetworkInfo& net) { return net.ssid == ssid; }); + + if (it == networkList.end()) { + networkList.push_back(NetworkInfo{ ssid, rssi, enc }); + } else { + // Keep the entry with the stronger signal + if (it->rssi < rssi) { + it->rssi = rssi; + it->encryptionType = enc; + } + } + } + + // Sort by rssi descending (strongest signal first) + std::sort(networkList.begin(), networkList.end(), [](const NetworkInfo& a, const NetworkInfo& b) { + return a.rssi > b.rssi; + }); +} diff --git a/lib/WebUI/WebUI.h b/lib/WebUI/WebUI.h index 7c5178e..39ec67c 100644 --- a/lib/WebUI/WebUI.h +++ b/lib/WebUI/WebUI.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "SpaInterface.h" #include "SpaUtils.h" @@ -27,14 +28,28 @@ class WebUI { void setSpaCallback(void (*f)(const String, const String)) { _setSpaCallback = f; } + /// @brief Check if a Wi-Fi scan is currently in progress. + /// @return True if a Wi-Fi scan is in progress, false otherwise. + bool isWiFiScanInProgress() { + return wifiScanInProgress && (millis() - wifiScanStartTime < 30000) || wifiConnect; + } void begin(); bool initialised = false; private: + struct NetworkInfo { + String ssid; + int32_t rssi; + wifi_auth_mode_t encryptionType; + }; + void RemoveDuplicateWiFiNetworks(std::vector& networkMap); AsyncWebServer server{80}; SpaInterface *_spa; Config *_config; MQTTClientWrapper *_mqttClient; + bool wifiScanInProgress = false; + unsigned long wifiScanStartTime = 0; + bool wifiConnect = false; void (*_wifiManagerCallback)() = nullptr; void (*_setSpaCallback)(const String, const String) = nullptr; diff --git a/src/main.cpp b/src/main.cpp index 51e8fad..e928f78 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,6 +43,7 @@ ulong mqttLastConnect = 0; ulong wifiLastConnect = millis(); ulong bootTime = millis(); ulong statusLastPublish = millis(); +ulong wifiLastScan = millis(); bool delayedStart = true; // Delay spa connection for 10sec after boot to allow for external debugging if required. bool autoDiscoveryPublished = false; bool wifiRestoredFlag = true; // Flag to indicate if Wi-Fi has been restored after a disconnect. @@ -686,7 +687,10 @@ void loop() { setSpaProperty(spaCallbackProperty, spaCallbackValue); } - if (WiFi.status() != WL_CONNECTED) { + if (ui.isWiFiScanInProgress() && millis() - wifiLastScan > 1000) { + debugD("WiFi scan in progress, waiting for completion..."); + wifiLastScan = millis(); // Reset the last scan time to prevent immediate re-scanning + } else if (WiFi.status() != WL_CONNECTED) { blinker.setState(STATE_WIFI_NOT_CONNECTED); wifiRestoredFlag = false; From 34128526d90cd72c99b02393c7073018612df641 Mon Sep 17 00:00:00 2001 From: lucas_nz <606314+lucasnz@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:16:03 +1200 Subject: [PATCH 2/2] Move spa interface to loop, so that we get spa data even when WiFi is not connected. --- src/main.cpp | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e928f78..8ac31df 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -681,6 +681,20 @@ void loop() { Debug.handle(); + si.loop(); + if (si.isInitialised() && spaSerialNumber=="") { + debugI("Initialising..."); + + spaSerialNumber = si.getSerialNo1()+"-"+si.getSerialNo2(); + debugI("Spa serial number is %s",spaSerialNumber.c_str()); + + mqttBase = String("sn_esp32/") + spaSerialNumber + String("/"); + mqttStatusTopic = mqttBase + "status"; + mqttSet = mqttBase + "set"; + mqttAvailability = mqttBase+"available"; + debugI("MQTT base topic is %s",mqttBase.c_str()); + } + if (setSpaCallbackReady) { debugD("Setting Spa Properties..."); setSpaCallbackReady = false; @@ -721,24 +735,12 @@ void loop() { if (delayedStart) { delayedStart = !(bootTime + 10000 < millis()); } else { - si.loop(); if (!si.isInitialised()) { // set status lights to indicate we are waiting for spa connection before we proceed blinker.setState(STATE_WAITING_FOR_SPA); } else { - if ( spaSerialNumber=="" ) { - debugI("Initialising..."); - - spaSerialNumber = si.getSerialNo1()+"-"+si.getSerialNo2(); - debugI("Spa serial number is %s",spaSerialNumber.c_str()); - - mqttBase = String("sn_esp32/") + spaSerialNumber + String("/"); - mqttStatusTopic = mqttBase + "status"; - mqttSet = mqttBase + "set"; - mqttAvailability = mqttBase+"available"; - debugI("MQTT base topic is %s",mqttBase.c_str()); - } + if (!mqttClient.connected()) { // MQTT broker reconnect if not connected long now=millis(); if (now - mqttLastConnect > 1000) {