// 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'); } }); }