Feature/wifi setup improvements (#187)

* fix: Handle permission errors when removing plugin directories

- Added _safe_remove_directory() method to handle permission errors gracefully
- Fixes permissions on __pycache__ directories before removal
- Updates uninstall_plugin() and install methods to use safe removal
- Resolves [Errno 13] Permission denied errors during plugin install/uninstall

* feat(wifi): Add grace period for AP mode and improve setup documentation

- Add 90-second grace period (3 checks at 30s intervals) before enabling AP mode
- Change AP to open network (no password) for easier initial setup
- Add verification script for WiFi setup
- Update documentation with grace period details and open network info
- Improve WiFi monitor daemon logging and error handling

* feat(wifi): Add Trixie compatibility and dynamic interface discovery

- Add dynamic WiFi interface discovery instead of hardcoded wlan0
  - Supports traditional (wlan0), predictable (wlp2s0), and USB naming
  - Falls back gracefully if detection fails

- Add Raspberry Pi OS Trixie (Debian 13) detection and compatibility
  - Detect Netplan configuration and connection file locations
  - Disable PMF (Protected Management Frames) on Trixie for better
    client compatibility with certain WiFi adapters

- Improve nmcli hotspot setup for Trixie
  - Add explicit IP configuration (192.168.4.1/24)
  - Add channel configuration to hotspot creation
  - Handle Trixie's default 10.42.0.1 IP override

- Add dnsmasq conflict detection
  - Warn if Pi-hole or other DNS services are using dnsmasq
  - Create backup before overwriting config

- Improve error handling
  - Replace bare except clauses with specific exceptions
  - All subprocess calls now have explicit timeouts

- Document sudoers requirements in module docstring
  - List all required NOPASSWD entries for ledpi user

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(wifi): Use NM_CONNECTIONS_PATHS constant in _detect_trixie

Replace hardcoded Path instances with references to the
NM_CONNECTIONS_PATHS constant for consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(verify): Use ETH_CONNECTED and AP_ACTIVE in summary output

Add connectivity summary section that displays Ethernet and AP mode
status using the previously unused ETH_CONNECTED and AP_ACTIVE flags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-01-19 16:43:02 -05:00
committed by GitHub
parent 2381ead03f
commit 1833e30c1d
5 changed files with 771 additions and 121 deletions

View File

@@ -51,10 +51,9 @@ You should see output indicating the service is active and running.
### Accessing the WiFi Setup Interface
1. **If WiFi is NOT connected**: The Raspberry Pi will automatically create an access point
- Connect to the WiFi network: **LEDMatrix-Setup**
- Password: **ledmatrix123** (default)
- Open a web browser and navigate to: `http://192.168.4.1:5000`
1. **If WiFi is NOT connected**: The Raspberry Pi will automatically create an access point (after a 90-second grace period)
- Connect to the WiFi network: **LEDMatrix-Setup** (open network, no password required)
- Open a web browser and navigate to: `http://192.168.4.1:5000` or `http://192.168.4.1` (captive portal may redirect)
- Or use the IP address shown in the web interface
2. **If WiFi IS connected**: Access the web interface normally
@@ -92,7 +91,7 @@ The WiFi monitor daemon (`wifi_monitor_daemon.py`) runs as a background service
3. Automatically disables AP mode when WiFi or Ethernet connection is established
4. Logs all state changes for troubleshooting
**Note**: By default, `auto_enable_ap_mode` is `true`, meaning AP mode will automatically activate when both WiFi and Ethernet are disconnected. This ensures you can always configure the device even when it has no network connection.
**Note**: By default, `auto_enable_ap_mode` is `true`, meaning AP mode will automatically activate when both WiFi and Ethernet are disconnected. However, there's a 90-second grace period (3 consecutive checks at 30-second intervals) to prevent AP mode from enabling on transient network hiccups. This ensures you can always configure the device even when it has no network connection.
### WiFi Manager Module
@@ -125,13 +124,14 @@ WiFi settings are stored in `config/wifi_config.json`:
**Configuration Options:**
- `ap_ssid`: SSID for the access point (default: "LEDMatrix-Setup")
- `ap_password`: Password for the access point (default: "ledmatrix123")
- `ap_channel`: WiFi channel for AP mode (default: 7)
- `auto_enable_ap_mode`: Automatically enable AP mode when WiFi/Ethernet disconnect (default: `true`)
- When `true`: AP mode automatically enables when both WiFi and Ethernet are disconnected
- When `true`: AP mode automatically enables after a 90-second grace period when both WiFi and Ethernet are disconnected
- When `false`: AP mode must be manually enabled through the web interface
- `saved_networks`: List of saved WiFi network credentials
**Note**: The access point is configured as an open network (no password required) for ease of initial setup. This allows any device to connect without credentials.
### Access Point Configuration
The AP mode uses `hostapd` and `dnsmasq` for access point functionality:
@@ -141,6 +141,31 @@ The AP mode uses `hostapd` and `dnsmasq` for access point functionality:
- **Gateway**: 192.168.4.1
- **Channel**: 7 (configurable)
## Verification
### Running the WiFi Verification Script
Use the comprehensive verification script to check your WiFi setup:
```bash
cd /home/ledpi/LEDMatrix
./scripts/verify_wifi_setup.sh
```
This script checks:
- Required packages are installed
- WiFi monitor service is running
- Configuration files are valid
- WiFi permissions are configured
- WiFi interface is available
- WiFi radio status
- Current connection status
- AP mode status
- WiFi Manager module availability
- Web interface API accessibility
The script provides a summary with passed/warning/failed checks to help diagnose issues.
## Troubleshooting
### WiFi Monitor Service Not Starting
@@ -254,12 +279,12 @@ sudo systemctl restart ledmatrix-wifi-monitor
## Security Considerations
- **Default AP Password**: The default AP password is "ledmatrix123". Consider changing this in `config/wifi_config.json` for production use
- **Open AP Network**: The access point is configured as an open network (no password) for ease of initial setup. This allows any device within range to connect to the setup network. Consider your deployment environment when using this feature.
- **WiFi Credentials**: Saved WiFi credentials are stored in `config/wifi_config.json`. Ensure proper file permissions:
```bash
sudo chmod 600 config/wifi_config.json
```
- **Network Access**: When in AP mode, anyone within range can connect to the setup network. Use strong passwords for production deployments
- **Network Access**: When in AP mode, anyone within range can connect to the setup network. This is by design to allow easy initial configuration. For production deployments in secure environments, consider using the web interface when connected to WiFi instead.
## API Endpoints
@@ -291,11 +316,13 @@ The system supports multiple scanning methods:
AP mode configuration:
- Uses `hostapd` for WiFi access point functionality
- Uses `dnsmasq` for DHCP and DNS services
- Uses `hostapd` (preferred) or `nmcli hotspot` (fallback) for WiFi access point functionality
- Uses `dnsmasq` for DHCP and DNS services (hostapd mode only)
- Configures wlan0 interface in AP mode
- Provides DHCP range: 192.168.4.2-20
- Gateway IP: 192.168.4.1
- **Open network**: No password required (configures as open network for easy setup)
- Captive portal: DNS redirection for automatic browser redirects (hostapd mode only)
## Development

View File

@@ -58,39 +58,69 @@ class WiFiMonitorDaemon:
logger.info("WiFi Monitor Daemon started")
logger.info(f"Check interval: {self.check_interval} seconds")
# Log initial configuration
auto_enable = self.wifi_manager.config.get("auto_enable_ap_mode", True)
ap_ssid = self.wifi_manager.config.get("ap_ssid", "LEDMatrix-Setup")
logger.info(f"Configuration: auto_enable_ap_mode={auto_enable}, ap_ssid={ap_ssid}")
# Log initial status
initial_status = self.wifi_manager.get_wifi_status()
initial_ethernet = self.wifi_manager._is_ethernet_connected()
logger.info(f"Initial status: WiFi connected={initial_status.connected}, "
f"Ethernet connected={initial_ethernet}, AP active={initial_status.ap_mode_active}")
if initial_status.connected:
logger.info(f" WiFi SSID: {initial_status.ssid}, IP: {initial_status.ip_address}, Signal: {initial_status.signal}%")
while self.running:
try:
# Get current status before checking
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
# Check WiFi status and manage AP mode
state_changed = self.wifi_manager.check_and_manage_ap_mode()
# Get current status for logging
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
# Get updated status after check
updated_status = self.wifi_manager.get_wifi_status()
updated_ethernet = self.wifi_manager._is_ethernet_connected()
current_state = {
'connected': status.connected,
'ethernet_connected': ethernet_connected,
'ap_active': status.ap_mode_active,
'ssid': status.ssid
'connected': updated_status.connected,
'ethernet_connected': updated_ethernet,
'ap_active': updated_status.ap_mode_active,
'ssid': updated_status.ssid
}
# Log state changes
# Log state changes with detailed information
if current_state != self.last_state:
if status.connected:
logger.info(f"WiFi connected: {status.ssid} (IP: {status.ip_address})")
logger.info("=== State Change Detected ===")
if updated_status.connected:
logger.info(f"WiFi connected: {updated_status.ssid} (IP: {updated_status.ip_address}, Signal: {updated_status.signal}%)")
else:
logger.info("WiFi disconnected")
logger.info("WiFi disconnected (no active connection)")
if ethernet_connected:
if updated_ethernet:
logger.info("Ethernet connected")
else:
logger.debug("Ethernet disconnected")
logger.debug("Ethernet not connected")
if status.ap_mode_active:
logger.info("AP mode active")
if updated_status.ap_mode_active:
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
else:
logger.debug("AP mode inactive")
if state_changed:
logger.info("AP mode state was changed by check_and_manage_ap_mode()")
logger.info("=============================")
self.last_state = current_state.copy()
else:
# Log periodic status (less verbose)
if updated_status.connected:
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
else:
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
# Sleep until next check
time.sleep(self.check_interval)
@@ -101,23 +131,40 @@ class WiFiMonitorDaemon:
break
except Exception as e:
logger.error(f"Error in monitor loop: {e}", exc_info=True)
logger.error(f"Error details - type: {type(e).__name__}, args: {e.args}")
# Log current state for debugging
try:
error_status = self.wifi_manager.get_wifi_status()
logger.error(f"State at error: WiFi={error_status.connected}, AP={error_status.ap_mode_active}")
except Exception as state_error:
logger.error(f"Could not get state at error: {state_error}")
# Continue running even if there's an error
time.sleep(self.check_interval)
logger.info("WiFi Monitor Daemon stopped")
# Ensure AP mode is disabled on shutdown if WiFi or Ethernet is connected
logger.info("Performing cleanup on shutdown...")
try:
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
logger.info(f"Final status: WiFi={status.connected}, Ethernet={ethernet_connected}, AP={status.ap_mode_active}")
if (status.connected or ethernet_connected) and status.ap_mode_active:
if status.connected:
logger.info("Disabling AP mode on shutdown (WiFi is connected)")
logger.info(f"Disabling AP mode on shutdown (WiFi is connected to {status.ssid})")
elif ethernet_connected:
logger.info("Disabling AP mode on shutdown (Ethernet is connected)")
self.wifi_manager.disable_ap_mode()
success, message = self.wifi_manager.disable_ap_mode()
if success:
logger.info(f"AP mode disabled successfully: {message}")
else:
logger.warning(f"Failed to disable AP mode: {message}")
else:
logger.debug("AP mode cleanup not needed (not active or no network connection)")
except Exception as e:
logger.error(f"Error disabling AP mode on shutdown: {e}")
logger.error(f"Error during shutdown cleanup: {e}", exc_info=True)
def main():

346
scripts/verify_wifi_setup.sh Executable file
View File

@@ -0,0 +1,346 @@
#!/bin/bash
# WiFi Setup Verification Script
# Comprehensive health check for WiFi management system
set -u # Fail on undefined variables
echo "=========================================="
echo "WiFi Setup Verification"
echo "=========================================="
echo ""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Counters
PASSED=0
FAILED=0
WARNINGS=0
check_pass() {
echo -e "${GREEN}${NC} $1"
PASSED=$((PASSED + 1))
}
check_fail() {
echo -e "${RED}${NC} $1"
FAILED=$((FAILED + 1))
}
check_warn() {
echo -e "${YELLOW}${NC} $1"
WARNINGS=$((WARNINGS + 1))
}
info() {
echo -e "${BLUE}${NC} $1"
}
# Determine project root
if [ -f "run.py" ]; then
PROJECT_ROOT="$(pwd)"
elif [ -f "../run.py" ]; then
PROJECT_ROOT="$(cd .. && pwd)"
else
echo "Error: Could not find project root. Please run this script from the LEDMatrix directory."
exit 1
fi
echo "Project root: $PROJECT_ROOT"
echo ""
# 1. Check required packages
echo "=== Required Packages ==="
PACKAGES=("nmcli" "hostapd" "dnsmasq")
MISSING_PACKAGES=()
for pkg in "${PACKAGES[@]}"; do
if command -v "$pkg" >/dev/null 2>&1; then
check_pass "$pkg is installed"
else
check_fail "$pkg is NOT installed"
MISSING_PACKAGES+=("$pkg")
fi
done
if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
echo ""
info "To install missing packages:"
echo " sudo apt update && sudo apt install -y ${MISSING_PACKAGES[*]}"
fi
echo ""
# 2. Check WiFi monitor service
echo "=== WiFi Monitor Service ==="
if systemctl list-unit-files | grep -q "ledmatrix-wifi-monitor.service"; then
check_pass "WiFi monitor service is installed"
if systemctl is-enabled --quiet ledmatrix-wifi-monitor.service 2>/dev/null; then
check_pass "WiFi monitor service is enabled"
else
check_warn "WiFi monitor service is installed but not enabled"
info "To enable: sudo systemctl enable ledmatrix-wifi-monitor.service"
fi
if systemctl is-active --quiet ledmatrix-wifi-monitor.service 2>/dev/null; then
check_pass "WiFi monitor service is running"
else
check_warn "WiFi monitor service is not running"
info "To start: sudo systemctl start ledmatrix-wifi-monitor.service"
info "Check logs: sudo journalctl -u ledmatrix-wifi-monitor -n 50"
fi
else
check_fail "WiFi monitor service is NOT installed"
info "To install: sudo $PROJECT_ROOT/scripts/install/install_wifi_monitor.sh"
fi
echo ""
# 3. Check WiFi configuration file
echo "=== Configuration Files ==="
WIFI_CONFIG="$PROJECT_ROOT/config/wifi_config.json"
if [ -f "$WIFI_CONFIG" ]; then
check_pass "WiFi config file exists: $WIFI_CONFIG"
# Check if JSON is valid
if python3 -m json.tool "$WIFI_CONFIG" >/dev/null 2>&1; then
check_pass "WiFi config file is valid JSON"
# Check for required fields
if grep -q "ap_ssid" "$WIFI_CONFIG"; then
AP_SSID=$(python3 -c "import json; print(json.load(open('$WIFI_CONFIG')).get('ap_ssid', 'N/A'))" 2>/dev/null)
info "AP SSID: $AP_SSID"
else
check_warn "ap_ssid not found in config"
fi
if grep -q "auto_enable_ap_mode" "$WIFI_CONFIG"; then
AUTO_ENABLE=$(python3 -c "import json; print(json.load(open('$WIFI_CONFIG')).get('auto_enable_ap_mode', 'N/A'))" 2>/dev/null)
info "Auto-enable AP mode: $AUTO_ENABLE"
else
check_warn "auto_enable_ap_mode not found in config"
fi
else
check_fail "WiFi config file is NOT valid JSON"
fi
else
check_warn "WiFi config file does not exist (will be created on first use)"
fi
echo ""
# 4. Check WiFi permissions
echo "=== WiFi Permissions ==="
if [ -f "/etc/sudoers.d/ledmatrix_wifi" ]; then
check_pass "WiFi sudoers file exists"
# Check if file is readable
if sudo -n test -r "/etc/sudoers.d/ledmatrix_wifi" 2>/dev/null; then
check_pass "WiFi sudoers file is readable"
else
check_warn "WiFi sudoers file may not be readable"
fi
else
check_warn "WiFi sudoers file does not exist"
info "To configure: $PROJECT_ROOT/scripts/install/configure_wifi_permissions.sh"
fi
if [ -f "/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules" ]; then
check_pass "WiFi PolicyKit rule exists"
else
check_warn "WiFi PolicyKit rule does not exist"
info "To configure: $PROJECT_ROOT/scripts/install/configure_wifi_permissions.sh"
fi
echo ""
# 5. Check WiFi interface
echo "=== WiFi Interface ==="
if ip link show wlan0 >/dev/null 2>&1; then
check_pass "WiFi interface wlan0 exists"
# Check if interface is up
if ip link show wlan0 | grep -q "state UP"; then
check_pass "WiFi interface wlan0 is UP"
else
check_warn "WiFi interface wlan0 is DOWN"
fi
else
check_fail "WiFi interface wlan0 does NOT exist"
info "Check if WiFi adapter is connected (USB WiFi or built-in)"
fi
echo ""
# 6. Check WiFi radio status
echo "=== WiFi Radio Status ==="
if command -v nmcli >/dev/null 2>&1; then
WIFI_RADIO=$(nmcli radio wifi 2>/dev/null || echo "unknown")
if echo "$WIFI_RADIO" | grep -qi "enabled"; then
check_pass "WiFi radio is enabled"
elif echo "$WIFI_RADIO" | grep -qi "disabled"; then
check_warn "WiFi radio is disabled"
info "To enable: sudo nmcli radio wifi on"
else
check_warn "WiFi radio status unknown: $WIFI_RADIO"
fi
elif command -v rfkill >/dev/null 2>&1; then
RFKILL_WIFI=$(rfkill list wifi 2>/dev/null || echo "")
if echo "$RFKILL_WIFI" | grep -q "Soft blocked: yes"; then
check_warn "WiFi is soft-blocked"
info "To unblock: sudo rfkill unblock wifi"
elif echo "$RFKILL_WIFI" | grep -q "Hard blocked: yes"; then
check_fail "WiFi is hard-blocked (hardware switch)"
else
check_pass "WiFi is not blocked"
fi
else
check_warn "Cannot check WiFi radio status (nmcli and rfkill not available)"
fi
echo ""
# 7. Check current WiFi connection
echo "=== Current WiFi Status ==="
if command -v nmcli >/dev/null 2>&1; then
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -E "wifi|wlan0" || echo "")
if echo "$WIFI_STATUS" | grep -q "connected"; then
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
IP=$(nmcli -t -f IP4.ADDRESS device show wlan0 2>/dev/null | cut -d: -f2 | cut -d/ -f1 | head -1)
SIGNAL=$(nmcli -t -f WIFI.SIGNAL device show wlan0 2>/dev/null | cut -d: -f2 | head -1)
check_pass "WiFi is connected"
info "SSID: $SSID"
info "IP Address: $IP"
info "Signal: $SIGNAL%"
else
check_warn "WiFi is not connected"
fi
elif command -v iwconfig >/dev/null 2>&1; then
if iwconfig wlan0 2>/dev/null | grep -q "ESSID:"; then
SSID=$(iwconfig wlan0 2>/dev/null | grep -oP 'ESSID:"\K[^"]*')
check_pass "WiFi is connected to: $SSID"
else
check_warn "WiFi is not connected"
fi
else
check_warn "Cannot check WiFi connection status"
fi
echo ""
# 8. Check Ethernet connection
echo "=== Ethernet Status ==="
ETH_CONNECTED=false
if command -v nmcli >/dev/null 2>&1; then
ETH_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -E "ethernet|eth" | grep "connected" || echo "")
if [ -n "$ETH_STATUS" ]; then
ETH_CONNECTED=true
check_pass "Ethernet is connected"
else
check_warn "Ethernet is not connected"
fi
elif command -v ip >/dev/null 2>&1; then
if ip addr show eth0 2>/dev/null | grep -q "inet " || ip addr show 2>/dev/null | grep -E "eth|enp" | grep -q "inet "; then
ETH_CONNECTED=true
check_pass "Ethernet appears to be connected"
else
check_warn "Ethernet does not appear to be connected"
fi
fi
echo ""
# 9. Check AP mode status
echo "=== AP Mode Status ==="
AP_ACTIVE=false
# Check hostapd
if systemctl is-active --quiet hostapd 2>/dev/null; then
AP_ACTIVE=true
check_warn "AP mode is ACTIVE (hostapd running)"
info "SSID: LEDMatrix-Setup (from config)"
info "IP: 192.168.4.1"
elif systemctl is-active --quiet dnsmasq 2>/dev/null; then
# dnsmasq might be running for other purposes, check if it's configured for AP
if grep -q "interface=wlan0" /etc/dnsmasq.conf 2>/dev/null; then
AP_ACTIVE=true
check_warn "AP mode appears to be active (dnsmasq configured for wlan0)"
fi
fi
# Check nmcli hotspot
if command -v nmcli >/dev/null 2>&1; then
HOTSPOT=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null | grep -i hotspot || echo "")
if [ -n "$HOTSPOT" ]; then
AP_ACTIVE=true
CONN_NAME=$(echo "$HOTSPOT" | cut -d: -f1)
check_warn "AP mode is ACTIVE (nmcli hotspot: $CONN_NAME)"
info "IP: 192.168.4.1"
fi
fi
if [ "$AP_ACTIVE" = false ]; then
check_pass "AP mode is not active"
fi
echo ""
# 10. Check WiFi Manager Python module
echo "=== WiFi Manager Module ==="
if python3 -c "from src.wifi_manager import WiFiManager" 2>/dev/null; then
check_pass "WiFi Manager module can be imported"
# Try to instantiate (but don't fail if it errors - may need config)
if python3 -c "import sys; sys.path.insert(0, '$PROJECT_ROOT'); from src.wifi_manager import WiFiManager; wm = WiFiManager(); print('OK')" 2>/dev/null; then
check_pass "WiFi Manager can be instantiated"
else
check_warn "WiFi Manager instantiation failed (may be expected)"
fi
else
check_fail "WiFi Manager module cannot be imported"
fi
echo ""
# 11. Check web interface WiFi API
echo "=== Web Interface WiFi API ==="
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
# Try to test the WiFi status API endpoint
if curl -s -f "http://localhost:5001/api/v3/wifi/status" >/dev/null 2>&1; then
check_pass "WiFi status API endpoint is accessible"
else
check_warn "WiFi status API endpoint is not accessible (may be expected if web interface requires auth)"
fi
else
check_warn "Web interface service is not running (cannot test API)"
fi
echo ""
# Summary
echo "=========================================="
echo "Summary"
echo "=========================================="
echo -e "${GREEN}Passed: $PASSED${NC}"
echo -e "${YELLOW}Warnings: $WARNINGS${NC}"
echo -e "${RED}Failed: $FAILED${NC}"
echo ""
# Show connectivity summary
echo "=== Connectivity ==="
if [ "$ETH_CONNECTED" = true ]; then
info "Ethernet: Connected"
else
info "Ethernet: Not connected"
fi
if [ "$AP_ACTIVE" = true ]; then
info "AP Mode: Active"
else
info "AP Mode: Inactive"
fi
echo ""
if [ $FAILED -eq 0 ] && [ $WARNINGS -eq 0 ]; then
echo -e "${GREEN}✓ All checks passed! WiFi setup looks good.${NC}"
exit 0
elif [ $FAILED -eq 0 ]; then
echo -e "${YELLOW}⚠ Setup looks mostly good, but there are some warnings.${NC}"
exit 0
else
echo -e "${RED}✗ Some checks failed. Please review the issues above.${NC}"
exit 1
fi

