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>
245 lines
8.9 KiB
Python
245 lines
8.9 KiB
Python
"""Pi Wi‑Fi 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": "Wi‑Fi 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)
|