Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions data/www/espa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<option selected disabled>Scanning...</option>`;
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 = `<option selected disabled>No Wi-Fi networks found</option>`;
} 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';
});
});
});


/************************************************************************************************
*
Expand Down
37 changes: 36 additions & 1 deletion data/www/index.htm
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css" integrity="sha384-xOolHFLEh07PJGoPkLv1IbcEPTNtaed2xpHsD9ESMhqIYd0nLMwNLD69Npy4HI+N" crossorigin="anonymous"> -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="styles.css">

<title>eSpa</title>
Expand Down Expand Up @@ -33,7 +34,7 @@
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="#" id="configLink">Settings</a>
<a class="dropdown-item" href="#" onclick="confirmAction('/wifi-manager'); return false;">Wi-Fi Manager</a>
<a class="dropdown-item" href="#" id="wifiLink">Wi-Fi Setup</a>
<a class="dropdown-item" href="#" id="fotaLink">Firmware Updater</a>
<a class="dropdown-item" href="#" onclick="sendCurrentTime();">Send Current Time to Spa</a>
<div class="dropdown-divider"></div>
Expand Down Expand Up @@ -307,6 +308,40 @@ <h5 class="modal-title" id="fotaModalTitle">Firmware Update</h5>
</div>
</div>

<!-- Wi-Fi Setup Modal -->
<div class="modal fade" id="wifiModal" tabindex="-1" aria-labelledby="wifiModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="wifiModalLabel">Wi-Fi Setup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="wifiErrorAlert" class="alert alert-danger" style="display: none;"></div>
<div class="mb-3">
<label for="wifiNetworks" class="form-label">Available Networks</label>
<select class="form-select" id="wifiNetworks">
<option selected disabled>Loading...</option>
</select>
</div>
<div class="mb-3">
<label for="wifiPassword" class="form-label">Wi-Fi Password</label>
<div class="input-group">
<input type="password" class="form-control pe-5" id="wifiPassword" placeholder="Enter password" disabled>
<button type="button" class="btn btn-sm btn-outline-secondary" id="togglePassword" aria-label="Toggle password visibility">
<i class="bi bi-eye" id="togglePasswordIcon"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" id="connectToWifi" disabled>Connect</button>
</div>
</div>
</div>
</div>

<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous"></script> -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
Expand Down
136 changes: 135 additions & 1 deletion lib/WebUI/WebUI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<NetworkInfo>& networkMap = *(new std::vector<NetworkInfo>());
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;
}
}

void WebUI::RemoveDuplicateWiFiNetworks(std::vector<NetworkInfo>& 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;
});
}
15 changes: 15 additions & 0 deletions lib/WebUI/WebUI.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <Arduino.h>
#include <SPIFFS.h>
#include <Update.h>
#include <WiFi.h>

#include "SpaInterface.h"
#include "SpaUtils.h"
Expand All @@ -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<NetworkInfo>& 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;

Expand Down
Loading