From cfd645b25c98f38529a3d4fcf8aaeee0242362b0 Mon Sep 17 00:00:00 2001 From: M-Factory Date: Tue, 27 Jan 2026 22:36:30 +0900 Subject: [PATCH] Added Wifi scan, improved captive portal stability - Added WiFi scan for easy SSID selection during setup - Fixed captive portal redirect loop causing repeated redirects and occasional OOM - Simplified handleCaptivePortal logic and removed conflicting 204 route handlers - Prevented API endpoints (/config.json, /ip, /hostname) from being redirected - Improved AP mode stability during repeated browser refresh and captive probes - Minor logging cleanup for better debugging visibility --- ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino | 72 +++++-- ESPTimeCast_ESP32/index_html.h | 213 +++++++++++++++++++- ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino | 71 +++++-- ESPTimeCast_ESP8266/index_html.h | 213 +++++++++++++++++++- 4 files changed, 524 insertions(+), 45 deletions(-) diff --git a/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino b/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino index 8f26b97..49f218a 100644 --- a/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino +++ b/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino @@ -733,11 +733,6 @@ void setupWebServer() { request->send(response); }); - server.on("/generate_204", HTTP_GET, handleCaptivePortal); // Android - server.on("/fwlink", HTTP_GET, handleCaptivePortal); // Windows - server.on("/hotspot-detect.html", HTTP_GET, handleCaptivePortal); // iOS/macOS - server.on("/ncsi.txt", HTTP_GET, handleCaptivePortal); // Windows NCSI (variation) - server.on("/cp/success.txt", HTTP_GET, handleCaptivePortal); // Android/Generic Success Check server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(204); // 204 No Content response }); @@ -1477,6 +1472,35 @@ void setupWebServer() { } }); + server.on("/scan", HTTP_GET, [](AsyncWebServerRequest *request) { + int scanStatus = WiFi.scanComplete(); + + // -2 means scan not triggered, -1 means scan in progress + if (scanStatus < -1 || scanStatus == WIFI_SCAN_FAILED) { + // Start the asynchronous scan + WiFi.scanNetworks(true); + request->send(202, "application/json", "{\"status\":\"processing\"}"); + } else if (scanStatus == -1) { + // Scan is currently running + request->send(202, "application/json", "{\"status\":\"processing\"}"); + } else { + // Scan finished (scanStatus >= 0) + String json = "["; + for (int i = 0; i < scanStatus; ++i) { + json += "{"; + json += "\"ssid\":\"" + WiFi.SSID(i) + "\","; + json += "\"rssi\":" + String(WiFi.RSSI(i)); + json += "}"; + if (i < scanStatus - 1) json += ","; + } + json += "]"; + + // Clean up scan results from memory + WiFi.scanDelete(); + request->send(200, "application/json", json); + } + }); + server.on("/ip", HTTP_GET, [](AsyncWebServerRequest *request) { String ip; @@ -1774,27 +1798,38 @@ void setupWebServer() { Serial.println(F("[WEBSERVER] Web server started")); } - void handleCaptivePortal(AsyncWebServerRequest *request) { String uri = request->url(); - // Filter out system-generated probe requests - if (!uri.endsWith("/204") && !uri.endsWith("/ipv6check") && !uri.endsWith("connecttest.txt") && !uri.endsWith("/generate_204") && !uri.endsWith("/fwlink") && !uri.endsWith("/hotspot-detect.html")) { - - Serial.print(F("[WEBSERVER] Captive Portal triggered for URL: ")); - Serial.println(uri); + // Never interfere with real UI or API + if ( + uri == "/" || uri == "/index.html" || uri.startsWith("/config") || uri.startsWith("/hostname") || uri.startsWith("/ip") || uri.endsWith(".json") || uri.endsWith(".js") || uri.endsWith(".css") || uri.endsWith(".png") || uri.endsWith(".ico")) { + return; // let normal handlers serve it } + // Known captive portal probes → redirect + if ( + uri == "/generate_204" || uri == "/gen_204" || uri == "/fwlink" || uri == "/hotspot-detect.html" || uri == "/ncsi.txt" || uri == "/cp/success.txt" || uri == "/library/test/success.html") { + if (isAPMode) { + IPAddress apIP = WiFi.softAPIP(); + String redirectUrl = "http://" + apIP.toString() + "/"; + //Serial.printf("[WEBSERVER] Captive probe %s → redirect\n", uri.c_str()); + request->redirect(redirectUrl); + return; + } + } + + // Unknown URLs in AP mode → redirect (helps odd OSes like /chat) if (isAPMode) { IPAddress apIP = WiFi.softAPIP(); String redirectUrl = "http://" + apIP.toString() + "/"; - Serial.print(F("[WEBSERVER] Redirecting to captive portal: ")); - Serial.println(redirectUrl); + Serial.printf("[WEBSERVER] Captive fallback redirect: %s\n", uri.c_str()); request->redirect(redirectUrl); - } else { - Serial.println(F("[WEBSERVER] Not in AP mode — sending 404")); - request->send(404, "text/plain", "Not found"); + return; } + + // STA mode fallback + request->send(404, "text/plain", "Not found"); } @@ -2401,8 +2436,9 @@ void setup() { } else { Serial.println(F("[SETUP] WiFi state is uncertain after connection attempt.")); } - - setupMDNS(); + if (!isAPMode && WiFi.status() == WL_CONNECTED) { + setupMDNS(); + } setupWebServer(); Serial.println(F("[SETUP] Webserver setup complete")); Serial.println(F("[SETUP] Setup complete")); diff --git a/ESPTimeCast_ESP32/index_html.h b/ESPTimeCast_ESP32/index_html.h index 8d869d9..7d86786 100644 --- a/ESPTimeCast_ESP32/index_html.h +++ b/ESPTimeCast_ESP32/index_html.h @@ -17,6 +17,111 @@ const char index_html[] PROGMEM = R"rawliteral( --accent-color: #0075ff; } + .ssid-wrapper { + position: relative; + } + + .combo-container { + display: flex; + box-sizing: border-box; + width: 100%; + border: 1.5px solid rgba(180, 230, 255, 0.08); + border-radius: 8px; + background-color: rgba(225, 245, 255, 0.07); + color: #ffffff; + font-size: 1rem; + appearance: none; + } + + #ssid { + border-radius: 8px 0 0 8px; + flex: 1; + border: none; + outline: none; + background: transparent; + } + + .icon-btn { + width: 40px; + border: none; + background: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #666; + transition: background 0.2s; + } + + #arrowBtn { + background: transparent; + border: none; + width: 40px; + border-left: 1.5px solid rgba(180, 230, 255, 0.08); + } + + #arrowBtn > svg{ + position: relative; + top: 2px; + filter: invert(0); + opacity: 1; + } + + #arrowBtn:disabled > svg{ + position: relative; + top: 2px; + opacity: 0.25; + } + + #arrowBtn:hover{ + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 122, 255, 0.35); + } + + #arrowBtn:disabled:hover{ + transform: translateY(0px); + box-shadow: none; + } + + #arrowBtn:disabled{ + cursor: not-allowed; + background: none; + color: rgba(255, 255, 255, 0.250); + } + + #scanBtn { + border-radius: 0 8px 8px 0; + width: 80px; + } + + .icon-btn:hover { background: #f5f5f5; } + #scanBtn:disabled { background: rgba(255, 255, 255, 0.5); cursor: wait; } + + /* The Dropdown Menu */ + #ssidList { + position: absolute; + width: 100%; + max-height: 50vh; + overflow-y: auto; + background: white; + border: 1px solid var(--border-color); + border-radius: 6px; + display: none; + z-index: 1000; + box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); + } + + .ssid-option { + padding: 10px 12px; + cursor: pointer; + color: black; + } + + .ssid-option:hover { + background-color: var(--accent-color); + color: white; + } + * { box-sizing: border-box; } html{ background: radial-gradient(ellipse at 70% 0%, #2b425a 0%, #171e23 100%); @@ -127,8 +232,8 @@ input[type="time"]::-webkit-calendar-picker-indicator, input[type="date"]::-webk input:-webkit-autofill, -input:-webkit-autofill:focus, -input:-webkit-autofill:hover { + input:-webkit-autofill:focus, + input:-webkit-autofill:hover { background: rgba(225,245,255,0.07) !important; color: #fff !important; -webkit-box-shadow: 0 0 0 1000px rgba(225,245,255,0.07) inset !important; @@ -326,8 +431,17 @@ textarea::placeholder {

