Add Network + Bluetooth config in Web UI

- src/system/network.py: WiFi scan/connect/hotspot via nmcli + iwlist fallback
- src/system/bluetooth.py: BT scan/pair/connect/MIDI via bluetoothctl
- REST endpoints: /api/wifi/* and /api/bluetooth/* (16 endpoints)
- Web UI: tabbed settings with Network + Bluetooth panels
- network.js: WiFi scan, connect modal, saved nets, hotspot
- bluetooth.js: BT scan, pair/unpair, connect, MIDI service
- style.css: tabs, network list, BT device list, signal indicators
- scripts/setup-bt-midi.sh: BT MIDI systemd bridge service
- tests/test_network.py: 22 tests (nmcli/iwlist, fallback, hotspot)
- tests/test_bt.py: 24 tests (status, scan, pair, MIDI, edge cases)
- _gather_state includes wifi + bluetooth sub-objects
- All 93 tests pass
This commit is contained in:
2026-06-09 01:14:41 -04:00
parent c73b1ffa6c
commit b4237f2f1d
10 changed files with 3305 additions and 186 deletions
+620
View File
@@ -0,0 +1,620 @@
"""WiFi network management module for Pi Multi-FX Pedal.
Uses nmcli (NetworkManager) by default — works on RPi OS and most
desktop Linux distros. Falls back to iwlist + manual wpa_supplicant
for minimized DietPi installs without NetworkManager.
All public methods return dicts suitable for JSON serialization.
"""
from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ── Constants ─────────────────────────────────────────────────────────────────
HOTSPOT_SCRIPT = Path(__file__).resolve().parent.parent.parent / "scripts" / "setup-wifi-ap.sh"
WPA_SUPPLICANT_CONF = Path("/etc/wpa_supplicant/wpa_supplicant.conf")
# ── Capability detection ──────────────────────────────────────────────────────
def _has_nmcli() -> bool:
"""Check if NetworkManager (nmcli) is available on this system."""
try:
subprocess.run(
["which", "nmcli"],
capture_output=True, timeout=5, check=True,
)
return True
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return False
def _run(cmd: list[str], timeout: int = 15, check: bool = True) -> subprocess.CompletedProcess:
"""Run a subprocess and return the result."""
logger.debug("Running: %s", " ".join(cmd))
return subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=check,
)
# ── nmcli helpers ─────────────────────────────────────────────────────────────
def _nmcli_scan() -> list[dict[str, Any]]:
"""Scan WiFi networks using nmcli.
Returns a list of networks with SSID, signal strength (%), and encryption type.
"""
result = _run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"],
timeout=20,
check=False,
)
networks: list[dict[str, Any]] = []
seen: set[str] = set()
for line in result.stdout.strip().split("\n"):
if not line.strip() or ":" not in line:
continue
parts = line.split(":", 2)
if len(parts) < 3:
continue
ssid, signal_str, security = parts
ssid = ssid.strip()
if not ssid or ssid in seen:
continue
seen.add(ssid)
try:
signal = int(signal_str)
except ValueError:
signal = 0
networks.append({
"ssid": ssid,
"signal": signal,
"encryption": security.strip() if security.strip() and security.strip() != "" else "Open",
})
return sorted(networks, key=lambda n: n["signal"], reverse=True)
def _nmcli_status() -> dict[str, Any]:
"""Get current WiFi connection status via nmcli.
Returns SSID, IP address, signal strength, and device name.
"""
result = _run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"],
timeout=10, check=False,
)
wifi_device = None
for line in result.stdout.strip().split("\n"):
parts = line.split(":")
if len(parts) >= 3 and parts[1] == "wifi":
wifi_device = parts[0]
break
if not wifi_device:
return {"connected": False, "ssid": None, "ip": None, "signal": 0}
# Get connection details
detail = _run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION,IP4.ADDRESS", "device", "show", wifi_device],
timeout=10, check=False,
)
ssid = None
ip = None
for line in detail.stdout.strip().split("\n"):
if line.startswith("GENERAL.CONNECTION:"):
val = line.split(":", 1)[1].strip()
if val and val != "--":
ssid = val
elif line.startswith("IP4.ADDRESS:"):
val = line.split(":", 1)[1].strip()
if val and val != "--":
ip = val.split("/")[0]
if not ssid:
return {"connected": False, "ssid": None, "ip": None, "signal": 0}
# Get signal strength
signal = 0
sig_result = _run(
["nmcli", "-t", "-f", "SSID,SIGNAL", "device", "wifi", "list"],
timeout=10, check=False,
)
for line in sig_result.stdout.strip().split("\n"):
if ":" in line:
s, sig = line.split(":", 1)
if s.strip() == ssid:
try:
signal = int(sig)
except ValueError:
pass
break
return {
"connected": True,
"ssid": ssid,
"ip": ip,
"signal": signal,
"device": wifi_device,
}
def _nmcli_saved() -> list[dict[str, Any]]:
"""List saved/known WiFi connections."""
result = _run(
["nmcli", "-t", "-f", "NAME,UUID,TYPE", "connection", "show"],
timeout=10, check=False,
)
saved: list[dict[str, Any]] = []
for line in result.stdout.strip().split("\n"):
parts = line.split(":")
if len(parts) >= 3 and "wireless" in parts[2]:
saved.append({
"ssid": parts[0],
"uuid": parts[1],
})
return saved
def _nmcli_connect(ssid: str, password: str) -> bool:
"""Connect to a WiFi network.
If the network is already saved, just activate it.
Otherwise create a new connection profile.
"""
# Check if already saved
saved = _nmcli_saved()
for net in saved:
if net["ssid"] == ssid:
_run(
["nmcli", "connection", "up", net["uuid"]],
timeout=30, check=False,
)
logger.info("Activated saved network: %s", ssid)
return True
# Connect with password
result = _run(
["nmcli", "device", "wifi", "connect", ssid, "password", password],
timeout=30, check=False,
)
ok = result.returncode == 0
if ok:
logger.info("Connected to new network: %s", ssid)
else:
logger.warning("Failed to connect to %s: %s", ssid, result.stderr.strip())
return ok
def _nmcli_disconnect() -> bool:
"""Disconnect from the current WiFi network."""
status = _nmcli_status()
if not status.get("connected"):
return True # already disconnected
device = status.get("device", "wlan0")
result = _run(
["nmcli", "device", "disconnect", device],
timeout=15, check=False,
)
return result.returncode == 0
def _nmcli_forget(ssid: str) -> bool:
"""Delete a saved WiFi network."""
saved = _nmcli_saved()
for net in saved:
if net["ssid"] == ssid:
_run(
["nmcli", "connection", "delete", net["uuid"]],
timeout=15, check=False,
)
logger.info("Forgot network: %s", ssid)
return True
return False
# ── iwlist fallback (minimized DietPi) ────────────────────────────────────────
def _iwlist_scan() -> list[dict[str, Any]]:
"""Fallback WiFi scan using iwlist (no NetworkManager)."""
result = _run(
["sudo", "iwlist", "wlan0", "scan"],
timeout=30, check=False,
)
networks: list[dict[str, Any]] = []
current: dict[str, Any] = {}
for line in result.stdout.split("\n"):
line = line.strip()
if "ESSID:" in line:
essid = line.split("ESSID:")[1].strip().strip('"')
if essid and essid != "":
current["ssid"] = essid
elif "Signal level=" in line:
match = re.search(r"Signal level=(-?\d+)", line)
if match:
dbm = int(match.group(1))
# Convert dBm to percentage (0% = -100dBm, 100% = -30dBm)
signal = max(0, min(100, int((dbm + 100) * 100 / 70)))
current["signal"] = signal
elif "Encryption key:" in line:
val = line.split(":")[1].strip()
current["encryption"] = "WPA/WPA2" if val == "on" else "Open"
elif "IE:" in line and "WPA" in line.upper():
if current.get("encryption") in (None, "Open"):
current["encryption"] = "WPA/WPA2"
if current.get("ssid") and "signal" in current and "encryption" in current:
networks.append(dict(current))
current = {}
seen: set[str] = set()
unique: list[dict[str, Any]] = []
for n in networks:
if n["ssid"] not in seen:
seen.add(n["ssid"])
unique.append(n)
return sorted(unique, key=lambda n: n.get("signal", 0), reverse=True)
def _iwlist_status() -> dict[str, Any]:
"""Fallback WiFi status using iwconfig."""
result = _run(
["iwconfig", "wlan0"],
timeout=10, check=False,
)
stdout = result.stdout
ssid = None
signal = 0
match_ssid = re.search(r'ESSID:"([^"]+)"', stdout)
if match_ssid:
ssid = match_ssid.group(1)
match_signal = re.search(r"Signal level=(-?\d+)", stdout)
if match_signal:
dbm = int(match_signal.group(1))
signal = max(0, min(100, int((dbm + 100) * 100 / 70)))
# Get IP
ip = None
ip_result = _run(
["ip", "-4", "addr", "show", "wlan0"],
timeout=5, check=False,
)
ip_match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", ip_result.stdout)
if ip_match:
ip = ip_match.group(1)
if not ssid or ssid == "off/any":
return {"connected": False, "ssid": None, "ip": None, "signal": 0}
return {"connected": True, "ssid": ssid, "ip": ip, "signal": signal, "device": "wlan0"}
def _wpa_connect(ssid: str, password: str) -> bool:
"""Fallback: add network to wpa_supplicant.conf and restart."""
try:
# Generate psk
psk_result = _run(
["wpa_passphrase", ssid, password],
timeout=10, check=True,
)
network_block = psk_result.stdout.strip()
# Append to wpa_supplicant.conf
if WPA_SUPPLICANT_CONF.exists():
current = WPA_SUPPLICANT_CONF.read_text()
if ssid in current:
# Remove old config for this SSID and re-add
lines = current.split("\n")
new_lines = []
skip = False
for line in lines:
if f'ssid="{ssid}"' in line:
skip = True
continue
if skip and "}" in line:
skip = False
continue
if not skip:
new_lines.append(line)
current = "\n".join(new_lines)
updated = current.strip() + "\n\n" + network_block + "\n"
else:
updated = network_block + "\n"
WPA_SUPPLICANT_CONF.parent.mkdir(parents=True, exist_ok=True)
# Write via temp to avoid partial writes
with tempfile.NamedTemporaryFile(
mode="w", dir=str(WPA_SUPPLICANT_CONF.parent),
prefix="wpa_temp_", delete=False,
) as f:
f.write(updated)
tmp_path = f.name
_run(["sudo", "cp", tmp_path, str(WPA_SUPPLICANT_CONF)], timeout=10)
os.unlink(tmp_path)
# Restart networking
_run(["sudo", "wpa_cli", "-i", "wlan0", "reconfigure"], timeout=10, check=False)
time.sleep(3)
return True
except Exception as e:
logger.error("Failed to connect with wpa_supplicant: %s", e)
return False
# ── Hotspot control ───────────────────────────────────────────────────────────
def _hotspot_status() -> dict[str, Any]:
"""Check if the WiFi hotspot is currently active."""
# Check if hostapd is running
result = _run(
["systemctl", "is-active", "hostapd"],
timeout=10, check=False,
)
active = result.stdout.strip() == "active"
if not active:
return {"active": False, "ssid": None, "clients": 0, "ip": None}
# Read SSID from config
ssid = None
hostapd_conf = Path("/etc/hostapd/hostapd.conf")
if hostapd_conf.exists():
for line in hostapd_conf.read_text().split("\n"):
if line.startswith("ssid="):
ssid = line.split("=", 1)[1].strip()
break
# Count connected clients via iw
clients = 0
iw_result = _run(
["iw", "dev", "wlan0", "station", "dump"],
timeout=10, check=False,
)
if iw_result.returncode == 0:
clients = len([l for l in iw_result.stdout.split("\n") if "Station" in l])
return {
"active": True,
"ssid": ssid or "Pi-Pedal",
"clients": clients,
"ip": "192.168.4.1",
}
def _hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> bool:
"""Enable WiFi hotspot mode by calling the setup script."""
result = _run(
["sudo", "bash", str(HOTSPOT_SCRIPT), "--ssid", ssid, "--psk", password],
timeout=60, check=False,
)
ok = result.returncode == 0
if ok:
logger.info("Hotspot enabled: SSID=%s", ssid)
else:
logger.warning("Failed to enable hotspot: %s", result.stderr.strip())
return ok
def _hotspot_disable() -> bool:
"""Disable WiFi hotspot, return to client mode."""
result = _run(
["sudo", "bash", str(HOTSPOT_SCRIPT), "--disable"],
timeout=60, check=False,
)
ok = result.returncode == 0
if ok:
logger.info("Hotspot disabled")
else:
logger.warning("Failed to disable hotspot: %s", result.stderr.strip())
return ok
# ── Public API ─────────────────────────────────────────────────────────────────
def wifi_scan() -> list[dict[str, Any]]:
"""Scan for available WiFi networks.
Returns a list of dicts with keys: ssid, signal (%), encryption.
"""
if _has_nmcli():
try:
return _nmcli_scan()
except Exception as e:
logger.warning("nmcli scan failed, falling back to iwlist: %s", e)
try:
return _iwlist_scan()
except Exception as e:
logger.error("All WiFi scan methods failed: %s", str(e))
return []
def wifi_status() -> dict[str, Any]:
"""Get current WiFi client connection status.
Returns dict with: connected (bool), ssid (str|None), ip (str|None),
signal (int), device (str|None).
"""
if _has_nmcli():
try:
return _nmcli_status()
except Exception as e:
logger.warning("nmcli status failed, falling back: %s", e)
try:
return _iwlist_status()
except Exception as e:
logger.error("All WiFi status methods failed: %s", str(e))
return {"connected": False, "ssid": None, "ip": None, "signal": 0}
def wifi_saved_networks() -> list[dict[str, Any]]:
"""List saved/known WiFi networks.
Returns list of dicts with keys: ssid, uuid (nmcli) or just ssid.
"""
if _has_nmcli():
try:
return _nmcli_saved()
except Exception as e:
logger.warning("nmcli saved networks failed: %s", e)
# Fallback: parse wpa_supplicant.conf
try:
if WPA_SUPPLICANT_CONF.exists():
networks: list[dict[str, Any]] = []
for line in WPA_SUPPLICANT_CONF.read_text().split("\n"):
match = re.search(r'ssid="([^"]+)"', line)
if match:
networks.append({"ssid": match.group(1)})
return networks
except Exception as e:
logger.warning("Failed to read wpa_supplicant.conf: %s", e)
return []
def wifi_connect(ssid: str, password: str) -> dict[str, Any]:
"""Connect to a WiFi network.
Args:
ssid: Network SSID.
password: Network password.
Returns:
dict with ok (bool) and error (str|None) keys.
"""
if not ssid:
return {"ok": False, "error": "SSID is required"}
if not password:
return {"ok": False, "error": "Password is required"}
if _has_nmcli():
try:
ok = _nmcli_connect(ssid, password)
return {"ok": ok, "error": None if ok else "Failed to connect via NetworkManager"}
except Exception as e:
logger.warning("nmcli connect failed, falling back: %s", e)
try:
ok = _wpa_connect(ssid, password)
return {"ok": ok, "error": None if ok else "Failed to connect via wpa_supplicant"}
except Exception as e:
logger.error("All connect methods failed: %s", str(e))
return {"ok": False, "error": str(e)}
def wifi_disconnect() -> dict[str, Any]:
"""Disconnect from the current WiFi network.
Returns dict with ok (bool) key.
"""
if _has_nmcli():
try:
ok = _nmcli_disconnect()
return {"ok": ok}
except Exception as e:
logger.warning("nmcli disconnect failed: %s", e)
try:
_run(["sudo", "iwconfig", "wlan0", "essid", "off"], timeout=10, check=False)
return {"ok": True}
except Exception as e:
logger.error("Disconnect failed: %s", str(e))
return {"ok": False}
def wifi_forget(ssid: str) -> dict[str, Any]:
"""Forget a saved WiFi network.
Args:
ssid: Network SSID to forget.
Returns:
dict with ok (bool) key.
"""
if _has_nmcli():
try:
ok = _nmcli_forget(ssid)
return {"ok": ok}
except Exception as e:
logger.warning("nmcli forget failed: %s", e)
# Fallback: remove from wpa_supplicant.conf
try:
if WPA_SUPPLICANT_CONF.exists():
lines = WPA_SUPPLICANT_CONF.read_text().split("\n")
new_lines: list[str] = []
skip = False
removed = False
for line in lines:
if f'ssid="{ssid}"' in line:
skip = True
removed = True
continue
if skip and "}" in line:
skip = False
continue
if not skip:
new_lines.append(line)
if removed:
WPA_SUPPLICANT_CONF.write_text("\n".join(new_lines))
_run(["sudo", "wpa_cli", "-i", "wlan0", "reconfigure"], timeout=10, check=False)
return {"ok": True}
return {"ok": False}
except Exception as e:
logger.error("Forget failed: %s", str(e))
return {"ok": False}
def hotspot_status() -> dict[str, Any]:
"""Get the current hotspot status.
Returns dict with active (bool), ssid (str|None), clients (int), ip (str|None).
"""
try:
return _hotspot_status()
except Exception as e:
logger.error("Hotspot status failed: %s", str(e))
return {"active": False, "ssid": None, "clients": 0, "ip": None}
def hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> dict[str, Any]:
"""Enable the WiFi hotspot.
Args:
ssid: Hotspot SSID.
password: Hotspot password (min 8 chars).
Returns:
dict with ok (bool) and error (str|None) keys.
"""
if len(password) < 8:
return {"ok": False, "error": "Password must be at least 8 characters"}
try:
ok = _hotspot_enable(ssid, password)
return {"ok": ok, "error": None if ok else "Failed to enable hotspot"}
except Exception as e:
logger.error("Hotspot enable failed: %s", str(e))
return {"ok": False, "error": str(e)}
def hotspot_disable() -> dict[str, Any]:
"""Disable the WiFi hotspot.
Returns dict with ok (bool) key.
"""
try:
ok = _hotspot_disable()
return {"ok": ok}
except Exception as e:
logger.error("Hotspot disable failed: %s", str(e))
return {"ok": False}