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 `