diff --git a/backend/app/api/routes/flash.py b/backend/app/api/routes/flash.py new file mode 100644 index 00000000..73c81a0b --- /dev/null +++ b/backend/app/api/routes/flash.py @@ -0,0 +1,297 @@ +""" +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 diff --git a/backend/app/main.py b/backend/app/main.py index 464657de..16154358 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ if sys.platform == 'win32': from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import compile, compile_chip, compile_rom, libraries +from app.api.routes import compile, compile_chip, compile_rom, flash, libraries from app.core.config import settings from app.core.hooks import run_lifespan_startup @@ -94,6 +94,12 @@ app.include_router(compile.router, prefix="/api/compile", tags=["compilation"]) app.include_router(compile_chip.router, prefix="/api/compile-chip", tags=["custom-chips"]) app.include_router(compile_rom.router, prefix="/api/compile-rom", tags=["custom-chips"]) app.include_router(libraries.router, prefix="/api/libraries", tags=["libraries"]) +# Hardware flash: subprocesses arduino-cli upload to write a compiled +# sketch to a real USB-attached board. Desktop-only in practice (the +# web build has no access to local serial ports without WebSerial), +# but the route lives in OSS so self-hosters with a sidecar reach +# get it too. +app.include_router(flash.router, prefix="/api/flash", tags=["flash"]) # Auth / projects / admin / metrics routers used to be wired up here, gated # on the auth/DB stack being importable. Phase 2 of the OSS split moved diff --git a/frontend/src/components/simulator/FlashModal.tsx b/frontend/src/components/simulator/FlashModal.tsx new file mode 100644 index 00000000..22d92fc3 --- /dev/null +++ b/frontend/src/components/simulator/FlashModal.tsx @@ -0,0 +1,487 @@ +/** + * FlashModal — hardware flash UI for a single board on the canvas. + * + * Opened from the board context menu's "Flash to real board" item. + * Walks the user through: + * 1. Picking a USB serial port (auto-enumerated) + * 2. Triggering the flash (streams arduino-cli output live) + * 3. Showing success / error with the option to retry + * + * Pure desktop concern: web has no access to local serial ports + * without WebSerial which is a separate sprint. The board context + * menu hides the entry entirely in web builds; if the modal IS + * mounted in web (defensive), it shows a "requires Velxio Desktop" + * fallback. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { BoardInstance } from '../../store/useSimulatorStore'; +import { isTauri, listSerialPorts, type SerialPortInfo } from '../../desktop/tauriBridge'; +import { streamFlash, type FlashEvent } from '../../services/flashService'; + +interface Props { + board: BoardInstance; + fqbn: string; + onClose: () => void; +} + +type ModalState = + | { kind: 'loading-ports' } + | { kind: 'picking'; ports: SerialPortInfo[]; selectedPath: string | null } + | { kind: 'flashing'; port: string; log: string[]; progress: number } + | { kind: 'success'; port: string; elapsedMs: number; log: string[] } + | { kind: 'error'; port: string | null; message: string; log: string[] }; + +export const FlashModal = ({ board, fqbn, onClose }: Props) => { + const [state, setState] = useState({ kind: 'loading-ports' }); + // Keep the latest log in a ref so the flash generator's setState + // calls aren't accumulating stale array copies. + const logRef = useRef([]); + + // ── Initial port enumeration ───────────────────────────────────── + useEffect(() => { + if (!isTauri()) { + setState({ + kind: 'error', + port: null, + message: + 'Hardware flashing requires Velxio Desktop. The web app cannot ' + + 'access local USB serial ports.', + log: [], + }); + return; + } + let cancelled = false; + void (async () => { + const ports = await listSerialPorts(); + if (cancelled) return; + setState({ + kind: 'picking', + ports, + selectedPath: ports[0]?.path ?? null, + }); + })(); + return () => { + cancelled = true; + }; + }, []); + + const refreshPorts = useCallback(async () => { + setState({ kind: 'loading-ports' }); + const ports = await listSerialPorts(); + setState({ + kind: 'picking', + ports, + selectedPath: ports[0]?.path ?? null, + }); + }, []); + + // ── Trigger the flash ──────────────────────────────────────────── + const doFlash = useCallback( + async (port: string) => { + if (!board.compiledProgram) { + setState({ + kind: 'error', + port, + message: + 'No compiled program for this board. Compile the sketch first ' + + '(Compile button in the toolbar).', + log: [], + }); + return; + } + logRef.current = []; + setState({ kind: 'flashing', port, log: [], progress: 0 }); + + const fmt = formatForFqbn(fqbn); + try { + for await (const ev of streamFlash({ + boardId: board.id, + port, + fqbn, + programFormat: fmt, + programData: board.compiledProgram, + })) { + if (ev.phase === 'done') { + if (ev.success) { + setState({ + kind: 'success', + port, + elapsedMs: ev.elapsed_ms, + log: [...logRef.current], + }); + } else { + setState({ + kind: 'error', + port, + message: ev.error, + log: [...logRef.current], + }); + } + return; + } + if ('line' in ev) { + logRef.current = [...logRef.current, ev.line]; + } + setState((prev) => { + if (prev.kind !== 'flashing') return prev; + return { + ...prev, + log: logRef.current, + progress: ev.phase === 'writing' && ev.progress !== undefined + ? ev.progress + : prev.progress, + }; + }); + } + } catch (err) { + setState({ + kind: 'error', + port, + message: err instanceof Error ? err.message : String(err), + log: [...logRef.current], + }); + } + }, + [board.compiledProgram, board.id, fqbn], + ); + + // ── Render ────────────────────────────────────────────────────── + const boardLabel = board.boardKind; + + return ( +
+
+
+

+ Flash {boardLabel} +

+ +
+ + {state.kind === 'loading-ports' && ( +
+ Detecting USB serial ports... +
+ )} + + {state.kind === 'picking' && ( + setState({ ...state, selectedPath: p })} + onRefresh={() => void refreshPorts()} + onFlash={(p) => void doFlash(p)} + /> + )} + + {(state.kind === 'flashing' || + state.kind === 'success' || + state.kind === 'error') && ( + state.port && void doFlash(state.port)} + onClose={onClose} + onBackToPicker={() => void refreshPorts()} + /> + )} +
+
+ ); +}; + +// ── Picker subview ────────────────────────────────────────────────── + +interface PickerProps { + board: BoardInstance; + ports: SerialPortInfo[]; + selected: string | null; + onSelect: (path: string) => void; + onRefresh: () => void; + onFlash: (path: string) => void; +} + +const PickerView = ({ board, ports, selected, onSelect, onRefresh, onFlash }: PickerProps) => { + const hasCompiled = !!board.compiledProgram; + + if (ports.length === 0) { + return ( +
+
+
+ No USB serial ports detected. +
+
+ Plug your board into a USB port and click Refresh. On Linux, + you may need to add yourself to the dialout group: +
+              sudo usermod -a -G dialout $USER
+            
+ (log out + back in after running.) +
+
+
+ +
+
+ ); + } + + return ( +
+ + + + {!hasCompiled && ( +
+ No compiled program for this board yet. Click Compile in the toolbar first. +
+ )} + +
+ + +
+
+ ); +}; + +// ── Progress / success / error subview ────────────────────────────── + +interface ProgressProps { + state: + | { kind: 'flashing'; port: string; log: string[]; progress: number } + | { kind: 'success'; port: string; elapsedMs: number; log: string[] } + | { kind: 'error'; port: string | null; message: string; log: string[] }; + onRetry: () => void; + onClose: () => void; + onBackToPicker: () => void; +} + +const ProgressView = ({ state, onRetry, onClose, onBackToPicker }: ProgressProps) => { + const logRef = useRef(null); + // Auto-scroll the log to the bottom as new lines come in. + useEffect(() => { + if (logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [state.log]); + + return ( +
+ {state.kind === 'flashing' && ( + <> +
+ Flashing on {state.port}... +
+
+
+
+
+ {Math.round(state.progress * 100)}% +
+ + )} + + {state.kind === 'success' && ( +
+ ✓ Flashed successfully in {(state.elapsedMs / 1000).toFixed(1)}s +
+ )} + + {state.kind === 'error' && ( +
+ {state.message} +
+ )} + +
+        {state.log.join('\n') || '(no output yet)'}
+      
+ +
+ {state.kind === 'flashing' ? ( + + Don't unplug the board while flashing. + + ) : ( + + )} +
+ {state.kind === 'error' && ( + + )} + +
+
+
+ ); +}; + +// ── Helpers + shared styles ───────────────────────────────────────── + +function portLabel(p: SerialPortInfo): string { + const parts: string[] = [p.path]; + if (p.product || p.manufacturer) { + parts.push('-', p.product ?? p.manufacturer ?? ''); + } + if (p.vid !== undefined && p.vid !== null && p.pid !== undefined && p.pid !== null) { + parts.push(`(${hex4(p.vid)}:${hex4(p.pid)})`); + } + return parts.join(' '); +} + +function hex4(n: number): string { + return n.toString(16).padStart(4, '0'); +} + +/** + * Decide the program file extension based on the FQBN. Mirrors the + * formats arduino-cli expects per uploader (avrdude wants .hex, + * esptool wants .bin, picotool accepts either .uf2 or .bin). + */ +function formatForFqbn(fqbn: string): 'hex' | 'bin' | 'uf2' | 'elf' { + if (fqbn.startsWith('arduino:avr') || fqbn.startsWith('ATTinyCore:avr')) { + return 'hex'; + } + if (fqbn.startsWith('esp32:esp32')) return 'bin'; + if (fqbn.startsWith('rp2040:rp2040')) return 'uf2'; + if (fqbn.startsWith('arduino:samd')) return 'bin'; + // Defensive fallback - arduino-cli's auto-detection should still + // do the right thing in most cases. + return 'bin'; +} + +const closeBtnStyle: React.CSSProperties = { + width: 28, + height: 28, + padding: 0, + background: 'transparent', + border: '1px solid #2c2c33', + borderRadius: 4, + color: '#999', + fontSize: 18, + cursor: 'pointer', + lineHeight: 1, +}; + +const primaryBtnStyle: React.CSSProperties = { + padding: '7px 16px', + fontSize: 13, + fontWeight: 600, + color: 'white', + background: 'linear-gradient(135deg, #007acc 0%, #005ea1 100%)', + border: '1px solid #005ea1', + borderRadius: 4, + cursor: 'pointer', + fontFamily: 'inherit', +}; + +const secondaryBtnStyle: React.CSSProperties = { + padding: '7px 14px', + fontSize: 13, + color: '#bbb', + background: 'transparent', + border: '1px solid #2c2c33', + borderRadius: 4, + cursor: 'pointer', + fontFamily: 'inherit', +}; + +const selectStyle: React.CSSProperties = { + width: '100%', + padding: '8px 10px', + background: '#0c0c11', + color: '#e6e6e9', + border: '1px solid #2c2c33', + borderRadius: 4, + fontSize: 13, + fontFamily: 'inherit', +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 7b064749..5dcca89b 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -42,7 +42,9 @@ import { import { useIsCoarsePointer } from '../../utils/useTouchDevice'; import type { ComponentMetadata } from '../../types/component-metadata'; import type { BoardKind } from '../../types/board'; -import { BOARD_KIND_LABELS } from '../../types/board'; +import { BOARD_KIND_FQBN, BOARD_KIND_LABELS } from '../../types/board'; +import { FlashModal } from './FlashModal'; +import { isTauri as isTauriRuntimeFn } from '../../desktop/tauriBridge'; import { isEsp32Family } from '../../types/boardOptions'; import { BoardOptionsModal } from './BoardOptionsModal'; import { useOscilloscopeStore } from '../../store/useOscilloscopeStore'; @@ -227,6 +229,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const [boardToRemove, setBoardToRemove] = useState(null); // Board Options modal — id of the board whose options are being edited. const [boardOptionsModalFor, setBoardOptionsModalFor] = useState(null); + // Hardware-flash modal: set to a board id when the user picks + // "Flash to real board" from the board context menu. The FlashModal + // owns its own port-picker / progress UI; we just gate the mount. + const [flashModalFor, setFlashModalFor] = useState(null); + // Cached Tauri-runtime probe — used to gate the "Flash to real + // board" menu item in web builds. Hooks-stable across re-renders. + const isTauriRuntime = useRef(isTauriRuntimeFn()).current; // Click vs drag detection const [clickStartTime, setClickStartTime] = useState(0); @@ -2872,6 +2881,65 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { margin: '4px 0', }} /> + {/* Hardware flash — only useful inside Tauri (web can't + talk to USB serial without WebSerial). Hidden in web + so the item doesn't tease a feature that won't work + until the WebSerial track lands. */} + {isTauriRuntime && ( + + )} +