WiFi Settings

- - + +
+
+ + + +
+
+
@@ -1764,7 +1878,96 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + +const ssidInput = document.getElementById("ssid"); +const list = document.getElementById("ssidList"); +const scanBtn = document.getElementById("scanBtn"); +const arrowBtn = document.getElementById("arrowBtn"); + +// Unlock the arrow button UI +function enableDropdown() { + arrowBtn.disabled = false; + arrowBtn.style.opacity = "1"; + arrowBtn.style.cursor = "pointer"; +} + +// Show/Hide the dropdown list +function toggleList(e) { + if (e) e.stopPropagation(); + if (list.children.length > 0) { + list.style.display = (list.style.display === "block") ? "none" : "block"; + } +} + +arrowBtn.onclick = toggleList; + +// Close dropdown if user clicks away +window.onclick = (e) => { + if (!e.target.matches('#arrowBtn') && !e.target.matches('#ssid')) { + list.style.display = "none"; + } +}; + +scanBtn.onclick = async function() { + // 1. Prepare UI + arrowBtn.disabled = true; + scanBtn.disabled = true; + list.style.display = "none"; + + // 2. Start Continuous Dot Animation + let dotCount = 0; + const dotInterval = setInterval(() => { + dotCount = (dotCount % 3) + 1; + scanBtn.innerText = ".".repeat(dotCount); + }, 850); + + // 3. Define the recursive Polling Function + const performPolling = async () => { + try { + const resp = await fetch("/scan"); + + if (resp.status === 202) { + // ESP is still busy. Wait 1 second then try again. + await new Promise(resolve => setTimeout(resolve, 1000)); + return await performPolling(); + } + + if (resp.status === 200) { + const networks = await resp.json(); + list.innerHTML = ""; + + if (networks && networks.length > 0) { + networks.forEach(net => { + const div = document.createElement("div"); + div.className = "ssid-option"; + div.innerText = net.ssid; + div.onclick = () => { + ssidInput.value = net.ssid; + list.style.display = "none"; + }; + list.appendChild(div); + }); + enableDropdown(); + list.style.display = "block"; + } else { + alert("No networks found."); + } + } + } catch (err) { + console.error("Scan error:", err); + alert("Device connection lost."); + } + }; + + // 4. Run the polling chain + await performPolling(); + + // 5. Final Cleanup (Runs only AFTER polling is completely finished) + clearInterval(dotInterval); + scanBtn.disabled = false; + scanBtn.innerText = "Scan"; +}; -)rawliteral"; +)rawliteral"; \ No newline at end of file diff --git a/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino b/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino index 66c6244..ffe42ed 100644 --- a/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino +++ b/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino @@ -729,11 +729,6 @@ void setupWebServer() { request->send(response); }); - server.on("/generate_204", HTTP_GET, handleCaptivePortal); // Android - server.on("/fwlink", HTTP_GET, handleCaptivePortal); // Windows - server.on("/hotspot-detect.html", HTTP_GET, handleCaptivePortal); // iOS/macOS - server.on("/ncsi.txt", HTTP_GET, handleCaptivePortal); // Windows NCSI (variation) - server.on("/cp/success.txt", HTTP_GET, handleCaptivePortal); // Android/Generic Success Check server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(204); // 204 No Content response }); @@ -1473,6 +1468,35 @@ void setupWebServer() { } }); + server.on("/scan", HTTP_GET, [](AsyncWebServerRequest *request) { + int scanStatus = WiFi.scanComplete(); + + // -2 means scan not triggered, -1 means scan in progress + if (scanStatus < -1 || scanStatus == WIFI_SCAN_FAILED) { + // Start the asynchronous scan + WiFi.scanNetworks(true); + request->send(202, "application/json", "{\"status\":\"processing\"}"); + } else if (scanStatus == -1) { + // Scan is currently running + request->send(202, "application/json", "{\"status\":\"processing\"}"); + } else { + // Scan finished (scanStatus >= 0) + String json = "["; + for (int i = 0; i < scanStatus; ++i) { + json += "{"; + json += "\"ssid\":\"" + WiFi.SSID(i) + "\","; + json += "\"rssi\":" + String(WiFi.RSSI(i)); + json += "}"; + if (i < scanStatus - 1) json += ","; + } + json += "]"; + + // Clean up scan results from memory + WiFi.scanDelete(); + request->send(200, "application/json", json); + } + }); + server.on("/ip", HTTP_GET, [](AsyncWebServerRequest *request) { String ip; @@ -1772,23 +1796,35 @@ void setupWebServer() { void handleCaptivePortal(AsyncWebServerRequest *request) { String uri = request->url(); - // Filter out system-generated probe requests - if (!uri.endsWith("/204") && !uri.endsWith("/ipv6check") && !uri.endsWith("connecttest.txt") && !uri.endsWith("/generate_204") && !uri.endsWith("/fwlink") && !uri.endsWith("/hotspot-detect.html")) { - - Serial.print(F("[WEBSERVER] Captive Portal triggered for URL: ")); - Serial.println(uri); + // Never interfere with real UI or API + if ( + uri == "/" || uri == "/index.html" || uri.startsWith("/config") || uri.startsWith("/hostname") || uri.startsWith("/ip") || uri.endsWith(".json") || uri.endsWith(".js") || uri.endsWith(".css") || uri.endsWith(".png") || uri.endsWith(".ico")) { + return; // let normal handlers serve it } + // Known captive portal probes → redirect + if ( + uri == "/generate_204" || uri == "/gen_204" || uri == "/fwlink" || uri == "/hotspot-detect.html" || uri == "/ncsi.txt" || uri == "/cp/success.txt" || uri == "/library/test/success.html") { + if (isAPMode) { + IPAddress apIP = WiFi.softAPIP(); + String redirectUrl = "http://" + apIP.toString() + "/"; + //Serial.printf("[WEBSERVER] Captive probe %s → redirect\n", uri.c_str()); + request->redirect(redirectUrl); + return; + } + } + + // Unknown URLs in AP mode → redirect (helps odd OSes like /chat) if (isAPMode) { IPAddress apIP = WiFi.softAPIP(); String redirectUrl = "http://" + apIP.toString() + "/"; - Serial.print(F("[WEBSERVER] Redirecting to captive portal: ")); - Serial.println(redirectUrl); + Serial.printf("[WEBSERVER] Captive fallback redirect: %s\n", uri.c_str()); request->redirect(redirectUrl); - } else { - Serial.println(F("[WEBSERVER] Not in AP mode — sending 404")); - request->send(404, "text/plain", "Not found"); + return; } + + // STA mode fallback + request->send(404, "text/plain", "Not found"); } String normalizeWeatherDescription(String str) { @@ -2387,8 +2423,9 @@ void setup() { } else { Serial.println(F("[SETUP] WiFi state is uncertain after connection attempt.")); } - - setupMDNS(); + if (!isAPMode && WiFi.status() == WL_CONNECTED) { + setupMDNS(); + } setupWebServer(); Serial.println(F("[SETUP] Webserver setup complete")); Serial.println(F("[SETUP] Setup complete")); diff --git a/ESPTimeCast_ESP8266/index_html.h b/ESPTimeCast_ESP8266/index_html.h index 8d869d9..7d86786 100644 --- a/ESPTimeCast_ESP8266/index_html.h +++ b/ESPTimeCast_ESP8266/index_html.h @@ -17,6 +17,111 @@ const char index_html[] PROGMEM = R"rawliteral( --accent-color: #0075ff; } + .ssid-wrapper { + position: relative; + } + + .combo-container { + display: flex; + box-sizing: border-box; + width: 100%; + border: 1.5px solid rgba(180, 230, 255, 0.08); + border-radius: 8px; + background-color: rgba(225, 245, 255, 0.07); + color: #ffffff; + font-size: 1rem; + appearance: none; + } + + #ssid { + border-radius: 8px 0 0 8px; + flex: 1; + border: none; + outline: none; + background: transparent; + } + + .icon-btn { + width: 40px; + border: none; + background: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #666; + transition: background 0.2s; + } + + #arrowBtn { + background: transparent; + border: none; + width: 40px; + border-left: 1.5px solid rgba(180, 230, 255, 0.08); + } + + #arrowBtn > svg{ + position: relative; + top: 2px; + filter: invert(0); + opacity: 1; + } + + #arrowBtn:disabled > svg{ + position: relative; + top: 2px; + opacity: 0.25; + } + + #arrowBtn:hover{ + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 122, 255, 0.35); + } + + #arrowBtn:disabled:hover{ + transform: translateY(0px); + box-shadow: none; + } + + #arrowBtn:disabled{ + cursor: not-allowed; + background: none; + color: rgba(255, 255, 255, 0.250); + } + + #scanBtn { + border-radius: 0 8px 8px 0; + width: 80px; + } + + .icon-btn:hover { background: #f5f5f5; } + #scanBtn:disabled { background: rgba(255, 255, 255, 0.5); cursor: wait; } + + /* The Dropdown Menu */ + #ssidList { + position: absolute; + width: 100%; + max-height: 50vh; + overflow-y: auto; + background: white; + border: 1px solid var(--border-color); + border-radius: 6px; + display: none; + z-index: 1000; + box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); + } + + .ssid-option { + padding: 10px 12px; + cursor: pointer; + color: black; + } + + .ssid-option:hover { + background-color: var(--accent-color); + color: white; + } + * { box-sizing: border-box; } html{ background: radial-gradient(ellipse at 70% 0%, #2b425a 0%, #171e23 100%); @@ -127,8 +232,8 @@ input[type="time"]::-webkit-calendar-picker-indicator, input[type="date"]::-webk input:-webkit-autofill, -input:-webkit-autofill:focus, -input:-webkit-autofill:hover { + input:-webkit-autofill:focus, + input:-webkit-autofill:hover { background: rgba(225,245,255,0.07) !important; color: #fff !important; -webkit-box-shadow: 0 0 0 1000px rgba(225,245,255,0.07) inset !important; @@ -326,8 +431,17 @@ textarea::placeholder {

