Files
microchip-application-pec93…/pec930_sdk-v1.0.1/UI/serial_monitor.html
T
Roy_01 a4ebbbbabc ui
2026-04-01 13:20:13 +08:00

1047 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Serial Monitor — PEC930</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-panel: #1c2230;
--bg-input: #0d1117;
--border: #30363d;
--border-focus: #58a6ff;
--text-primary: #e6edf3;
--text-secondary:#8b949e;
--text-muted: #484f58;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-orange: #d29922;
--accent-purple: #bc8cff;
--accent-teal: #39d353;
--rx-color: #58a6ff;
--tx-color: #3fb950;
--timestamp-color:#484f58;
--tab-active-bg: #21262d;
--scrollbar-thumb:#30363d;
--glow-blue: rgba(88,166,255,0.15);
--glow-green: rgba(63,185,80,0.15);
--glow-red: rgba(248,81,73,0.15);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 13px;
}
/* ──────────────── TOP BAR ──────────────── */
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.topbar-logo {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 13px;
color: var(--accent-blue);
letter-spacing: 0.5px;
white-space: nowrap;
margin-right: 6px;
}
.topbar-sep {
width: 1px;
height: 22px;
background: var(--border);
flex-shrink: 0;
}
/* Status Badge */
.status-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
background: rgba(248,81,73,0.12);
color: var(--accent-red);
border: 1px solid rgba(248,81,73,0.3);
transition: all 0.3s ease;
white-space: nowrap;
}
.status-badge.connected {
background: rgba(63,185,80,0.12);
color: var(--accent-green);
border-color: rgba(63,185,80,0.3);
}
.status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: currentColor;
animation: none;
}
.status-badge.connected .status-dot {
animation: pulse-dot 1.5s infinite;
}
@keyframes pulse-dot {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ──────────────── CONTROLS ──────────────── */
.ctrl-group {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
label.ctrl-label {
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
input[type="number"], input[type="text"], select {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: 6px;
padding: 4px 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
height: 30px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="number"]:focus, input[type="text"]:focus, select:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--glow-blue);
}
select option { background: var(--bg-secondary); }
input#baudrate { width: 90px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 6px;
border: 1px solid transparent;
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
height: 30px;
white-space: nowrap;
flex-shrink: 0;
}
.btn:active { transform: scale(0.96); }
.btn-connect {
background: linear-gradient(135deg, #238636, #2ea043);
color: #fff;
border-color: #2ea043;
box-shadow: 0 0 12px rgba(63,185,80,0.2);
}
.btn-connect:hover { box-shadow: 0 0 18px rgba(63,185,80,0.4); }
.btn-disconnect {
background: linear-gradient(135deg, #b91c1c, #dc2626);
color: #fff;
border-color: #dc2626;
box-shadow: 0 0 12px rgba(248,81,73,0.2);
}
.btn-disconnect:hover { box-shadow: 0 0 18px rgba(248,81,73,0.4); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border-color: var(--border);
}
.btn-ghost:hover {
color: var(--text-primary);
border-color: var(--accent-blue);
background: var(--glow-blue);
}
.btn-send {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #fff;
border-color: #2563eb;
box-shadow: 0 0 12px rgba(88,166,255,0.2);
padding: 5px 18px;
}
.btn-send:hover { box-shadow: 0 0 18px rgba(88,166,255,0.4); }
.btn-send:disabled {
opacity: 0.4;
cursor: not-allowed;
box-shadow: none;
}
/* Toggle chips */
.toggle-row {
display: flex;
align-items: center;
gap: 4px;
}
.chip {
padding: 3px 9px;
border-radius: 20px;
border: 1px solid var(--border);
font-size: 11px;
font-weight: 600;
cursor: pointer;
color: var(--text-secondary);
background: transparent;
font-family: 'JetBrains Mono', monospace;
transition: all 0.15s ease;
white-space: nowrap;
}
.chip:hover { color: var(--text-primary); border-color: #58a6ff66; }
.chip.active {
background: rgba(88,166,255,0.15);
border-color: var(--accent-blue);
color: var(--accent-blue);
}
/* ──────────────── DISPLAY MODE TABS ──────────────── */
.mode-tabs {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-primary);
border-radius: 6px;
padding: 2px;
border: 1px solid var(--border);
}
.mode-tab {
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
color: var(--text-secondary);
background: transparent;
border: none;
font-family: 'JetBrains Mono', monospace;
transition: all 0.15s ease;
}
.mode-tab:hover { color: var(--text-primary); }
.mode-tab.active {
background: var(--bg-panel);
color: var(--accent-blue);
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
/* ──────────────── MAIN CONTENT ──────────────── */
.main {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
padding: 8px;
gap: 6px;
}
/* Terminal */
.terminal-wrap {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
flex-shrink: 0;
}
.terminal-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.terminal-stats {
display: flex;
gap: 14px;
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.stat-rx { color: var(--accent-blue); }
.stat-tx { color: var(--accent-green); }
#terminal {
flex: 1;
overflow-y: auto;
padding: 8px 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.65;
word-break: break-all;
min-height: 0;
}
#terminal::-webkit-scrollbar { width: 6px; }
#terminal::-webkit-scrollbar-track { background: transparent; }
#terminal::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.line {
display: flex;
align-items: baseline;
gap: 8px;
padding: 1px 0;
border-bottom: 1px solid transparent;
transition: background 0.1s;
}
.line:hover { background: rgba(255,255,255,0.02); border-radius: 3px; }
.ts {
color: var(--timestamp-color);
font-size: 10px;
flex-shrink: 0;
min-width: 78px;
}
.dir {
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.dir.rx { color: var(--accent-blue); }
.dir.tx { color: var(--accent-green); }
.data {
color: var(--text-primary);
flex: 1;
white-space: pre-wrap;
}
.data .hex-byte {
display: inline-block;
background: rgba(88,166,255,0.08);
border: 1px solid rgba(88,166,255,0.15);
border-radius: 3px;
padding: 0 4px;
margin: 0 1px;
color: #79c0ff;
font-size: 11px;
}
.data .dec-byte {
display: inline-block;
background: rgba(63,185,80,0.08);
border: 1px solid rgba(63,185,80,0.15);
border-radius: 3px;
padding: 0 4px;
margin: 0 1px;
color: #85e89d;
font-size: 11px;
}
.data .bin-byte {
display: inline-block;
background: rgba(188,140,255,0.08);
border: 1px solid rgba(188,140,255,0.15);
border-radius: 3px;
padding: 0 4px;
margin: 0 1px;
color: var(--accent-purple);
font-size: 10px;
}
.data .nl-mark {
color: var(--accent-orange);
font-size: 10px;
opacity: 0.7;
}
.log-info { color: var(--accent-orange); font-style: italic; font-size: 11px; padding: 3px 0; }
.log-error { color: var(--accent-red); font-style: italic; font-size: 11px; padding: 3px 0; }
/* Auto-scroll overlay button */
.scroll-btn {
position: absolute;
bottom: 12px;
right: 14px;
width: 28px; height: 28px;
border-radius: 50%;
background: var(--bg-panel);
border: 1px solid var(--border);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-secondary);
transition: all 0.2s;
opacity: 0.8;
z-index: 5;
}
.scroll-btn:hover { opacity: 1; border-color: var(--accent-blue); }
.scroll-btn.on { color: var(--accent-blue); border-color: var(--accent-blue); background: var(--glow-blue); }
/* ──────────────── SEND BAR ──────────────── */
.send-bar {
display: flex;
gap: 6px;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 8px;
flex-shrink: 0;
}
.send-prefix {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
color: var(--accent-green);
flex-shrink: 0;
}
#send-input {
flex: 1;
background: transparent;
border: none;
border-radius: 0;
height: auto;
padding: 0;
font-size: 13px;
color: var(--text-primary);
}
#send-input:focus { box-shadow: none; }
#send-input::placeholder { color: var(--text-muted); }
.send-ending-label {
font-size: 11px;
color: var(--text-secondary);
font-weight: 500;
flex-shrink: 0;
white-space: nowrap;
}
#send-ending {
width: 90px;
height: 26px;
padding: 2px 6px;
font-size: 11px;
}
/* ──────────────── FOOTER ──────────────── */
.footer {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 14px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
font-size: 10px;
color: var(--text-muted);
flex-wrap: wrap;
}
.footer span { transition: color 0.2s; }
.footer .sep { color: var(--border); }
/* ──────────────── RESPONSIVE ──────────────── */
@media (max-width: 900px) {
.topbar { gap: 5px; }
.topbar-sep { display: none; }
}
/* ──────────────── TOAST ──────────────── */
.toast {
position: fixed;
top: 50px;
right: 16px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 16px;
font-size: 12px;
color: var(--text-primary);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 999;
animation: slideIn 0.25s ease, fadeOut 0.4s ease 2.6s forwards;
}
.toast.error { border-color: var(--accent-red); color: var(--accent-red); }
.toast.success { border-color: var(--accent-green); color: var(--accent-green); }
@keyframes slideIn {
from { opacity:0; transform: translateX(20px); }
to { opacity:1; transform: translateX(0); }
}
@keyframes fadeOut {
from { opacity:1; }
to { opacity:0; pointer-events:none; }
}
</style>
</head>
<body>
<!-- ═══════════════ TOP BAR ═══════════════ -->
<div class="topbar">
<div class="topbar-logo">⚡ Serial Monitor</div>
<!-- Status -->
<div class="status-badge" id="statusBadge">
<div class="status-dot"></div>
<span id="statusText">已斷線</span>
</div>
<div class="topbar-sep"></div>
<!-- Baud Rate -->
<div class="ctrl-group">
<label class="ctrl-label">Baud</label>
<input type="number" id="baudrate" value="115200" min="300" max="12000000" step="1">
</div>
<!-- Data Bits -->
<div class="ctrl-group">
<label class="ctrl-label">Data</label>
<select id="databits" style="width:54px">
<option value="8" selected>8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
</div>
<!-- Parity -->
<div class="ctrl-group">
<label class="ctrl-label">Parity</label>
<select id="parity" style="width:68px">
<option value="none" selected>None</option>
<option value="even">Even</option>
<option value="odd">Odd</option>
</select>
</div>
<!-- Stop Bits -->
<div class="ctrl-group">
<label class="ctrl-label">Stop</label>
<select id="stopbits" style="width:50px">
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</div>
<div class="topbar-sep"></div>
<!-- Connect / Disconnect -->
<button class="btn btn-connect" id="btnConnect" onclick="connectPort()">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.5 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.499 2.499 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5zm-8.5 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm11 5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
</svg>
連線
</button>
<button class="btn btn-disconnect" id="btnDisconnect" onclick="disconnectPort()" style="display:none">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
</svg>
斷線
</button>
<div class="topbar-sep"></div>
<!-- Display Mode -->
<label class="ctrl-label">模式</label>
<div class="mode-tabs">
<button class="mode-tab active" data-mode="ascii" onclick="setMode('ascii')">ASCII</button>
<button class="mode-tab" data-mode="hex" onclick="setMode('hex')">HEX</button>
<button class="mode-tab" data-mode="dec" onclick="setMode('dec')">DEC</button>
<button class="mode-tab" data-mode="bin" onclick="setMode('bin')">BIN</button>
</div>
<div class="topbar-sep"></div>
<!-- Options chips -->
<div class="toggle-row">
<div class="chip active" id="chipNewline" onclick="toggleChip('newline')" title="看到換行符號 (\n 或 \r\n) 就換行">↵ 自動換行</div>
<div class="chip active" id="chipTimestamp" onclick="toggleChip('timestamp')" title="顯示時間戳">⏱ 時間戳</div>
<div class="chip active" id="chipAutoScroll" onclick="toggleChip('autoscroll')" title="自動捲到底部">⬇ 自動捲動</div>
<div class="chip" id="chipShowNL" onclick="toggleChip('shownl')" title="顯示換行符號標記">⌤ 顯示\\n\\r</div>
</div>
<div class="topbar-sep"></div>
<!-- Clear -->
<button class="btn btn-ghost" onclick="clearTerminal()">🗑 清除</button>
</div>
<!-- ═══════════════ MAIN ═══════════════ -->
<div class="main">
<!-- Terminal -->
<div class="terminal-wrap">
<div class="terminal-header">
<div class="terminal-title">
<svg width="12" height="12" viewBox="0 0 16 16" fill="#58a6ff">
<path d="M6 9a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3A.5.5 0 0 1 6 9M3.854 4.146a.5.5 0 1 0-.708.708L4.793 6.5 3.146 8.146a.5.5 0 1 0 .708.708l2-2a.5.5 0 0 0 0-.708z"/>
<path d="M2 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm12 1a1 1 0 0 1 1 1v1H1V3a1 1 0 0 1 1-1zm1 3v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V5z"/>
</svg>
Monitor Output
</div>
<div class="terminal-stats">
<span>RX: <span class="stat-rx" id="statsRX">0</span> bytes</span>
<span>TX: <span class="stat-tx" id="statsTX">0</span> bytes</span>
<span id="statsLines">0 行</span>
</div>
</div>
<div id="terminal">
<div class="log-info">▶ 請點擊「連線」選擇 COM Port(需使用 Chrome / Edge</div>
</div>
<!-- Auto-scroll toggle overlay -->
<div class="scroll-btn on" id="scrollBtn" onclick="toggleChip('autoscroll')" title="自動捲動"></div>
</div>
<!-- Send Bar -->
<div class="send-bar">
<div class="send-prefix">TX&gt;</div>
<input type="text" id="send-input" placeholder="輸入要傳送的資料…" onkeydown="handleSendKey(event)">
<span class="send-ending-label">Line Ending:</span>
<select id="send-ending">
<option value="none">None</option>
<option value="lf" selected>LF (\n)</option>
<option value="cr">CR (\r)</option>
<option value="crlf">CR+LF</option>
</select>
<button class="btn btn-send" id="btnSend" onclick="sendData()" disabled>
➤ 送出
</button>
</div>
</div>
<!-- ═══════════════ FOOTER ═══════════════ -->
<div class="footer">
<span>PEC930 Serial Monitor</span>
<span class="sep">|</span>
<span id="footerPort">Port: —</span>
<span class="sep">|</span>
<span id="footerBaud"></span>
<span class="sep">|</span>
<span id="footerMode">模式: ASCII</span>
<span class="sep">|</span>
<span id="footerTime"></span>
<span style="margin-left:auto; color:#484f58">Web Serial API · Chrome/Edge only</span>
</div>
<script>
'use strict';
// ─── State ───────────────────────────────────────────────────
let port = null;
let reader = null;
let writer = null;
let rxBytes = 0;
let txBytes = 0;
let lineCount = 0;
let displayMode = 'ascii';
let readingActive = false;
let opts = {
newline: true,
timestamp: true,
autoscroll: true,
shownl: false,
};
// RX buffer for accumulating incomplete lines
let rxLineBuffer = new Uint8Array(0);
// ─── Clock ───────────────────────────────────────────────────
function fmtTime() {
const n = new Date();
return n.toLocaleTimeString('zh-TW', {hour12:false, hour:'2-digit', minute:'2-digit', second:'2-digit'})
+ '.' + String(n.getMilliseconds()).padStart(3,'0');
}
setInterval(() => {
document.getElementById('footerTime').textContent = new Date().toLocaleString('zh-TW',{hour12:false});
}, 500);
// ─── Connect ─────────────────────────────────────────────────
async function connectPort() {
if (!('serial' in navigator)) {
showToast('⚠ 瀏覽器不支援 Web Serial API\n請使用 Chrome / Edge 且版本 ≥ 89', 'error');
return;
}
try {
port = await navigator.serial.requestPort();
const baud = parseInt(document.getElementById('baudrate').value) || 115200;
const databits = parseInt(document.getElementById('databits').value);
const parity = document.getElementById('parity').value;
const stopbits = parseInt(document.getElementById('stopbits').value);
await port.open({ baudRate: baud, dataBits: databits, parity, stopBits: stopbits });
// Get writer
writer = port.writable.getWriter();
setConnected(true, baud);
rxLineBuffer = new Uint8Array(0);
logInfo(`已連線 — ${baud} baud, ${databits}${parity === 'none' ? 'N' : parity.charAt(0).toUpperCase()}${stopbits}`);
// Start read loop
readingActive = true;
startReading();
} catch (e) {
port = null; writer = null;
if (e.name !== 'AbortError') {
logError('連線失敗: ' + e.message);
showToast('連線失敗: ' + e.message, 'error');
}
}
}
// ─── Disconnect ──────────────────────────────────────────────
async function disconnectPort() {
readingActive = false;
// 1. Cancel the reader — this will cause reader.read() to resolve with done=true
if (reader) {
try { await reader.cancel(); } catch(_) {}
// reader.releaseLock() will be called by the read loop's finally block
// Give it a moment to finish
await new Promise(r => setTimeout(r, 100));
reader = null;
}
// 2. Release writer
if (writer) {
try { writer.releaseLock(); } catch(_) {}
writer = null;
}
// 3. Close port
if (port) {
try { await port.close(); } catch(e) { logError('關閉 port 失敗: ' + e.message); }
port = null;
}
setConnected(false);
logInfo('已斷線');
rxLineBuffer = new Uint8Array(0);
}
function setConnected(on, baud) {
document.getElementById('btnConnect').style.display = on ? 'none' : '';
document.getElementById('btnDisconnect').style.display = on ? '' : 'none';
document.getElementById('btnSend').disabled = !on;
const badge = document.getElementById('statusBadge');
badge.className = 'status-badge' + (on ? ' connected' : '');
document.getElementById('statusText').textContent = on ? '已連線' : '已斷線';
document.getElementById('footerPort').textContent = 'Port: ' + (on ? 'Connected' : '—');
document.getElementById('footerBaud').textContent = on ? ((baud || '') + ' baud') : '—';
}
// ─── Read Loop ───────────────────────────────────────────────
async function startReading() {
while (readingActive && port && port.readable) {
reader = port.readable.getReader();
try {
while (readingActive) {
const { value, done } = await reader.read();
if (done) break; // cancel() was called
if (value && value.length) processRxData(value);
}
} catch (e) {
// Only log if not a deliberate disconnect
if (readingActive) logError('讀取錯誤: ' + e.message);
} finally {
try { reader.releaseLock(); } catch(_) {}
}
}
}
// ─── RX Data Processing ──────────────────────────────────────
function processRxData(chunk) {
rxBytes += chunk.length;
document.getElementById('statsRX').textContent = rxBytes;
if (!opts.newline) {
// No auto-newline: dump entire chunk as one line
appendLine(chunk, 'rx');
return;
}
// Merge with buffered data
const merged = new Uint8Array(rxLineBuffer.length + chunk.length);
merged.set(rxLineBuffer);
merged.set(chunk, rxLineBuffer.length);
rxLineBuffer = new Uint8Array(0);
let start = 0;
for (let i = 0; i < merged.length; i++) {
const b = merged[i];
if (b === 0x0A) { // LF
appendLine(merged.slice(start, i + 1), 'rx');
start = i + 1;
} else if (b === 0x0D) { // CR
// peek next byte
if (i + 1 < merged.length) {
if (merged[i + 1] === 0x0A) { // CR+LF
appendLine(merged.slice(start, i + 2), 'rx');
start = i + 2;
i++;
} else {
appendLine(merged.slice(start, i + 1), 'rx');
start = i + 1;
}
} else {
// Might be incomplete CR+LF, buffer CR for next chunk
rxLineBuffer = merged.slice(i);
start = merged.length;
break;
}
}
}
if (start < merged.length) {
rxLineBuffer = merged.slice(start);
}
}
// ─── Append a display line ────────────────────────────────────
function appendLine(bytes, dir) {
const terminal = document.getElementById('terminal');
lineCount++;
document.getElementById('statsLines').textContent = lineCount + ' 行';
const row = document.createElement('div');
row.className = 'line';
if (opts.timestamp) {
const ts = document.createElement('span');
ts.className = 'ts';
ts.textContent = fmtTime();
row.appendChild(ts);
}
const dirSpan = document.createElement('span');
dirSpan.className = 'dir ' + dir;
dirSpan.textContent = dir === 'rx' ? '▼' : '▲';
row.appendChild(dirSpan);
const data = document.createElement('span');
data.className = 'data';
data.innerHTML = formatBytes(bytes);
row.appendChild(data);
terminal.appendChild(row);
if (opts.autoscroll) terminal.scrollTop = terminal.scrollHeight;
}
// ─── Format bytes by mode ─────────────────────────────────────
function formatBytes(bytes) {
switch (displayMode) {
case 'hex': return formatHex(bytes);
case 'dec': return formatDec(bytes);
case 'bin': return formatBin(bytes);
default: return formatAscii(bytes);
}
}
function formatAscii(bytes) {
let out = '';
for (const b of bytes) {
if (b === 0x0A) {
out += opts.shownl ? '<span class="nl-mark">\\n</span>' : '';
} else if (b === 0x0D) {
out += opts.shownl ? '<span class="nl-mark">\\r</span>' : '';
} else if (b >= 0x20 && b < 0x7F) {
out += escapeHtml(String.fromCharCode(b));
} else if (b === 0x09) {
out += opts.shownl ? '<span class="nl-mark">\\t</span>' : '\t';
} else {
// Non-printable: show as hex badge
out += `<span class="hex-byte">${b.toString(16).padStart(2,'0').toUpperCase()}</span>`;
}
}
return out;
}
function formatHex(bytes) {
return [...bytes].map(b =>
`<span class="hex-byte">${b.toString(16).padStart(2,'0').toUpperCase()}</span>`
).join(' ');
}
function formatDec(bytes) {
return [...bytes].map(b =>
`<span class="dec-byte">${b}</span>`
).join(' ');
}
function formatBin(bytes) {
return [...bytes].map(b =>
`<span class="bin-byte">${b.toString(2).padStart(8,'0')}</span>`
).join(' ');
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── Send ─────────────────────────────────────────────────────
async function sendData() {
if (!writer) return;
const input = document.getElementById('send-input');
const ending = document.getElementById('send-ending').value;
const text = input.value;
let payload = new TextEncoder().encode(text);
let suffix = new Uint8Array(0);
if (ending === 'lf') suffix = new Uint8Array([0x0A]);
else if (ending === 'cr') suffix = new Uint8Array([0x0D]);
else if (ending === 'crlf') suffix = new Uint8Array([0x0D, 0x0A]);
const data = new Uint8Array(payload.length + suffix.length);
data.set(payload);
data.set(suffix, payload.length);
try {
await writer.write(data);
txBytes += data.length;
document.getElementById('statsTX').textContent = txBytes;
appendLine(data, 'tx');
input.value = '';
input.focus();
} catch(e) {
logError('傳送失敗: ' + e.message);
}
}
function handleSendKey(e) {
if (e.key === 'Enter') sendData();
}
// ─── UI Helpers ──────────────────────────────────────────────
function setMode(mode) {
displayMode = mode;
document.querySelectorAll('.mode-tab').forEach(t => {
t.classList.toggle('active', t.dataset.mode === mode);
});
document.getElementById('footerMode').textContent = '模式: ' + mode.toUpperCase();
}
function toggleChip(key) {
opts[key] = !opts[key];
const id = 'chip' + key.charAt(0).toUpperCase() + key.slice(1);
const el = document.getElementById(id);
if (el) el.classList.toggle('active', opts[key]);
// Sync scroll overlay button
if (key === 'autoscroll') {
document.getElementById('scrollBtn').classList.toggle('on', opts.autoscroll);
if (opts.autoscroll) {
document.getElementById('terminal').scrollTop = document.getElementById('terminal').scrollHeight;
}
}
}
function clearTerminal() {
document.getElementById('terminal').innerHTML = '';
rxBytes = txBytes = lineCount = 0;
rxLineBuffer = new Uint8Array(0);
document.getElementById('statsRX').textContent = '0';
document.getElementById('statsTX').textContent = '0';
document.getElementById('statsLines').textContent = '0 行';
logInfo('畫面已清除');
}
function logInfo(msg) {
const el = document.createElement('div');
el.className = 'log-info';
el.textContent = '▸ ' + msg;
document.getElementById('terminal').appendChild(el);
if (opts.autoscroll) document.getElementById('terminal').scrollTop = 99999;
}
function logError(msg) {
const el = document.createElement('div');
el.className = 'log-error';
el.textContent = '✖ ' + msg;
document.getElementById('terminal').appendChild(el);
if (opts.autoscroll) document.getElementById('terminal').scrollTop = 99999;
}
let toastTimer = null;
function showToast(msg, type='') {
const old = document.querySelector('.toast');
if (old) old.remove();
if (toastTimer) clearTimeout(toastTimer);
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
document.body.appendChild(t);
toastTimer = setTimeout(() => t.remove(), 3200);
}
// Handle serial port disconnect from OS side
navigator.serial?.addEventListener('disconnect', (e) => {
if (port && e.target === port) {
port = null; reader = null; writer = null;
setConnected(false);
logError('裝置已被拔除');
}
});
// Init footer clock
document.getElementById('footerTime').textContent = new Date().toLocaleString('zh-TW',{hour12:false});
</script>
</body>
</html>