Merge commit 'e6df4ae8acacce956c1b112123ec7e00438c4bd5'
This commit is contained in:
commit
bab33e9e55
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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 &&
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue