From b99af5f7345dea71e54e3334359d76abd85dfaaf Mon Sep 17 00:00:00 2001 From: roy01 Date: Thu, 4 Jun 2026 11:38:38 +0800 Subject: [PATCH 1/3] feat: implement single-port UART --- dea_api.py | 6 + uart_example/README.md | 55 + uart_example/app.js | 418 +++++++ ...mentation_plan_Single_web_uart_terminal.md | 77 ++ uart_example/index.css | 686 +++++++++++ uart_example/index.html | 212 ++++ uart_example/package-lock.json | 1026 +++++++++++++++++ uart_example/package.json | 14 + uart_example/vite.config.js | 9 + 9 files changed, 2503 insertions(+) create mode 100644 uart_example/README.md create mode 100644 uart_example/app.js create mode 100644 uart_example/implementation_plan_Single_web_uart_terminal.md create mode 100644 uart_example/index.css create mode 100644 uart_example/index.html create mode 100644 uart_example/package-lock.json create mode 100644 uart_example/package.json create mode 100644 uart_example/vite.config.js diff --git a/dea_api.py b/dea_api.py index 2a48d62..1091d36 100755 --- a/dea_api.py +++ b/dea_api.py @@ -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: diff --git a/uart_example/README.md b/uart_example/README.md new file mode 100644 index 0000000..b133bc4 --- /dev/null +++ b/uart_example/README.md @@ -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. diff --git a/uart_example/app.js b/uart_example/app.js new file mode 100644 index 0000000..518880f --- /dev/null +++ b/uart_example/app.js @@ -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 = 'Web Serial API not supported. 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 ${portLabel} 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'); + } + }); +} diff --git a/uart_example/implementation_plan_Single_web_uart_terminal.md b/uart_example/implementation_plan_Single_web_uart_terminal.md new file mode 100644 index 0000000..55a65c1 --- /dev/null +++ b/uart_example/implementation_plan_Single_web_uart_terminal.md @@ -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. diff --git a/uart_example/index.css b/uart_example/index.css new file mode 100644 index 0000000..6a8053b --- /dev/null +++ b/uart_example/index.css @@ -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); +} diff --git a/uart_example/index.html b/uart_example/index.html new file mode 100644 index 0000000..d72574a --- /dev/null +++ b/uart_example/index.html @@ -0,0 +1,212 @@ + + + + + + UART Terminal + + + + + + + +
+ +
+

UART Terminal

+ +
+ + +
+ + +
+
+ + + +
+ + +
+ + + + + + Not connected. Click the connect button to select a port. +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+

