Merge commit 'e6df4ae8acacce956c1b112123ec7e00438c4bd5'

This commit is contained in:
David Montero 2026-05-27 08:34:17 +02:00
commit bab33e9e55
6 changed files with 1058 additions and 2 deletions

View File

@ -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 `<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

View File

@ -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

View File

@ -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<ModalState>({ 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<string[]>([]);
// ── 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 (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.6)',
zIndex: 9600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 560,
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'calc(100vh - 64px)',
background: '#1a1d24',
color: '#e6e6e9',
border: '1px solid #2c2c33',
borderRadius: 8,
padding: 20,
boxShadow: '0 12px 36px rgba(0,0,0,0.7)',
display: 'flex',
flexDirection: 'column',
gap: 14,
fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
Flash {boardLabel}
</h2>
<button
type="button"
onClick={onClose}
style={closeBtnStyle}
aria-label="Close"
>
×
</button>
</div>
{state.kind === 'loading-ports' && (
<div style={{ padding: '24px 0', textAlign: 'center', color: '#888' }}>
Detecting USB serial ports...
</div>
)}
{state.kind === 'picking' && (
<PickerView
board={board}
ports={state.ports}
selected={state.selectedPath}
onSelect={(p) => setState({ ...state, selectedPath: p })}
onRefresh={() => void refreshPorts()}
onFlash={(p) => void doFlash(p)}
/>
)}
{(state.kind === 'flashing' ||
state.kind === 'success' ||
state.kind === 'error') && (
<ProgressView
state={state}
onRetry={() => state.port && void doFlash(state.port)}
onClose={onClose}
onBackToPicker={() => void refreshPorts()}
/>
)}
</div>
</div>
);
};
// ── 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 (
<div>
<div style={{ padding: 16, background: '#0c0c11', borderRadius: 4, marginBottom: 12 }}>
<div style={{ color: '#aaa', fontSize: 13, marginBottom: 8 }}>
No USB serial ports detected.
</div>
<div style={{ color: '#777', fontSize: 12, lineHeight: 1.5 }}>
Plug your board into a USB port and click Refresh. On Linux,
you may need to add yourself to the dialout group:
<pre style={{ marginTop: 6, fontSize: 11 }}>
sudo usermod -a -G dialout $USER
</pre>
(log out + back in after running.)
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onRefresh} style={primaryBtnStyle}>
Refresh
</button>
</div>
</div>
);
}
return (
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 12, color: '#aaa' }}>
Serial port
</label>
<select
value={selected ?? ''}
onChange={(e) => onSelect(e.target.value)}
style={selectStyle}
>
{ports.map((p) => (
<option key={p.path} value={p.path}>
{portLabel(p)}
</option>
))}
</select>
{!hasCompiled && (
<div style={{ marginTop: 10, padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12 }}>
No compiled program for this board yet. Click Compile in the toolbar first.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<button type="button" onClick={onRefresh} style={secondaryBtnStyle}>
Refresh ports
</button>
<button
type="button"
disabled={!selected || !hasCompiled}
onClick={() => selected && onFlash(selected)}
style={{ ...primaryBtnStyle, opacity: !selected || !hasCompiled ? 0.5 : 1 }}
>
Flash
</button>
</div>
</div>
);
};
// ── 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<HTMLPreElement | null>(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 (
<div>
{state.kind === 'flashing' && (
<>
<div style={{ fontSize: 13, color: '#ccc', marginBottom: 8 }}>
Flashing on {state.port}...
</div>
<div style={{ height: 6, background: '#0c0c11', borderRadius: 3, overflow: 'hidden', marginBottom: 6 }}>
<div
style={{
height: '100%',
width: `${Math.round(state.progress * 100)}%`,
background: 'linear-gradient(90deg, #007acc 0%, #00a4ff 100%)',
transition: 'width 0.2s ease',
}}
/>
</div>
<div style={{ fontSize: 11, color: '#888', marginBottom: 12 }}>
{Math.round(state.progress * 100)}%
</div>
</>
)}
{state.kind === 'success' && (
<div style={{ padding: 12, background: '#143824', color: '#7ee87e', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
Flashed successfully in {(state.elapsedMs / 1000).toFixed(1)}s
</div>
)}
{state.kind === 'error' && (
<div style={{ padding: 12, background: '#3a1a1a', color: '#ff8585', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
{state.message}
</div>
)}
<pre
ref={logRef}
style={{
height: 240,
margin: 0,
padding: 10,
background: '#0c0c11',
color: '#9aa5b1',
borderRadius: 4,
fontSize: 11,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{state.log.join('\n') || '(no output yet)'}
</pre>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 12 }}>
{state.kind === 'flashing' ? (
<span style={{ fontSize: 11, color: '#666' }}>
Don't unplug the board while flashing.
</span>
) : (
<button type="button" onClick={onBackToPicker} style={secondaryBtnStyle}>
Pick another port
</button>
)}
<div style={{ display: 'flex', gap: 8 }}>
{state.kind === 'error' && (
<button type="button" onClick={onRetry} style={primaryBtnStyle}>
Retry
</button>
)}
<button type="button" onClick={onClose} style={secondaryBtnStyle}>
{state.kind === 'flashing' ? 'Hide' : 'Close'}
</button>
</div>
</div>
</div>
);
};
// ── 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',
};

View File

@ -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<string | null>(null);
// Board Options modal — id of the board whose options are being edited.
const [boardOptionsModalFor, setBoardOptionsModalFor] = useState<string | null>(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<string | null>(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<number>(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 && (
<button
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '7px 14px',
background: 'none',
border: 'none',
color: !!board?.compiledProgram ? '#e6e6e9' : '#666',
cursor: !!board?.compiledProgram ? 'pointer' : 'not-allowed',
fontSize: 13,
textAlign: 'left',
}}
onMouseEnter={(e) => {
if (board?.compiledProgram) e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none';
}}
disabled={!board?.compiledProgram}
title={
board?.compiledProgram
? 'Flash the compiled sketch to a real USB-attached board'
: 'Compile the sketch first'
}
onClick={() => {
if (!board?.compiledProgram) return;
setFlashModalFor(boardContextMenu.boardId);
setBoardContextMenu(null);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
Flash to real board
</button>
)}
<div
style={{
height: 1,
background: '#3c3c3c',
margin: '4px 0',
}}
/>
<button
style={{
display: 'flex',
@ -2922,6 +2990,31 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
);
})()}
{/* Hardware flash modal opens from board context menu when
the user has compiled the sketch + clicks "Flash to real
board". Only present in Tauri (web hides the menu item). */}
{flashModalFor &&
(() => {
const b = boards.find((x) => x.id === flashModalFor);
if (!b) return null;
const fqbn = BOARD_KIND_FQBN[b.boardKind];
if (!fqbn) {
// The board kind has no arduino-cli FQBN (e.g. some
// virtual boards or chips). Auto-close — surface a toast
// via the existing menu-event system if/when that exists.
console.warn('[flash] no FQBN for board kind', b.boardKind);
setFlashModalFor(null);
return null;
}
return (
<FlashModal
board={b}
fqbn={fqbn}
onClose={() => setFlashModalFor(null)}
/>
);
})()}
{/* Board Options modal (ESP32 only) */}
{boardOptionsModalFor &&
(() => {

View File

@ -155,6 +155,37 @@ function randomNonce(): string {
return `v-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
/**
* USB serial port info returned by the Rust shell's `list_serial_ports`
* command. The hardware-flash modal reads this to populate the port
* dropdown. `vid` / `pid` etc. are absent for non-USB ports (legacy
* RS-232, Bluetooth SPP virtual COM ports).
*/
export interface SerialPortInfo {
path: string;
vid?: number | null;
pid?: number | null;
manufacturer?: string | null;
product?: string | null;
serial_number?: string | null;
}
/**
* Enumerate USB serial ports plugged into the host. Empty array
* when the Tauri runtime isn't present (web build) so the UI can
* render its "open Velxio Desktop to flash real boards" CTA without
* blowing up.
*/
export async function listSerialPorts(): Promise<SerialPortInfo[]> {
if (!isTauri()) return [];
try {
return await invoke<SerialPortInfo[]>('list_serial_ports');
} catch (err) {
tryLog('listSerialPorts: command failed', { err: String(err) });
return [];
}
}
/**
* Read the current license gate state (v0.3.0+). Defaults the grandfather
* fields to {0, false} outside Tauri so dev-in-browser doesn't crash.

View File

@ -0,0 +1,142 @@
/**
* Frontend client for the hardware flash endpoint.
*
* Wraps `POST /api/flash/upload` (SSE response) and yields parsed
* events back to the caller. The FlashModal renders the stream
* line-by-line and updates a progress bar based on the optional
* `progress` field.
*
* Why a generator: the modal needs per-event UI updates AND a final
* success/error verdict. Returning the whole log as a promise
* forces the modal to wait until the flash is done before showing
* anything; an AsyncGenerator gives it both.
*/
import { getApiBase } from '../lib/apiBase';
export type FlashEvent =
| { phase: 'queued'; line: string }
| { phase: 'starting'; line: string }
| { phase: 'writing'; line: string; progress?: number }
| { phase: 'done'; success: true; elapsed_ms: number }
| { phase: 'done'; success: false; error: string; elapsed_ms: number };
export interface FlashRequest {
/** Board UUID from useSimulatorStore - echoed in log lines so
* the user can tell which board's log they're reading. */
boardId: string;
/** OS-native port string from listSerialPorts(). */
port: string;
/** arduino-cli FQBN (`arduino:avr:uno`, `esp32:esp32:esp32`, ...). */
fqbn: string;
/** "hex" | "bin" | "uf2" | "elf" - matches the file the compile
* endpoint produced and what arduino-cli expects. */
programFormat: 'hex' | 'bin' | 'uf2' | 'elf';
/** The compiled bytes. AVR compile returns Intel HEX text;
* ESP32 / RP2040 return binary. The store keeps it as a string
* either way - we wrap it in a Blob for the multipart upload. */
programData: string;
}
/**
* Decode a base64 string into a Uint8Array. atob is browser-native;
* we wrap it because TypeScript's lib.dom types still mark it as
* deprecated despite every browser supporting it.
*/
function base64ToUint8Array(b64: string): Uint8Array {
const cleaned = b64.replace(/\s+/g, '');
const binary = atob(cleaned);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
/**
* Open the SSE stream and yield events as they arrive. Caller
* MUST consume the generator to completion (or call `return`)
* to release the underlying ReadableStream.
*
* Throws on transport errors (network down, sidecar crashed mid-
* stream); arduino-cli failures come back as `phase:'done',
* success:false` events, not exceptions.
*/
export async function* streamFlash(req: FlashRequest): AsyncGenerator<FlashEvent> {
const fd = new FormData();
fd.append('board_id', req.boardId);
fd.append('port', req.port);
fd.append('fqbn', req.fqbn);
fd.append('program_format', req.programFormat);
// The compile endpoint returns Intel HEX as plain text but
// binary formats (.bin / .uf2) as base64 to keep the JSON safe.
// For binary we MUST decode before posting - otherwise the form
// upload encodes the base64 ASCII as the file contents and
// arduino-cli sees a non-binary text blob.
const isBinary = req.programFormat !== 'hex';
const bytes = isBinary
? base64ToUint8Array(req.programData)
: new TextEncoder().encode(req.programData);
fd.append(
'program',
new Blob([bytes], { type: 'application/octet-stream' }),
`program.${req.programFormat}`,
);
const res = await fetch(`${getApiBase()}/flash/upload`, {
method: 'POST',
body: fd,
// No credentials needed - the flash endpoint is unauthenticated
// because it's hardware-local. The desktop sidecar binds to
// 127.0.0.1 only, so cross-machine attacks aren't a concern.
});
if (!res.ok || !res.body) {
// Errors BEFORE the stream starts (validation, 503 missing
// toolchain, etc.) come back as plain JSON, not SSE.
let detail = `HTTP ${res.status}`;
try {
const body = await res.json();
detail = body?.detail ?? detail;
} catch {
/* keep the status-line detail */
}
yield {
phase: 'done',
success: false,
error: detail,
elapsed_ms: 0,
};
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// SSE: events are delimited by blank lines (\n\n). Each event
// is `data: <json>` (one line). We don't use multi-line `data:`
// continuations, so a simple split is enough.
const chunks = buf.split('\n\n');
buf = chunks.pop() ?? '';
for (const chunk of chunks) {
const line = chunk.trim();
if (!line.startsWith('data:')) continue;
const json = line.slice(5).trim();
try {
yield JSON.parse(json) as FlashEvent;
} catch (err) {
// Garbled event - keep going so a single bad packet
// doesn't kill the whole stream.
console.warn('[flashService] malformed SSE event:', json, err);
}
}
}
} finally {
reader.releaseLock();
}
}