View File

@@ -1415,10 +1415,10 @@ class PluginStoreManager:
try:
# Try to fix permissions on __pycache__ directories recursively
import stat
for root, _dirs, files in os.walk(path):
for root, dirs, files in os.walk(path):
root_path = Path(root)
try:
# Make directory writable (0o777 is acceptable here - temporary before deletion)
# Make directory writable
os.chmod(root_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
except (OSError, PermissionError):
pass
@@ -1436,7 +1436,7 @@ class PluginStoreManager:
self.logger.info(f"Successfully removed {path} after fixing permissions")
return True
except Exception as e2:
self.logger.exception(f"Failed to remove {path} even after fixing permissions: {e2}")
self.logger.error(f"Failed to remove {path} even after fixing permissions: {e2}")
# Last resort: try with ignore_errors
try:
shutil.rmtree(path, ignore_errors=True)
@@ -1448,10 +1448,10 @@ class PluginStoreManager:
self.logger.error(f"Could not remove {path} even with ignore_errors")
return False
except Exception as e3:
self.logger.exception(f"Final removal attempt failed for {path}: {e3}")
self.logger.error(f"Final removal attempt failed for {path}: {e3}")
return False
except Exception as e:
self.logger.exception(f"Unexpected error removing {path}: {e}")
self.logger.error(f"Unexpected error removing {path}: {e}")
return False
def _find_plugin_path(self, plugin_id: str) -> Optional[Path]:

View File

@@ -3,6 +3,29 @@ WiFi Manager for Raspberry Pi LED Matrix
Handles WiFi connection management, access point mode, and network scanning.
Only enables AP mode when there is no active WiFi connection.
Tested and optimized for:
- Raspberry Pi OS Trixie (Debian 13) with NetworkManager/Netplan
- Raspberry Pi OS Bookworm (Debian 12) with NetworkManager
- Raspberry Pi 3B+, 4, 5 with built-in WiFi
Sudoers Requirements:
The following sudoers entries are required for passwordless operation.
Add to /etc/sudoers.d/ledmatrix_wifi:
ledpi ALL=(ALL) NOPASSWD: /usr/bin/nmcli
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl start hostapd
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop hostapd
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl start dnsmasq
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop dnsmasq
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart NetworkManager
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/ip
ledpi ALL=(ALL) NOPASSWD: /sbin/ip
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/rfkill
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
"""
import subprocess
@@ -13,7 +36,7 @@ import time
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@@ -47,6 +70,12 @@ DEFAULT_AP_CHANNEL = 7
# LED status message file (for display_controller integration)
LED_STATUS_FILE = None # Will be set dynamically
# NetworkManager connection file locations (Trixie uses /run, Bookworm uses /etc)
NM_CONNECTIONS_PATHS = [
Path("/etc/NetworkManager/system-connections"),
Path("/run/NetworkManager/system-connections"), # Trixie with Netplan
]
@dataclass
class WiFiNetwork:
@@ -97,13 +126,20 @@ class WiFiManager:
self.has_hostapd = self._check_command("hostapd")
self.has_dnsmasq = self._check_command("dnsmasq")
# Discover WiFi interface (don't hardcode wlan0)
self._wifi_interface = self._discover_wifi_interface()
# Detect if we're running on Trixie (Netplan-based NetworkManager)
self._is_trixie = self._detect_trixie()
# Initialize disconnected check counter for grace period
# This prevents AP mode from enabling on transient network hiccups
self._disconnected_checks = 0
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}")
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
def _show_led_message(self, message: str, duration: int = 5):
"""
@@ -161,9 +197,98 @@ class WiFiManager:
return True
return False
except:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
return False
def _discover_wifi_interface(self) -> str:
"""
Discover the primary WiFi interface name dynamically.
Returns the first WiFi interface found, or 'wlan0' as fallback.
Supports various interface naming schemes:
- Traditional: wlan0, wlan1
- Predictable: wlp2s0, wlx<mac>
- USB adapters: wlan1, wlx*
"""
try:
if self.has_nmcli:
# Use nmcli to find WiFi devices (most reliable on NetworkManager systems)
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE", "device", "status"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.split(':')
if len(parts) >= 2 and parts[1].strip() == 'wifi':
interface = parts[0].strip()
logger.debug(f"Discovered WiFi interface via nmcli: {interface}")
return interface
# Fallback: Check /sys/class/net for wireless interfaces
net_path = Path("/sys/class/net")
if net_path.exists():
for iface in net_path.iterdir():
wireless_path = iface / "wireless"
if wireless_path.exists():
interface = iface.name
logger.debug(f"Discovered WiFi interface via /sys: {interface}")
return interface
# Last resort: Check common interface names
for iface in ["wlan0", "wlan1", "wlp2s0", "wlp3s0"]:
iface_path = Path(f"/sys/class/net/{iface}")
if iface_path.exists():
logger.debug(f"Found WiFi interface by name probe: {iface}")
return iface
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.warning(f"Error discovering WiFi interface: {e}")
logger.warning("Could not discover WiFi interface, defaulting to wlan0")
return "wlan0"
def _detect_trixie(self) -> bool:
"""
Detect if running on Raspberry Pi OS Trixie (Debian 13).
Trixie uses Netplan with NetworkManager, which changes behavior:
- Connection files are stored in /run/NetworkManager/system-connections
- nmcli hotspot requires different handling
- PMF (Protected Management Frames) may need to be disabled
"""
try:
# Check for Netplan (primary indicator of Trixie)
netplan_path = Path("/etc/netplan")
if netplan_path.exists() and any(netplan_path.glob("*.yaml")):
logger.debug("Detected Trixie: Netplan configuration found")
return True
# Check Debian version
os_release = Path("/etc/os-release")
if os_release.exists():
content = os_release.read_text()
if 'VERSION_CODENAME=trixie' in content or 'VERSION_ID="13"' in content:
logger.debug("Detected Trixie: os-release indicates Debian 13")
return True
# Check if NM connections are in /run (Trixie behavior)
# NM_CONNECTIONS_PATHS[0] = /etc/..., NM_CONNECTIONS_PATHS[1] = /run/...
etc_nm_path = NM_CONNECTIONS_PATHS[0] # Bookworm location
run_nm_path = NM_CONNECTIONS_PATHS[1] # Trixie location
if run_nm_path.exists() and any(run_nm_path.glob("*.nmconnection")):
if not etc_nm_path.exists() or not any(etc_nm_path.glob("*.nmconnection")):
logger.debug("Detected Trixie: NM connections in /run only")
return True
except (OSError, PermissionError) as e:
logger.debug(f"Could not detect Trixie: {e}")
return False
def _load_config(self):
"""Load WiFi configuration from file"""
if self.config_path.exists():
@@ -364,7 +489,7 @@ class WiFiManager:
"""Get WiFi status using iwconfig (fallback)"""
try:
result = subprocess.run(
["iwconfig", "wlan0"],
["iwconfig", self._wifi_interface],
capture_output=True,
text=True,
timeout=5
@@ -530,7 +655,7 @@ class WiFiManager:
return True
return False
except:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
return False
def scan_networks(self) -> List[WiFiNetwork]:
@@ -662,7 +787,7 @@ class WiFiManager:
networks = []
try:
result = subprocess.run(
["iwlist", "wlan0", "scan"],
["iwlist", self._wifi_interface, "scan"],
capture_output=True,
text=True,
timeout=30
@@ -745,9 +870,9 @@ class WiFiManager:
status = self.get_wifi_status()
if status.connected and status.ssid:
original_ssid = status.ssid
# Get the active connection name/UUID for wlan0
# Get the active connection name/UUID for WiFi interface
result = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", "wlan0"],
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", self._wifi_interface],
capture_output=True,
text=True,
timeout=5
@@ -834,7 +959,7 @@ class WiFiManager:
while wait_count < max_wait:
time.sleep(1)
result = subprocess.run(
["nmcli", "-t", "-f", "STATE", "device", "status", "wlan0"],
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
capture_output=True,
text=True,
timeout=5
@@ -1013,7 +1138,7 @@ class WiFiManager:
max_wait = 3
for wait_attempt in range(max_wait):
device_result = subprocess.run(
["nmcli", "-t", "-f", "STATE", "device", "status", "wlan0"],
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
capture_output=True,
text=True,
timeout=5
@@ -1171,7 +1296,7 @@ class WiFiManager:
# Also disconnect the device to ensure clean state
result = subprocess.run(
["nmcli", "device", "disconnect", "wlan0"],
["nmcli", "device", "disconnect", self._wifi_interface],
capture_output=True,
text=True,
timeout=10
@@ -1406,34 +1531,34 @@ class WiFiManager:
# Create dnsmasq config
self._create_dnsmasq_config()
# Set up wlan0 for AP mode
# Set up WiFi interface for AP mode
try:
# Disconnect from any existing WiFi network
subprocess.run(
["sudo", "nmcli", "device", "disconnect", "wlan0"],
["sudo", "nmcli", "device", "disconnect", self._wifi_interface],
capture_output=True,
timeout=10
)
# Set static IP for AP mode
subprocess.run(
["sudo", "ip", "addr", "flush", "dev", "wlan0"],
["sudo", "ip", "addr", "flush", "dev", self._wifi_interface],
capture_output=True,
timeout=10
)
subprocess.run(
["sudo", "ip", "addr", "add", "192.168.4.1/24", "dev", "wlan0"],
["sudo", "ip", "addr", "add", "192.168.4.1/24", "dev", self._wifi_interface],
capture_output=True,
timeout=10
)
subprocess.run(
["sudo", "ip", "link", "set", "wlan0", "up"],
["sudo", "ip", "link", "set", self._wifi_interface, "up"],
capture_output=True,
timeout=10
)
logger.info("Configured wlan0 with IP 192.168.4.1 for AP mode")
except Exception as e:
logger.warning(f"Error setting up wlan0 IP: {e}")
logger.info(f"Configured {self._wifi_interface} with IP 192.168.4.1 for AP mode")
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.warning(f"Error setting up {self._wifi_interface} IP: {e}")
# Start services
try:
@@ -1480,10 +1605,10 @@ class WiFiManager:
timeout=5
)
# Add NAT rule to redirect port 80 to 5000 on wlan0
# Add NAT rule to redirect port 80 to 5000 on WiFi interface
# First check if rule already exists
check_result = subprocess.run(
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING", "-i", "wlan0", "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
capture_output=True,
timeout=5
)
@@ -1491,7 +1616,7 @@ class WiFiManager:
if check_result.returncode != 0:
# Rule doesn't exist, add it
subprocess.run(
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING", "-i", "wlan0", "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
capture_output=True,
timeout=5
)
@@ -1499,14 +1624,14 @@ class WiFiManager:
# Also allow incoming connections on port 80
check_input = subprocess.run(
["sudo", "iptables", "-C", "INPUT", "-i", "wlan0", "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
["sudo", "iptables", "-C", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
capture_output=True,
timeout=5
)
if check_input.returncode != 0:
subprocess.run(
["sudo", "iptables", "-A", "INPUT", "-i", "wlan0", "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
["sudo", "iptables", "-A", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
capture_output=True,
timeout=5
)
@@ -1529,8 +1654,14 @@ class WiFiManager:
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
"""
Enable AP mode using nmcli hotspot (simpler fallback, no captive portal).
This is a fallback when hostapd/dnsmasq is not available or fails.
Enable AP mode using nmcli hotspot.
This method is optimized for both Bookworm and Trixie:
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections
On Trixie, we also disable PMF (Protected Management Frames) which can cause
connection issues with certain WiFi adapters and clients.
"""
try:
# Stop any existing connection
@@ -1599,18 +1730,21 @@ class WiFiManager:
# Get AP settings from config
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
# Use nmcli hotspot command (simpler, works with Broadcom chips)
# Open network (no password) for easy setup access
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} (no password)")
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)")
# Note: Some NetworkManager versions add a default password to hotspots
# We'll create it and then immediately remove all security settings
cmd = [
"nmcli", "device", "wifi", "hotspot",
"ifname", "wlan0",
"ifname", self._wifi_interface,
"con-name", "LEDMatrix-Setup-AP",
"ssid", ap_ssid,
"band", "bg" # 2.4GHz for maximum compatibility
"band", "bg", # 2.4GHz for maximum compatibility
"channel", str(ap_channel),
# Don't pass password parameter - we'll remove security after creation
]
@@ -1636,6 +1770,12 @@ class WiFiManager:
("802-11-wireless-security.auth-alg", "open"),
]
# On Trixie, also disable PMF (Protected Management Frames)
# This can cause connection issues with certain WiFi adapters and clients
if self._is_trixie:
security_settings.append(("802-11-wireless-security.pmf", "disable"))
logger.info("Trixie detected: disabling PMF for better client compatibility")
for setting, value in security_settings:
result_modify = subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", setting, str(value)],
@@ -1646,6 +1786,17 @@ class WiFiManager:
if result_modify.returncode != 0:
logger.debug(f"Could not set {setting} to {value}: {result_modify.stderr}")
# On Trixie, set static IP address for the hotspot (default is 10.42.0.1)
# We want 192.168.4.1 for consistency
subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
"ipv4.addresses", "192.168.4.1/24",
"ipv4.method", "shared"],
capture_output=True,
text=True,
timeout=5
)
# Verify it's open
verify_result = subprocess.run(
["nmcli", "-t", "-f", "802-11-wireless-security.key-mgmt,802-11-wireless-security.psk", "connection", "show", "LEDMatrix-Setup-AP"],
@@ -1681,12 +1832,29 @@ class WiFiManager:
# Recreate without any password parameters
cmd_recreate = [
"nmcli", "device", "wifi", "hotspot",
"ifname", "wlan0",
"ifname", self._wifi_interface,
"con-name", "LEDMatrix-Setup-AP",
"ssid", ap_ssid,
"band", "bg"
"band", "bg",
"channel", str(ap_channel),
]
subprocess.run(cmd_recreate, capture_output=True, timeout=30)
# Set IP address for consistency
subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
"ipv4.addresses", "192.168.4.1/24",
"ipv4.method", "shared"],
capture_output=True,
timeout=5
)
# Disable PMF on Trixie
if self._is_trixie:
subprocess.run(
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
"802-11-wireless-security.pmf", "disable"],
capture_output=True,
timeout=5
)
logger.info("Recreated hotspot as open network")
else:
logger.info("Hotspot verified as open (no password)")
@@ -1733,7 +1901,7 @@ class WiFiManager:
Get AP status using nmcli (for hotspot mode).
Returns:
Dict with AP status info
Dict with AP status info including active state, SSID, IP, and interface
"""
try:
# Check if hotspot connection is active
@@ -1747,16 +1915,34 @@ class WiFiManager:
for line in result.stdout.strip().split('\n'):
parts = line.split(':')
if len(parts) >= 2 and 'hotspot' in parts[1].lower():
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
ip = '192.168.4.1'
interface = parts[2] if len(parts) > 2 else self._wifi_interface
try:
ip_result = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", interface],
capture_output=True,
text=True,
timeout=5
)
if ip_result.returncode == 0:
for ip_line in ip_result.stdout.strip().split('\n'):
if '/' in ip_line:
ip = ip_line.split('/')[0].split(':')[-1].strip()
break
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
return {
'active': True,
'ssid': self.config.get("ap_ssid", DEFAULT_AP_SSID),
'ip': '192.168.4.1', # nmcli hotspot uses this IP
'interface': parts[2] if len(parts) > 2 else "wlan0"
'ip': ip,
'interface': interface
}
return {'active': False}
except Exception as e:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.error(f"Error getting AP status with nmcli: {e}")
return {'active': False}
@@ -1782,7 +1968,7 @@ class WiFiManager:
timeout=2
)
hostapd_active = result.stdout.strip() == "active"
except:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
pass
# Stop services
@@ -1847,14 +2033,14 @@ class WiFiManager:
if iptables_check.returncode == 0:
# Remove NAT redirect rule
subprocess.run(
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING", "-i", "wlan0", "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
capture_output=True,
timeout=5
)
# Remove INPUT rule
subprocess.run(
["sudo", "iptables", "-D", "INPUT", "-i", "wlan0", "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
["sudo", "iptables", "-D", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
capture_output=True,
timeout=5
)
@@ -1870,13 +2056,13 @@ class WiFiManager:
timeout=5
)
logger.info("Disabled IP forwarding")
except Exception as e:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.warning(f"Could not remove iptables rules or disable forwarding: {e}")
# Continue anyway
# Clean up wlan0 IP configuration
# Clean up WiFi interface IP configuration
subprocess.run(
["sudo", "ip", "addr", "del", "192.168.4.1/24", "dev", "wlan0"],
["sudo", "ip", "addr", "del", "192.168.4.1/24", "dev", self._wifi_interface],
capture_output=True,
timeout=10
)
@@ -1942,7 +2128,7 @@ class WiFiManager:
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
# Open network configuration (no password) for easy setup access
config_content = f"""interface=wlan0
config_content = f"""interface={self._wifi_interface}
driver=nl80211
ssid={ap_ssid}
hw_mode=g
@@ -1964,22 +2150,66 @@ ignore_broadcast_ssid=0
timeout=10
)
logger.info(f"Created hostapd config at {HOSTAPD_CONFIG_PATH}")
except Exception as e:
logger.info(f"Created hostapd config at {HOSTAPD_CONFIG_PATH} for {self._wifi_interface}")
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
logger.error(f"Error creating hostapd config: {e}")
raise
def _create_dnsmasq_config(self):
"""Create dnsmasq configuration file with captive portal DNS redirection"""
def _check_dnsmasq_conflict(self) -> Tuple[bool, str]:
"""
Check if dnsmasq is already in use for other purposes (e.g., Pi-hole).
Returns:
Tuple of (conflict_detected, description)
"""
try:
# Check if dnsmasq service is active
result = subprocess.run(
["systemctl", "is-active", "dnsmasq"],
capture_output=True,
text=True,
timeout=5
)
if result.stdout.strip() == "active":
# Check if it's configured for something other than our AP
if DNSMASQ_CONFIG_PATH.exists():
try:
content = DNSMASQ_CONFIG_PATH.read_text()
# Check for Pi-hole or other common dnsmasq uses
if 'pihole' in content.lower() or 'pi-hole' in content.lower():
return True, "Pi-hole detected - dnsmasq is in use"
if 'server=' in content and self._wifi_interface not in content:
return True, "dnsmasq appears to be configured for DNS forwarding"
except (OSError, PermissionError):
pass
return False, ""
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
return False, ""
def _create_dnsmasq_config(self):
"""
Create dnsmasq configuration file with 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.
"""
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")
config_content = """interface=wlan0
config_content = f"""interface={self._wifi_interface}
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
# Captive portal: Redirect all DNS queries to Pi
@@ -2002,8 +2232,8 @@ address=/detectportal.firefox.com/192.168.4.1
timeout=10
)
logger.info(f"Created dnsmasq config at {DNSMASQ_CONFIG_PATH} with captive portal DNS redirection")
except Exception as e:
logger.info(f"Created dnsmasq config at {DNSMASQ_CONFIG_PATH} for {self._wifi_interface}")
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
logger.error(f"Error creating dnsmasq config: {e}")
raise