Received Data

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + +
+
No data received yet...
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+ + +
+
+ +
+
+ + + + diff --git a/uart_example/package-lock.json b/uart_example/package-lock.json new file mode 100644 index 0000000..552a9dd --- /dev/null +++ b/uart_example/package-lock.json @@ -0,0 +1,1026 @@ +{ + "name": "web-uart-terminal", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-uart-terminal", + "version": "1.0.0", + "devDependencies": { + "vite": "^5.2.11" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/uart_example/package.json b/uart_example/package.json new file mode 100644 index 0000000..c4597e1 --- /dev/null +++ b/uart_example/package.json @@ -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" + } +} diff --git a/uart_example/vite.config.js b/uart_example/vite.config.js new file mode 100644 index 0000000..4d08179 --- /dev/null +++ b/uart_example/vite.config.js @@ -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: './', +}); From 7991f1fcba62f8327444dc2de8ed0a263f6d8757 Mon Sep 17 00:00:00 2001 From: roy01 Date: Thu, 4 Jun 2026 11:55:46 +0800 Subject: [PATCH 2/3] feat: implement multi-session support with sidebar management and dynamic terminal switching --- uart_example/app.js | 521 ++++++++++++++++------ uart_example/implementation_plan_multi.md | 61 +++ uart_example/index.css | 214 ++++++++- uart_example/index.html | 326 +++++++------- 4 files changed, 842 insertions(+), 280 deletions(-) create mode 100644 uart_example/implementation_plan_multi.md diff --git a/uart_example/app.js b/uart_example/app.js index 518880f..c5cf1e2 100644 --- a/uart_example/app.js +++ b/uart_example/app.js @@ -14,7 +14,7 @@ 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'); +const terminalContainer = document.getElementById('terminal-container'); // Sending Inputs const sendInput = document.getElementById('send-input'); @@ -22,19 +22,99 @@ const sendFormatSelect = document.getElementById('send-format'); const sendEolSelect = document.getElementById('send-eol'); const sendBtn = document.getElementById('send-btn'); const clearBtn = document.getElementById('clear-btn'); +const addSessionBtn = document.getElementById('add-session-btn'); +const sessionListContainer = document.getElementById('session-list'); -// 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; +// PortSession Class definition +class PortSession { + constructor(id, name) { + this.id = id; + this.name = name; + + // Web Serial State + this.port = null; + this.reader = null; + this.writer = null; + this.keepReading = false; + this.receivedLogBuffer = []; + this.isTerminalEmpty = true; + this.deviceInfo = ''; + + // Settings state + this.baudRate = '115200'; + this.dataBits = '8'; + this.stopBits = '1'; + this.parity = 'none'; + + // Console controls + this.scrollMode = 'auto'; + this.displayFormat = 'auto'; + this.sendFormat = 'ascii'; + this.sendEol = 'crlf'; + this.sendText = ''; + + // Dedicated Terminal Element + this.terminalOutputElement = document.createElement('div'); + this.terminalOutputElement.id = `terminal-output-${this.id}`; + this.terminalOutputElement.className = 'terminal-output empty'; + this.terminalOutputElement.tabIndex = 0; + this.terminalOutputElement.textContent = 'No data received yet...'; + } +} + +// Global Application State +let sessions = []; +let activeSession = null; // Initialize theme and browser support on load document.addEventListener('DOMContentLoaded', () => { initTheme(); checkWebSerialSupport(); + + // Wire up main event listeners + addSessionBtn.addEventListener('click', addSession); + + baudRateSelect.addEventListener('change', () => { + if (activeSession) { + activeSession.baudRate = baudRateSelect.value; + renderSessionsList(); + } + }); + + dataBitsSelect.addEventListener('change', () => { + if (activeSession) activeSession.dataBits = dataBitsSelect.value; + }); + + stopBitsSelect.addEventListener('change', () => { + if (activeSession) activeSession.stopBits = stopBitsSelect.value; + }); + + paritySelect.addEventListener('change', () => { + if (activeSession) activeSession.parity = paritySelect.value; + }); + + scrollModeSelect.addEventListener('change', () => { + if (activeSession) activeSession.scrollMode = scrollModeSelect.value; + }); + + displayFormatSelect.addEventListener('change', () => { + if (activeSession) activeSession.displayFormat = displayFormatSelect.value; + }); + + sendFormatSelect.addEventListener('change', () => { + if (activeSession) activeSession.sendFormat = sendFormatSelect.value; + }); + + sendEolSelect.addEventListener('change', () => { + if (activeSession) activeSession.sendEol = sendEolSelect.value; + }); + + sendInput.addEventListener('input', () => { + if (activeSession) activeSession.sendText = sendInput.value; + }); + + // Initialize with a default session + addSession(); }); // Toast notification helper @@ -78,20 +158,27 @@ function checkWebSerialSupport() { connectBtn.disabled = true; connectBtn.style.opacity = '0.5'; connectBtn.style.cursor = 'not-allowed'; + addSessionBtn.disabled = true; + addSessionBtn.style.opacity = '0.5'; + addSessionBtn.style.cursor = 'not-allowed'; showToast('Web Serial API is not supported in this browser.', 'error'); } } function updateConnectionUI(connected, errorMsg = '') { - if (connected) { + if (!activeSession) return; + + if (connected && activeSession.port) { connectBtn.className = 'connect-btn connected'; connectBtn.title = 'Disconnect from serial port'; statusBanner.className = 'status-banner connected'; - const info = port.getInfo(); - const portLabel = info.usbVendorId ? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})` : 'Serial Device'; + const info = activeSession.port.getInfo(); + const portLabel = info.usbVendorId + ? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})` + : 'Serial Device'; - statusText.innerHTML = `Connected to ${portLabel} at ${baudRateSelect.value} bps.`; + statusText.innerHTML = `Connected to ${portLabel} at ${activeSession.baudRate} bps.`; } else { connectBtn.className = 'connect-btn disconnected'; connectBtn.title = 'Connect to serial port'; @@ -107,92 +194,287 @@ function updateConnectionUI(connected, errorMsg = '') { } // ------------------------------------------------------------- -// 3. Serial Port Connection & Reader Loop +// 3. Serial Session Management // ------------------------------------------------------------- -async function connectSerial() { +function addSession() { + const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + const name = `Session ${sessions.length + 1}`; + const newSession = new PortSession(id, name); + + sessions.push(newSession); + terminalContainer.appendChild(newSession.terminalOutputElement); + + selectSession(newSession); + showToast(`${name} created.`, 'success'); +} + +function selectSession(session) { + if (activeSession) { + activeSession.sendText = sendInput.value; + } + + activeSession = session; + renderSessionsList(); + + // Show only active session's terminal DOM element + const terminals = terminalContainer.querySelectorAll('.terminal-output'); + terminals.forEach(el => { + if (el.id === activeSession.terminalOutputElement.id) { + el.classList.remove('hidden'); + } else { + el.classList.add('hidden'); + } + }); + + // Sync dropdown UI values + baudRateSelect.value = activeSession.baudRate; + dataBitsSelect.value = activeSession.dataBits; + stopBitsSelect.value = activeSession.stopBits; + paritySelect.value = activeSession.parity; + + scrollModeSelect.value = activeSession.scrollMode; + displayFormatSelect.value = activeSession.displayFormat; + sendFormatSelect.value = activeSession.sendFormat; + sendEolSelect.value = activeSession.sendEol; + sendInput.value = activeSession.sendText; + + downloadBtn.disabled = activeSession.isTerminalEmpty; + + // Sync status banner and buttons + const isConnected = activeSession.port !== null && activeSession.port.readable; + updateConnectionUI(isConnected); +} + +async function deleteSession(session, event) { + if (event) { + event.stopPropagation(); + } + + if (sessions.length <= 1) { + showToast('Cannot delete the last remaining session.', 'error'); + return; + } + + // Disconnect if connected + if (session.port) { + await disconnectSession(session); + } + + // Remove terminal from DOM + if (session.terminalOutputElement && session.terminalOutputElement.parentNode) { + session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement); + } + + const index = sessions.indexOf(session); + if (index > -1) { + sessions.splice(index, 1); + } + + if (activeSession === session) { + const nextActive = sessions[Math.max(0, index - 1)]; + selectSession(nextActive); + } else { + renderSessionsList(); + } + + showToast(`${session.name} deleted.`, 'success'); +} + +function renderSessionsList() { + sessionListContainer.innerHTML = ''; + + sessions.forEach(session => { + const item = document.createElement('div'); + item.className = `session-item ${session === activeSession ? 'active' : ''}`; + item.addEventListener('click', () => selectSession(session)); + + const leftGroup = document.createElement('div'); + leftGroup.className = 'session-item-left'; + + // Status dot + const dot = document.createElement('span'); + const isConnected = session.port !== null && session.port.readable; + dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`; + leftGroup.appendChild(dot); + + // Session labels + const info = document.createElement('div'); + info.className = 'session-info'; + + const nameEl = document.createElement('span'); + nameEl.className = 'session-name'; + nameEl.textContent = session.name; + + // Double click to rename + nameEl.addEventListener('dblclick', (e) => { + e.stopPropagation(); + const input = document.createElement('input'); + input.type = 'text'; + input.value = session.name; + input.className = 'session-rename-input'; + + const saveRename = () => { + const val = input.value.trim(); + if (val) { + session.name = val; + } + renderSessionsList(); + }; + + input.addEventListener('keydown', (evt) => { + if (evt.key === 'Enter') { + saveRename(); + } else if (evt.key === 'Escape') { + renderSessionsList(); + } + }); + + input.addEventListener('blur', saveRename); + + info.innerHTML = ''; + info.appendChild(input); + input.focus(); + input.select(); + }); + + const detailEl = document.createElement('span'); + detailEl.className = 'session-detail'; + if (isConnected) { + detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`; + } else { + detailEl.textContent = `${session.baudRate} bps | Disconnected`; + } + + info.appendChild(nameEl); + info.appendChild(detailEl); + leftGroup.appendChild(info); + item.appendChild(leftGroup); + + // Close / Delete Session button + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'session-delete-btn'; + deleteBtn.title = 'Delete Session'; + deleteBtn.innerHTML = ` + + + + + `; + deleteBtn.addEventListener('click', (e) => deleteSession(session, e)); + item.appendChild(deleteBtn); + + sessionListContainer.appendChild(item); + }); +} + +// ------------------------------------------------------------- +// 4. Serial Port Connection & Reader Loop +// ------------------------------------------------------------- +async function connectSession(session) { try { - // Request a port from the user - port = await navigator.serial.requestPort(); + session.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 + baudRate: parseInt(session.baudRate, 10), + dataBits: parseInt(session.dataBits, 10), + stopBits: parseInt(session.stopBits, 10), + parity: session.parity }; - // Open connection - await port.open(options); + await session.port.open(options); - updateConnectionUI(true); - keepReading = true; - startReadingLoop(); + const info = session.port.getInfo(); + if (info.usbVendorId) { + session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`; + } else { + session.deviceInfo = 'Serial Device'; + } + if (session === activeSession) { + updateConnectionUI(true); + } + renderSessionsList(); + + session.keepReading = true; + startSessionReadingLoop(session); + + showToast(`${session.name} connected.`, 'success'); } catch (err) { console.error('Connection failed:', err); - updateConnectionUI(false, `Connection failed: ${err.message}`); - showToast('Failed to open port.', 'error'); + session.port = null; + if (session === activeSession) { + updateConnectionUI(false, `Connection failed: ${err.message}`); + } + showToast(`Failed to open port for ${session.name}.`, 'error'); } } -async function disconnectSerial() { - keepReading = false; +async function disconnectSession(session) { + session.keepReading = false; - if (reader) { + if (session.reader) { try { - await reader.cancel(); + await session.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); + return new Promise((resolve) => { + setTimeout(async () => { + if (session.port) { + try { + await session.port.close(); + } catch (e) { + console.error('Error closing port:', e); + } + session.port = null; } - port = null; - } - updateConnectionUI(false); - showToast('Disconnected from port.', 'success'); - }, 100); + session.deviceInfo = ''; + if (session === activeSession) { + updateConnectionUI(false); + } + renderSessionsList(); + showToast(`${session.name} disconnected.`, 'success'); + resolve(); + }, 100); + }); } -// Background read loop -async function startReadingLoop() { +async function startSessionReadingLoop(session) { const decoder = new TextDecoder(); - while (port && port.readable && keepReading) { + while (session.port && session.port.readable && session.keepReading) { try { - reader = port.readable.getReader(); + session.reader = session.port.readable.getReader(); - while (keepReading) { - const { value, done } = await reader.read(); + while (session.keepReading) { + const { value, done } = await session.reader.read(); if (done) break; if (value) { - handleIncomingData(value, decoder); + handleIncomingSessionData(session, value, decoder); } } } catch (err) { console.error('Read error:', err); - updateConnectionUI(false, `Port disconnected: ${err.message}`); - showToast('Serial device disconnected.', 'error'); + if (session === activeSession) { + updateConnectionUI(false, `Port disconnected: ${err.message}`); + } + showToast(`${session.name} disconnected unexpectedly.`, 'error'); + session.port = null; + renderSessionsList(); break; } finally { - if (reader) { - reader.releaseLock(); - reader = null; + if (session.reader) { + session.reader.releaseLock(); + session.reader = null; } } } } -// Handle incoming bytes -function handleIncomingData(uint8Array, decoder) { - const format = displayFormatSelect.value; +function handleIncomingSessionData(session, uint8Array, decoder) { + const format = session.displayFormat; let formattedText = ''; if (format === 'hex') { @@ -200,35 +482,32 @@ function handleIncomingData(uint8Array, decoder) { .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'); + session.receivedLogBuffer.push(formattedText); + appendSessionTerminalText(session, formattedText, 'received'); } // ------------------------------------------------------------- -// 4. Writing Data (Transmission) +// 5. Writing Data (Transmission) // ------------------------------------------------------------- -async function sendData() { - if (!port || !port.writable) { - showToast('Cannot send. Serial port is not connected.', 'error'); +async function sendSessionData() { + if (!activeSession) return; + const session = activeSession; + + if (!session.port || !session.port.writable) { + showToast(`Cannot send. ${session.name} is not connected.`, 'error'); return; } const rawInput = sendInput.value; if (!rawInput) return; - const format = sendFormatSelect.value; - const eol = sendEolSelect.value; + const format = session.sendFormat; + const eol = session.sendEol; let bytesToSend; - // Determine EOL bytes/string let eolText = ''; let eolBytes = new Uint8Array(0); if (eol === 'lf') { @@ -257,10 +536,9 @@ async function sendData() { } try { - writer = port.writable.getWriter(); - await writer.write(bytesToSend); + session.writer = session.port.writable.getWriter(); + await session.writer.write(bytesToSend); - // Log transmission visually on the terminal let displaySent = ''; if (format === 'hex') { displaySent = Array.from(bytesToSend) @@ -273,23 +551,22 @@ async function sendData() { else if (eol === 'crlf') displaySent += '\\r\\n'; } - appendTerminalText(`\n[Sent] ${displaySent}\n`, 'sent'); - sendInput.value = ''; // clear input field + appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent'); + sendInput.value = ''; + session.sendText = ''; } catch (err) { console.error('Send error:', err); showToast(`Failed to send: ${err.message}`, 'error'); } finally { - if (writer) { - writer.releaseLock(); - writer = null; + if (session.writer) { + session.writer.releaseLock(); + session.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)) { @@ -308,45 +585,44 @@ function parseHexString(hexString) { } // ------------------------------------------------------------- -// 5. Terminal Console Operations (Append, Clear, Scroll) +// 6. Terminal Console Operations // ------------------------------------------------------------- -function appendTerminalText(text, className = '') { - if (isTerminalEmpty) { - terminalOutput.textContent = ''; - terminalOutput.classList.remove('empty'); - isTerminalEmpty = false; - downloadBtn.disabled = false; +function appendSessionTerminalText(session, text, className = '') { + if (session.isTerminalEmpty) { + session.terminalOutputElement.textContent = ''; + session.terminalOutputElement.classList.remove('empty'); + session.isTerminalEmpty = false; + if (session === activeSession) { + downloadBtn.disabled = false; + } } - // Create text container const span = document.createElement('span'); if (className) { span.className = `terminal-line ${className}`; } span.textContent = text; - terminalOutput.appendChild(span); + session.terminalOutputElement.appendChild(span); - // Auto-scroll handler - if (scrollModeSelect.value === 'auto') { - terminalOutput.scrollTop = terminalOutput.scrollHeight; + if (session.scrollMode === 'auto') { + session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight; } } -// Clear terminal function function clearTerminal() { - terminalOutput.innerHTML = 'No data received yet...'; - terminalOutput.classList.add('empty'); - isTerminalEmpty = true; - receivedLogBuffer = []; + if (!activeSession) return; + activeSession.terminalOutputElement.innerHTML = 'No data received yet...'; + activeSession.terminalOutputElement.classList.add('empty'); + activeSession.isTerminalEmpty = true; + activeSession.receivedLogBuffer = []; downloadBtn.disabled = true; showToast('Terminal cleared.', 'success'); } -// Download received console logs function downloadLog() { - if (receivedLogBuffer.length === 0) return; + if (!activeSession || activeSession.receivedLogBuffer.length === 0) return; - const content = receivedLogBuffer.join(''); + const content = activeSession.receivedLogBuffer.join(''); const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); @@ -354,7 +630,7 @@ function downloadLog() { const a = document.createElement('a'); a.href = url; - a.download = `uart_log_${dateStr}.txt`; + a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${dateStr}.txt`; document.body.appendChild(a); a.click(); @@ -367,52 +643,45 @@ function downloadLog() { } // ------------------------------------------------------------- -// 6. Event Listeners & Keyboard Shortcuts +// 7. Event Listeners & Keyboard Shortcuts // ------------------------------------------------------------- connectBtn.addEventListener('click', () => { - if (port) { - disconnectSerial(); - } else { - connectSerial(); + if (activeSession) { + if (activeSession.port) { + disconnectSession(activeSession); + } else { + connectSession(activeSession); + } } }); -sendBtn.addEventListener('click', sendData); +sendBtn.addEventListener('click', sendSessionData); 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(); + sendSessionData(); } - // 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; + if (activeSession) activeSession.sendText = sendInput.value; } }); -// Auto-detect browser USB connect/disconnect events +// Auto-detect browser USB disconnect events globally 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'); + navigator.serial.addEventListener('disconnect', async (event) => { + const disconnectedSession = sessions.find(s => s.port === event.target); + if (disconnectedSession) { + await disconnectSession(disconnectedSession); + showToast(`${disconnectedSession.name} device disconnected.`, 'error'); } }); } diff --git a/uart_example/implementation_plan_multi.md b/uart_example/implementation_plan_multi.md new file mode 100644 index 0000000..3889f90 --- /dev/null +++ b/uart_example/implementation_plan_multi.md @@ -0,0 +1,61 @@ +# UART Terminal Multi-Port Implementation Plan + +The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session. + +## User Review Required + +> [!IMPORTANT] +> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks. +> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history. +> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module"). + +## Proposed Changes + +### UI / Layout Configuration + +#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html) +- Wrap the `
` and a new ` - -
-
- -
- + +
+ + +
+
+ + +
-
-
- -
- + +
+ + + + + + Not connected. Click the connect button to select a port.
-
+
-
- -
- -
-
- -
- -
- -
-
- - - -
-
-