WiFi Settings

- - + +
+
+ + + +
+
+
@@ -1764,7 +1878,96 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + +const ssidInput = document.getElementById("ssid"); +const list = document.getElementById("ssidList"); +const scanBtn = document.getElementById("scanBtn"); +const arrowBtn = document.getElementById("arrowBtn"); + +// Unlock the arrow button UI +function enableDropdown() { + arrowBtn.disabled = false; + arrowBtn.style.opacity = "1"; + arrowBtn.style.cursor = "pointer"; +} + +// Show/Hide the dropdown list +function toggleList(e) { + if (e) e.stopPropagation(); + if (list.children.length > 0) { + list.style.display = (list.style.display === "block") ? "none" : "block"; + } +} + +arrowBtn.onclick = toggleList; + +// Close dropdown if user clicks away +window.onclick = (e) => { + if (!e.target.matches('#arrowBtn') && !e.target.matches('#ssid')) { + list.style.display = "none"; + } +}; + +scanBtn.onclick = async function() { + // 1. Prepare UI + arrowBtn.disabled = true; + scanBtn.disabled = true; + list.style.display = "none"; + + // 2. Start Continuous Dot Animation + let dotCount = 0; + const dotInterval = setInterval(() => { + dotCount = (dotCount % 3) + 1; + scanBtn.innerText = ".".repeat(dotCount); + }, 850); + + // 3. Define the recursive Polling Function + const performPolling = async () => { + try { + const resp = await fetch("/scan"); + + if (resp.status === 202) { + // ESP is still busy. Wait 1 second then try again. + await new Promise(resolve => setTimeout(resolve, 1000)); + return await performPolling(); + } + + if (resp.status === 200) { + const networks = await resp.json(); + list.innerHTML = ""; + + if (networks && networks.length > 0) { + networks.forEach(net => { + const div = document.createElement("div"); + div.className = "ssid-option"; + div.innerText = net.ssid; + div.onclick = () => { + ssidInput.value = net.ssid; + list.style.display = "none"; + }; + list.appendChild(div); + }); + enableDropdown(); + list.style.display = "block"; + } else { + alert("No networks found."); + } + } + } catch (err) { + console.error("Scan error:", err); + alert("Device connection lost."); + } + }; + + // 4. Run the polling chain + await performPolling(); + + // 5. Final Cleanup (Runs only AFTER polling is completely finished) + clearInterval(dotInterval); + scanBtn.disabled = false; + scanBtn.innerText = "Scan"; +}; -)rawliteral"; +)rawliteral"; \ No newline at end of file