2026-03-05 16:56:14 +07:00
|
|
|
/**
|
2026-03-16 00:04:01 +07:00
|
|
|
* Serial Monitor — multi-board tabbed view.
|
|
|
|
|
* Each board has its own tab with serial output, input, and clear button.
|
2026-03-05 16:56:14 +07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
2026-03-30 07:27:41 +07:00
|
|
|
import { useSimulatorStore, getBoardSimulator } from '../../store/useSimulatorStore';
|
|
|
|
|
import { RP2040Simulator } from '../../simulation/RP2040Simulator';
|
2026-03-16 00:04:01 +07:00
|
|
|
import type { BoardKind } from '../../types/board';
|
|
|
|
|
import { BOARD_KIND_LABELS } from '../../types/board';
|
|
|
|
|
|
|
|
|
|
// Short labels for tabs
|
|
|
|
|
const BOARD_SHORT_LABEL: Record<BoardKind, string> = {
|
|
|
|
|
'arduino-uno': 'Uno',
|
|
|
|
|
'arduino-nano': 'Nano',
|
|
|
|
|
'arduino-mega': 'Mega',
|
|
|
|
|
'raspberry-pi-pico': 'Pico',
|
|
|
|
|
'raspberry-pi-3': 'Pi 3B',
|
|
|
|
|
'esp32': 'ESP32',
|
|
|
|
|
'esp32-s3': 'ESP32-S3',
|
|
|
|
|
'esp32-c3': 'ESP32-C3',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const BOARD_ICON: Record<BoardKind, string> = {
|
|
|
|
|
'arduino-uno': '⬤',
|
|
|
|
|
'arduino-nano': '▪',
|
|
|
|
|
'arduino-mega': '▬',
|
|
|
|
|
'raspberry-pi-pico': '◆',
|
|
|
|
|
'raspberry-pi-3': '⬛',
|
|
|
|
|
'esp32': '⬡',
|
|
|
|
|
'esp32-s3': '⬡',
|
|
|
|
|
'esp32-c3': '⬡',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const BOARD_COLOR: Record<BoardKind, string> = {
|
|
|
|
|
'arduino-uno': '#4fc3f7',
|
|
|
|
|
'arduino-nano': '#4fc3f7',
|
|
|
|
|
'arduino-mega': '#4fc3f7',
|
|
|
|
|
'raspberry-pi-pico': '#ce93d8',
|
|
|
|
|
'raspberry-pi-3': '#ef9a9a',
|
|
|
|
|
'esp32': '#a5d6a7',
|
|
|
|
|
'esp32-s3': '#a5d6a7',
|
|
|
|
|
'esp32-c3': '#a5d6a7',
|
|
|
|
|
};
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
|
|
|
export const SerialMonitor: React.FC = () => {
|
2026-03-16 00:04:01 +07:00
|
|
|
const boards = useSimulatorStore((s) => s.boards);
|
|
|
|
|
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
|
|
|
|
|
const serialWriteToBoard = useSimulatorStore((s) => s.serialWriteToBoard);
|
|
|
|
|
const clearBoardSerialOutput = useSimulatorStore((s) => s.clearBoardSerialOutput);
|
|
|
|
|
|
|
|
|
|
// Active tab defaults to activeBoardId, updates when active board changes
|
|
|
|
|
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
|
|
|
|
// Track last-seen serial output length per board for unread dots
|
|
|
|
|
const [lastSeenLen, setLastSeenLen] = useState<Record<string, number>>({});
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
|
|
|
const [inputValue, setInputValue] = useState('');
|
|
|
|
|
const [lineEnding, setLineEnding] = useState<'none' | 'nl' | 'cr' | 'both'>('nl');
|
|
|
|
|
const [autoscroll, setAutoscroll] = useState(true);
|
|
|
|
|
const outputRef = useRef<HTMLPreElement>(null);
|
|
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
// Sync active tab to activeBoardId when it changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeBoardId) setActiveTabId(activeBoardId);
|
|
|
|
|
}, [activeBoardId]);
|
|
|
|
|
|
|
|
|
|
// Fallback: if activeTab is gone, pick first board
|
|
|
|
|
const resolvedTabId = (boards.find((b) => b.id === activeTabId) ? activeTabId : boards[0]?.id) ?? null;
|
|
|
|
|
const activeBoard = boards.find((b) => b.id === resolvedTabId);
|
|
|
|
|
|
|
|
|
|
// Mark tab as read when it becomes active
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (resolvedTabId && activeBoard) {
|
|
|
|
|
setLastSeenLen((prev) => ({ ...prev, [resolvedTabId]: activeBoard.serialOutput.length }));
|
|
|
|
|
}
|
|
|
|
|
}, [resolvedTabId, activeBoard?.serialOutput.length]);
|
|
|
|
|
|
|
|
|
|
// Auto-scroll when output changes on the visible tab
|
2026-03-05 16:56:14 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (autoscroll && outputRef.current) {
|
|
|
|
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
}, [activeBoard?.serialOutput, autoscroll]);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
|
|
|
|
const handleSend = useCallback(() => {
|
2026-03-16 00:04:01 +07:00
|
|
|
if (!resolvedTabId) return;
|
2026-03-05 16:56:14 +07:00
|
|
|
if (!inputValue && lineEnding === 'none') return;
|
|
|
|
|
let text = inputValue;
|
|
|
|
|
switch (lineEnding) {
|
|
|
|
|
case 'nl': text += '\n'; break;
|
|
|
|
|
case 'cr': text += '\r'; break;
|
|
|
|
|
case 'both': text += '\r\n'; break;
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
serialWriteToBoard(resolvedTabId, text);
|
2026-03-05 16:56:14 +07:00
|
|
|
setInputValue('');
|
2026-03-16 00:04:01 +07:00
|
|
|
}, [resolvedTabId, inputValue, lineEnding, serialWriteToBoard]);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
2026-03-30 07:27:41 +07:00
|
|
|
const isMicroPython = activeBoard?.languageMode === 'micropython';
|
|
|
|
|
|
2026-03-05 16:56:14 +07:00
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSend();
|
2026-03-30 07:27:41 +07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// MicroPython REPL control characters
|
|
|
|
|
if (resolvedTabId && e.ctrlKey) {
|
|
|
|
|
const sim = getBoardSimulator(resolvedTabId);
|
|
|
|
|
if (sim instanceof RP2040Simulator && sim.isMicroPythonMode()) {
|
|
|
|
|
if (e.key === 'c' || e.key === 'C') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
sim.serialWriteByte(0x03); // Ctrl+C — keyboard interrupt
|
|
|
|
|
} else if (e.key === 'd' || e.key === 'D') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
sim.serialWriteByte(0x04); // Ctrl+D — soft reset
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 16:56:14 +07:00
|
|
|
}
|
2026-03-30 07:27:41 +07:00
|
|
|
}, [handleSend, resolvedTabId]);
|
2026-03-05 16:56:14 +07:00
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
const handleTabClick = (boardId: string) => {
|
|
|
|
|
setActiveTabId(boardId);
|
|
|
|
|
const board = boards.find((b) => b.id === boardId);
|
|
|
|
|
if (board) {
|
|
|
|
|
setLastSeenLen((prev) => ({ ...prev, [boardId]: board.serialOutput.length }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (boards.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={styles.container}>
|
|
|
|
|
<div style={styles.header}>
|
|
|
|
|
<span style={styles.title}>Serial Monitor</span>
|
|
|
|
|
</div>
|
|
|
|
|
<pre style={styles.output}>Add a board to start the serial monitor.</pre>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 16:56:14 +07:00
|
|
|
return (
|
|
|
|
|
<div style={styles.container}>
|
2026-03-16 00:04:01 +07:00
|
|
|
{/* Tab strip */}
|
|
|
|
|
<div style={styles.tabStrip}>
|
|
|
|
|
{boards.map((board) => {
|
|
|
|
|
const isActive = board.id === resolvedTabId;
|
|
|
|
|
const color = BOARD_COLOR[board.boardKind];
|
|
|
|
|
const hasUnread = (board.serialOutput.length) > (lastSeenLen[board.id] ?? 0);
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={board.id}
|
|
|
|
|
style={{
|
|
|
|
|
...styles.tab,
|
|
|
|
|
...(isActive ? { ...styles.tabActive, borderBottomColor: color, color } : {}),
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => handleTabClick(board.id)}
|
|
|
|
|
title={BOARD_KIND_LABELS[board.boardKind]}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ fontSize: 9, marginRight: 3, color: isActive ? color : '#888' }}>
|
|
|
|
|
{BOARD_ICON[board.boardKind]}
|
|
|
|
|
</span>
|
|
|
|
|
{BOARD_SHORT_LABEL[board.boardKind]}
|
|
|
|
|
{hasUnread && !isActive && <span style={styles.unreadDot} />}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Right-side controls */}
|
|
|
|
|
<div style={styles.tabControls}>
|
2026-03-30 07:27:41 +07:00
|
|
|
{isMicroPython && (
|
|
|
|
|
<span style={{ color: '#ce93d8', fontSize: 11, fontWeight: 600 }}>MicroPython REPL</span>
|
|
|
|
|
)}
|
|
|
|
|
{activeBoard?.serialBaudRate != null && activeBoard.serialBaudRate > 0 && !isMicroPython && (
|
2026-03-16 00:04:01 +07:00
|
|
|
<span style={styles.baudRate}>{activeBoard.serialBaudRate.toLocaleString()} baud</span>
|
2026-03-06 07:07:10 +07:00
|
|
|
)}
|
2026-03-05 16:56:14 +07:00
|
|
|
<label style={styles.autoscrollLabel}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={autoscroll}
|
|
|
|
|
onChange={(e) => setAutoscroll(e.target.checked)}
|
|
|
|
|
style={styles.checkbox}
|
|
|
|
|
/>
|
|
|
|
|
Autoscroll
|
|
|
|
|
</label>
|
2026-03-16 00:04:01 +07:00
|
|
|
<button
|
|
|
|
|
onClick={() => resolvedTabId && clearBoardSerialOutput(resolvedTabId)}
|
|
|
|
|
style={styles.clearBtn}
|
|
|
|
|
title="Clear output for this board"
|
|
|
|
|
>
|
2026-03-05 16:56:14 +07:00
|
|
|
Clear
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Output area */}
|
|
|
|
|
<pre ref={outputRef} style={styles.output}>
|
2026-03-16 00:04:01 +07:00
|
|
|
{activeBoard?.serialOutput ||
|
|
|
|
|
(activeBoard?.running ? 'Waiting for serial data...\n' : 'Start simulation to see serial output.\n')}
|
2026-03-05 16:56:14 +07:00
|
|
|
</pre>
|
|
|
|
|
|
|
|
|
|
{/* Input row */}
|
|
|
|
|
<div style={styles.inputRow}>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={inputValue}
|
|
|
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
2026-03-30 07:27:41 +07:00
|
|
|
placeholder={isMicroPython ? 'Type Python expression... (Ctrl+C to interrupt)' : 'Type message to send...'}
|
2026-03-05 16:56:14 +07:00
|
|
|
style={styles.input}
|
2026-03-16 00:04:01 +07:00
|
|
|
disabled={!activeBoard?.running}
|
2026-03-05 16:56:14 +07:00
|
|
|
/>
|
|
|
|
|
<select
|
|
|
|
|
value={lineEnding}
|
|
|
|
|
onChange={(e) => setLineEnding(e.target.value as typeof lineEnding)}
|
|
|
|
|
style={styles.select}
|
|
|
|
|
>
|
|
|
|
|
<option value="none">No line ending</option>
|
|
|
|
|
<option value="nl">Newline</option>
|
|
|
|
|
<option value="cr">Carriage return</option>
|
|
|
|
|
<option value="both">Both NL & CR</option>
|
|
|
|
|
</select>
|
2026-03-16 00:04:01 +07:00
|
|
|
<button onClick={handleSend} disabled={!activeBoard?.running} style={styles.sendBtn}>
|
2026-03-05 16:56:14 +07:00
|
|
|
Send
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
|
|
|
container: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
height: '100%',
|
|
|
|
|
background: '#1e1e1e',
|
|
|
|
|
borderTop: '1px solid #333',
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
fontSize: 13,
|
2026-03-16 00:04:01 +07:00
|
|
|
minHeight: 0,
|
2026-03-05 16:56:14 +07:00
|
|
|
},
|
2026-03-16 00:04:01 +07:00
|
|
|
tabStrip: {
|
2026-03-05 16:56:14 +07:00
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
background: '#252526',
|
|
|
|
|
borderBottom: '1px solid #333',
|
2026-03-16 00:04:01 +07:00
|
|
|
minHeight: 32,
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
overflow: 'hidden',
|
2026-03-05 16:56:14 +07:00
|
|
|
},
|
2026-03-16 00:04:01 +07:00
|
|
|
tab: {
|
|
|
|
|
background: 'none',
|
|
|
|
|
border: 'none',
|
|
|
|
|
borderBottom: '2px solid transparent',
|
|
|
|
|
color: '#999',
|
|
|
|
|
padding: '5px 12px',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontSize: 11,
|
2026-03-05 16:56:14 +07:00
|
|
|
fontWeight: 600,
|
2026-03-16 00:04:01 +07:00
|
|
|
fontFamily: 'inherit',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 2,
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
position: 'relative',
|
|
|
|
|
},
|
|
|
|
|
tabActive: {
|
|
|
|
|
background: 'rgba(255,255,255,0.04)',
|
2026-03-05 16:56:14 +07:00
|
|
|
},
|
2026-03-16 00:04:01 +07:00
|
|
|
tabControls: {
|
|
|
|
|
marginLeft: 'auto',
|
2026-03-05 16:56:14 +07:00
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 8,
|
2026-03-16 00:04:01 +07:00
|
|
|
paddingRight: 8,
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
},
|
|
|
|
|
unreadDot: {
|
|
|
|
|
width: 6,
|
|
|
|
|
height: 6,
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
background: '#4fc3f7',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
marginLeft: 3,
|
|
|
|
|
},
|
|
|
|
|
title: {
|
|
|
|
|
color: '#cccccc',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
fontSize: 12,
|
2026-03-05 16:56:14 +07:00
|
|
|
},
|
2026-03-06 07:07:10 +07:00
|
|
|
baudRate: {
|
|
|
|
|
color: '#569cd6',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
background: '#1e1e1e',
|
|
|
|
|
border: '1px solid #3a3a3a',
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
padding: '1px 6px',
|
|
|
|
|
},
|
2026-03-05 16:56:14 +07:00
|
|
|
autoscrollLabel: {
|
|
|
|
|
color: '#999',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 4,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
},
|
|
|
|
|
checkbox: {
|
|
|
|
|
margin: 0,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
},
|
|
|
|
|
clearBtn: {
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
border: '1px solid #555',
|
|
|
|
|
color: '#ccc',
|
|
|
|
|
padding: '2px 8px',
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
},
|
|
|
|
|
output: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
margin: 0,
|
|
|
|
|
padding: 8,
|
|
|
|
|
color: '#00ff41',
|
|
|
|
|
background: '#0a0a0a',
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
whiteSpace: 'pre-wrap',
|
|
|
|
|
wordBreak: 'break-all',
|
|
|
|
|
minHeight: 0,
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
lineHeight: '1.4',
|
|
|
|
|
},
|
|
|
|
|
inputRow: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
gap: 4,
|
|
|
|
|
padding: 4,
|
|
|
|
|
background: '#252526',
|
|
|
|
|
borderTop: '1px solid #333',
|
2026-03-16 00:04:01 +07:00
|
|
|
flexShrink: 0,
|
2026-03-05 16:56:14 +07:00
|
|
|
},
|
|
|
|
|
input: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
background: '#1e1e1e',
|
|
|
|
|
border: '1px solid #444',
|
|
|
|
|
color: '#ccc',
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
outline: 'none',
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
background: '#1e1e1e',
|
|
|
|
|
border: '1px solid #444',
|
|
|
|
|
color: '#ccc',
|
|
|
|
|
padding: '4px',
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
outline: 'none',
|
|
|
|
|
},
|
|
|
|
|
sendBtn: {
|
|
|
|
|
background: '#0e639c',
|
|
|
|
|
border: 'none',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
padding: '4px 12px',
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
},
|
|
|
|
|
};
|