1047 lines
31 KiB
HTML
1047 lines
31 KiB
HTML
<!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></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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ─── 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>
|