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>
This commit is contained in:
2026-06-11 22:55:28 +12:00
parent cb9758b97b
commit ace5770b3a
73 changed files with 4540 additions and 4487 deletions

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Migrate Microdot controllers to native FastAPI (no compat layer)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
CONTROLLERS = Path(__file__).resolve().parents[1] / "src" / "controllers"
IMPORT_BLOCK = """from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
"""
_JSON_HEADERS = re.compile(
r"return json\.dumps\(([\s\S]*?)\),\s*(\d+)\s*,\s*\{\s*"
r"['\"]Content-Type['\"]\s*:\s*['\"]application/json['\"]\s*,?\s*\}",
re.MULTILINE,
)
_JSON_PLAIN = re.compile(
r"^(\s*)return json\.dumps\((.+)\),\s*(\d+)\s*$",
re.MULTILINE,
)
_HTML_LINE = re.compile(
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*$",
re.MULTILINE,
)
_PLAIN_LINE = re.compile(
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/plain[^'\"]*['\"]\}\s*$",
re.MULTILINE,
)
_PAREN_JSON = re.compile(
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,\s*"
r"\{['\"]Content-Type['\"]: ['\"]application/json['\"]\}\s*,?\s*\)",
re.MULTILINE,
)
_PAREN_JSON_NOHDR = re.compile(
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,?\s*\)",
re.MULTILINE,
)
_PAREN_HTML = re.compile(
r"return \(\s*([^,]+?)\s*,\s*(\d+)\s*,\s*"
r"\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*,?\s*\)",
re.DOTALL,
)
def _insert_imports(text: str) -> str:
if "from fastapi import APIRouter" in text:
return text
fut = re.search(r"^from __future__ import annotations\n", text, re.M)
if fut:
return text[: fut.end()] + "\n" + IMPORT_BLOCK + text[fut.end() :]
doc = re.match(r'("""[\s\S]*?"""\n+|\'\'\'[\s\S]*?\'\'\'\n+)', text)
if doc:
return text[: doc.end()] + "\n" + IMPORT_BLOCK + text[doc.end() :]
return IMPORT_BLOCK + text
def _strip_microdot(text: str) -> str:
text = re.sub(r"from microdot import Microdot(?:, send_file)?\n", "", text)
text = re.sub(r"from microdot\.session import with_session\n", "", text)
text = re.sub(r"from microdot import send_file\n", "", text)
text = text.replace("controller = Microdot()", "router = APIRouter()")
text = text.replace("@controller.", "@router.")
return text
def _convert_paths(text: str) -> str:
def fix(m: re.Match) -> str:
method, path = m.group(1), m.group(2)
if path == "":
path = "/"
path = re.sub(r"<(\w+)>", r"{\1}", path)
return f'@router.{method}("{path}")'
return re.sub(
r"@router\.(get|post|put|delete|patch)\(['\"]([^'\"]*)['\"]\)",
fix,
text,
)
def _convert_request_access(text: str) -> str:
text = text.replace("request.json or {}", "await read_json(request)")
text = re.sub(r"(?<![.\w])request\.json(?!\w)", "await read_json(request)", text)
text = text.replace("request.args.get", "request.query_params.get")
return text
def _convert_request_annotations(text: str) -> str:
text = re.sub(r"async def (\w+)\(request,", r"async def \1(request: Request,", text)
text = re.sub(r"async def (\w+)\(request\)", r"async def \1(request: Request)", text)
return text
def _convert_returns(text: str) -> str:
text = _PAREN_JSON.sub(r"return J(\1, \2)", text)
text = _PAREN_JSON_NOHDR.sub(r"return J(\1, \2)", text)
text = _PAREN_HTML.sub(r"return html_response(\1, \2)", text)
text = _JSON_HEADERS.sub(r"return J(\1, \2)", text)
text = _JSON_PLAIN.sub(r"\1return J(\2, \3)", text)
text = _HTML_LINE.sub(r"\1return html_response(\2, \3)", text)
text = _PLAIN_LINE.sub(r"\1return plain(\2, \3)", text)
text = re.sub(
r'^(\s*)return "([^"]+)",\s*(\d+)\s*$',
r'\1return plain("\2", \3)',
text,
flags=re.MULTILINE,
)
return text
def convert_file(path: Path) -> str:
text = path.read_text(encoding="utf-8")
if "Microdot" not in text:
return text
text = _strip_microdot(text)
text = _insert_imports(text)
text = _convert_paths(text)
text = _convert_request_access(text)
text = _convert_request_annotations(text)
text = _convert_returns(text)
return text
def main() -> int:
for path in sorted(CONTROLLERS.glob("*.py")):
if path.name == "__init__.py":
continue
path.write_text(convert_file(path), encoding="utf-8")
print(path.name)
return 0
if __name__ == "__main__":
sys.exit(main())