feat: implement single-port UART
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+1026
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: './',
|
||||
});
|
||||
Reference in New Issue
Block a user