fix: overhaul WiFi captive portal for reliable setup (#296)

* fix: overhaul WiFi captive portal for reliable device detection and fast setup

The captive portal detection endpoints were returning "success" responses
that told every OS (iOS, Android, Windows, Firefox) that internet was
working — so the portal popup never appeared. This fixes the core issue
and improves the full setup flow:

- Return portal-triggering redirects when AP mode is active; normal
  success responses when not (no false popups on connected devices)
- Add lightweight self-contained setup page (9KB, no frameworks) for
  the captive portal webview instead of the full UI
- Cache AP mode check with 5s TTL (single systemctl call vs full
  WiFiManager instantiation per request)
- Stop disabling AP mode during WiFi scans (which disconnected users);
  serve cached/pre-scanned results instead
- Pre-scan networks before enabling AP mode so captive portal has
  results immediately
- Use dnsmasq.d drop-in config instead of overwriting /etc/dnsmasq.conf
  (preserves Pi-hole and other services)
- Fix manual SSID input bug that incorrectly overwrote dropdown selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for WiFi captive portal

- Remove orphaned comment left over from old scan_networks() finally block
- Add sudoers rules for dnsmasq drop-in copy/remove to install script
- Combine cached-network message into single showMsg call (was overwriting)
- Return (networks, was_cached) tuple from scan_networks() so API endpoint
  derives cached flag from the scan itself instead of a redundant AP check
- Narrow exception catch in AP mode cache to SubprocessError/OSError and
  log the failure for remote debugging
- Bound checkNewIP retries to 20 attempts (60s) before showing fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-03-27 14:50:33 -04:00
committed by GitHub
parent 6eccb74415
commit 77e9eba294
7 changed files with 435 additions and 126 deletions

View File

@@ -62,6 +62,11 @@ $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
# Allow copying hostapd and dnsmasq config files into place
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
EOF EOF
echo "Generated sudoers configuration:" echo "Generated sudoers configuration:"

View File

@@ -25,7 +25,8 @@ Sudoers Requirements:
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/sysctl ledpi ALL=(ALL) NOPASSWD: /usr/sbin/sysctl
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.conf ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
ledpi ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
""" """
import subprocess import subprocess
@@ -58,7 +59,7 @@ def get_wifi_config_path():
return Path(project_root) / "config" / "wifi_config.json" return Path(project_root) / "config" / "wifi_config.json"
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf") HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.conf") DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
HOSTAPD_SERVICE = "hostapd" HOSTAPD_SERVICE = "hostapd"
DNSMASQ_SERVICE = "dnsmasq" DNSMASQ_SERVICE = "dnsmasq"
@@ -658,33 +659,31 @@ class WiFiManager:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
return False return False
def scan_networks(self) -> List[WiFiNetwork]: def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
""" """
Scan for available WiFi networks Scan for available WiFi networks.
If AP mode is active, it will be temporarily disabled during scanning When AP mode is active, returns cached scan results instead of
and re-enabled afterward. This is necessary because WiFi interfaces disabling AP (which would disconnect the user). Cached results
in AP mode cannot scan for other networks. come from either nmcli's internal cache or a pre-scan file saved
before AP mode was enabled.
Returns: Returns:
List of WiFiNetwork objects Tuple of (list of WiFiNetwork objects, was_cached bool)
""" """
ap_was_active = False
try: try:
# Check if AP mode is active - if so, we need to disable it temporarily ap_active = self._is_ap_mode_active()
ap_was_active = self._is_ap_mode_active()
if ap_was_active: if ap_active:
logger.info("AP mode is active, temporarily disabling for WiFi scan...") # Don't disable AP — user would lose their connection.
success, message = self.disable_ap_mode() # Try nmcli cached results first (no rescan trigger).
if not success: logger.info("AP mode active — returning cached scan results")
logger.warning(f"Failed to disable AP mode for scanning: {message}") networks = self._scan_nmcli_cached()
# Continue anyway - scan might still work if not networks and allow_cached:
else: networks = self._load_cached_scan()
# Wait for interface to switch modes return networks, True
time.sleep(3)
# Perform the scan # Normal scan (not in AP mode)
if self.has_nmcli: if self.has_nmcli:
networks = self._scan_nmcli() networks = self._scan_nmcli()
elif self.has_iwlist: elif self.has_iwlist:
@@ -693,22 +692,85 @@ class WiFiManager:
logger.error("No WiFi scanning tools available") logger.error("No WiFi scanning tools available")
networks = [] networks = []
return networks # Save results for later use in AP mode
if networks:
self._save_cached_scan(networks)
return networks, False
except Exception as e: except Exception as e:
logger.error(f"Error scanning networks: {e}") logger.error(f"Error scanning networks: {e}")
return [], False
def _scan_nmcli_cached(self) -> List[WiFiNetwork]:
"""Return nmcli's cached WiFi list without triggering a rescan."""
networks = []
try:
result = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,FREQ", "device", "wifi", "list"],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return [] return []
finally:
# Always try to restore AP mode if it was active before seen_ssids = set()
if ap_was_active: for line in result.stdout.strip().split('\n'):
logger.info("Re-enabling AP mode after WiFi scan...") if not line or ':' not in line:
time.sleep(1) # Brief delay before re-enabling continue
success, message = self.enable_ap_mode() parts = line.split(':')
if success: if len(parts) >= 3:
logger.info("AP mode re-enabled successfully after scan") ssid = parts[0].strip()
if not ssid or ssid in seen_ssids:
continue
seen_ssids.add(ssid)
try:
signal = int(parts[1].strip())
security = parts[2].strip() if len(parts) > 2 else "open"
frequency_str = parts[3].strip() if len(parts) > 3 else "0"
frequency_str = frequency_str.replace(" MHz", "").replace("MHz", "").strip()
frequency = float(frequency_str) if frequency_str else 0.0
if "WPA3" in security:
sec_type = "wpa3"
elif "WPA2" in security:
sec_type = "wpa2"
elif "WPA" in security:
sec_type = "wpa"
else: else:
logger.warning(f"Failed to re-enable AP mode after scan: {message}") sec_type = "open"
# Log but don't fail - user can manually re-enable if needed networks.append(WiFiNetwork(ssid=ssid, signal=signal, security=sec_type, frequency=frequency))
except (ValueError, IndexError):
continue
networks.sort(key=lambda x: x.signal, reverse=True)
except Exception as e:
logger.debug(f"nmcli cached list failed: {e}")
return networks
def _save_cached_scan(self, networks: List[WiFiNetwork]) -> None:
"""Save scan results to a cache file for use during AP mode."""
try:
cache_path = get_wifi_config_path().parent / "cached_networks.json"
data = [{"ssid": n.ssid, "signal": n.signal, "security": n.security, "frequency": n.frequency} for n in networks]
with open(cache_path, 'w') as f:
json.dump({"timestamp": time.time(), "networks": data}, f)
except Exception as e:
logger.debug(f"Failed to save cached scan: {e}")
def _load_cached_scan(self) -> List[WiFiNetwork]:
"""Load pre-cached scan results (saved before AP mode was enabled)."""
try:
cache_path = get_wifi_config_path().parent / "cached_networks.json"
if not cache_path.exists():
return []
with open(cache_path) as f:
data = json.load(f)
# Accept cache up to 10 minutes old
if time.time() - data.get("timestamp", 0) > 600:
return []
return [WiFiNetwork(ssid=n["ssid"], signal=n["signal"], security=n["security"], frequency=n.get("frequency", 0.0))
for n in data.get("networks", [])]
except Exception as e:
logger.debug(f"Failed to load cached scan: {e}")
return []
def _scan_nmcli(self) -> List[WiFiNetwork]: def _scan_nmcli(self) -> List[WiFiNetwork]:
"""Scan networks using nmcli""" """Scan networks using nmcli"""
@@ -1999,26 +2061,16 @@ class WiFiManager:
timeout=10 timeout=10
) )
# Restore original dnsmasq config if backup exists (only for hostapd mode) # Remove the drop-in captive portal config (only for hostapd mode)
if hostapd_active: if hostapd_active and DNSMASQ_CONFIG_PATH.exists():
backup_path = f"{DNSMASQ_CONFIG_PATH}.backup" try:
if os.path.exists(backup_path):
subprocess.run( subprocess.run(
["sudo", "cp", backup_path, str(DNSMASQ_CONFIG_PATH)], ["sudo", "rm", "-f", str(DNSMASQ_CONFIG_PATH)],
timeout=10 capture_output=True, timeout=5
) )
logger.info("Restored original dnsmasq config from backup") logger.info(f"Removed captive portal dnsmasq config: {DNSMASQ_CONFIG_PATH}")
else: except Exception as e:
# No backup - clear the captive portal config logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
# Create a minimal config that won't interfere
minimal_config = "# dnsmasq config - restored to minimal\n"
with open("/tmp/dnsmasq.conf", 'w') as f:
f.write(minimal_config)
subprocess.run(
["sudo", "cp", "/tmp/dnsmasq.conf", str(DNSMASQ_CONFIG_PATH)],
timeout=10
)
logger.info("Cleared dnsmasq captive portal config")
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode) # Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
if hostapd_active: if hostapd_active:
@@ -2189,26 +2241,14 @@ ignore_broadcast_ssid=0
def _create_dnsmasq_config(self): def _create_dnsmasq_config(self):
""" """
Create dnsmasq configuration file with captive portal DNS redirection. Create dnsmasq drop-in configuration for captive portal DNS redirection.
Note: This will overwrite /etc/dnsmasq.conf. If dnsmasq is already in use Writes to /etc/dnsmasq.d/ledmatrix-captive.conf so we don't overwrite
(e.g., for Pi-hole), this may break that service. A backup is created. the main /etc/dnsmasq.conf (preserves Pi-hole, etc.).
""" """
try: try:
# Check for conflicts # Using a drop-in file in /etc/dnsmasq.d/ to avoid overwriting the
conflict, conflict_msg = self._check_dnsmasq_conflict() # main /etc/dnsmasq.conf (which may belong to Pi-hole or other services).
if conflict:
logger.warning(f"dnsmasq conflict detected: {conflict_msg}")
logger.warning("Proceeding anyway - backup will be created")
# Backup existing config
if DNSMASQ_CONFIG_PATH.exists():
subprocess.run(
["sudo", "cp", str(DNSMASQ_CONFIG_PATH), f"{DNSMASQ_CONFIG_PATH}.backup"],
timeout=10
)
logger.info(f"Backed up existing dnsmasq config to {DNSMASQ_CONFIG_PATH}.backup")
config_content = f"""interface={self._wifi_interface} config_content = f"""interface={self._wifi_interface}
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
@@ -2289,7 +2329,16 @@ address=/detectportal.firefox.com/192.168.4.1
self._disconnected_checks >= self._disconnected_checks_required) self._disconnected_checks >= self._disconnected_checks_required)
if should_have_ap and not ap_active: if should_have_ap and not ap_active:
# Should have AP but don't - enable AP mode (only if auto-enable is on and grace period passed) # Pre-cache a WiFi scan so the captive portal can show networks
try:
logger.info("Running pre-AP WiFi scan for captive portal cache...")
networks, _cached = self.scan_networks(allow_cached=False)
if networks:
self._save_cached_scan(networks)
logger.info(f"Cached {len(networks)} networks for captive portal")
except Exception as scan_err:
logger.debug(f"Pre-AP scan failed (non-critical): {scan_err}")
logger.info(f"Enabling AP mode after {self._disconnected_checks} consecutive disconnected checks") logger.info(f"Enabling AP mode after {self._disconnected_checks} consecutive disconnected checks")
success, message = self.enable_ap_mode() success, message = self.enable_ap_mode()
if success: if success:

