/** * 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 { openDeviceGateway } from '../../lib/openDeviceGateway'; import type { BoardKind } from '../../types/board'; import { boardDisplayName, isPiBoardKind } from '../../types/board'; import { PiTerminal } from '../raspberry-pi/PiTerminal'; // Short labels for tabs const BOARD_SHORT_LABEL: Partial> = { '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> = { '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> = { '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(null); // Track last-seen serial output length per board for unread dots const [lastSeenLen, setLastSeenLen] = useState>({}); const [inputValue, setInputValue] = useState(''); const [lineEnding, setLineEnding] = useState<'none' | 'nl' | 'cr' | 'both'>('nl'); const [autoscroll, setAutoscroll] = useState(true); const outputRef = useRef(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 (
{t('editor.serial.title')}
{t('editor.serial.addBoard')}
); } return (
{/* Tab strip */}
{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 ( ); })} {/* Right-side controls */}
{isMicroPython && ( MicroPython REPL )} {activeBoard?.serialBaudRate != null && activeBoard.serialBaudRate > 0 && !isMicroPython && ( {activeBoard.serialBaudRate.toLocaleString()} baud )}
{/* Output area. QEMU-Linux boards get the interactive xterm (shell input, line editing, ANSI) instead of the read-only mirror — this replaced the separate RaspberryPiWorkspace as the one terminal. */} {isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running && activeBoard?.engineMode !== 'instant' ? (
) : (
        {activeBoard?.serialOutput
          ? (() => {
              // Firmwares and the Pi's Linux console emit ANSI escapes the
              // 
 can't render, so they leak through as literal text:
              // SGR colour (`\x1b[0;32m`), and — on the Pi shell — the DSR
              // cursor-position query `\x1b[6n` plus cursor moves / clears,
              // which showed up as a stray `[6n` next to the prompt. Strip
              // all CSI sequences (and keypad-mode toggles); a raw log 
              // can't act on any of them anyway. (xterm in PiTerminal still
              // parses + answers them — this only cleans the dumb mirror.)
              const text = activeBoard.serialOutput
                .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
                .replace(/\x1b[=>]/g, '')
                // Line-editing bytes the guest shell echoes (DEL on
                // backspace, BEL, other C0 controls) render as tofu boxes
                // in a 
; strip everything except \t \n \r.
                .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
              // ESP32 (QEMU slirp) hands out 192.168.4.x; the Pico W virtual
              // net hands out 10.13.37.x. Both reach their emulated server
              // through the same /api/gateway proxy, so linkify either subnet.
              const ipRegex = /http:\/\/(?:192\.168\.4|10\.13\.37)\.(\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));
                  const isPicoW = activeBoard.boardKind === 'pi-pico-w';
                  parts.push(
                     {
                              // Pico W runs in this tab; a new tab freezes the
                              // emulation. Show the page in an in-app iframe.
                              e.preventDefault();
                              openDeviceGateway(gatewayUrl);
                            }
                          : undefined
                      }
                      style={{
                        color: '#4fc3f7',
                        textDecoration: 'underline',
                        fontWeight: 'bold',
                        cursor: 'pointer',
                      }}
                      title={t('editor.serial.iotGatewayTitle')}
                    >
                      {m[0]} ({t('editor.serial.openIotGateway')} ↗)
                    ,
                  );
                  lastIdx = end;
                });
                parts.push(text.slice(lastIdx));
                return parts;
              }
              return text;
            })()
          : activeBoard?.running
            ? t('editor.serial.waitingData') + '\n'
            : t('editor.serial.startSim') + '\n'}
      
)} {/* Input row — the xterm handles Pi input itself */} {!( isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running && activeBoard?.engineMode !== 'instant' ) && (
setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={ isMicroPython ? t('editor.serial.placeholderPython') : t('editor.serial.placeholderText') } style={styles.input} disabled={!activeBoard?.running} />
)}
); }; const styles: Record = { 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, }, };