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
|
|
|
|
|
|
*
|
2026-08-12 01:49:54 +07:00
|
|
|
|
* 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';
|
2026-08-12 03:55:47 +07:00
|
|
|
|
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 }
|
2026-08-12 01:49:54 +07:00
|
|
|
|
| { 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[]>([]);
|
2026-08-12 01:49:54 +07:00
|
|
|
|
// Web Serial mode: the pro overlay's flasher handles this board in
|
2026-08-12 02:23:54 +07:00
|
|
|
|
// this browser (the impl is installed before any modal can open).
|
2026-08-12 01:49:54 +07:00
|
|
|
|
const isWebMode = !isTauri() && webFlashAvailable(board.boardKind);
|
2026-08-12 03:55:47 +07:00
|
|
|
|
// MicroPython projects use the firmware-install + raw-REPL upload path.
|
|
|
|
|
|
const isMpy = board.languageMode === 'micropython';
|
|
|
|
|
|
const mpyWebOk = isMpy && !isTauri() && webFlashMpyAvailable(board.boardKind);
|
2026-08-12 01:49:54 +07:00
|
|
|
|
// 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
|
|
|
|
|
2026-08-12 01:49:54 +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()) {
|
2026-08-12 01:49:54 +07:00
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
2026-08-12 01:49:54 +07:00
|
|
|
|
}, [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],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-12 01:49:54 +07:00
|
|
|
|
// ── Trigger a Web Serial flash (pro overlay backend) ─────────────
|
|
|
|
|
|
const doWebFlash = useCallback(async () => {
|
|
|
|
|
|
const impl = getWebFlashImpl();
|
|
|
|
|
|
if (!impl) return;
|
2026-08-12 03:55:47 +07:00
|
|
|
|
if (!isMpy && !board.compiledProgram) {
|
2026-08-12 01:49:54 +07:00
|
|
|
|
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;
|
2026-08-12 03:55:47 +07:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-08-12 01:49:54 +07:00
|
|
|
|
});
|
2026-08-12 03:55:47 +07:00
|
|
|
|
};
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-08-12 01:49:54 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-12 03:55:47 +07:00
|
|
|
|
}, [board.compiledProgram, board.id, board.boardKind, board.activeFileGroupId, isMpy]);
|
2026-08-12 01:49:54 +07:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-08-12 01:49:54 +07:00
|
|
|
|
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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-12 01:49:54 +07:00
|
|
|
|
{state.kind === 'web-ready' && (
|
2026-08-12 03:55:47 +07:00
|
|
|
|
<WebReadyView
|
|
|
|
|
|
board={board}
|
|
|
|
|
|
mpyWebOk={mpyWebOk}
|
|
|
|
|
|
onFlash={() => void doWebFlash()}
|
|
|
|
|
|
/>
|
2026-08-12 01:49:54 +07:00
|
|
|
|
)}
|
|
|
|
|
|
|
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}
|
2026-08-12 01:49:54 +07:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-12 01:49:54 +07:00
|
|
|
|
// ── Web Serial subview ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface WebReadyProps {
|
|
|
|
|
|
board: BoardInstance;
|
2026-08-12 03:55:47 +07:00
|
|
|
|
/** MicroPython path available (overlay implements firmware+upload). */
|
|
|
|
|
|
mpyWebOk: boolean;
|
2026-08-12 01:49:54 +07:00
|
|
|
|
onFlash: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 03:55:47 +07:00
|
|
|
|
const WebReadyView = ({ board, mpyWebOk, onFlash }: WebReadyProps) => {
|
2026-08-12 03:37:15 +07:00
|
|
|
|
// MicroPython boards carry the 'micropython-loaded' sentinel instead of
|
2026-08-12 03:55:47 +07:00
|
|
|
|
// a flash image — they flash only via the overlay's MicroPython path
|
|
|
|
|
|
// (firmware install + raw-REPL file upload), which needs no compile.
|
2026-08-12 03:37:15 +07:00
|
|
|
|
const isMpy = board.languageMode === 'micropython';
|
2026-08-12 03:55:47 +07:00
|
|
|
|
const canFlash = isMpy ? mpyWebOk : !!board.compiledProgram;
|
2026-08-12 01:49:54 +07:00
|
|
|
|
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 & Flash — your
|
|
|
|
|
|
browser will ask which serial port to use. Close any other
|
|
|
|
|
|
program using the port (serial monitors, IDEs) first.
|
2026-08-12 03:55:47 +07:00
|
|
|
|
{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.
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-08-12 01:49:54 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-12 03:55:47 +07:00
|
|
|
|
{isMpy && !mpyWebOk && (
|
2026-08-12 03:37:15 +07:00
|
|
|
|
<div style={{ padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12, marginBottom: 12 }}>
|
2026-08-12 03:55:47 +07:00
|
|
|
|
MicroPython projects cannot be flashed to a real board from here.
|
2026-08-12 03:37:15 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!board.compiledProgram && !isMpy && (
|
2026-08-12 01:49:54 +07:00
|
|
|
|
<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"
|
2026-08-12 03:55:47 +07:00
|
|
|
|
disabled={!canFlash}
|
2026-08-12 01:49:54 +07:00
|
|
|
|
onClick={onFlash}
|
2026-08-12 03:55:47 +07:00
|
|
|
|
style={{ ...primaryBtnStyle, opacity: canFlash ? 1 : 0.5 }}
|
2026-08-12 01:49:54 +07:00
|
|
|
|
>
|
|
|
|
|
|
Connect & 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[] };
|
2026-08-12 01:49:54 +07:00
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 01:49:54 +07:00
|
|
|
|
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}>
|
2026-08-12 01:49:54 +07:00
|
|
|
|
{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}>
|
2026-08-12 01:49:54 +07:00
|
|
|
|
{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',
|
|
|
|
|
|
};
|