diff --git a/scripts/install/configure_wifi_permissions.sh b/scripts/install/configure_wifi_permissions.sh index ef87a7bf..cfba4bc4 100755 --- a/scripts/install/configure_wifi_permissions.sh +++ b/scripts/install/configure_wifi_permissions.sh @@ -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 restart dnsmasq $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 echo "Generated sudoers configuration:" diff --git a/src/wifi_manager.py b/src/wifi_manager.py index 114d33dc..8ddba9e6 100644 --- a/src/wifi_manager.py +++ b/src/wifi_manager.py @@ -25,7 +25,8 @@ Sudoers Requirements: ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables 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/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 @@ -58,7 +59,7 @@ def get_wifi_config_path(): return Path(project_root) / "config" / "wifi_config.json" 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" DNSMASQ_SERVICE = "dnsmasq" @@ -658,33 +659,31 @@ class WiFiManager: except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): 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 - - If AP mode is active, it will be temporarily disabled during scanning - and re-enabled afterward. This is necessary because WiFi interfaces - in AP mode cannot scan for other networks. - + Scan for available WiFi networks. + + When AP mode is active, returns cached scan results instead of + disabling AP (which would disconnect the user). Cached results + come from either nmcli's internal cache or a pre-scan file saved + before AP mode was enabled. + Returns: - List of WiFiNetwork objects + Tuple of (list of WiFiNetwork objects, was_cached bool) """ - ap_was_active = False try: - # Check if AP mode is active - if so, we need to disable it temporarily - ap_was_active = self._is_ap_mode_active() - - if ap_was_active: - logger.info("AP mode is active, temporarily disabling for WiFi scan...") - success, message = self.disable_ap_mode() - if not success: - logger.warning(f"Failed to disable AP mode for scanning: {message}") - # Continue anyway - scan might still work - else: - # Wait for interface to switch modes - time.sleep(3) - - # Perform the scan + ap_active = self._is_ap_mode_active() + + if ap_active: + # Don't disable AP — user would lose their connection. + # Try nmcli cached results first (no rescan trigger). + logger.info("AP mode active — returning cached scan results") + networks = self._scan_nmcli_cached() + if not networks and allow_cached: + networks = self._load_cached_scan() + return networks, True + + # Normal scan (not in AP mode) if self.has_nmcli: networks = self._scan_nmcli() elif self.has_iwlist: @@ -692,24 +691,87 @@ class WiFiManager: else: logger.error("No WiFi scanning tools available") 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: 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 [] + + seen_ssids = set() + for line in result.stdout.strip().split('\n'): + if not line or ':' not in line: + continue + parts = line.split(':') + if len(parts) >= 3: + 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: + sec_type = "open" + 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 [] - finally: - # Always try to restore AP mode if it was active before - if ap_was_active: - logger.info("Re-enabling AP mode after WiFi scan...") - time.sleep(1) # Brief delay before re-enabling - success, message = self.enable_ap_mode() - if success: - logger.info("AP mode re-enabled successfully after scan") - else: - logger.warning(f"Failed to re-enable AP mode after scan: {message}") - # Log but don't fail - user can manually re-enable if needed - + def _scan_nmcli(self) -> List[WiFiNetwork]: """Scan networks using nmcli""" networks = [] @@ -1999,26 +2061,16 @@ class WiFiManager: timeout=10 ) - # Restore original dnsmasq config if backup exists (only for hostapd mode) - if hostapd_active: - backup_path = f"{DNSMASQ_CONFIG_PATH}.backup" - if os.path.exists(backup_path): + # Remove the drop-in captive portal config (only for hostapd mode) + if hostapd_active and DNSMASQ_CONFIG_PATH.exists(): + try: subprocess.run( - ["sudo", "cp", backup_path, str(DNSMASQ_CONFIG_PATH)], - timeout=10 + ["sudo", "rm", "-f", str(DNSMASQ_CONFIG_PATH)], + capture_output=True, timeout=5 ) - logger.info("Restored original dnsmasq config from backup") - else: - # No backup - clear the captive portal config - # 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") + logger.info(f"Removed captive portal dnsmasq config: {DNSMASQ_CONFIG_PATH}") + except Exception as e: + logger.warning(f"Could not remove dnsmasq drop-in config: {e}") # Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode) if hostapd_active: @@ -2189,26 +2241,14 @@ ignore_broadcast_ssid=0 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 - (e.g., for Pi-hole), this may break that service. A backup is created. + Writes to /etc/dnsmasq.d/ledmatrix-captive.conf so we don't overwrite + the main /etc/dnsmasq.conf (preserves Pi-hole, etc.). """ try: - # Check for conflicts - conflict, conflict_msg = self._check_dnsmasq_conflict() - 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") - + # Using a drop-in file in /etc/dnsmasq.d/ to avoid overwriting the + # main /etc/dnsmasq.conf (which may belong to Pi-hole or other services). config_content = f"""interface={self._wifi_interface} 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) 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") success, message = self.enable_ap_mode() if success: diff --git a/web_interface/app.py b/web_interface/app.py index 4263dcc8..66684f86 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -1,5 +1,6 @@ from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory import json +import logging import os import sys import subprocess @@ -225,48 +226,62 @@ def serve_plugin_asset(plugin_id, filename): 'message': 'Internal server error' }), 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(): """ - Check if access point mode is currently active. - - Returns: - bool: True if AP mode is active, False otherwise. - Returns False on error to avoid breaking normal operation. + Check if access point mode is currently active (cached, 5s TTL). + Uses a direct systemctl check instead of instantiating WiFiManager. """ + now = time.time() + if (now - _ap_mode_cache['timestamp']) < _AP_MODE_CACHE_TTL: + return _ap_mode_cache['value'] try: - wifi_manager = WiFiManager() - return wifi_manager._is_ap_mode_active() - except Exception as e: - # Log error but don't break normal operation - # Default to False so normal web interface works even if check fails - print(f"Warning: Could not check AP mode status: {e}") - return False + result = subprocess.run( + ['systemctl', 'is-active', 'hostapd'], + capture_output=True, text=True, timeout=2 + ) + active = result.stdout.strip() == 'active' + _ap_mode_cache['value'] = active + _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 -# 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') def hotspot_detect(): """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 'SuccessSuccess', 200 @app.route('/generate_204') def generate_204(): """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 @app.route('/connecttest.txt') def connecttest_txt(): """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 @app.route('/success.txt') def success_txt(): """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 # Initialize logging @@ -367,10 +382,9 @@ def captive_portal_redirect(): path = request.path # 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 = [ - '/v3', # Main interface and all sub-paths - '/api/v3/', # All API endpoints (plugins, config, wifi, stream, etc.) + '/v3', # Main interface and all sub-paths (includes /v3/setup) + '/api/v3/', # All API endpoints '/static/', # Static files (CSS, JS, images) '/hotspot-detect.html', # iOS/macOS detection '/generate_204', # Android detection @@ -378,17 +392,13 @@ def captive_portal_redirect(): '/success.txt', # Firefox detection '/favicon.ico', # Favicon ] - - # Check if this path should be allowed + for allowed_path in allowed_paths: if path.startswith(allowed_path): - return None # Allow this request to proceed normally - - # For all other paths, redirect to main interface - # This ensures users see the WiFi setup page when they try to access any website - # 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) + return None + + # Redirect to lightweight captive portal setup page (not the full UI) + return redirect(url_for('pages_v3.captive_setup'), code=302) # Add security headers and caching to all responses @app.after_request diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 19a93d51..92a296f2 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -6363,24 +6363,17 @@ def get_wifi_status(): @api_v3.route('/wifi/scan', methods=['GET']) 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 - and automatically re-enabled afterward. Users connected to the AP will - be briefly disconnected during this process. + When AP mode is active, returns cached scan results to avoid + disconnecting the user from the setup network. """ try: from src.wifi_manager import 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 = [ { 'ssid': net.ssid, @@ -6393,16 +6386,14 @@ def scan_wifi_networks(): response_data = { 'status': 'success', - 'data': networks_data + 'data': networks_data, + 'cached': was_cached, } - # Inform user if AP mode was temporarily disabled - if ap_was_active: - response_data['message'] = ( - f'Found {len(networks_data)} networks. ' - '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.' - ) + if was_cached and networks_data: + response_data['message'] = f'Found {len(networks_data)} cached networks.' + elif was_cached and not networks_data: + response_data['message'] = 'No cached networks available. Enter your network name manually.' return jsonify(response_data) except Exception as e: diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 75ff9f5c..209d1470 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -296,6 +296,11 @@ def _load_raw_json_partial(): except Exception as e: 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(): """Load WiFi setup partial""" try: diff --git a/web_interface/templates/v3/captive_setup.html b/web_interface/templates/v3/captive_setup.html new file mode 100644 index 00000000..9025659e --- /dev/null +++ b/web_interface/templates/v3/captive_setup.html @@ -0,0 +1,249 @@ + + + + + +LEDMatrix WiFi Setup + + + + +

LEDMatrix WiFi Setup

+

Connect your device to a WiFi network

+ +
+ +
+
+
Step 1 — Choose Network
+ +
+ + +
+
or enter manually
+ +
+ +
+
Step 2 — Password
+ + +
+ +
+ +
+
+ + + + + + + + diff --git a/web_interface/templates/v3/partials/wifi.html b/web_interface/templates/v3/partials/wifi.html index fa30fd17..ff1e7882 100644 --- a/web_interface/templates/v3/partials/wifi.html +++ b/web_interface/templates/v3/partials/wifi.html @@ -114,7 +114,7 @@