""" 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 `/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: \\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