Received Data

-
-
- -
- -
+ +
+
+ +
+
-
- -
- +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+

Received Data

+
+
+ +
+ +
+
+
+ +
+ +
-
- -
-
No data received yet...
-
-
+ +
+ +
+ - -
-
-
- + +
+
+
+ +
+
+ +
+
+ +
+ +
-
- -
-
- -
- - -
-
+ -
+ + From 750bea70ec9ac25c88f2e4e828177e1a47406a22 Mon Sep 17 00:00:00 2001 From: roy01 Date: Thu, 4 Jun 2026 15:53:42 +0800 Subject: [PATCH 3/3] feat: enhance download functionality with selectable log types and command history management --- uart_example/app.js | 88 +++++++++++++++++++++++++++++++++++++---- uart_example/index.css | 54 +++++++++++++++++++++++++ uart_example/index.html | 58 ++++++++++++++++++--------- 3 files changed, 174 insertions(+), 26 deletions(-) diff --git a/uart_example/app.js b/uart_example/app.js index c5cf1e2..6b72d3e 100644 --- a/uart_example/app.js +++ b/uart_example/app.js @@ -24,6 +24,7 @@ const sendBtn = document.getElementById('send-btn'); const clearBtn = document.getElementById('clear-btn'); const addSessionBtn = document.getElementById('add-session-btn'); const sessionListContainer = document.getElementById('session-list'); +const downloadTypeSelect = document.getElementById('download-type'); // PortSession Class definition class PortSession { @@ -36,7 +37,12 @@ class PortSession { this.reader = null; this.writer = null; this.keepReading = false; - this.receivedLogBuffer = []; + this.rxLogBuffer = []; + this.txLogBuffer = []; + this.combinedLogBuffer = []; + this.downloadType = 'rx_tx'; + this.commandHistory = []; + this.historyIndex = -1; this.isTerminalEmpty = true; this.deviceInfo = ''; @@ -113,6 +119,10 @@ document.addEventListener('DOMContentLoaded', () => { if (activeSession) activeSession.sendText = sendInput.value; }); + downloadTypeSelect.addEventListener('change', () => { + if (activeSession) activeSession.downloadType = downloadTypeSelect.value; + }); + // Initialize with a default session addSession(); }); @@ -237,8 +247,9 @@ function selectSession(session) { sendFormatSelect.value = activeSession.sendFormat; sendEolSelect.value = activeSession.sendEol; sendInput.value = activeSession.sendText; + downloadTypeSelect.value = activeSession.downloadType; - downloadBtn.disabled = activeSession.isTerminalEmpty; + downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0); // Sync status banner and buttons const isConnected = activeSession.port !== null && activeSession.port.readable; @@ -485,7 +496,8 @@ function handleIncomingSessionData(session, uint8Array, decoder) { formattedText = decoder.decode(uint8Array); } - session.receivedLogBuffer.push(formattedText); + session.rxLogBuffer.push(formattedText); + session.combinedLogBuffer.push(formattedText); appendSessionTerminalText(session, formattedText, 'received'); } @@ -504,6 +516,19 @@ async function sendSessionData() { const rawInput = sendInput.value; if (!rawInput) return; + // Add command to history (maximum 10 items) + const trimmed = rawInput.trim(); + if (trimmed) { + const history = session.commandHistory; + if (history.length === 0 || history[history.length - 1] !== trimmed) { + history.push(trimmed); + if (history.length > 10) { + history.shift(); + } + } + session.historyIndex = history.length; + } + const format = session.sendFormat; const eol = session.sendEol; let bytesToSend; @@ -552,6 +577,11 @@ async function sendSessionData() { } appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent'); + + // Save sent log + session.txLogBuffer.push(`[Sent] ${displaySent}\n`); + session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`); + sendInput.value = ''; session.sendText = ''; @@ -614,15 +644,35 @@ function clearTerminal() { activeSession.terminalOutputElement.innerHTML = 'No data received yet...'; activeSession.terminalOutputElement.classList.add('empty'); activeSession.isTerminalEmpty = true; - activeSession.receivedLogBuffer = []; + activeSession.rxLogBuffer = []; + activeSession.txLogBuffer = []; + activeSession.combinedLogBuffer = []; downloadBtn.disabled = true; showToast('Terminal cleared.', 'success'); } function downloadLog() { - if (!activeSession || activeSession.receivedLogBuffer.length === 0) return; + if (!activeSession) return; + + let content = ''; + let filenameSuffix = ''; + + if (activeSession.downloadType === 'rx') { + content = activeSession.rxLogBuffer.join(''); + filenameSuffix = 'rx'; + } else if (activeSession.downloadType === 'tx') { + content = activeSession.txLogBuffer.join(''); + filenameSuffix = 'tx'; + } else { + content = activeSession.combinedLogBuffer.join(''); + filenameSuffix = 'combined'; + } + + if (!content) { + showToast('No logs to download for the selected type.', 'error'); + return; + } - const content = activeSession.receivedLogBuffer.join(''); const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); @@ -630,7 +680,7 @@ function downloadLog() { const a = document.createElement('a'); a.href = url; - a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${dateStr}.txt`; + a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`; document.body.appendChild(a); a.click(); @@ -664,6 +714,30 @@ sendInput.addEventListener('keydown', (e) => { e.preventDefault(); sendSessionData(); } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (activeSession && activeSession.commandHistory.length > 0) { + if (activeSession.historyIndex > 0) { + activeSession.historyIndex--; + sendInput.value = activeSession.commandHistory[activeSession.historyIndex]; + activeSession.sendText = sendInput.value; + } + } + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (activeSession && activeSession.commandHistory.length > 0) { + if (activeSession.historyIndex < activeSession.commandHistory.length - 1) { + activeSession.historyIndex++; + sendInput.value = activeSession.commandHistory[activeSession.historyIndex]; + activeSession.sendText = sendInput.value; + } else { + activeSession.historyIndex = activeSession.commandHistory.length; + sendInput.value = ''; + activeSession.sendText = ''; + } + } + } if (e.key === 'Tab' && e.ctrlKey) { e.preventDefault(); const start = sendInput.selectionStart; diff --git a/uart_example/index.css b/uart_example/index.css index c56782f..081a215 100644 --- a/uart_example/index.css +++ b/uart_example/index.css @@ -896,3 +896,57 @@ body.light-theme .session-item.active { .hidden { display: none !important; } + +/* Download Group Control Styling */ +.download-group { + display: flex; + align-items: center; + gap: 0.25rem; + background-color: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 3px 6px 3px 3px; + height: 48px; + box-shadow: var(--shadow-sm); + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.download-group:hover { + border-color: var(--border-hover); +} + +.download-group .download-btn { + height: 40px; + width: 40px; + border-radius: 6px; + box-shadow: none; +} + +.download-group .download-btn:hover:not(:disabled) { + transform: none; +} + +.download-select-wrapper { + width: 105px; + flex-shrink: 0; +} + +.download-select-wrapper select { + background-color: transparent !important; + border: none !important; + padding: 0.4rem 1.75rem 0.4rem 0.5rem !important; + font-weight: 600; + font-size: 0.85rem; + color: var(--text-main); + box-shadow: none !important; +} + +.download-select-wrapper select option { + background-color: var(--bg-panel); + color: var(--text-main); +} + +.download-select-wrapper::after { + right: 0.5rem; +} + diff --git a/uart_example/index.html b/uart_example/index.html index 81a2d1e..318be71 100644 --- a/uart_example/index.html +++ b/uart_example/index.html @@ -1,5 +1,6 @@ + @@ -7,9 +8,12 @@ - + +
@@ -17,7 +21,8 @@

UART Terminal

@@ -43,7 +49,8 @@

Sessions

- - +
+ +
+ +
+
- + @@ -188,7 +205,7 @@
- +