feat: implement single-port UART

This commit is contained in:
roy01
2026-06-04 11:38:38 +08:00
parent 5413062eff
commit b99af5f734
9 changed files with 2503 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.
+418
View File
@@ -0,0 +1,418 @@
// 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 terminalOutput = document.getElementById('terminal-output');
// 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');
// App Serial State Variables
let port = null;
let reader = null;
let writer = null;
let keepReading = false;
let receivedLogBuffer = []; // Accumulates all received characters/data for download log
let isTerminalEmpty = true;
// Initialize theme and browser support on load
document.addEventListener('DOMContentLoaded', () => {
initTheme();
checkWebSerialSupport();
});
// 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';
showToast('Web Serial API is not supported in this browser.', 'error');
}
}
function updateConnectionUI(connected, errorMsg = '') {
if (connected) {
connectBtn.className = 'connect-btn connected';
connectBtn.title = 'Disconnect from serial port';
statusBanner.className = 'status-banner connected';
const info = 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 ${baudRateSelect.value} 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 Port Connection & Reader Loop
// -------------------------------------------------------------
async function connectSerial() {
try {
// Request a port from the user
port = await navigator.serial.requestPort();
const options = {
baudRate: parseInt(baudRateSelect.value, 10),
dataBits: parseInt(dataBitsSelect.value, 10),
stopBits: parseInt(stopBitsSelect.value, 10),
parity: paritySelect.value
};
// Open connection
await port.open(options);
updateConnectionUI(true);
keepReading = true;
startReadingLoop();
} catch (err) {
console.error('Connection failed:', err);
updateConnectionUI(false, `Connection failed: ${err.message}`);
showToast('Failed to open port.', 'error');
}
}
async function disconnectSerial() {
keepReading = false;
if (reader) {
try {
await reader.cancel();
} catch (e) {
console.error('Error cancelling reader:', e);
}
}
// Wait a short moment for loops to clear
setTimeout(async () => {
if (port) {
try {
await port.close();
} catch (e) {
console.error('Error closing port:', e);
}
port = null;
}
updateConnectionUI(false);
showToast('Disconnected from port.', 'success');
}, 100);
}
// Background read loop
async function startReadingLoop() {
const decoder = new TextDecoder();
while (port && port.readable && keepReading) {
try {
reader = port.readable.getReader();
while (keepReading) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
handleIncomingData(value, decoder);
}
}
} catch (err) {
console.error('Read error:', err);
updateConnectionUI(false, `Port disconnected: ${err.message}`);
showToast('Serial device disconnected.', 'error');
break;
} finally {
if (reader) {
reader.releaseLock();
reader = null;
}
}
}
}
// Handle incoming bytes
function handleIncomingData(uint8Array, decoder) {
const format = displayFormatSelect.value;
let formattedText = '';
if (format === 'hex') {
formattedText = Array.from(uint8Array)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ') + ' ';
} else {
// text or auto mode
formattedText = decoder.decode(uint8Array);
}
// Save to download log buffer
// Always store string representation
receivedLogBuffer.push(formattedText);
// Display in terminal UI
appendTerminalText(formattedText, 'received');
}
// -------------------------------------------------------------
// 4. Writing Data (Transmission)
// -------------------------------------------------------------
async function sendData() {
if (!port || !port.writable) {
showToast('Cannot send. Serial port is not connected.', 'error');
return;
}
const rawInput = sendInput.value;
if (!rawInput) return;
const format = sendFormatSelect.value;
const eol = sendEolSelect.value;
let bytesToSend;
// Determine EOL bytes/string
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 {
writer = port.writable.getWriter();
await writer.write(bytesToSend);
// Log transmission visually on the terminal
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';
}
appendTerminalText(`\n[Sent] ${displaySent}\n`, 'sent');
sendInput.value = ''; // clear input field
} catch (err) {
console.error('Send error:', err);
showToast(`Failed to send: ${err.message}`, 'error');
} finally {
if (writer) {
writer.releaseLock();
writer = null;
}
}
}
// Hex Parsing Utility
function parseHexString(hexString) {
// Remove all white spaces
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;
}
// -------------------------------------------------------------
// 5. Terminal Console Operations (Append, Clear, Scroll)
// -------------------------------------------------------------
function appendTerminalText(text, className = '') {
if (isTerminalEmpty) {
terminalOutput.textContent = '';
terminalOutput.classList.remove('empty');
isTerminalEmpty = false;
downloadBtn.disabled = false;
}
// Create text container
const span = document.createElement('span');
if (className) {
span.className = `terminal-line ${className}`;
}
span.textContent = text;
terminalOutput.appendChild(span);
// Auto-scroll handler
if (scrollModeSelect.value === 'auto') {
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
}
// Clear terminal function
function clearTerminal() {
terminalOutput.innerHTML = 'No data received yet...';
terminalOutput.classList.add('empty');
isTerminalEmpty = true;
receivedLogBuffer = [];
downloadBtn.disabled = true;
showToast('Terminal cleared.', 'success');
}
// Download received console logs
function downloadLog() {
if (receivedLogBuffer.length === 0) return;
const content = receivedLogBuffer.join('');
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_${dateStr}.txt`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
showToast('Download started.', 'success');
}
// -------------------------------------------------------------
// 6. Event Listeners & Keyboard Shortcuts
// -------------------------------------------------------------
connectBtn.addEventListener('click', () => {
if (port) {
disconnectSerial();
} else {
connectSerial();
}
});
sendBtn.addEventListener('click', sendData);
clearBtn.addEventListener('click', clearTerminal);
downloadBtn.addEventListener('click', downloadLog);
// Input Keyboard Shortcuts
sendInput.addEventListener('keydown', (e) => {
// Ctrl + Enter or Enter (standard text input submit) to send
if (e.key === 'Enter') {
e.preventDefault();
sendData();
}
// Ctrl + Tab to insert actual tab characters
if (e.key === 'Tab' && e.ctrlKey) {
e.preventDefault();
const start = sendInput.selectionStart;
const end = sendInput.selectionEnd;
// Set input value: [text before tab] + \t + [text after tab]
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
// Put caret in right position
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
}
});
// Auto-detect browser USB connect/disconnect events
if ('serial' in navigator) {
navigator.serial.addEventListener('connect', (event) => {
showToast('Serial device connected to port.', 'success');
});
navigator.serial.addEventListener('disconnect', (event) => {
if (port && event.target === port) {
disconnectSerial();
updateConnectionUI(false, 'Selected serial device was disconnected.');
showToast('Device connection lost.', '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.
+686
View File
@@ -0,0 +1,686 @@
/* 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: 960px;
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);
}
+212
View File
@@ -0,0 +1,212 @@
<!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>
<!-- 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>
<button id="download-btn" class="download-btn" title="Download received 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>
<!-- 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 class="terminal-wrapper">
<div id="terminal-output" class="terminal-output empty" tabindex="0">No data received yet...</div>
</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... (Ctrl+Tab to insert tab)" 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>
<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: './',
});