487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
/**
|
|
* Serial Monitor — multi-board tabbed view.
|
|
* Each board has its own tab with serial output, input, and clear button.
|
|
*/
|
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
|
import { getTabSessionId } from '../../simulation/Esp32Bridge';
|
|
import type { BoardKind } from '../../types/board';
|
|
import { BOARD_KIND_LABELS } from '../../types/board';
|
|
|
|
// Short labels for tabs
|
|
const BOARD_SHORT_LABEL: Partial<Record<string, string>> = {
|
|
'arduino-uno': 'Uno',
|
|
'arduino-nano': 'Nano',
|
|
'arduino-mega': 'Mega',
|
|
'raspberry-pi-pico': 'Pico',
|
|
'pi-pico-w': 'Pico W',
|
|
'raspberry-pi-3': 'Pi 3B',
|
|
'raspberry-pi-4': 'Pi 4B',
|
|
'raspberry-pi-5': 'Pi 5',
|
|
esp32: 'ESP32',
|
|
'esp32-devkit-c-v4': 'ESP32',
|
|
'esp32-cam': 'ESP32-CAM',
|
|
'wemos-lolin32-lite': 'Lolin32',
|
|
'esp32-s3': 'ESP32-S3',
|
|
'xiao-esp32-s3': 'XIAO-S3',
|
|
'arduino-nano-esp32': 'Nano ESP32',
|
|
'esp32-c3': 'ESP32-C3',
|
|
'xiao-esp32-c3': 'XIAO-C3',
|
|
'aitewinrobot-esp32c3-supermini': 'C3 Mini',
|
|
attiny85: 'ATtiny85',
|
|
};
|
|
|
|
const BOARD_ICON: Partial<Record<string, string>> = {
|
|
'arduino-uno': '⬤',
|
|
'arduino-nano': '▪',
|
|
'arduino-mega': '▬',
|
|
'raspberry-pi-pico': '◆',
|
|
'pi-pico-w': '◆',
|
|
'raspberry-pi-3': '⬛',
|
|
'raspberry-pi-4': '⬛',
|
|
'raspberry-pi-5': '⬛',
|
|
esp32: '⬡',
|
|
'esp32-devkit-c-v4': '⬡',
|
|
'esp32-cam': '⬡',
|
|
'wemos-lolin32-lite': '⬡',
|
|
'esp32-s3': '⬡',
|
|
'xiao-esp32-s3': '⬡',
|
|
'arduino-nano-esp32': '⬡',
|
|
'esp32-c3': '⬡',
|
|
'xiao-esp32-c3': '⬡',
|
|
'aitewinrobot-esp32c3-supermini': '⬡',
|
|
attiny85: '▪',
|
|
};
|
|
|
|
const BOARD_COLOR: Partial<Record<string, string>> = {
|
|
'arduino-uno': '#4fc3f7',
|
|
'arduino-nano': '#4fc3f7',
|
|
'arduino-mega': '#4fc3f7',
|
|
'raspberry-pi-pico': '#ce93d8',
|
|
'pi-pico-w': '#ce93d8',
|
|
'raspberry-pi-3': '#ef9a9a',
|
|
'raspberry-pi-4': '#ef9a9a',
|
|
'raspberry-pi-5': '#ef9a9a',
|
|
esp32: '#a5d6a7',
|
|
'esp32-devkit-c-v4': '#a5d6a7',
|
|
'esp32-cam': '#a5d6a7',
|
|
'wemos-lolin32-lite': '#a5d6a7',
|
|
'esp32-s3': '#a5d6a7',
|
|
'xiao-esp32-s3': '#a5d6a7',
|
|
'arduino-nano-esp32': '#a5d6a7',
|
|
'esp32-c3': '#a5d6a7',
|
|
'xiao-esp32-c3': '#a5d6a7',
|
|
'aitewinrobot-esp32c3-supermini': '#a5d6a7',
|
|
attiny85: '#ffcc80',
|
|
};
|
|
|
|
export const SerialMonitor: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
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>>({});
|
|
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [lineEnding, setLineEnding] = useState<'none' | 'nl' | 'cr' | 'both'>('nl');
|
|
const [autoscroll, setAutoscroll] = useState(true);
|
|
const outputRef = useRef<HTMLPreElement>(null);
|
|
|
|
// 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);
|
|
|
|
// Snapshot the current length the moment a tab becomes active so any
|
|
// future bytes register as unread on *other* tabs. Deliberately omits
|
|
// `activeBoard?.serialOutput.length` from the deps — the unread dot is
|
|
// already hidden on the active tab (`hasUnread && !isActive`), so there's
|
|
// no need to re-snapshot on every byte arrival. Doing so under a high
|
|
// serial rate (~600 bytes/s from `Serial.println` in a tight loop) drove
|
|
// React's useSyncExternalStore into "Maximum update depth exceeded".
|
|
useEffect(() => {
|
|
if (resolvedTabId) {
|
|
const board = boards.find((b) => b.id === resolvedTabId);
|
|
if (board) {
|
|
setLastSeenLen((prev) => ({ ...prev, [resolvedTabId]: board.serialOutput.length }));
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [resolvedTabId]);
|
|
|
|
// Auto-scroll when output changes on the visible tab. Depending on the
|
|
// string identity (not its length) means one scroll per RAF flush — the
|
|
// batcher guarantees this doesn't run faster than 60 Hz.
|
|
useEffect(() => {
|
|
if (autoscroll && outputRef.current) {
|
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
}
|
|
}, [activeBoard?.serialOutput, autoscroll]);
|
|
|
|
const handleSend = useCallback(() => {
|
|
if (!resolvedTabId) return;
|
|
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;
|
|
}
|
|
serialWriteToBoard(resolvedTabId, text);
|
|
setInputValue('');
|
|
}, [resolvedTabId, inputValue, lineEnding, serialWriteToBoard]);
|
|
|
|
const isMicroPython = activeBoard?.languageMode === 'micropython';
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleSend();
|
|
return;
|
|
}
|
|
// MicroPython REPL control characters (works for RP2040 and ESP32)
|
|
if (resolvedTabId && isMicroPython && e.ctrlKey) {
|
|
if (e.key === 'c' || e.key === 'C') {
|
|
e.preventDefault();
|
|
serialWriteToBoard(resolvedTabId, '\x03'); // Ctrl+C — keyboard interrupt
|
|
} else if (e.key === 'd' || e.key === 'D') {
|
|
e.preventDefault();
|
|
serialWriteToBoard(resolvedTabId, '\x04'); // Ctrl+D — soft reset
|
|
}
|
|
}
|
|
},
|
|
[handleSend, resolvedTabId],
|
|
);
|
|
|
|
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}>{t('editor.serial.title')}</span>
|
|
</div>
|
|
<pre style={styles.output}>{t('editor.serial.addBoard')}</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{/* Tab strip */}
|
|
<div style={styles.tabStrip}>
|
|
{boards.map((board) => {
|
|
const isActive = board.id === resolvedTabId;
|
|
const color = BOARD_COLOR[board.boardKind] ?? '#999';
|
|
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] ?? board.boardKind}
|
|
{hasUnread && !isActive && <span style={styles.unreadDot} />}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* Right-side controls */}
|
|
<div style={styles.tabControls}>
|
|
{isMicroPython && (
|
|
<span style={{ color: '#ce93d8', fontSize: 11, fontWeight: 600 }}>
|
|
MicroPython REPL
|
|
</span>
|
|
)}
|
|
{activeBoard?.serialBaudRate != null &&
|
|
activeBoard.serialBaudRate > 0 &&
|
|
!isMicroPython && (
|
|
<span style={styles.baudRate}>
|
|
{activeBoard.serialBaudRate.toLocaleString()} baud
|
|
</span>
|
|
)}
|
|
<label style={styles.autoscrollLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={autoscroll}
|
|
onChange={(e) => setAutoscroll(e.target.checked)}
|
|
style={styles.checkbox}
|
|
/>
|
|
{t('editor.serial.autoscroll')}
|
|
</label>
|
|
<button
|
|
onClick={() => resolvedTabId && clearBoardSerialOutput(resolvedTabId)}
|
|
style={styles.clearBtn}
|
|
title={t('editor.serial.clearTitle')}
|
|
>
|
|
{t('editor.serial.clear')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Output area */}
|
|
<pre ref={outputRef} style={styles.output}>
|
|
{activeBoard?.serialOutput
|
|
? (() => {
|
|
// ESP-IDF and many other firmwares emit ANSI SGR escapes
|
|
// (`\x1b[0;32m...`). The <pre> renders them literally, so
|
|
// users saw raw `[0;32m` mixed into their Serial.print output.
|
|
// Strip them before any further processing — color isn't
|
|
// worth a full parser here.
|
|
const text = activeBoard.serialOutput.replace(/\x1b\[[0-9;]*m/g, '');
|
|
const ipRegex = /http:\/\/192\.168\.4\.(\d+)(\/[^\s]*)?/g;
|
|
const matches = [...text.matchAll(ipRegex)];
|
|
|
|
if (matches.length > 0) {
|
|
const parts: (string | React.ReactNode)[] = [];
|
|
let lastIdx = 0;
|
|
const sessionId = getTabSessionId();
|
|
const backendBase =
|
|
(import.meta.env.VITE_API_BASE as string | undefined) ??
|
|
'http://localhost:8001/api';
|
|
|
|
matches.forEach((m, i) => {
|
|
const start = m.index!;
|
|
const end = start + m[0].length;
|
|
const path = m[2] || '/';
|
|
const clientId = `${sessionId}::${activeBoard.id}`;
|
|
const gatewayUrl = `${backendBase}/gateway/${clientId}${path}`;
|
|
|
|
parts.push(text.slice(lastIdx, start));
|
|
parts.push(
|
|
<a
|
|
key={i}
|
|
href={gatewayUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
style={{
|
|
color: '#4fc3f7',
|
|
textDecoration: 'underline',
|
|
fontWeight: 'bold',
|
|
cursor: 'pointer',
|
|
}}
|
|
title={t('editor.serial.iotGatewayTitle')}
|
|
>
|
|
{m[0]} ({t('editor.serial.openIotGateway')} ↗)
|
|
</a>,
|
|
);
|
|
lastIdx = end;
|
|
});
|
|
parts.push(text.slice(lastIdx));
|
|
return parts;
|
|
}
|
|
return text;
|
|
})()
|
|
: activeBoard?.running
|
|
? t('editor.serial.waitingData') + '\n'
|
|
: t('editor.serial.startSim') + '\n'}
|
|
</pre>
|
|
|
|
{/* Input row */}
|
|
<div style={styles.inputRow}>
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={
|
|
isMicroPython
|
|
? t('editor.serial.placeholderPython')
|
|
: t('editor.serial.placeholderText')
|
|
}
|
|
style={styles.input}
|
|
disabled={!activeBoard?.running}
|
|
/>
|
|
<select
|
|
value={lineEnding}
|
|
onChange={(e) => setLineEnding(e.target.value as typeof lineEnding)}
|
|
style={styles.select}
|
|
>
|
|
<option value="none">{t('editor.serial.lineEnd.none')}</option>
|
|
<option value="nl">{t('editor.serial.lineEnd.nl')}</option>
|
|
<option value="cr">{t('editor.serial.lineEnd.cr')}</option>
|
|
<option value="both">{t('editor.serial.lineEnd.both')}</option>
|
|
</select>
|
|
<button onClick={handleSend} disabled={!activeBoard?.running} style={styles.sendBtn}>
|
|
{t('editor.serial.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,
|
|
minHeight: 0,
|
|
},
|
|
tabStrip: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
background: '#252526',
|
|
borderBottom: '1px solid #333',
|
|
minHeight: 32,
|
|
flexShrink: 0,
|
|
overflow: 'hidden',
|
|
},
|
|
tab: {
|
|
background: 'none',
|
|
border: 'none',
|
|
borderBottom: '2px solid transparent',
|
|
color: '#999',
|
|
padding: '5px 12px',
|
|
cursor: 'pointer',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
fontFamily: 'inherit',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
whiteSpace: 'nowrap',
|
|
position: 'relative',
|
|
},
|
|
tabActive: {
|
|
background: 'rgba(255,255,255,0.04)',
|
|
},
|
|
tabControls: {
|
|
marginLeft: 'auto',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingRight: 8,
|
|
flexShrink: 0,
|
|
},
|
|
unreadDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: '50%',
|
|
background: '#4fc3f7',
|
|
flexShrink: 0,
|
|
marginLeft: 3,
|
|
},
|
|
title: {
|
|
color: '#cccccc',
|
|
fontWeight: 600,
|
|
fontSize: 12,
|
|
},
|
|
baudRate: {
|
|
color: '#569cd6',
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
background: '#1e1e1e',
|
|
border: '1px solid #3a3a3a',
|
|
borderRadius: 3,
|
|
padding: '1px 6px',
|
|
},
|
|
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',
|
|
flexShrink: 0,
|
|
},
|
|
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,
|
|
},
|
|
};
|