mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
346
scripts/verify_wifi_setup.sh
Executable 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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user