298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""
|
|
Hardware flash router — `POST /api/flash/upload`.
|
|
|
|
Wraps `arduino-cli upload` so the desktop frontend can write a
|
|
compiled sketch to a real USB-attached board. Same arduino-cli the
|
|
compile path uses, so AVR / RP2040 / ESP32 (Arduino-core) all share
|
|
one code path — arduino-cli internally dispatches to avrdude /
|
|
picotool / esptool based on the FQBN.
|
|
|
|
Why a route (and not a pure Tauri command on the shell):
|
|
- Sidecar already has arduino-cli on PATH (see
|
|
`pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`) +
|
|
knows the bundled `binaries/arduino-data` location. Reusing it
|
|
avoids duplicating the resolution logic in Rust.
|
|
- Streaming stdout via SSE works the same shape the compile flow
|
|
already uses for live build output, so the frontend's modal
|
|
can reuse most of the rendering plumbing.
|
|
- The web build can later proxy to a WebSerial-based flasher
|
|
instead — keeping the surface as `/api/flash/*` lets us route
|
|
based on `isTauri()` without changing the call sites.
|
|
|
|
Concurrency: one in-flight flash per port. A second request to the
|
|
same port returns 409 Conflict immediately so the user gets a clear
|
|
error instead of two arduino-cli runs fighting over the device.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import AsyncIterator
|
|
|
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Per-port locks. Keyed by the port string the client sends — same
|
|
# port name from list_serial_ports MUST hash the same on both sides
|
|
# (case + leading slashes matter on Windows COM ports). Locks live
|
|
# for the process lifetime; abandoned ones don't leak meaningfully
|
|
# (a port that's never flashed again just keeps its lock object
|
|
# around, ~200 bytes).
|
|
_PORT_LOCKS: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
def _lock_for(port: str) -> asyncio.Lock:
|
|
if port not in _PORT_LOCKS:
|
|
_PORT_LOCKS[port] = asyncio.Lock()
|
|
return _PORT_LOCKS[port]
|
|
|
|
|
|
# Allow-list of FQBN prefixes we know arduino-cli can flash via the
|
|
# bundled cores. Anything outside this set returns 400 so we don't
|
|
# accidentally let a typo through to a confusing arduino-cli error
|
|
# ("platform not installed"). Add more as cores get bundled.
|
|
_FQBN_PREFIXES = (
|
|
"arduino:avr:", # UNO, Mega, Nano, Leonardo, Pro Mini, ...
|
|
"ATTinyCore:avr:", # ATtiny85 via DigiSpark, ATtiny84, ...
|
|
"rp2040:rp2040:", # Pi Pico, Pico W, Pico 2, ...
|
|
"esp32:esp32:", # DevKitC, S3, C3, S2, ...
|
|
"arduino:samd:", # MKR boards, Nano 33 IoT (defensive — only
|
|
# works if the SAMD core is installed)
|
|
)
|
|
|
|
# Format → file extension hint for arduino-cli. Some flashers key
|
|
# off the extension; passing the wrong one makes esptool refuse a
|
|
# .hex it would otherwise burn as .bin.
|
|
_FORMAT_EXTENSIONS = {
|
|
"hex": ".hex",
|
|
"bin": ".bin",
|
|
"uf2": ".uf2",
|
|
"elf": ".elf",
|
|
}
|
|
|
|
# Hard cap on uploaded program size. AVR programs are <32 KB,
|
|
# ESP32 apps are typically <1.5 MB, RP2040 max app is ~2 MB. 8 MB
|
|
# is generous + protects against a buggy client uploading the full
|
|
# sketch dir.
|
|
MAX_PROGRAM_BYTES = 8 * 1024 * 1024
|
|
|
|
|
|
def _arduino_cli_bin() -> str | None:
|
|
"""Pick the arduino-cli binary the sidecar uses for compile.
|
|
|
|
Desktop bundle: `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`
|
|
has already prepended `<resources>/binaries/arduino-cli/` to PATH,
|
|
so `shutil.which("arduino-cli")` resolves to the bundled one.
|
|
Self-host / dev: relies on the user's system arduino-cli.
|
|
"""
|
|
explicit = os.environ.get("ARDUINO_CLI_BIN", "").strip()
|
|
if explicit:
|
|
return explicit if Path(explicit).is_file() else None
|
|
return shutil.which("arduino-cli")
|
|
|
|
|
|
def _safe_port_label(port: str) -> str:
|
|
"""Cosmetic — sanitise the port string for log lines so a
|
|
malicious frontend can't smuggle ANSI escapes through us."""
|
|
return re.sub(r"[^\w./\\:-]", "_", port)[:64]
|
|
|
|
|
|
@router.post("/upload")
|
|
async def flash_upload(
|
|
board_id: str = Form(..., description="Frontend's board UUID, echoed in log"),
|
|
port: str = Form(..., description="Serial port: COM3 / /dev/ttyUSB0 / /dev/cu.*"),
|
|
fqbn: str = Form(..., description="arduino-cli FQBN (board target)"),
|
|
program_format: str = Form(..., description="hex / bin / uf2 / elf"),
|
|
program: UploadFile = File(..., description="The compiled sketch bytes"),
|
|
) -> StreamingResponse:
|
|
"""Stream-flash `program` to `port` using `arduino-cli upload`.
|
|
|
|
Returns an SSE stream of `{phase, line?, progress?, success?, error?}`
|
|
events. The frontend modal consumes the stream line-by-line.
|
|
"""
|
|
# ── Validate inputs ──────────────────────────────────────────────
|
|
if program_format not in _FORMAT_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"Unknown program_format {program_format!r}. "
|
|
f"Expected one of {sorted(_FORMAT_EXTENSIONS)}."
|
|
),
|
|
)
|
|
if not any(fqbn.startswith(p) for p in _FQBN_PREFIXES):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"FQBN {fqbn!r} is not in the flash allow-list. "
|
|
f"Supported prefixes: {list(_FQBN_PREFIXES)}."
|
|
),
|
|
)
|
|
if not port.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Empty port.",
|
|
)
|
|
|
|
cli = _arduino_cli_bin()
|
|
if cli is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=(
|
|
"arduino-cli not found. Set ARDUINO_CLI_BIN or install "
|
|
"it on the host. The desktop bundle ships one - this "
|
|
"error usually means the sidecar's PATH wasn't extended."
|
|
),
|
|
)
|
|
|
|
# ── Stream the upload into a temp file ───────────────────────────
|
|
# arduino-cli wants the program on disk - no stdin path. The
|
|
# extension matters: arduino-cli uses it (and the FQBN) to pick
|
|
# the right uploader. Wrong extension = wrong uploader = silent
|
|
# failure or a confusing "format not recognised" error.
|
|
suffix = _FORMAT_EXTENSIONS[program_format]
|
|
fd, tmp_path_str = tempfile.mkstemp(prefix="velxio-flash-", suffix=suffix)
|
|
tmp_path = Path(tmp_path_str)
|
|
total = 0
|
|
try:
|
|
with os.fdopen(fd, "wb") as fh:
|
|
while True:
|
|
chunk = await program.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > MAX_PROGRAM_BYTES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=(
|
|
f"Program exceeds {MAX_PROGRAM_BYTES} bytes. "
|
|
"Real sketches stay well under that — check the "
|
|
"upload payload."
|
|
),
|
|
)
|
|
fh.write(chunk)
|
|
except Exception:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
logger.info(
|
|
"[flash] queued board=%s port=%s fqbn=%s size=%d",
|
|
board_id, _safe_port_label(port), fqbn, total,
|
|
)
|
|
|
|
# ── SSE generator ────────────────────────────────────────────────
|
|
async def stream() -> AsyncIterator[bytes]:
|
|
# Per-port lock prevents two simultaneous flashes from fighting
|
|
# for the same /dev/ttyACM0. Yield a "queued" event if we end
|
|
# up waiting so the frontend knows the request landed but is
|
|
# blocked on a prior flash.
|
|
lock = _lock_for(port)
|
|
if lock.locked():
|
|
yield _sse({"phase": "queued", "line": f"Waiting for prior flash on {port}..."})
|
|
async with lock:
|
|
try:
|
|
async for event in _run_flash(cli, port, fqbn, tmp_path):
|
|
yield _sse(event)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
# X-Accel-Buffering: no tells nginx to NOT buffer SSE chunks
|
|
# (default proxy_buffering=on holds the whole response). Without
|
|
# it the frontend sees no output until the flash is done.
|
|
return StreamingResponse(
|
|
stream(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
"Connection": "keep-alive",
|
|
},
|
|
)
|
|
|
|
|
|
def _sse(payload: dict) -> bytes:
|
|
"""Wrap a dict in the SSE `data: <json>\\n\\n` envelope."""
|
|
return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode("utf-8")
|
|
|
|
|
|
async def _run_flash(
|
|
cli: str, port: str, fqbn: str, program: Path,
|
|
) -> AsyncIterator[dict]:
|
|
"""Spawn arduino-cli upload, stream stdout/stderr line-by-line as
|
|
SSE events, yield a final `done` event with success + elapsed_ms.
|
|
"""
|
|
started = time.monotonic()
|
|
cmd = [
|
|
cli, "upload",
|
|
"-p", port,
|
|
"-i", str(program),
|
|
"--fqbn", fqbn,
|
|
"-v", # verbose — gives the uploader's per-byte progress
|
|
]
|
|
yield {
|
|
"phase": "starting",
|
|
"line": f"$ {' '.join(cmd)}",
|
|
}
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
yield _done(False, error=f"could not start arduino-cli: {exc}",
|
|
elapsed_ms=int((time.monotonic() - started) * 1000))
|
|
return
|
|
|
|
assert proc.stdout is not None
|
|
# avrdude / esptool progress lines look like:
|
|
# "Writing | ################################################## | 100% 1.23s"
|
|
# capture the 0-100 number for the frontend's progress bar.
|
|
progress_re = re.compile(r"(\d{1,3})%")
|
|
async for raw in proc.stdout:
|
|
try:
|
|
line = raw.decode(errors="replace").rstrip("\r\n")
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
if not line:
|
|
continue
|
|
event: dict = {"phase": "writing", "line": line}
|
|
m = progress_re.search(line)
|
|
if m:
|
|
try:
|
|
pct = max(0, min(100, int(m.group(1))))
|
|
event["progress"] = pct / 100.0
|
|
except ValueError:
|
|
pass
|
|
yield event
|
|
|
|
rc = await proc.wait()
|
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
if rc == 0:
|
|
yield _done(True, elapsed_ms=elapsed_ms)
|
|
else:
|
|
yield _done(
|
|
False,
|
|
error=f"arduino-cli upload exited {rc}",
|
|
elapsed_ms=elapsed_ms,
|
|
)
|
|
|
|
|
|
def _done(success: bool, *, elapsed_ms: int, error: str | None = None) -> dict:
|
|
payload: dict = {"phase": "done", "success": success, "elapsed_ms": elapsed_ms}
|
|
if error:
|
|
payload["error"] = error
|
|
return payload
|