velxio/frontend/src/components/simulator/FlashModal.tsx

673 lines
22 KiB
TypeScript
Raw Normal View History

feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
/**
* 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
*
* Two backends:
* - Desktop (Tauri): enumerates ports via the sidecar and streams
* arduino-cli output over SSE.
* - Web: if the pro overlay installed a Web Serial flasher for this
* board kind (see `lib/proWebFlash.ts`), the browser's own port
* picker replaces the dropdown — the modal opens on a single
* "Connect & Flash" button. Without an overlay (pure OSS web
* build), it shows the "requires Velxio Desktop" fallback.
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
*/
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';
import {
getWebFlashImpl,
webFlashAvailable,
webFlashMpyAvailable,
} from '../../lib/proWebFlash';
import { useEditorStore } from '../../store/useEditorStore';
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
interface Props {
board: BoardInstance;
fqbn: string;
onClose: () => void;
}
type ModalState =
| { kind: 'loading-ports' }
| { kind: 'picking'; ports: SerialPortInfo[]; selectedPath: string | null }
| { kind: 'web-ready' }
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
| { 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[]>([]);
// Web Serial mode: the pro overlay's flasher handles this board in
// this browser (the impl is installed before any modal can open).
const isWebMode = !isTauri() && webFlashAvailable(board.boardKind);
// MicroPython projects use the firmware-install + raw-REPL upload path.
const isMpy = board.languageMode === 'micropython';
const mpyWebOk = isMpy && !isTauri() && webFlashMpyAvailable(board.boardKind);
// Abort handle for an in-flight web flash (closing the modal cancels).
const abortRef = useRef<AbortController | null>(null);
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
// ── Initial state: enumerate ports (desktop) or arm the web flow ──
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
useEffect(() => {
if (!isTauri()) {
// Web Serial's requestPort() must run from a user gesture, so the
// web flow can't start here — it waits on "Connect & Flash".
setState(
isWebMode
? { kind: 'web-ready' }
: {
kind: 'error',
port: null,
message:
'Hardware flashing requires Velxio Desktop. The web app cannot ' +
'access local USB serial ports.',
log: [],
},
);
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
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;
};
}, [isWebMode]);
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
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],
);
// ── Trigger a Web Serial flash (pro overlay backend) ─────────────
const doWebFlash = useCallback(async () => {
const impl = getWebFlashImpl();
if (!impl) return;
if (!isMpy && !board.compiledProgram) {
setState({
kind: 'error',
port: null,
message:
'No compiled program for this board. Compile the sketch first ' +
'(Compile button in the toolbar).',
log: [],
});
return;
}
logRef.current = [];
setState({ kind: 'flashing', port: 'Web Serial', log: [], progress: 0 });
const ctrl = new AbortController();
abortRef.current = ctrl;
const onProgress = (p: { phase: string; pct: number; line?: string }) => {
if (p.line) {
logRef.current = [...logRef.current, p.line];
}
setState((prev) => {
if (prev.kind !== 'flashing') return prev;
return {
...prev,
log: logRef.current,
// Seam reports 0-100; the bar renders 0-1 like the SSE path.
progress:
p.phase === 'writing' || p.phase === 'uploading'
? p.pct / 100
: prev.progress,
};
});
};
try {
const result =
isMpy && impl.flashMicroPython
? await impl.flashMicroPython({
boardId: board.id,
boardKind: board.boardKind,
// Same file collection as the MicroPython Run path
// (EditorToolbar): the board's workspace group.
files: useEditorStore
.getState()
.getGroupFiles(board.activeFileGroupId)
.map((f) => ({ name: f.name, content: f.content })),
signal: ctrl.signal,
onProgress,
})
: await impl.flash({
boardId: board.id,
boardKind: board.boardKind,
binaryBase64: board.compiledProgram ?? '',
signal: ctrl.signal,
onProgress,
});
setState({
kind: 'success',
port: result.chipName,
elapsedMs: result.elapsedMs,
log: [...logRef.current],
});
} catch (err) {
if (ctrl.signal.aborted) return; // user cancelled — modal is closing
setState({
kind: 'error',
port: null,
message: err instanceof Error ? err.message : String(err),
log: [...logRef.current],
});
} finally {
abortRef.current = null;
}
}, [board.compiledProgram, board.id, board.boardKind, board.activeFileGroupId, isMpy]);
const handleClose = useCallback(() => {
abortRef.current?.abort();
onClose();
}, [onClose]);
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
// ── 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={handleClose}
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
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 === 'web-ready' && (
<WebReadyView
board={board}
mpyWebOk={mpyWebOk}
onFlash={() => void doWebFlash()}
/>
)}
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
{(state.kind === 'flashing' ||
state.kind === 'success' ||
state.kind === 'error') && (
<ProgressView
state={state}
webMode={isWebMode}
onRetry={() =>
isWebMode
? void doWebFlash()
: state.port && void doFlash(state.port)
}
onClose={handleClose}
onBackToPicker={() =>
isWebMode ? setState({ kind: 'web-ready' }) : void refreshPorts()
}
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
/>
)}
</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>
);
};
// ── Web Serial subview ──────────────────────────────────────────────
interface WebReadyProps {
board: BoardInstance;
/** MicroPython path available (overlay implements firmware+upload). */
mpyWebOk: boolean;
onFlash: () => void;
}
const WebReadyView = ({ board, mpyWebOk, onFlash }: WebReadyProps) => {
// MicroPython boards carry the 'micropython-loaded' sentinel instead of
// a flash image — they flash only via the overlay's MicroPython path
// (firmware install + raw-REPL file upload), which needs no compile.
const isMpy = board.languageMode === 'micropython';
const canFlash = isMpy ? mpyWebOk : !!board.compiledProgram;
return (
<div>
<div style={{ padding: 16, background: '#0c0c11', borderRadius: 4, marginBottom: 12 }}>
<div style={{ color: '#aaa', fontSize: 13, marginBottom: 8 }}>
Flash over USB, right from the browser.
</div>
<div style={{ color: '#777', fontSize: 12, lineHeight: 1.5 }}>
Plug the board in via USB and click Connect &amp; Flash — your
browser will ask which serial port to use. Close any other
program using the port (serial monitors, IDEs) first.
{isMpy && mpyWebOk && (
<>
{' '}This MicroPython project flashes in two steps: the
MicroPython firmware is installed first if the board doesn't
have it (about a minute), then your .py files are uploaded
and the board reboots into main.py.
</>
)}
</div>
</div>
{isMpy && !mpyWebOk && (
<div style={{ padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12, marginBottom: 12 }}>
MicroPython projects cannot be flashed to a real board from here.
</div>
)}
{!board.compiledProgram && !isMpy && (
<div style={{ padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12, marginBottom: 12 }}>
No compiled program for this board yet. Click Compile in the toolbar first.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={!canFlash}
onClick={onFlash}
style={{ ...primaryBtnStyle, opacity: canFlash ? 1 : 0.5 }}
>
Connect &amp; Flash
</button>
</div>
</div>
);
};
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
// ── 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[] };
/** Web Serial backend: closing cancels the flash and there is no port picker. */
webMode: boolean;
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
onRetry: () => void;
onClose: () => void;
onBackToPicker: () => void;
}
const ProgressView = ({ state, webMode, onRetry, onClose, onBackToPicker }: ProgressProps) => {
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
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}>
{webMode ? 'Start over' : 'Pick another port'}
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
</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' ? (webMode ? 'Cancel' : 'Hide') : 'Close'}
feat(flash): write compiled sketches to real USB boards (phases D1+D3) Brings hardware flashing into Velxio Desktop. Per-board "Flash to real board" entry in the canvas context menu opens a modal that enumerates USB serial ports, lets the user pick one, then streams arduino-cli upload output live until the board is flashed. Backend (Phase D1) — backend/app/api/routes/flash.py (new): POST /api/flash/upload (multipart: board_id, port, fqbn, program_format, program) → SSE stream of {phase, line?, progress?} events → final {phase:'done', success, elapsed_ms, error?} - Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v` so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD (bossac) all share one code path — arduino-cli internally dispatches by FQBN. - Per-port asyncio.Lock prevents two simultaneous flashes from fighting over the same /dev/ttyACM0. - Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr, rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't cause a confusing arduino-cli error. - Format allow-list (hex / bin / uf2 / elf) drives the temp file extension - arduino-cli uses the extension to route to the right uploader. - 8MB hard cap on the uploaded program (real sketches are well under that; protects against a runaway frontend). - X-Accel-Buffering: no header so nginx doesn't hold the SSE chunks until the flash completes. Frontend (Phase D3): - frontend/src/services/flashService.ts (new): async generator streamFlash() yields parsed SSE events. Handles the base64-vs-text gotcha (compile returns hex_content as text but binary_content as base64; for binary formats we atob() into a Uint8Array before posting so the form upload sends actual bytes, not the base64 ASCII). - frontend/src/components/simulator/FlashModal.tsx (new): Three-state UI: picking (port dropdown), flashing (progress bar + live log), success/error (verdict + retry). Empty-ports state shows a Linux dialout-group hint. - SimulatorCanvas.tsx: board context menu gains "Flash to real board" entry, gated on isTauri() + presence of compiledProgram. Hidden in web (WebSerial is a separate sprint). - tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper that invokes the Rust shell command added in Phase D2. The sidecar already has arduino-cli on PATH (per `pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so no installer changes are needed — flash works the moment the 0.4.x desktop bundle ships with these commits. Plan + remaining phase tracked in project/hardware-flashing/. D2 (Rust serial enum) committed separately as a Tauri-shell-only concern; D4 (manual smoke matrix with real boards) requires physical hardware so it stays a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:20:20 +07:00
</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',
};