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.
+
+
+
+## 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..6b72d3e
--- /dev/null
+++ b/uart_example/app.js
@@ -0,0 +1,761 @@
+// DOM Elements Selection
+const themeToggle = document.getElementById('theme-toggle');
+const connectBtn = document.getElementById('connect-btn');
+const downloadBtn = document.getElementById('download-btn');
+const statusBanner = document.getElementById('status-banner');
+const statusText = document.getElementById('status-text');
+
+// Settings Dropdowns
+const baudRateSelect = document.getElementById('baud-rate');
+const dataBitsSelect = document.getElementById('data-bits');
+const stopBitsSelect = document.getElementById('stop-bits');
+const paritySelect = document.getElementById('parity');
+
+// Controls & Console
+const scrollModeSelect = document.getElementById('scroll-mode');
+const displayFormatSelect = document.getElementById('display-format');
+const terminalContainer = document.getElementById('terminal-container');
+
+// Sending Inputs
+const sendInput = document.getElementById('send-input');
+const sendFormatSelect = document.getElementById('send-format');
+const sendEolSelect = document.getElementById('send-eol');
+const sendBtn = document.getElementById('send-btn');
+const clearBtn = document.getElementById('clear-btn');
+const addSessionBtn = document.getElementById('add-session-btn');
+const sessionListContainer = document.getElementById('session-list');
+const downloadTypeSelect = document.getElementById('download-type');
+
+// PortSession Class definition
+class PortSession {
+ constructor(id, name) {
+ this.id = id;
+ this.name = name;
+
+ // Web Serial State
+ this.port = null;
+ this.reader = null;
+ this.writer = null;
+ this.keepReading = false;
+ this.rxLogBuffer = [];
+ this.txLogBuffer = [];
+ this.combinedLogBuffer = [];
+ this.downloadType = 'rx_tx';
+ this.commandHistory = [];
+ this.historyIndex = -1;
+ this.isTerminalEmpty = true;
+ this.deviceInfo = '';
+
+ // Settings state
+ this.baudRate = '115200';
+ this.dataBits = '8';
+ this.stopBits = '1';
+ this.parity = 'none';
+
+ // Console controls
+ this.scrollMode = 'auto';
+ this.displayFormat = 'auto';
+ this.sendFormat = 'ascii';
+ this.sendEol = 'crlf';
+ this.sendText = '';
+
+ // Dedicated Terminal Element
+ this.terminalOutputElement = document.createElement('div');
+ this.terminalOutputElement.id = `terminal-output-${this.id}`;
+ this.terminalOutputElement.className = 'terminal-output empty';
+ this.terminalOutputElement.tabIndex = 0;
+ this.terminalOutputElement.textContent = 'No data received yet...';
+ }
+}
+
+// Global Application State
+let sessions = [];
+let activeSession = null;
+
+// Initialize theme and browser support on load
+document.addEventListener('DOMContentLoaded', () => {
+ initTheme();
+ checkWebSerialSupport();
+
+ // Wire up main event listeners
+ addSessionBtn.addEventListener('click', addSession);
+
+ baudRateSelect.addEventListener('change', () => {
+ if (activeSession) {
+ activeSession.baudRate = baudRateSelect.value;
+ renderSessionsList();
+ }
+ });
+
+ dataBitsSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.dataBits = dataBitsSelect.value;
+ });
+
+ stopBitsSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.stopBits = stopBitsSelect.value;
+ });
+
+ paritySelect.addEventListener('change', () => {
+ if (activeSession) activeSession.parity = paritySelect.value;
+ });
+
+ scrollModeSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
+ });
+
+ displayFormatSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
+ });
+
+ sendFormatSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
+ });
+
+ sendEolSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.sendEol = sendEolSelect.value;
+ });
+
+ sendInput.addEventListener('input', () => {
+ if (activeSession) activeSession.sendText = sendInput.value;
+ });
+
+ downloadTypeSelect.addEventListener('change', () => {
+ if (activeSession) activeSession.downloadType = downloadTypeSelect.value;
+ });
+
+ // Initialize with a default session
+ addSession();
+});
+
+// Toast notification helper
+function showToast(message, type = 'success') {
+ const toast = document.createElement('div');
+ toast.className = `toast-msg show toast-${type}`;
+ toast.textContent = message;
+ document.body.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.remove('show');
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+}
+
+// -------------------------------------------------------------
+// 1. Theme Configuration (Dark / Light Mode)
+// -------------------------------------------------------------
+function initTheme() {
+ const savedTheme = localStorage.getItem('theme') || 'dark';
+ if (savedTheme === 'light') {
+ document.body.classList.add('light-theme');
+ } else {
+ document.body.classList.remove('light-theme');
+ }
+}
+
+themeToggle.addEventListener('click', () => {
+ document.body.classList.toggle('light-theme');
+ const activeTheme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
+ localStorage.setItem('theme', activeTheme);
+});
+
+// -------------------------------------------------------------
+// 2. Web Serial API Checks & State Updates
+// -------------------------------------------------------------
+function checkWebSerialSupport() {
+ if (!('serial' in navigator)) {
+ statusBanner.className = 'status-banner error';
+ statusText.innerHTML = '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';
+ addSessionBtn.disabled = true;
+ addSessionBtn.style.opacity = '0.5';
+ addSessionBtn.style.cursor = 'not-allowed';
+ showToast('Web Serial API is not supported in this browser.', 'error');
+ }
+}
+
+function updateConnectionUI(connected, errorMsg = '') {
+ if (!activeSession) return;
+
+ if (connected && activeSession.port) {
+ connectBtn.className = 'connect-btn connected';
+ connectBtn.title = 'Disconnect from serial port';
+ statusBanner.className = 'status-banner connected';
+
+ const info = activeSession.port.getInfo();
+ const portLabel = info.usbVendorId
+ ? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
+ : 'Serial Device';
+
+ statusText.innerHTML = `Connected to ${portLabel} at ${activeSession.baudRate} bps.`;
+ } else {
+ connectBtn.className = 'connect-btn disconnected';
+ connectBtn.title = 'Connect to serial port';
+
+ if (errorMsg) {
+ statusBanner.className = 'status-banner error';
+ statusText.textContent = errorMsg;
+ } else {
+ statusBanner.className = 'status-banner warn';
+ statusText.textContent = 'Not connected. Click the connect button to select a port.';
+ }
+ }
+}
+
+// -------------------------------------------------------------
+// 3. Serial Session Management
+// -------------------------------------------------------------
+function addSession() {
+ const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
+ const name = `Session ${sessions.length + 1}`;
+ const newSession = new PortSession(id, name);
+
+ sessions.push(newSession);
+ terminalContainer.appendChild(newSession.terminalOutputElement);
+
+ selectSession(newSession);
+ showToast(`${name} created.`, 'success');
+}
+
+function selectSession(session) {
+ if (activeSession) {
+ activeSession.sendText = sendInput.value;
+ }
+
+ activeSession = session;
+ renderSessionsList();
+
+ // Show only active session's terminal DOM element
+ const terminals = terminalContainer.querySelectorAll('.terminal-output');
+ terminals.forEach(el => {
+ if (el.id === activeSession.terminalOutputElement.id) {
+ el.classList.remove('hidden');
+ } else {
+ el.classList.add('hidden');
+ }
+ });
+
+ // Sync dropdown UI values
+ baudRateSelect.value = activeSession.baudRate;
+ dataBitsSelect.value = activeSession.dataBits;
+ stopBitsSelect.value = activeSession.stopBits;
+ paritySelect.value = activeSession.parity;
+
+ scrollModeSelect.value = activeSession.scrollMode;
+ displayFormatSelect.value = activeSession.displayFormat;
+ sendFormatSelect.value = activeSession.sendFormat;
+ sendEolSelect.value = activeSession.sendEol;
+ sendInput.value = activeSession.sendText;
+ downloadTypeSelect.value = activeSession.downloadType;
+
+ downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0);
+
+ // Sync status banner and buttons
+ const isConnected = activeSession.port !== null && activeSession.port.readable;
+ updateConnectionUI(isConnected);
+}
+
+async function deleteSession(session, event) {
+ if (event) {
+ event.stopPropagation();
+ }
+
+ if (sessions.length <= 1) {
+ showToast('Cannot delete the last remaining session.', 'error');
+ return;
+ }
+
+ // Disconnect if connected
+ if (session.port) {
+ await disconnectSession(session);
+ }
+
+ // Remove terminal from DOM
+ if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
+ session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
+ }
+
+ const index = sessions.indexOf(session);
+ if (index > -1) {
+ sessions.splice(index, 1);
+ }
+
+ if (activeSession === session) {
+ const nextActive = sessions[Math.max(0, index - 1)];
+ selectSession(nextActive);
+ } else {
+ renderSessionsList();
+ }
+
+ showToast(`${session.name} deleted.`, 'success');
+}
+
+function renderSessionsList() {
+ sessionListContainer.innerHTML = '';
+
+ sessions.forEach(session => {
+ const item = document.createElement('div');
+ item.className = `session-item ${session === activeSession ? 'active' : ''}`;
+ item.addEventListener('click', () => selectSession(session));
+
+ const leftGroup = document.createElement('div');
+ leftGroup.className = 'session-item-left';
+
+ // Status dot
+ const dot = document.createElement('span');
+ const isConnected = session.port !== null && session.port.readable;
+ dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
+ leftGroup.appendChild(dot);
+
+ // Session labels
+ const info = document.createElement('div');
+ info.className = 'session-info';
+
+ const nameEl = document.createElement('span');
+ nameEl.className = 'session-name';
+ nameEl.textContent = session.name;
+
+ // Double click to rename
+ nameEl.addEventListener('dblclick', (e) => {
+ e.stopPropagation();
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.value = session.name;
+ input.className = 'session-rename-input';
+
+ const saveRename = () => {
+ const val = input.value.trim();
+ if (val) {
+ session.name = val;
+ }
+ renderSessionsList();
+ };
+
+ input.addEventListener('keydown', (evt) => {
+ if (evt.key === 'Enter') {
+ saveRename();
+ } else if (evt.key === 'Escape') {
+ renderSessionsList();
+ }
+ });
+
+ input.addEventListener('blur', saveRename);
+
+ info.innerHTML = '';
+ info.appendChild(input);
+ input.focus();
+ input.select();
+ });
+
+ const detailEl = document.createElement('span');
+ detailEl.className = 'session-detail';
+ if (isConnected) {
+ detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
+ } else {
+ detailEl.textContent = `${session.baudRate} bps | Disconnected`;
+ }
+
+ info.appendChild(nameEl);
+ info.appendChild(detailEl);
+ leftGroup.appendChild(info);
+ item.appendChild(leftGroup);
+
+ // Close / Delete Session button
+ const deleteBtn = document.createElement('button');
+ deleteBtn.className = 'session-delete-btn';
+ deleteBtn.title = 'Delete Session';
+ deleteBtn.innerHTML = `
+
+ `;
+ deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
+ item.appendChild(deleteBtn);
+
+ sessionListContainer.appendChild(item);
+ });
+}
+
+// -------------------------------------------------------------
+// 4. Serial Port Connection & Reader Loop
+// -------------------------------------------------------------
+async function connectSession(session) {
+ try {
+ session.port = await navigator.serial.requestPort();
+
+ const options = {
+ baudRate: parseInt(session.baudRate, 10),
+ dataBits: parseInt(session.dataBits, 10),
+ stopBits: parseInt(session.stopBits, 10),
+ parity: session.parity
+ };
+
+ await session.port.open(options);
+
+ const info = session.port.getInfo();
+ if (info.usbVendorId) {
+ session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
+ } else {
+ session.deviceInfo = 'Serial Device';
+ }
+
+ if (session === activeSession) {
+ updateConnectionUI(true);
+ }
+ renderSessionsList();
+
+ session.keepReading = true;
+ startSessionReadingLoop(session);
+
+ showToast(`${session.name} connected.`, 'success');
+ } catch (err) {
+ console.error('Connection failed:', err);
+ session.port = null;
+ if (session === activeSession) {
+ updateConnectionUI(false, `Connection failed: ${err.message}`);
+ }
+ showToast(`Failed to open port for ${session.name}.`, 'error');
+ }
+}
+
+async function disconnectSession(session) {
+ session.keepReading = false;
+
+ if (session.reader) {
+ try {
+ await session.reader.cancel();
+ } catch (e) {
+ console.error('Error cancelling reader:', e);
+ }
+ }
+
+ return new Promise((resolve) => {
+ setTimeout(async () => {
+ if (session.port) {
+ try {
+ await session.port.close();
+ } catch (e) {
+ console.error('Error closing port:', e);
+ }
+ session.port = null;
+ }
+ session.deviceInfo = '';
+ if (session === activeSession) {
+ updateConnectionUI(false);
+ }
+ renderSessionsList();
+ showToast(`${session.name} disconnected.`, 'success');
+ resolve();
+ }, 100);
+ });
+}
+
+async function startSessionReadingLoop(session) {
+ const decoder = new TextDecoder();
+
+ while (session.port && session.port.readable && session.keepReading) {
+ try {
+ session.reader = session.port.readable.getReader();
+
+ while (session.keepReading) {
+ const { value, done } = await session.reader.read();
+ if (done) break;
+ if (value) {
+ handleIncomingSessionData(session, value, decoder);
+ }
+ }
+ } catch (err) {
+ console.error('Read error:', err);
+ if (session === activeSession) {
+ updateConnectionUI(false, `Port disconnected: ${err.message}`);
+ }
+ showToast(`${session.name} disconnected unexpectedly.`, 'error');
+ session.port = null;
+ renderSessionsList();
+ break;
+ } finally {
+ if (session.reader) {
+ session.reader.releaseLock();
+ session.reader = null;
+ }
+ }
+ }
+}
+
+function handleIncomingSessionData(session, uint8Array, decoder) {
+ const format = session.displayFormat;
+ let formattedText = '';
+
+ if (format === 'hex') {
+ formattedText = Array.from(uint8Array)
+ .map(b => b.toString(16).padStart(2, '0').toUpperCase())
+ .join(' ') + ' ';
+ } else {
+ formattedText = decoder.decode(uint8Array);
+ }
+
+ session.rxLogBuffer.push(formattedText);
+ session.combinedLogBuffer.push(formattedText);
+ appendSessionTerminalText(session, formattedText, 'received');
+}
+
+// -------------------------------------------------------------
+// 5. Writing Data (Transmission)
+// -------------------------------------------------------------
+async function sendSessionData() {
+ if (!activeSession) return;
+ const session = activeSession;
+
+ if (!session.port || !session.port.writable) {
+ showToast(`Cannot send. ${session.name} is not connected.`, 'error');
+ return;
+ }
+
+ const rawInput = sendInput.value;
+ if (!rawInput) return;
+
+ // Add command to history (maximum 10 items)
+ const trimmed = rawInput.trim();
+ if (trimmed) {
+ const history = session.commandHistory;
+ if (history.length === 0 || history[history.length - 1] !== trimmed) {
+ history.push(trimmed);
+ if (history.length > 10) {
+ history.shift();
+ }
+ }
+ session.historyIndex = history.length;
+ }
+
+ const format = session.sendFormat;
+ const eol = session.sendEol;
+ let bytesToSend;
+
+ let eolText = '';
+ let eolBytes = new Uint8Array(0);
+ if (eol === 'lf') {
+ eolText = '\n';
+ eolBytes = new Uint8Array([10]);
+ } else if (eol === 'cr') {
+ eolText = '\r';
+ eolBytes = new Uint8Array([13]);
+ } else if (eol === 'crlf') {
+ eolText = '\r\n';
+ eolBytes = new Uint8Array([13, 10]);
+ }
+
+ try {
+ if (format === 'hex') {
+ const parsedBytes = parseHexString(rawInput);
+ bytesToSend = new Uint8Array(parsedBytes.length + eolBytes.length);
+ bytesToSend.set(parsedBytes);
+ bytesToSend.set(eolBytes, parsedBytes.length);
+ } else {
+ bytesToSend = new TextEncoder().encode(rawInput + eolText);
+ }
+ } catch (err) {
+ showToast(err.message, 'error');
+ return;
+ }
+
+ try {
+ session.writer = session.port.writable.getWriter();
+ await session.writer.write(bytesToSend);
+
+ let displaySent = '';
+ if (format === 'hex') {
+ displaySent = Array.from(bytesToSend)
+ .map(b => b.toString(16).padStart(2, '0').toUpperCase())
+ .join(' ') + ' ';
+ } else {
+ displaySent = rawInput;
+ if (eol === 'lf') displaySent += '\\n';
+ else if (eol === 'cr') displaySent += '\\r';
+ else if (eol === 'crlf') displaySent += '\\r\\n';
+ }
+
+ appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
+
+ // Save sent log
+ session.txLogBuffer.push(`[Sent] ${displaySent}\n`);
+ session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`);
+
+ sendInput.value = '';
+ session.sendText = '';
+
+ } catch (err) {
+ console.error('Send error:', err);
+ showToast(`Failed to send: ${err.message}`, 'error');
+ } finally {
+ if (session.writer) {
+ session.writer.releaseLock();
+ session.writer = null;
+ }
+ }
+}
+
+function parseHexString(hexString) {
+ const clean = hexString.replace(/\s+/g, '');
+
+ if (!/^[0-9a-fA-F]*$/.test(clean)) {
+ throw new Error('Hex contains invalid characters (0-9, A-F only)');
+ }
+
+ if (clean.length % 2 !== 0) {
+ throw new Error('Hex string must have an even number of characters');
+ }
+
+ const result = new Uint8Array(clean.length / 2);
+ for (let i = 0; i < clean.length; i += 2) {
+ result[i / 2] = parseInt(clean.substring(i, i + 2), 16);
+ }
+ return result;
+}
+
+// -------------------------------------------------------------
+// 6. Terminal Console Operations
+// -------------------------------------------------------------
+function appendSessionTerminalText(session, text, className = '') {
+ if (session.isTerminalEmpty) {
+ session.terminalOutputElement.textContent = '';
+ session.terminalOutputElement.classList.remove('empty');
+ session.isTerminalEmpty = false;
+ if (session === activeSession) {
+ downloadBtn.disabled = false;
+ }
+ }
+
+ const span = document.createElement('span');
+ if (className) {
+ span.className = `terminal-line ${className}`;
+ }
+ span.textContent = text;
+ session.terminalOutputElement.appendChild(span);
+
+ if (session.scrollMode === 'auto') {
+ session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
+ }
+}
+
+function clearTerminal() {
+ if (!activeSession) return;
+ activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
+ activeSession.terminalOutputElement.classList.add('empty');
+ activeSession.isTerminalEmpty = true;
+ activeSession.rxLogBuffer = [];
+ activeSession.txLogBuffer = [];
+ activeSession.combinedLogBuffer = [];
+ downloadBtn.disabled = true;
+ showToast('Terminal cleared.', 'success');
+}
+
+function downloadLog() {
+ if (!activeSession) return;
+
+ let content = '';
+ let filenameSuffix = '';
+
+ if (activeSession.downloadType === 'rx') {
+ content = activeSession.rxLogBuffer.join('');
+ filenameSuffix = 'rx';
+ } else if (activeSession.downloadType === 'tx') {
+ content = activeSession.txLogBuffer.join('');
+ filenameSuffix = 'tx';
+ } else {
+ content = activeSession.combinedLogBuffer.join('');
+ filenameSuffix = 'combined';
+ }
+
+ if (!content) {
+ showToast('No logs to download for the selected type.', 'error');
+ return;
+ }
+
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+
+ const dateStr = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`;
+ document.body.appendChild(a);
+ a.click();
+
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, 100);
+
+ showToast('Download started.', 'success');
+}
+
+// -------------------------------------------------------------
+// 7. Event Listeners & Keyboard Shortcuts
+// -------------------------------------------------------------
+connectBtn.addEventListener('click', () => {
+ if (activeSession) {
+ if (activeSession.port) {
+ disconnectSession(activeSession);
+ } else {
+ connectSession(activeSession);
+ }
+ }
+});
+
+sendBtn.addEventListener('click', sendSessionData);
+clearBtn.addEventListener('click', clearTerminal);
+downloadBtn.addEventListener('click', downloadLog);
+
+sendInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ sendSessionData();
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (activeSession && activeSession.commandHistory.length > 0) {
+ if (activeSession.historyIndex > 0) {
+ activeSession.historyIndex--;
+ sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
+ activeSession.sendText = sendInput.value;
+ }
+ }
+ }
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (activeSession && activeSession.commandHistory.length > 0) {
+ if (activeSession.historyIndex < activeSession.commandHistory.length - 1) {
+ activeSession.historyIndex++;
+ sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
+ activeSession.sendText = sendInput.value;
+ } else {
+ activeSession.historyIndex = activeSession.commandHistory.length;
+ sendInput.value = '';
+ activeSession.sendText = '';
+ }
+ }
+ }
+ if (e.key === 'Tab' && e.ctrlKey) {
+ e.preventDefault();
+ const start = sendInput.selectionStart;
+ const end = sendInput.selectionEnd;
+
+ sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
+ sendInput.selectionStart = sendInput.selectionEnd = start + 1;
+ if (activeSession) activeSession.sendText = sendInput.value;
+ }
+});
+
+// Auto-detect browser USB disconnect events globally
+if ('serial' in navigator) {
+ navigator.serial.addEventListener('disconnect', async (event) => {
+ const disconnectedSession = sessions.find(s => s.port === event.target);
+ if (disconnectedSession) {
+ await disconnectSession(disconnectedSession);
+ showToast(`${disconnectedSession.name} device disconnected.`, 'error');
+ }
+ });
+}
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/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 `