Files
led-controller/src/controllers/wifi_bridge.py
Jimmy ace5770b3a refactor(api): complete fastapi migration and related features
Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:55:28 +12:00

245 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pi WiFi and saved ESP-NOW bridge profiles."""
from __future__ import annotations
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import json
import secrets
from settings import get_settings
from util.bridge_profiles import find_bridge_profile, normalise_bridges
from util.bridge_runtime import (
active_bridge_profile_id,
bridge_connected,
bridge_serial_connected,
bridge_ws_connected,
connect_bridge_profile,
connect_bridge_serial,
connect_bridge_wifi,
)
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
router = APIRouter()
def _bridge_transport(settings) -> str:
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
return mode if mode in ("wifi", "serial") else "wifi"
def _bridges_payload(settings) -> dict:
return {
"ok": True,
"wifi_interface": settings.get("wifi_interface") or "",
"bridge_ws_url": settings.get("bridge_ws_url") or "",
"bridge_connected": bridge_connected(),
"bridge_wifi_connected": bridge_ws_connected(),
"bridge_serial_connected": bridge_serial_connected(),
"bridge_transport": _bridge_transport(settings),
"active_bridge_id": active_bridge_profile_id(settings) or "",
"bridge_serial_port": settings.get("bridge_serial_port") or "",
"bridge_serial_baudrate": int(settings.get("bridge_serial_baudrate") or 921600),
"bridges": normalise_bridges(settings.get("bridges")),
}
@router.get("/interfaces")
async def wifi_interfaces(request: Request):
_ = request
if not nmcli_available():
return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503)
return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200)
@router.get("/scan")
async def wifi_scan(request: Request):
device = (request.query_params.get("device") or "").strip()
if not device:
return J({"error": "device query param required"}, 400)
if not nmcli_available():
return J({"ok": False, "error": "nmcli not found"}, 503)
try:
networks = await scan_wifi(device)
return J({"ok": True, "device": device, "networks": networks}, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)
@router.get("/bridges")
async def get_bridges(request: Request):
_ = request
settings = get_settings()
return J(_bridges_payload(settings), 200)
@router.put("/bridges")
async def put_bridges(request: Request):
try:
data = await read_json(request)
settings = get_settings()
if "wifi_interface" in data:
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
if "bridge_transport" in data:
mode = str(data.get("bridge_transport") or "").strip().lower()
if mode in ("wifi", "serial"):
settings["bridge_transport"] = mode
if "bridge_ws_url" in data:
settings["bridge_ws_url"] = str(data.get("bridge_ws_url") or "").strip()
if "bridge_serial_port" in data:
settings["bridge_serial_port"] = str(data.get("bridge_serial_port") or "").strip()
if "bridge_serial_baudrate" in data:
settings["bridge_serial_baudrate"] = int(data.get("bridge_serial_baudrate") or 921600)
if "bridges" in data:
settings["bridges"] = normalise_bridges(data.get("bridges"))
settings.save()
return J({"ok": True, "message": "Bridge profiles saved"}, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 400)
@router.delete("/bridges/{bridge_id}")
async def delete_bridge_profile(request: Request, bridge_id):
_ = request
settings = get_settings()
bid = str(bridge_id or "").strip()
bridges = normalise_bridges(settings.get("bridges"))
kept = [b for b in bridges if str(b.get("id") or "") != bid]
if len(kept) == len(bridges):
return J({"ok": False, "error": "Bridge profile not found"}, 404)
settings["bridges"] = kept
settings.save()
payload = _bridges_payload(settings)
payload["message"] = "Bridge profile deleted"
return J(payload, 200)
@router.post("/bridges/{bridge_id}/connect")
async def connect_saved_bridge(request: Request, bridge_id):
_ = request
settings = get_settings()
profile = find_bridge_profile(settings, bridge_id)
if not profile:
return J({"error": "Bridge profile not found"}, 404)
try:
ok, err = await connect_bridge_profile(profile, settings)
if not ok:
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["message"] = f"Connected to {profile.get('label')}"
return J(payload, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)
@router.post("/connect")
async def wifi_connect_bridge(request: Request):
"""Join a bridge AP and open its WebSocket."""
try:
data = await read_json(request)
settings = get_settings()
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
ssid = str(data.get("ssid") or "").strip()
password = str(data.get("password") or "")
ap_ip = str(data.get("ap_ip") or "192.168.4.1").strip()
try:
ws_port = int(data.get("ws_port") or 80)
except (TypeError, ValueError):
ws_port = 80
label = str(data.get("label") or ssid).strip() or ssid
save_profile = bool(data.get("save_profile", True))
if not device:
return J({"error": "WiFi interface (device) is required"}, 400)
if not ssid:
return J({"error": "ssid is required"}, 400)
settings["wifi_interface"] = device
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
if save_profile:
profile_id = secrets.token_hex(6)
bridges = [
b
for b in bridges
if not (b.get("transport") == "wifi" and b.get("ssid") == ssid)
]
bridges.append(
{
"id": profile_id,
"label": label,
"transport": "wifi",
"ssid": ssid,
"password": password,
"ap_ip": ap_ip,
"ws_port": ws_port,
}
)
settings["bridges"] = bridges
settings.save()
profile = {
"transport": "wifi",
"ssid": ssid,
"password": password,
"ap_ip": ap_ip,
"ws_port": ws_port,
"wifi_interface": device,
}
ok, err = await connect_bridge_wifi(profile, settings)
if not ok:
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected to {ssid}"
return J(payload, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)
@router.post("/serial/connect")
async def serial_connect_bridge(request: Request):
try:
data = await read_json(request)
port = str(data.get("port") or data.get("serial_port") or "").strip()
save_profile = bool(data.get("save_profile", True))
label = str(data.get("label") or port).strip() or port
try:
baud = int(data.get("baudrate") or data.get("serial_baudrate") or 921600)
except (TypeError, ValueError):
baud = 921600
if not port:
return J({"error": "port is required"}, 400)
settings = get_settings()
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
if save_profile:
profile_id = secrets.token_hex(6)
bridges = [
b
for b in bridges
if not (b.get("transport") == "serial" and b.get("serial_port") == port)
]
bridges.append(
{
"id": profile_id,
"label": label,
"transport": "serial",
"serial_port": port,
"serial_baudrate": baud,
}
)
settings["bridges"] = bridges
settings.save()
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
ok, err = await connect_bridge_serial(profile, settings)
if not ok:
return J({"ok": False, "error": err}, 500)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected on {port}"
return J(payload, 200)
except Exception as e:
return J({"ok": False, "error": str(e)}, 500)