View File

@@ -1,5 +1,6 @@
from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory
import json import json
import logging
import os import os
import sys import sys
import subprocess import subprocess
@@ -225,48 +226,62 @@ def serve_plugin_asset(plugin_id, filename):
'message': 'Internal server error' 'message': 'Internal server error'
}), 500 }), 500
# Helper function to check if AP mode is active # Cached AP mode check — avoids creating a WiFiManager per request
_ap_mode_cache = {'value': False, 'timestamp': 0}
_AP_MODE_CACHE_TTL = 5 # seconds
def is_ap_mode_active(): def is_ap_mode_active():
""" """
Check if access point mode is currently active. Check if access point mode is currently active (cached, 5s TTL).
Uses a direct systemctl check instead of instantiating WiFiManager.
Returns:
bool: True if AP mode is active, False otherwise.
Returns False on error to avoid breaking normal operation.
""" """
now = time.time()
if (now - _ap_mode_cache['timestamp']) < _AP_MODE_CACHE_TTL:
return _ap_mode_cache['value']
try: try:
wifi_manager = WiFiManager() result = subprocess.run(
return wifi_manager._is_ap_mode_active() ['systemctl', 'is-active', 'hostapd'],
except Exception as e: capture_output=True, text=True, timeout=2
# Log error but don't break normal operation )
# Default to False so normal web interface works even if check fails active = result.stdout.strip() == 'active'
print(f"Warning: Could not check AP mode status: {e}") _ap_mode_cache['value'] = active
return False _ap_mode_cache['timestamp'] = now
return active
except (subprocess.SubprocessError, OSError) as e:
logging.getLogger('web_interface').error(f"AP mode check failed: {e}")
return _ap_mode_cache['value']
# Captive portal detection endpoints # Captive portal detection endpoints
# These help devices detect that a captive portal is active # When AP mode is active, return responses that TRIGGER the captive portal popup.
# When not in AP mode, return normal "success" responses so connectivity checks pass.
@app.route('/hotspot-detect.html') @app.route('/hotspot-detect.html')
def hotspot_detect(): def hotspot_detect():
"""iOS/macOS captive portal detection endpoint""" """iOS/macOS captive portal detection endpoint"""
# Return simple HTML that redirects to setup page if is_ap_mode_active():
# Non-"Success" title triggers iOS captive portal popup
return redirect(url_for('pages_v3.captive_setup'), code=302)
return '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>', 200 return '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>', 200
@app.route('/generate_204') @app.route('/generate_204')
def generate_204(): def generate_204():
"""Android captive portal detection endpoint""" """Android captive portal detection endpoint"""
# Return 204 No Content - Android checks for this if is_ap_mode_active():
# Android expects 204 = "internet works". Non-204 triggers portal popup.
return redirect(url_for('pages_v3.captive_setup'), code=302)
return '', 204 return '', 204
@app.route('/connecttest.txt') @app.route('/connecttest.txt')
def connecttest_txt(): def connecttest_txt():
"""Windows captive portal detection endpoint""" """Windows captive portal detection endpoint"""
# Return simple text response if is_ap_mode_active():
return redirect(url_for('pages_v3.captive_setup'), code=302)
return 'Microsoft Connect Test', 200 return 'Microsoft Connect Test', 200
@app.route('/success.txt') @app.route('/success.txt')
def success_txt(): def success_txt():
"""Firefox captive portal detection endpoint""" """Firefox captive portal detection endpoint"""
# Return simple text response if is_ap_mode_active():
return redirect(url_for('pages_v3.captive_setup'), code=302)
return 'success', 200 return 'success', 200
# Initialize logging # Initialize logging
@@ -367,10 +382,9 @@ def captive_portal_redirect():
path = request.path path = request.path
# List of paths that should NOT be redirected (allow normal operation) # List of paths that should NOT be redirected (allow normal operation)
# This ensures the full web interface works normally when in AP mode
allowed_paths = [ allowed_paths = [
'/v3', # Main interface and all sub-paths '/v3', # Main interface and all sub-paths (includes /v3/setup)
'/api/v3/', # All API endpoints (plugins, config, wifi, stream, etc.) '/api/v3/', # All API endpoints
'/static/', # Static files (CSS, JS, images) '/static/', # Static files (CSS, JS, images)
'/hotspot-detect.html', # iOS/macOS detection '/hotspot-detect.html', # iOS/macOS detection
'/generate_204', # Android detection '/generate_204', # Android detection
@@ -379,16 +393,12 @@ def captive_portal_redirect():
'/favicon.ico', # Favicon '/favicon.ico', # Favicon
] ]
# Check if this path should be allowed
for allowed_path in allowed_paths: for allowed_path in allowed_paths:
if path.startswith(allowed_path): if path.startswith(allowed_path):
return None # Allow this request to proceed normally return None
# For all other paths, redirect to main interface # Redirect to lightweight captive portal setup page (not the full UI)
# This ensures users see the WiFi setup page when they try to access any website return redirect(url_for('pages_v3.captive_setup'), code=302)
# The main interface (/v3) is already in allowed_paths, so it won't redirect
# Static files (/static/) and API calls (/api/v3/) are also allowed
return redirect(url_for('pages_v3.index'), code=302)
# Add security headers and caching to all responses # Add security headers and caching to all responses
@app.after_request @app.after_request

View File

@@ -6363,24 +6363,17 @@ def get_wifi_status():
@api_v3.route('/wifi/scan', methods=['GET']) @api_v3.route('/wifi/scan', methods=['GET'])
def scan_wifi_networks(): def scan_wifi_networks():
"""Scan for available WiFi networks """Scan for available WiFi networks.
If AP mode is active, it will be temporarily disabled during scanning When AP mode is active, returns cached scan results to avoid
and automatically re-enabled afterward. Users connected to the AP will disconnecting the user from the setup network.
be briefly disconnected during this process.
""" """
try: try:
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
networks, was_cached = wifi_manager.scan_networks()
# Check if AP mode is active before scanning (for user notification)
ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally)
networks = wifi_manager.scan_networks()
# Convert to dict format
networks_data = [ networks_data = [
{ {
'ssid': net.ssid, 'ssid': net.ssid,
@@ -6393,16 +6386,14 @@ def scan_wifi_networks():
response_data = { response_data = {
'status': 'success', 'status': 'success',
'data': networks_data 'data': networks_data,
'cached': was_cached,
} }
# Inform user if AP mode was temporarily disabled if was_cached and networks_data:
if ap_was_active: response_data['message'] = f'Found {len(networks_data)} cached networks.'
response_data['message'] = ( elif was_cached and not networks_data:
f'Found {len(networks_data)} networks. ' response_data['message'] = 'No cached networks available. Enter your network name manually.'
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
'If you were connected to the setup network, you may need to reconnect.'
)
return jsonify(response_data) return jsonify(response_data)
except Exception as e: except Exception as e:

View File

@@ -296,6 +296,11 @@ def _load_raw_json_partial():
except Exception as e: except Exception as e:
return f"Error: {str(e)}", 500 return f"Error: {str(e)}", 500
@pages_v3.route('/setup')
def captive_setup():
"""Lightweight captive portal setup page — self-contained, no frameworks."""
return render_template('v3/captive_setup.html')
def _load_wifi_partial(): def _load_wifi_partial():
"""Load WiFi setup partial""" """Load WiFi setup partial"""
try: try:

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEDMatrix WiFi Setup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f3f4f6;color:#1f2937;padding:16px;max-width:480px;margin:0 auto}
h1{font-size:20px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:20px}
.card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;color:#374151;margin-bottom:6px}
select,input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff;-webkit-appearance:none;appearance:none}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-scan{background:#e5e7eb;color:#374151}.btn-scan:hover{background:#d1d5db}
.btn:disabled{background:#d1d5db;color:#9ca3af;cursor:not-allowed}
.row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1}
.msg{padding:12px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none}
.msg-ok{background:#d1fae5;color:#065f46;display:block}
.msg-err{background:#fee2e2;color:#991b1b;display:block}
.msg-info{background:#dbeafe;color:#1e40af;display:block}
.step{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.sep{margin:16px 0;border:none;border-top:1px solid #e5e7eb}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;margin-top:20px;font-size:12px;color:#9ca3af}
.footer a{color:#3b82f6;text-decoration:none}
.success-box{text-align:center;padding:24px}
.success-box .icon{font-size:48px;margin-bottom:12px}
.success-box .ip{font-size:18px;font-weight:700;color:#2563eb;word-break:break-all}
.hidden{display:none}
.or-divider{text-align:center;color:#9ca3af;font-size:12px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:40%;height:1px;background:#e5e7eb}
.or-divider::before{left:0}
.or-divider::after{right:0}
</style>
</head>
<body>
<h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg"></div>
<div id="setup-form">
<div class="card">
<div class="step">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label>
<div class="row">
<select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option>
</select>
<button class="btn btn-scan" id="btn-scan" onclick="doScan()" style="width:auto;padding:10px 16px">
Scan
</button>
</div>
<div class="or-divider">or enter manually</div>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" oninput="onManualInput()">
</div>
<div class="card">
<div class="step">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks">
</div>
<div class="card">
<button class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect
</button>
</div>
</div>
<div id="success-view" class="card hidden">
<div class="success-box">
<div class="icon">&#10003;</div>
<p style="font-size:16px;font-weight:600;margin-bottom:8px">Connected!</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p>
<p style="font-size:12px;color:#9ca3af;margin-top:12px">You may need to reconnect your phone to the same WiFi network.</p>
</div>
</div>
<div class="footer">
<a href="/v3">Open Full Interface</a>
</div>
<script>
var selectedSSID = '';
var scanning = false;
var connecting = false;
function $(id) { return document.getElementById(id); }
function showMsg(text, type) {
var el = $('msg');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() { $('msg').className = 'msg'; }
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
$('btn-connect').disabled = !ssid || connecting;
}
function onSelectNetwork() {
$('manual-ssid').value = '';
selectedSSID = $('net-select').value;
updateConnectBtn();
}
function onManualInput() {
$('net-select').value = '';
selectedSSID = '';
updateConnectBtn();
}
function doScan() {
if (scanning) return;
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Scanning';
clearMsg();
fetch('/api/v3/wifi/scan')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = $('net-select');
sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) {
var nets = data.data;
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')';
sel.appendChild(opt);
}
if (nets.length > 0) {
var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : '');
if (data.cached) {
msg += ' \u2014 Showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info');
}
} else {
showMsg(data.message || 'Scan failed', 'err');
}
})
.catch(function(e) {
showMsg('Scan failed: ' + e.message, 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
btn.innerHTML = 'Scan';
updateConnectBtn();
});
}
function doConnect() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
if (!ssid || connecting) return;
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connecting...';
clearMsg();
showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info');
fetch('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
clearMsg();
// Poll for the new IP
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
showMsg(data.message || 'Connection failed', 'err');
connecting = false;
btn.disabled = false;
btn.innerHTML = 'Connect';
}
})
.catch(function(e) {
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
setTimeout(function() { showSuccessFallback(ssid); }, 5000);
});
}
var MAX_IP_RETRIES = 20;
function checkNewIP(ssid, retriesLeft) {
if (retriesLeft === undefined) retriesLeft = MAX_IP_RETRIES;
if (retriesLeft <= 0) {
showSuccessFallback(ssid);
return;
}
fetch('/api/v3/wifi/status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
} else {
setTimeout(function() { checkNewIP(ssid, retriesLeft - 1); }, 3000);
}
})
.catch(function() {
// AP likely down — show fallback
showSuccessFallback(ssid);
});
}
function showSuccess(ip) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'http://' + ip + ':5000';
$('msg').className = 'msg';
}
function showSuccessFallback(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'Check your router for the device IP';
$('msg').className = 'msg';
}
// Auto-scan on load
doScan();
</script>
</body>
</html>

View File

@@ -114,7 +114,7 @@
<input type="text" <input type="text"
id="manual-ssid" id="manual-ssid"
x-model="manualSSID" x-model="manualSSID"
@input="selectedSSID = ''; selectedSSID = $event.target.value" @input="selectedSSID = ''"
placeholder="Enter network name" placeholder="Enter network name"
class="form-control"> class="form-control">
</div> </div>