Merge branch 'dev/uart_terminal'

This commit is contained in:
ws50529
2026-06-05 10:51:03 +08:00
10 changed files with 3213 additions and 0 deletions
+6
View File
@@ -51,6 +51,7 @@ async def restrict_to_lan(request: Request, call_next):
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
app.mount("/uart_terminal", StaticFiles(directory="uart_example", html=True), name="uart_terminal")
@app.get("/")
@@ -58,6 +59,11 @@ def redirect_to_ui():
return RedirectResponse(url="/ui/")
@app.get("/uart_terminal")
def redirect_to_uart_terminal():
return RedirectResponse(url="/uart_terminal/")
@app.post("/api/design")
def design_filter(params: DesignParams):
try:
+55
View File
@@ -0,0 +1,55 @@
# Web UART Terminal
A sleek, premium, self-contained web application that enables serial port communication directly from your browser using the **Web Serial API**. It supports standard UART configuration controls (Baud rate, data bits, stop bits, parity), ASCII/Hex display, Hex data sending, log exporting, and a responsive dark/light themed user interface.
![Web UART Terminal Mockup](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.html)
## Prerequisites & Compatibility
1. **Browser Support**: The Web Serial API is supported in modern Chromium-based browsers, including:
- Google Chrome (version 89+)
- Microsoft Edge (version 89+)
- Opera (version 75+)
- *(Note: Firefox, Safari, and iOS browsers are not supported at this time.)*
2. **Secure Context**: The browser only permits Web Serial API calls in a secure context (`https://`) or from `localhost` (which Vite handles automatically).
## Quick Start
To launch the development server locally:
1. **Install dependencies**:
```bash
npm install
```
2. **Start the local server**:
```bash
cd ui/uart_terminal
# If not create the virtual environment
python3 -m venv .venv
# Activate it
source .venv/bin/activate
# start server
npm run dev
```
3. **Open the browser**:
Navigate to the URL printed in the terminal (usually `http://localhost:3000`).
## Features & Usage
- **Connect / Disconnect**: Click the circular green play icon in the top left to request a COM/USB serial port and open connection. The button changes to a red stop icon when connected.
- **Save Logs**: Click the blue download button next to the connection status to export the accumulated console output log as a `.txt` file.
- **Scroll Modes**: Switch between "Auto-scroll" and "No scroll" to control terminal navigation.
- **Display Formats**:
- `Auto`: Prints incoming data as decoded UTF-8 text string (default).
- `Hex`: Displays incoming bytes as space-separated hexadecimal numbers (e.g., `55 AA 01 02`).
- **Data Transmission**:
- Enter text in the send input at the bottom and click the Send button or press `Enter` to transmit.
- Switch the send format select dropdown between `ASCII` and `Hex` to format data packages.
- **Hex Parsing Rules**: When in Hex mode, you can input hex characters with or without spaces (e.g., `55 AA FF` or `55aaff`). The string must contain an even number of characters and valid hexadecimal characters only.
### Keyboard Shortcuts
- **Send Data**: Press `Enter` (or `Ctrl+Enter`) inside the input bar to send.
- **Insert Tabs**: Press `Ctrl+Tab` in the input bar to insert a literal tab character (`\t`) instead of losing input focus.
+761
View File
@@ -0,0 +1,761 @@
// DOM Elements Selection
const themeToggle = document.getElementById('theme-toggle');
const connectBtn = document.getElementById('connect-btn');
const downloadBtn = document.getElementById('download-btn');
const statusBanner = document.getElementById('status-banner');
const statusText = document.getElementById('status-text');
// Settings Dropdowns
const baudRateSelect = document.getElementById('baud-rate');
const dataBitsSelect = document.getElementById('data-bits');
const stopBitsSelect = document.getElementById('stop-bits');
const paritySelect = document.getElementById('parity');
// Controls & Console
const scrollModeSelect = document.getElementById('scroll-mode');
const displayFormatSelect = document.getElementById('display-format');
const terminalContainer = document.getElementById('terminal-container');
// Sending Inputs
const sendInput = document.getElementById('send-input');
const sendFormatSelect = document.getElementById('send-format');
const sendEolSelect = document.getElementById('send-eol');
const sendBtn = document.getElementById('send-btn');
const clearBtn = document.getElementById('clear-btn');
const addSessionBtn = document.getElementById('add-session-btn');
const sessionListContainer = document.getElementById('session-list');
const downloadTypeSelect = document.getElementById('download-type');
// PortSession Class definition
class PortSession {
constructor(id, name) {
this.id = id;
this.name = name;
// Web Serial State
this.port = null;
this.reader = null;
this.writer = null;
this.keepReading = false;
this.rxLogBuffer = [];
this.txLogBuffer = [];
this.combinedLogBuffer = [];
this.downloadType = 'rx_tx';
this.commandHistory = [];
this.historyIndex = -1;
this.isTerminalEmpty = true;
this.deviceInfo = '';
// Settings state
this.baudRate = '115200';
this.dataBits = '8';
this.stopBits = '1';
this.parity = 'none';
// Console controls
this.scrollMode = 'auto';
this.displayFormat = 'auto';
this.sendFormat = 'ascii';
this.sendEol = 'crlf';
this.sendText = '';
// Dedicated Terminal Element
this.terminalOutputElement = document.createElement('div');
this.terminalOutputElement.id = `terminal-output-${this.id}`;
this.terminalOutputElement.className = 'terminal-output empty';
this.terminalOutputElement.tabIndex = 0;
this.terminalOutputElement.textContent = 'No data received yet...';
}
}
// Global Application State
let sessions = [];
let activeSession = null;
// Initialize theme and browser support on load
document.addEventListener('DOMContentLoaded', () => {
initTheme();
checkWebSerialSupport();
// Wire up main event listeners
addSessionBtn.addEventListener('click', addSession);
baudRateSelect.addEventListener('change', () => {
if (activeSession) {
activeSession.baudRate = baudRateSelect.value;
renderSessionsList();
}
});
dataBitsSelect.addEventListener('change', () => {
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
});
stopBitsSelect.addEventListener('change', () => {
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
});
paritySelect.addEventListener('change', () => {
if (activeSession) activeSession.parity = paritySelect.value;
});
scrollModeSelect.addEventListener('change', () => {
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
});
displayFormatSelect.addEventListener('change', () => {
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
});
sendFormatSelect.addEventListener('change', () => {
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
});
sendEolSelect.addEventListener('change', () => {
if (activeSession) activeSession.sendEol = sendEolSelect.value;
});
sendInput.addEventListener('input', () => {
if (activeSession) activeSession.sendText = sendInput.value;
});
downloadTypeSelect.addEventListener('change', () => {
if (activeSession) activeSession.downloadType = downloadTypeSelect.value;
});
// Initialize with a default session
addSession();
});
// Toast notification helper
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast-msg show toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// -------------------------------------------------------------
// 1. Theme Configuration (Dark / Light Mode)
// -------------------------------------------------------------
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'light') {
document.body.classList.add('light-theme');
} else {
document.body.classList.remove('light-theme');
}
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('light-theme');
const activeTheme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
localStorage.setItem('theme', activeTheme);
});
// -------------------------------------------------------------
// 2. Web Serial API Checks & State Updates
// -------------------------------------------------------------
function checkWebSerialSupport() {
if (!('serial' in navigator)) {
statusBanner.className = 'status-banner error';
statusText.innerHTML = '<strong>Web Serial API not supported.</strong> Please use a compatible Chromium-based browser (Chrome, Edge, Opera).';
connectBtn.disabled = true;
connectBtn.style.opacity = '0.5';
connectBtn.style.cursor = 'not-allowed';
addSessionBtn.disabled = true;
addSessionBtn.style.opacity = '0.5';
addSessionBtn.style.cursor = 'not-allowed';
showToast('Web Serial API is not supported in this browser.', 'error');
}
}
function updateConnectionUI(connected, errorMsg = '') {
if (!activeSession) return;
if (connected && activeSession.port) {
connectBtn.className = 'connect-btn connected';
connectBtn.title = 'Disconnect from serial port';
statusBanner.className = 'status-banner connected';
const info = activeSession.port.getInfo();
const portLabel = info.usbVendorId
? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
: 'Serial Device';
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${activeSession.baudRate} bps.`;
} else {
connectBtn.className = 'connect-btn disconnected';
connectBtn.title = 'Connect to serial port';
if (errorMsg) {
statusBanner.className = 'status-banner error';
statusText.textContent = errorMsg;
} else {
statusBanner.className = 'status-banner warn';
statusText.textContent = 'Not connected. Click the connect button to select a port.';
}
}
}
// -------------------------------------------------------------
// 3. Serial Session Management
// -------------------------------------------------------------
function addSession() {
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
const name = `Session ${sessions.length + 1}`;
const newSession = new PortSession(id, name);
sessions.push(newSession);
terminalContainer.appendChild(newSession.terminalOutputElement);
selectSession(newSession);
showToast(`${name} created.`, 'success');
}
function selectSession(session) {
if (activeSession) {
activeSession.sendText = sendInput.value;
}
activeSession = session;
renderSessionsList();
// Show only active session's terminal DOM element
const terminals = terminalContainer.querySelectorAll('.terminal-output');
terminals.forEach(el => {
if (el.id === activeSession.terminalOutputElement.id) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
// Sync dropdown UI values
baudRateSelect.value = activeSession.baudRate;
dataBitsSelect.value = activeSession.dataBits;
stopBitsSelect.value = activeSession.stopBits;
paritySelect.value = activeSession.parity;
scrollModeSelect.value = activeSession.scrollMode;
displayFormatSelect.value = activeSession.displayFormat;
sendFormatSelect.value = activeSession.sendFormat;
sendEolSelect.value = activeSession.sendEol;
sendInput.value = activeSession.sendText;
downloadTypeSelect.value = activeSession.downloadType;
downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0);
// Sync status banner and buttons
const isConnected = activeSession.port !== null && activeSession.port.readable;
updateConnectionUI(isConnected);
}
async function deleteSession(session, event) {
if (event) {
event.stopPropagation();
}
if (sessions.length <= 1) {
showToast('Cannot delete the last remaining session.', 'error');
return;
}
// Disconnect if connected
if (session.port) {
await disconnectSession(session);
}
// Remove terminal from DOM
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
}
const index = sessions.indexOf(session);
if (index > -1) {
sessions.splice(index, 1);
}
if (activeSession === session) {
const nextActive = sessions[Math.max(0, index - 1)];
selectSession(nextActive);
} else {
renderSessionsList();
}
showToast(`${session.name} deleted.`, 'success');
}
function renderSessionsList() {
sessionListContainer.innerHTML = '';
sessions.forEach(session => {
const item = document.createElement('div');
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
item.addEventListener('click', () => selectSession(session));
const leftGroup = document.createElement('div');
leftGroup.className = 'session-item-left';
// Status dot
const dot = document.createElement('span');
const isConnected = session.port !== null && session.port.readable;
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
leftGroup.appendChild(dot);
// Session labels
const info = document.createElement('div');
info.className = 'session-info';
const nameEl = document.createElement('span');
nameEl.className = 'session-name';
nameEl.textContent = session.name;
// Double click to rename
nameEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
const input = document.createElement('input');
input.type = 'text';
input.value = session.name;
input.className = 'session-rename-input';
const saveRename = () => {
const val = input.value.trim();
if (val) {
session.name = val;
}
renderSessionsList();
};
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
saveRename();
} else if (evt.key === 'Escape') {
renderSessionsList();
}
});
input.addEventListener('blur', saveRename);
info.innerHTML = '';
info.appendChild(input);
input.focus();
input.select();
});
const detailEl = document.createElement('span');
detailEl.className = 'session-detail';
if (isConnected) {
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
} else {
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
}
info.appendChild(nameEl);
info.appendChild(detailEl);
leftGroup.appendChild(info);
item.appendChild(leftGroup);
// Close / Delete Session button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'session-delete-btn';
deleteBtn.title = 'Delete Session';
deleteBtn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
item.appendChild(deleteBtn);
sessionListContainer.appendChild(item);
});
}
// -------------------------------------------------------------
// 4. Serial Port Connection & Reader Loop
// -------------------------------------------------------------
async function connectSession(session) {
try {
session.port = await navigator.serial.requestPort();
const options = {
baudRate: parseInt(session.baudRate, 10),
dataBits: parseInt(session.dataBits, 10),
stopBits: parseInt(session.stopBits, 10),
parity: session.parity
};
await session.port.open(options);
const info = session.port.getInfo();
if (info.usbVendorId) {
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
} else {
session.deviceInfo = 'Serial Device';
}
if (session === activeSession) {
updateConnectionUI(true);
}
renderSessionsList();
session.keepReading = true;
startSessionReadingLoop(session);
showToast(`${session.name} connected.`, 'success');
} catch (err) {
console.error('Connection failed:', err);
session.port = null;
if (session === activeSession) {
updateConnectionUI(false, `Connection failed: ${err.message}`);
}
showToast(`Failed to open port for ${session.name}.`, 'error');
}
}
async function disconnectSession(session) {
session.keepReading = false;
if (session.reader) {
try {
await session.reader.cancel();
} catch (e) {
console.error('Error cancelling reader:', e);
}
}
return new Promise((resolve) => {
setTimeout(async () => {
if (session.port) {
try {
await session.port.close();
} catch (e) {
console.error('Error closing port:', e);
}
session.port = null;
}
session.deviceInfo = '';
if (session === activeSession) {
updateConnectionUI(false);
}
renderSessionsList();
showToast(`${session.name} disconnected.`, 'success');
resolve();
}, 100);
});
}
async function startSessionReadingLoop(session) {
const decoder = new TextDecoder();
while (session.port && session.port.readable && session.keepReading) {
try {
session.reader = session.port.readable.getReader();
while (session.keepReading) {
const { value, done } = await session.reader.read();
if (done) break;
if (value) {
handleIncomingSessionData(session, value, decoder);
}
}
} catch (err) {
console.error('Read error:', err);
if (session === activeSession) {
updateConnectionUI(false, `Port disconnected: ${err.message}`);
}
showToast(`${session.name} disconnected unexpectedly.`, 'error');
session.port = null;
renderSessionsList();
break;
} finally {
if (session.reader) {
session.reader.releaseLock();
session.reader = null;
}
}
}
}
function handleIncomingSessionData(session, uint8Array, decoder) {
const format = session.displayFormat;
let formattedText = '';
if (format === 'hex') {
formattedText = Array.from(uint8Array)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ') + ' ';
} else {
formattedText = decoder.decode(uint8Array);
}
session.rxLogBuffer.push(formattedText);
session.combinedLogBuffer.push(formattedText);
appendSessionTerminalText(session, formattedText, 'received');
}
// -------------------------------------------------------------
// 5. Writing Data (Transmission)
// -------------------------------------------------------------
async function sendSessionData() {
if (!activeSession) return;
const session = activeSession;
if (!session.port || !session.port.writable) {
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
return;
}
const rawInput = sendInput.value;
if (!rawInput) return;
// Add command to history (maximum 10 items)
const trimmed = rawInput.trim();
if (trimmed) {
const history = session.commandHistory;
if (history.length === 0 || history[history.length - 1] !== trimmed) {
history.push(trimmed);
if (history.length > 10) {
history.shift();
}
}
session.historyIndex = history.length;
}
const format = session.sendFormat;
const eol = session.sendEol;
let bytesToSend;
let eolText = '';
let eolBytes = new Uint8Array(0);
if (eol === 'lf') {
eolText = '\n';
eolBytes = new Uint8Array([10]);
} else if (eol === 'cr') {
eolText = '\r';
eolBytes = new Uint8Array([13]);
} else if (eol === 'crlf') {
eolText = '\r\n';
eolBytes = new Uint8Array([13, 10]);
}
try {
if (format === 'hex') {
const parsedBytes = parseHexString(rawInput);
bytesToSend = new Uint8Array(parsedBytes.length + eolBytes.length);
bytesToSend.set(parsedBytes);
bytesToSend.set(eolBytes, parsedBytes.length);
} else {
bytesToSend = new TextEncoder().encode(rawInput + eolText);
}
} catch (err) {
showToast(err.message, 'error');
return;
}
try {
session.writer = session.port.writable.getWriter();
await session.writer.write(bytesToSend);
let displaySent = '';
if (format === 'hex') {
displaySent = Array.from(bytesToSend)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ') + ' ';
} else {
displaySent = rawInput;
if (eol === 'lf') displaySent += '\\n';
else if (eol === 'cr') displaySent += '\\r';
else if (eol === 'crlf') displaySent += '\\r\\n';
}
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
// Save sent log
session.txLogBuffer.push(`[Sent] ${displaySent}\n`);
session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`);
sendInput.value = '';
session.sendText = '';
} catch (err) {
console.error('Send error:', err);
showToast(`Failed to send: ${err.message}`, 'error');
} finally {
if (session.writer) {
session.writer.releaseLock();
session.writer = null;
}
}
}
function parseHexString(hexString) {
const clean = hexString.replace(/\s+/g, '');
if (!/^[0-9a-fA-F]*$/.test(clean)) {
throw new Error('Hex contains invalid characters (0-9, A-F only)');
}
if (clean.length % 2 !== 0) {
throw new Error('Hex string must have an even number of characters');
}
const result = new Uint8Array(clean.length / 2);
for (let i = 0; i < clean.length; i += 2) {
result[i / 2] = parseInt(clean.substring(i, i + 2), 16);
}
return result;
}
// -------------------------------------------------------------
// 6. Terminal Console Operations
// -------------------------------------------------------------
function appendSessionTerminalText(session, text, className = '') {
if (session.isTerminalEmpty) {
session.terminalOutputElement.textContent = '';
session.terminalOutputElement.classList.remove('empty');
session.isTerminalEmpty = false;
if (session === activeSession) {
downloadBtn.disabled = false;
}
}
const span = document.createElement('span');
if (className) {
span.className = `terminal-line ${className}`;
}
span.textContent = text;
session.terminalOutputElement.appendChild(span);
if (session.scrollMode === 'auto') {
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
}
}
function clearTerminal() {
if (!activeSession) return;
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
activeSession.terminalOutputElement.classList.add('empty');
activeSession.isTerminalEmpty = true;
activeSession.rxLogBuffer = [];
activeSession.txLogBuffer = [];
activeSession.combinedLogBuffer = [];
downloadBtn.disabled = true;
showToast('Terminal cleared.', 'success');
}
function downloadLog() {
if (!activeSession) return;
let content = '';
let filenameSuffix = '';
if (activeSession.downloadType === 'rx') {
content = activeSession.rxLogBuffer.join('');
filenameSuffix = 'rx';
} else if (activeSession.downloadType === 'tx') {
content = activeSession.txLogBuffer.join('');
filenameSuffix = 'tx';
} else {
content = activeSession.combinedLogBuffer.join('');
filenameSuffix = 'combined';
}
if (!content) {
showToast('No logs to download for the selected type.', 'error');
return;
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const dateStr = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
showToast('Download started.', 'success');
}
// -------------------------------------------------------------
// 7. Event Listeners & Keyboard Shortcuts
// -------------------------------------------------------------
connectBtn.addEventListener('click', () => {
if (activeSession) {
if (activeSession.port) {
disconnectSession(activeSession);
} else {
connectSession(activeSession);
}
}
});
sendBtn.addEventListener('click', sendSessionData);
clearBtn.addEventListener('click', clearTerminal);
downloadBtn.addEventListener('click', downloadLog);
sendInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendSessionData();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (activeSession && activeSession.commandHistory.length > 0) {
if (activeSession.historyIndex > 0) {
activeSession.historyIndex--;
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
activeSession.sendText = sendInput.value;
}
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (activeSession && activeSession.commandHistory.length > 0) {
if (activeSession.historyIndex < activeSession.commandHistory.length - 1) {
activeSession.historyIndex++;
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
activeSession.sendText = sendInput.value;
} else {
activeSession.historyIndex = activeSession.commandHistory.length;
sendInput.value = '';
activeSession.sendText = '';
}
}
}
if (e.key === 'Tab' && e.ctrlKey) {
e.preventDefault();
const start = sendInput.selectionStart;
const end = sendInput.selectionEnd;
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
if (activeSession) activeSession.sendText = sendInput.value;
}
});
// Auto-detect browser USB disconnect events globally
if ('serial' in navigator) {
navigator.serial.addEventListener('disconnect', async (event) => {
const disconnectedSession = sessions.find(s => s.port === event.target);
if (disconnectedSession) {
await disconnectSession(disconnectedSession);
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
}
});
}
@@ -0,0 +1,77 @@
# Implementation Plan - Web UART Terminal
This plan details the implementation of a modern, responsive, and feature-rich Web UART Terminal application. The design will match the dark-themed UI provided in the user's reference image and support the Web Serial API for real-time serial communication with hardware.
## User Review Required
> [!IMPORTANT]
> **Browser Compatibility**: The Web Serial API is supported in Chromium-based browsers (Chrome, Edge, Opera). It is not supported by default in Firefox or Safari. A friendly warning banner will be displayed to users accessing the page from unsupported browsers.
> **Local Server Requirement**: The Web Serial API requires a secure context (HTTPS) or localhost to work. To facilitate this, we will configure a simple dev environment using **Vite** so you can run it locally with `npm run dev` (serving at `http://localhost:5173`).
## Proposed Changes
We will create a self-contained, lightweight, and high-performance Vanilla HTML/CSS/JS application within the `/home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal` directory.
### UI & Dev Environment Configuration
---
#### [NEW] [package.json](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/package.json)
- Define a project package with `vite` as a dev dependency.
- Include scripts for local development (`npm run dev`) and building (`npm run build`).
#### [NEW] [vite.config.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/vite.config.js)
- A basic configuration file for Vite to ensure it runs correctly and resolves static assets.
#### [NEW] [index.html](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.html)
- Create the HTML skeleton of the UART Terminal.
- Match the layout in the screenshot:
- Header: App Title, Theme Toggle button.
- Action Control: Connect/Disconnect button (circle with play/stop icon), Download button (square download icon).
- Info Banner: "Not connected" warning banner, dynamically changing based on serial connection status.
- Serial Settings Grid: Dropdowns for Baud Rate, Data Bits, Stop Bits, Parity.
- Received Data Section: Section title, controls (Scroll: Auto-scroll/None, Display: Auto/Hex/Text), console output textarea/container.
- Send Data Footer: Form input with keyboard shortcut tooltip, Send Format dropdown (ASCII/Hex), Send button, and Clear Terminal (trash) button.
- Embed all required SVG icons inline for zero external dependencies and fast load times.
- Include a Google Font link (e.g., "Inter" for UI typography, "JetBrains Mono" for the terminal console).
#### [NEW] [index.css](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.css)
- Implement a premium design system using CSS custom properties (variables) for HSL colors.
- Support Dark Mode (default) and Light Mode.
- Incorporate subtle animations (transitions on hover/active, pulsing connection status, smooth modal fades).
- Implement responsive grids and flex layouts, ensuring pixel-perfect alignment with the reference image.
- Design custom scrollbars for the terminal output.
#### [NEW] [app.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/app.js)
- Implement core UART logic via Web Serial API:
- Request serial port and open connection using selected settings (Baud Rate, Data Bits, Stop Bits, Parity).
- Background reading loop with cancellation support.
- Writing loop supporting ASCII/Hex conversion.
- Implement helper utilities:
- Hex parser (validating and converting hex string inputs like `AA BB CC` or `AABBCC` to Uint8Array).
- Hex formatter (representing received bytes as spaced hex strings e.g. `00 12 AF`).
- Implement UI interactions:
- Terminal text appending with Auto-scroll capability.
- Download log: converts terminal content to a `.txt` blob and triggers a browser download.
- Clear log: clears the screen and reset states.
- Keyboard shortcuts (e.g. `Ctrl+Enter` to send, `Ctrl+Tab` to insert tab in input, etc.).
- Theme toggling with `localStorage` persistence.
#### [NEW] [README.md](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/README.md)
- Instructions on installing dependencies (`npm install`) and launching the development server (`npm run dev`).
- Quick notes on Web Serial browser compatibility and security requirements.
## Verification Plan
### Manual Verification
1. Launch the local dev server using `npm run dev`.
2. Access the app via Chrome or Edge browser.
3. Verify that the UI matches the reference screenshot (dark theme, layouts, styling, icons, font sizes).
4. Verify the theme toggle (switching from dark to light mode and back).
5. Verify inputs:
- Check if serial configuration dropdowns have correct options.
- Verify that clicking "Connect" triggers the browser's Serial Port prompt.
- Verify Hex transmission (e.g., inputting `48 45 4C 4C 4F` in Hex mode sends `HELLO`).
- Verify Text transmission.
- Test "Clear" (trash bin) and "Download" buttons.
- Test auto-scroll behaviour with mock data.
+61
View File
@@ -0,0 +1,61 @@
# UART Terminal Multi-Port Implementation Plan
The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session.
## User Review Required
> [!IMPORTANT]
> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks.
> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history.
> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module").
## Proposed Changes
### UI / Layout Configuration
#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html)
- Wrap the `<main class="app-main">` and a new `<aside class="app-sidebar">` inside a container `<div class="app-layout">`.
- Adjust container structure:
- Increase `.app-container` max-width to `1200px` to comfortably host the sidebar and terminal split.
- Implement dynamic placeholders for the terminal output elements. Instead of a single `#terminal-output` div, we'll make `#terminal-container` host dynamically created terminal-output elements for each session, toggling their visibility based on the active session.
- Add an "Add Session" (`#add-session-btn`) button to the sidebar.
#### [MODIFY] [index.css](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.css)
- Increase `.app-container` `max-width` to `1200px`.
- Add rules for `.app-layout` (CSS Grid or Flexbox layout, split `260px` and `1fr`).
- Add styles for the sidebar (`.app-sidebar`):
- `.sidebar-header` (flex row, title, add-session button).
- `.session-list` (flex column, scrolling).
- `.session-item` (states: hover, active, status-dots, title/subtitle, hover delete button).
- `.session-rename-input` (input box replacing title on double-click).
- Add support for displaying/hiding terminal sessions via class `.hidden { display: none !important; }`.
### State & Logic Configuration
#### [MODIFY] [app.js](file:///home/roy/zData/WTPCode/bodeplot/uart_example/app.js)
- Define a `PortSession` class to encapsulate all states of a single UART connection:
- ID, name, connection state, terminal DOM element, log buffers, input text cache.
- Port options: `baudRate`, `dataBits`, `stopBits`, `parity`.
- Terminal configs: `scrollMode`, `displayFormat`, `sendFormat`, `sendEol`.
- Maintain global variables:
- `sessions` array.
- `activeSession` pointer.
- Refactor connection functions (`connectSerial`, `disconnectSerial`, `startReadingLoop`, `sendData`) to execute against the active session's parameters.
- Re-route UI change listeners (Baud Rate selector, display formats, send input, etc.) to update properties on `activeSession` when changed.
- Build functions to:
- Add a session (`addSession()`).
- Select/Activate a session (`activateSession(session)`).
- Delete/Remove a session (`deleteSession(session)`).
- Sync UI selectors with active session states.
## Verification Plan
### Automated Tests
We will run `npm run dev` or a local static files server to review the layout, and use our understanding of the DOM state to ensure multiple concurrent sessions behave correctly.
### Manual Verification
1. **Multiple Tabs**: Add 3 ports in the sidebar. Verify they are named Session 1, Session 2, Session 3.
2. **Settings Persistence**: Change Baud Rate in Session 1 to `9600` and Session 2 to `115200`. Switch between them and verify that the dropdown value updates to reflect the active session's settings.
3. **Session Renaming**: Double-click "Session 1" name in the sidebar, type "Test Port", and press Enter. Verify the label changes.
4. **Delete Session**: Select a session and click the `x` delete icon. Verify it gets removed. If connected, it should disconnect safely first.
5. **Console logs isolation**: Verify that sending data or logs in one session does not bleed into another session's terminal panel.
+952
View File
@@ -0,0 +1,952 @@
/* CSS Variables for Premium Design System */
:root {
/* Dark Theme Variables (Default) */
--bg-app: #0b0f19;
--bg-panel: #111827;
--bg-input: #1f2937;
--border-color: #374151;
--border-hover: #4b5563;
--border-focus: #6366f1;
--text-main: #f9fafb;
--text-muted: #9ca3af;
--terminal-bg: #090d16;
--terminal-text: #e5e7eb;
--terminal-border: #1f2937;
/* Accent Colors */
--accent-green: #10b981;
--accent-green-hover: #059669;
--accent-blue: #3b82f6;
--accent-blue-hover: #2563eb;
--accent-purple: #6366f1;
--accent-purple-hover: #4f46e5;
--accent-warn-bg: #fef3c7;
--accent-warn-text: #b45309;
--accent-warn-border: #f59e0b;
--accent-danger: #ef4444;
--accent-danger-hover: #dc2626;
/* Layout and Styling Details */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', source-code-pro, Menlo, Monaco, Consolas, monospace;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.15);
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
}
/* Light Theme Variables */
body.light-theme {
--bg-app: #f3f4f6;
--bg-panel: #ffffff;
--bg-input: #f9fafb;
--border-color: #d1d5db;
--border-hover: #9ca3af;
--border-focus: #4f46e5;
--text-main: #111827;
--text-muted: #6b7280;
--terminal-bg: #f8fafc;
--terminal-text: #0f172a;
--terminal-border: #e2e8f0;
--accent-warn-bg: #fffbeb;
--accent-warn-text: #b45309;
--accent-warn-border: #f59e0b;
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.02);
}
/* Base resets & styling */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-app);
color: var(--text-main);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* App container */
.app-container {
width: 100%;
max-width: 1200px;
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header */
.app-header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
margin-bottom: 0.5rem;
}
.app-header h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--text-main);
text-align: center;
}
#theme-toggle {
position: absolute;
right: 0;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 8px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
}
#theme-toggle:hover {
border-color: var(--border-hover);
background-color: rgba(255, 255, 255, 0.05);
transform: scale(1.05);
}
body.light-theme #theme-toggle:hover {
background-color: rgba(0, 0, 0, 0.03);
}
/* Toggle Sun/Moon icons depending on active class */
.sun-icon {
display: none;
width: 20px;
height: 20px;
}
.moon-icon {
display: block;
width: 20px;
height: 20px;
}
body.light-theme .sun-icon {
display: block;
}
body.light-theme .moon-icon {
display: none;
}
/* Main Content Area */
.app-main {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 1.75rem;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
/* Action Controls & Banner Row */
.control-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.action-buttons {
display: flex;
gap: 0.75rem;
align-items: center;
}
/* Circular Connect Button */
.connect-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast);
box-shadow: var(--shadow-md);
}
.connect-btn:hover {
transform: scale(1.08);
}
.connect-btn.disconnected {
background-color: var(--accent-green);
}
.connect-btn.disconnected:hover {
background-color: var(--accent-green-hover);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
}
.connect-btn.connected {
background-color: var(--accent-danger);
}
.connect-btn.connected:hover {
background-color: var(--accent-danger-hover);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4);
}
.connect-btn svg {
width: 20px;
height: 20px;
}
/* Play/Stop SVG displays */
.connect-btn.disconnected .stop-icon {
display: none;
}
.connect-btn.disconnected .play-icon {
display: block;
}
.connect-btn.connected .stop-icon {
display: block;
}
.connect-btn.connected .play-icon {
display: none;
}
/* Download Button */
.download-btn {
width: 48px;
height: 48px;
border-radius: 8px;
border: none;
background-color: var(--accent-blue);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast), background-color var(--transition-fast), opacity var(--transition-fast);
box-shadow: var(--shadow-md);
}
.download-btn:hover:not(:disabled) {
background-color: var(--accent-blue-hover);
transform: scale(1.08);
}
.download-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.download-btn svg {
width: 22px;
height: 22px;
}
/* Status Banner */
.status-banner {
flex-grow: 1;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1.25rem;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid transparent;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.status-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
/* Status Banner Themes */
.status-banner.warn {
background-color: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.25);
color: var(--accent-warn-border);
}
body.light-theme .status-banner.warn {
background-color: var(--accent-warn-bg);
border-color: var(--accent-warn-border);
color: var(--accent-warn-text);
}
.status-banner.connected {
background-color: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.25);
color: var(--accent-green);
}
body.light-theme .status-banner.connected {
background-color: #ecfdf5;
border-color: var(--accent-green);
color: #047857;
}
.status-banner.error {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.25);
color: var(--accent-danger);
}
body.light-theme .status-banner.error {
background-color: #fef2f2;
border-color: var(--accent-danger);
color: #b91c1c;
}
/* Port Config Dropdowns Grid */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
}
@media (max-width: 600px) {
.settings-grid {
grid-template-columns: 1fr;
}
.control-row {
flex-direction: column;
align-items: stretch;
}
.action-buttons {
justify-content: center;
}
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-group label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
/* Custom Styled Select Dropdown */
.select-wrapper {
position: relative;
width: 100%;
}
.select-wrapper select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-main);
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
appearance: none;
cursor: pointer;
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
}
.select-wrapper select:hover {
border-color: var(--border-hover);
}
.select-wrapper select:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* Select arrow icon custom styling */
.select-wrapper::after {
content: "";
position: absolute;
right: 1.25rem;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 6px;
background-color: var(--text-muted);
clip-path: polygon(100% 0, 0 0, 50% 100%);
pointer-events: none;
}
/* Small Select Dropdowns in UI Controls */
.select-wrapper-sm select {
padding: 0.4rem 2rem 0.4rem 0.75rem;
font-size: 0.85rem;
border-radius: var(--radius-sm);
}
.select-wrapper-sm::after {
right: 0.75rem;
width: 8px;
height: 5px;
}
/* Received Data Terminal Area */
.received-data-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.received-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.received-header h2 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
}
.received-controls {
display: flex;
gap: 1.25rem;
align-items: center;
}
.control-select-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-select-group label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.terminal-wrapper {
position: relative;
width: 100%;
}
.terminal-output {
width: 100%;
height: 400px;
background-color: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: var(--radius-md);
padding: 1.25rem;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.6;
color: var(--terminal-text);
white-space: pre-wrap;
word-break: break-all;
outline: none;
transition: border-color var(--transition-fast), background-color var(--transition-normal), color var(--transition-normal);
}
.terminal-output:focus {
border-color: var(--border-focus);
}
.terminal-output.empty {
color: var(--text-muted);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
}
/* Custom Scrollbar for Terminal Output */
.terminal-output::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.terminal-output::-webkit-scrollbar-track {
background: var(--terminal-bg);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.terminal-output::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 5px;
border: 2px solid var(--terminal-bg);
}
.terminal-output::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* Send Data Controls Footer */
.send-data-section {
margin-top: 0.25rem;
}
.send-bar {
display: flex;
gap: 0.75rem;
align-items: center;
}
.input-container {
flex-grow: 1;
position: relative;
}
.input-container input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-family: var(--font-ui);
color: var(--text-main);
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
}
.input-container input:hover {
border-color: var(--border-hover);
}
.input-container input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.input-container input::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
.send-format-wrapper {
width: 90px;
flex-shrink: 0;
}
.send-eol-wrapper {
width: 120px;
flex-shrink: 0;
}
/* Button UI */
.send-btn, .clear-btn {
width: 44px;
height: 44px;
border-radius: 8px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: transform var(--transition-fast), background-color var(--transition-fast);
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.send-btn {
background-color: var(--accent-purple);
}
.send-btn:hover {
background-color: var(--accent-purple-hover);
transform: scale(1.05);
}
.send-btn:active {
transform: scale(0.98);
}
.clear-btn {
background-color: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.clear-btn:hover {
border-color: var(--accent-danger);
color: var(--accent-danger);
background-color: rgba(239, 68, 68, 0.05);
transform: scale(1.05);
}
body.light-theme .clear-btn:hover {
background-color: #fef2f2;
}
.clear-btn:active {
transform: scale(0.98);
}
.send-btn svg, .clear-btn svg {
width: 18px;
height: 18px;
}
/* Terminal Line Highlights & Formatting */
.terminal-line {
margin-bottom: 2px;
}
.terminal-line.sent {
color: var(--accent-purple);
font-weight: 500;
}
body.light-theme .terminal-line.sent {
color: #4f46e5;
}
.terminal-line.error {
color: var(--accent-danger);
font-weight: 600;
}
.terminal-line.system {
color: var(--text-muted);
font-style: italic;
}
/* Toast Notification Utility */
.toast-msg {
position: fixed;
bottom: 24px;
right: 24px;
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
transform: translateY(100px);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease;
z-index: 1000;
}
.toast-msg.show {
transform: translateY(0);
opacity: 1;
}
.toast-success {
border-color: var(--accent-green);
color: var(--accent-green);
}
.toast-error {
border-color: var(--accent-danger);
color: var(--accent-danger);
}
/* Sidebar and Split Layout */
.app-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
width: 100%;
align-items: start;
}
@media (max-width: 900px) {
.app-layout {
grid-template-columns: 1fr;
}
}
.app-sidebar {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 1.25rem;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-main);
}
.add-session-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background-color: var(--accent-purple);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast), background-color var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.add-session-btn:hover {
background-color: var(--accent-purple-hover);
transform: scale(1.08);
}
.add-session-btn svg {
width: 16px;
height: 16px;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 500px;
overflow-y: auto;
padding-right: 4px;
}
/* Custom Scrollbar for Session List */
.session-list::-webkit-scrollbar {
width: 6px;
}
.session-list::-webkit-scrollbar-track {
background: transparent;
}
.session-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.session-list::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
user-select: none;
position: relative;
}
.session-item:hover {
border-color: var(--border-hover);
background-color: rgba(255, 255, 255, 0.02);
}
body.light-theme .session-item:hover {
background-color: rgba(0, 0, 0, 0.01);
}
.session-item.active {
border-color: var(--border-focus);
background-color: rgba(99, 102, 241, 0.08);
box-shadow: 0 0 0 1px var(--border-focus);
}
body.light-theme .session-item.active {
background-color: rgba(99, 102, 241, 0.05);
}
.session-item-left {
display: flex;
align-items: center;
gap: 0.75rem;
flex-grow: 1;
min-width: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
}
.status-dot.disconnected {
background-color: var(--text-muted);
}
.status-dot.connected {
background-color: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.session-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.session-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-detail {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-delete-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), background-color var(--transition-fast);
opacity: 0.6;
}
.session-item:hover .session-delete-btn {
opacity: 1;
}
.session-delete-btn:hover {
color: var(--accent-danger);
background-color: rgba(239, 68, 68, 0.1);
}
.session-rename-input {
background-color: var(--bg-panel);
border: 1px solid var(--border-focus);
color: var(--text-main);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
outline: none;
width: 100%;
}
/* Utilities */
.hidden {
display: none !important;
}
/* Download Group Control Styling */
.download-group {
display: flex;
align-items: center;
gap: 0.25rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 3px 6px 3px 3px;
height: 48px;
box-shadow: var(--shadow-sm);
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.download-group:hover {
border-color: var(--border-hover);
}
.download-group .download-btn {
height: 40px;
width: 40px;
border-radius: 6px;
box-shadow: none;
}
.download-group .download-btn:hover:not(:disabled) {
transform: none;
}
.download-select-wrapper {
width: 105px;
flex-shrink: 0;
}
.download-select-wrapper select {
background-color: transparent !important;
border: none !important;
padding: 0.4rem 1.75rem 0.4rem 0.5rem !important;
font-weight: 600;
font-size: 0.85rem;
color: var(--text-main);
box-shadow: none !important;
}
.download-select-wrapper select option {
background-color: var(--bg-panel);
color: var(--text-main);
}
.download-select-wrapper::after {
right: 0.5rem;
}
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UART Terminal</title>
<!-- Google Fonts for premium design: Inter for UI, JetBrains Mono for monospace data -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="./index.css">
</head>
<body class="dark-theme">
<div class="app-container">
<!-- Header -->
<header class="app-header">
<h1>UART Terminal</h1>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
<!-- Sun Icon -->
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<!-- Moon Icon -->
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</header>
<!-- App Layout Split -->
<div class="app-layout">
<!-- Sidebar Panel -->
<aside class="app-sidebar">
<div class="sidebar-header">
<h2>Sessions</h2>
<button id="add-session-btn" class="add-session-btn" title="Add new session">
<!-- Plus Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div id="session-list" class="session-list">
<!-- Session items dynamically injected -->
</div>
</aside>
<!-- Main Workspace -->
<main class="app-main">
<!-- Connection Controls & Status -->
<section class="control-row">
<div class="action-buttons">
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
<!-- Play Icon -->
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
<polygon points="6,4 20,12 6,20"></polygon>
</svg>
<!-- Stop Icon -->
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
</svg>
</button>
<div class="download-group">
<button id="download-btn" class="download-btn" title="Download log" disabled>
<!-- Download Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<div class="select-wrapper select-wrapper-sm download-select-wrapper">
<select id="download-type" title="Select download content">
<option value="rx_tx" selected>RX & TX</option>
<option value="rx">RX Only</option>
<option value="tx">TX Only</option>
</select>
</div>
</div>
</div>
<!-- Connection Status Banner -->
<div id="status-banner" class="status-banner warn">
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span id="status-text">Not connected. Click the connect button to select a port.</span>
</div>
</section>
<!-- Settings Form Grid -->
<section class="settings-grid">
<div class="settings-group">
<label for="baud-rate">Baud Rate</label>
<div class="select-wrapper">
<select id="baud-rate">
<option value="300">300</option>
<option value="1200">1200</option>
<option value="2400">2400</option>
<option value="4800">4800</option>
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="74880">74880</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="250000">250000</option>
<option value="500000">500000</option>
<option value="1000000">1000000</option>
<option value="2000000">2000000</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="data-bits">Data Bits</label>
<div class="select-wrapper">
<select id="data-bits">
<option value="8" selected>8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="stop-bits">Stop Bits</label>
<div class="select-wrapper">
<select id="stop-bits">
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="parity">Parity</label>
<div class="select-wrapper">
<select id="parity">
<option value="none" selected>none</option>
<option value="even">even</option>
<option value="odd">odd</option>
</select>
</div>
</div>
</section>
<!-- Received Data Section -->
<section class="received-data-section">
<div class="received-header">
<h2>Received Data</h2>
<div class="received-controls">
<div class="control-select-group">
<label for="scroll-mode">Scroll</label>
<div class="select-wrapper select-wrapper-sm">
<select id="scroll-mode">
<option value="auto" selected>Auto-scroll</option>
<option value="none">No scroll</option>
</select>
</div>
</div>
<div class="control-select-group">
<label for="display-format">Display Format</label>
<div class="select-wrapper select-wrapper-sm">
<select id="display-format">
<option value="auto" selected>Auto</option>
<option value="text">Text</option>
<option value="hex">Hex</option>
</select>
</div>
</div>
</div>
</div>
<!-- Terminal Console -->
<div id="terminal-container" class="terminal-wrapper">
<!-- Terminal elements will be dynamically appended here per session -->
</div>
</section>
<!-- Send Data Input Area -->
<section class="send-data-section">
<div class="send-bar">
<div class="input-container">
<input type="text" id="send-input" placeholder="Enter data to send..." autocomplete="off">
</div>
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
<select id="send-format">
<option value="ascii" selected>ASCII</option>
<option value="hex">Hex</option>
</select>
</div>
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
<select id="send-eol">
<option value="none">None</option>
<option value="lf">LF (\n)</option>
<option value="cr">CR (\r)</option>
<option value="crlf" selected>CRLF (\r\n)</option>
</select>
</div>
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
<!-- Send / Paper Airplane Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
<!-- Trash Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
+1026
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "web-uart-terminal",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.2.11"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
open: false, // Don't auto open browser in command-line environments to avoid errors
},
base: './',
});