feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
282
src/controllers/wifi_bridge.py
Normal file
282
src/controllers/wifi_bridge.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Pi Wi‑Fi and saved ESP-NOW bridge profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from microdot import Microdot
|
||||
|
||||
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
|
||||
|
||||
controller = Microdot()
|
||||
|
||||
|
||||
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")),
|
||||
}
|
||||
|
||||
|
||||
@controller.get("/interfaces")
|
||||
async def wifi_interfaces(request):
|
||||
_ = request
|
||||
if not nmcli_available():
|
||||
return (
|
||||
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
|
||||
503,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return (
|
||||
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
@controller.get("/scan")
|
||||
async def wifi_scan(request):
|
||||
device = (request.args.get("device") or "").strip()
|
||||
if not device:
|
||||
return json.dumps({"error": "device query param required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if not nmcli_available():
|
||||
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
networks = await scan_wifi(device)
|
||||
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.get("/bridges")
|
||||
async def get_bridges(request):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.put("/bridges")
|
||||
async def put_bridges(request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
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 json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.delete("/bridges/<bridge_id>")
|
||||
async def delete_bridge_profile(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 json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
settings["bridges"] = kept
|
||||
settings.save()
|
||||
payload = _bridges_payload(settings)
|
||||
payload["message"] = "Bridge profile deleted"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/bridges/<bridge_id>/connect")
|
||||
async def connect_saved_bridge(request, bridge_id):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
profile = find_bridge_profile(settings, bridge_id)
|
||||
if not profile:
|
||||
return json.dumps({"error": "Bridge profile not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
ok, err = await connect_bridge_profile(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _bridges_payload(settings)
|
||||
payload["message"] = f"Connected to {profile.get('label')}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/connect")
|
||||
async def wifi_connect_bridge(request):
|
||||
"""Join a bridge AP and open its WebSocket."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
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 json.dumps({"error": "Wi‑Fi interface (device) is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if not ssid:
|
||||
return json.dumps({"error": "ssid is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
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 json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _bridges_payload(settings)
|
||||
payload["profile_id"] = profile_id
|
||||
payload["message"] = f"Connected to {ssid}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/serial/connect")
|
||||
async def serial_connect_bridge(request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
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 json.dumps({"error": "port is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
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 json.dumps({"ok": False, "error": err}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _bridges_payload(settings)
|
||||
payload["profile_id"] = profile_id
|
||||
payload["message"] = f"Connected on {port}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
Reference in New Issue
Block a user