'use strict'; (async () => { // bind to the html const uiBody = document.body; const uiToggleThemeBtn = document.getElementById('theme-toggle'); const uiConnectWebUsbSerialBtn = document.getElementById('connect_webusb_serial_btn'); const uiConnectSerialBtn = document.getElementById('connect_serial_btn'); const uiDisconnectBtn = document.getElementById('disconnect_btn'); const uiNewlineModeSelect = document.getElementById('newline_mode_select'); const uiAutoReconnectCheckbox = document.getElementById('auto_reconnect_checkbox'); const uiForgetDeviceBtn = document.getElementById('forget_device_btn'); const uiForgetAllDevicesBtn = document.getElementById('forget_all_devices_btn'); const uiResetAllBtn = document.getElementById('reset_all_btn'); const uiCopyOutputBtn = document.getElementById('copy_output_btn'); const uiDownloadOutputCsvBtn = document.getElementById('download_csv_output_btn'); const uiStatusSpan = document.getElementById('status_span'); const uiCommandHistoryClearBtn = document.getElementById('clear_command_history_btn'); const uiCommandHistoryScrollbox = document.getElementById('command_history_scrollbox'); const uiCommandLineInput = document.getElementById('command_line_input'); const uiSendModeBtn = document.getElementById('send_mode_btn'); const uiReceivedDataClearBtn = document.getElementById('clear_received_data_btn'); const uiReceivedDataScrollbox = document.getElementById('received_data_scrollbox'); const uiNearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll const maxCommandHistoryLength = 123; // max number of command history entries const maxReceivedDataLength = 8192 / 8; // max number of received data entries const THEME_STATES = ['auto', 'light', 'dark']; class CommandHistoryEntry { constructor(text) { this.text = text; this.time = Date.now(); this.count = 1; } } class ReceivedDataEntry { constructor(text) { this.text = text; this.time = Date.now(); this.terminated = false; } } class Application { constructor() { this.currentPort = null; this.textEncoder = new TextEncoder(); this.textDecoder = new TextDecoder(); this.reconnectTimeoutId = null; this.commandHistory = []; this.uiCommandHistoryIndex = -1; this.receivedData = []; // bind the UI elements uiToggleThemeBtn.addEventListener('click', () => this.toggleTheme()); // Listener for OS Theme Changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { const currentPreference = localStorage.getItem('theme') || 'auto'; // Only act if the user is in automatic mode if (currentPreference === 'auto') { this.setTheme('auto'); } }); uiConnectWebUsbSerialBtn.addEventListener('click', () => this.connectWebUsbSerialPort()); uiConnectSerialBtn.addEventListener('click', () => this.connectSerialPort()); uiDisconnectBtn.addEventListener('click', () => this.disconnectPort()); uiNewlineModeSelect.addEventListener('change', () => this.setNewlineMode()); uiAutoReconnectCheckbox.addEventListener('change', () => this.autoReconnectChanged()); uiForgetDeviceBtn.addEventListener('click', () => this.forgetPort()); uiForgetAllDevicesBtn.addEventListener('click', () => this.forgetAllPorts()); uiResetAllBtn.addEventListener('click', () => this.resetAll()); uiCopyOutputBtn.addEventListener('click', () => this.copyOutput()); uiDownloadOutputCsvBtn.addEventListener('click', () => this.downloadOutputCsv()); uiCommandHistoryClearBtn.addEventListener('click', () => this.clearCommandHistory()); uiCommandLineInput.addEventListener('keydown', (e) => this.handleCommandLineInput(e)); uiSendModeBtn.addEventListener('click', () => this.toggleSendMode()); uiReceivedDataClearBtn.addEventListener('click', () => this.clearReceivedData()); // restore state from localStorage try { this.restoreState(); } catch (error) { console.error('Failed to restore state from localStorage', error); this.resetAll(); this.restoreState(); } this.updateUIConnectionState(); this.connectWebUsbSerialPort(true); } restoreState() { // Restore theme choice const savedTheme = localStorage.getItem('theme'); if (savedTheme) { this.setTheme(savedTheme); } // Restore command history let savedCommandHistory = JSON.parse(localStorage.getItem('commandHistory') || '[]'); for (const cmd of savedCommandHistory) { this.appendCommandToHistory(cmd); } // Restore received data let savedReceivedData = JSON.parse(localStorage.getItem('receivedData') || '[]'); for (let line of savedReceivedData) { line.terminated = true; this.appendReceivedData(line); } this.sendMode = localStorage.getItem('sendMode') || 'command'; this.setSendMode(this.sendMode); uiAutoReconnectCheckbox.checked = !(localStorage.getItem('autoReconnect') === 'false'); let savedNewlineMode = localStorage.getItem('newlineMode'); if (savedNewlineMode) { uiNewlineModeSelect.value = savedNewlineMode; } } setTheme(theme) { const modeName = theme.charAt(0).toUpperCase() + theme.slice(1); uiToggleThemeBtn.textContent = `Theme: ${modeName}`; if (theme === 'auto') { // In auto mode, we rely on the OS preference. // We check the media query and add/remove the class accordingly. const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (prefersDark) { uiBody.classList.add('dark-mode'); } else { uiBody.classList.remove('dark-mode'); } } else if (theme === 'light') { // Force light mode by removing the class. uiBody.classList.remove('dark-mode'); } else if (theme === 'dark') { // Force dark mode by adding the class. uiBody.classList.add('dark-mode'); } // Save the theme to localStorage localStorage.setItem('theme', theme); } toggleTheme() { const currentTheme = localStorage.getItem('theme') || 'auto'; const nextThemeIndex = (THEME_STATES.indexOf(currentTheme) + 1) % THEME_STATES.length; const nextTheme = THEME_STATES[nextThemeIndex]; this.setTheme(nextTheme); } appendCommandToHistory(commandHistoryEntry) { const wasNearBottom = uiCommandHistoryScrollbox.scrollHeight - uiCommandHistoryScrollbox.scrollTop <= uiCommandHistoryScrollbox.clientHeight + uiNearTheBottomThreshold; let commandHistoryEntryBtn = null; let lastCommandMatched = false; if (this.commandHistory.length > 0) { let lastCommandEntry = this.commandHistory[this.commandHistory.length - 1]; if (lastCommandEntry.text === commandHistoryEntry.text) { lastCommandEntry.count++; lastCommandEntry.time = Date.now(); lastCommandMatched = true; // Update the last command entry commandHistoryEntryBtn = uiCommandHistoryScrollbox.lastElementChild; let time_str = new Date(lastCommandEntry.time).toLocaleString(); commandHistoryEntryBtn.querySelector('.command-history-entry-time').textContent = time_str; commandHistoryEntryBtn.querySelector('.command-history-entry-text').textContent = lastCommandEntry.text; commandHistoryEntryBtn.querySelector('.command-history-entry-count').textContent = '×' + lastCommandEntry.count; } } if (!lastCommandMatched) { this.commandHistory.push(commandHistoryEntry); // Create a new command history entry commandHistoryEntryBtn = document.createElement('button'); commandHistoryEntryBtn.className = 'command-history-entry'; commandHistoryEntryBtn.type = 'button'; let time_str = new Date(commandHistoryEntry.time).toLocaleString(); commandHistoryEntryBtn.innerHTML = ` ${time_str} ${commandHistoryEntry.text} ×${commandHistoryEntry.count} `; commandHistoryEntryBtn.addEventListener('click', () => { if (uiCommandLineInput.disabled) return; uiCommandLineInput.value = commandHistoryEntry.text; uiCommandLineInput.focus(); }); uiCommandHistoryScrollbox.appendChild(commandHistoryEntryBtn); } // Limit the command history length while (this.commandHistory.length > maxCommandHistoryLength) { this.commandHistory.shift(); uiCommandHistoryScrollbox.removeChild(uiCommandHistoryScrollbox.firstElementChild); } // Save the command history to localStorage localStorage.setItem('commandHistory', JSON.stringify(this.commandHistory)); // Scroll to the new entry if near the bottom if (wasNearBottom) { requestAnimationFrame(() => { uiCommandHistoryScrollbox.scrollTop = uiCommandHistoryScrollbox.scrollHeight; }); } } clearCommandHistory() { this.commandHistory = []; uiCommandHistoryScrollbox.innerHTML = ''; localStorage.removeItem('commandHistory'); this.setStatus('Command history cleared', 'info'); } appendReceivedData(receivedDataEntry) { const wasNearBottom = uiReceivedDataScrollbox.scrollHeight - uiReceivedDataScrollbox.scrollTop <= uiReceivedDataScrollbox.clientHeight + uiNearTheBottomThreshold; let newReceivedDataEntries = []; let updateLastReceivedDataEntry = false; if (this.receivedData.length <= 0) { newReceivedDataEntries.push(receivedDataEntry); } else { let lastReceivedDataEntry = this.receivedData[this.receivedData.length - 1]; // Check if the last entry is terminated if (lastReceivedDataEntry.terminated) { newReceivedDataEntries.push(receivedDataEntry); } else { if (!lastReceivedDataEntry.terminated) { updateLastReceivedDataEntry = true; this.receivedData.pop(); receivedDataEntry.text = lastReceivedDataEntry.text + receivedDataEntry.text; } // split the text into lines let lines = receivedDataEntry.text.split(/\r?\n/); // check if the last line is terminated by checking if it ends with an empty string let lastLineTerminated = lines[lines.length - 1] === ''; if (lastLineTerminated) { lines.pop(); // remove the last empty line } // create new entries for each line for (let i = 0; i < lines.length; i++) { let line = lines[i]; let entry = new ReceivedDataEntry(line); if (i === lines.length - 1) { entry.terminated = lastLineTerminated; } else { entry.terminated = true; } newReceivedDataEntries.push(entry); } // if the last line is terminated, modify the last entry if (lastLineTerminated) { newReceivedDataEntries[newReceivedDataEntries.length - 1].terminated = true; } else { newReceivedDataEntries[newReceivedDataEntries.length - 1].terminated = false; } } } this.receivedData.push(...newReceivedDataEntries); if (updateLastReceivedDataEntry) { // update the rendering of the last entry let lastReceivedDataEntryBtn = uiReceivedDataScrollbox.lastElementChild; lastReceivedDataEntryBtn.querySelector('.received-data-entry-text').textContent = newReceivedDataEntries[0].text; lastReceivedDataEntryBtn.querySelector('.received-data-entry-time').textContent = new Date(newReceivedDataEntries[0].time).toLocaleString(); newReceivedDataEntries.shift(); } // render the new entries let documentFragment = document.createDocumentFragment(); for (const entry of newReceivedDataEntries) { let receivedDataEntryBtn = document.createElement('div'); receivedDataEntryBtn.className = 'received-data-entry'; receivedDataEntryBtn.innerHTML = ` ${new Date(entry.time).toLocaleString()} ${entry.text} `; documentFragment.appendChild(receivedDataEntryBtn); } uiReceivedDataScrollbox.appendChild(documentFragment); // Limit the received data length while (this.receivedData.length > maxReceivedDataLength) { this.receivedData.shift(); uiReceivedDataScrollbox.removeChild(uiReceivedDataScrollbox.firstElementChild); } // Save the received data to localStorage localStorage.setItem('receivedData', JSON.stringify(this.receivedData)); // Scroll to the new entry if near the bottom if (wasNearBottom) { requestAnimationFrame(() => { uiReceivedDataScrollbox.scrollTop = uiReceivedDataScrollbox.scrollHeight; }); } } clearReceivedData() { this.receivedData = []; uiReceivedDataScrollbox.innerHTML = ''; localStorage.removeItem('receivedData'); this.setStatus('Received data cleared', 'info'); } setStatus(msg, level = 'info') { console.log(msg); uiStatusSpan.textContent = msg; uiStatusSpan.className = 'status status-' + level; } updateUIConnectionState() { if (this.currentPort && this.currentPort.isConnected) { uiConnectWebUsbSerialBtn.style.display = 'none'; uiConnectSerialBtn.style.display = 'none'; uiDisconnectBtn.style.display = 'block'; uiCommandLineInput.disabled = false; uiCommandLineInput.focus(); } else { if (serial.isWebUsbSupported()) { uiConnectWebUsbSerialBtn.style.display = 'block'; } if (serial.isWebSerialSupported()) { uiConnectSerialBtn.style.display = 'block'; } if (!serial.isWebUsbSupported() && !serial.isWebSerialSupported()) { this.setStatus('Your browser does not support WebUSB or WebSerial', 'error'); } uiDisconnectBtn.style.display = 'none'; uiCommandLineInput.disabled = true; uiCommandLineInput.value = ''; uiCommandLineInput.blur(); } } async disconnectPort() { this.stopAutoReconnect(); if (!this.currentPort) { this.updateUIConnectionState(); return; }; try { await this.currentPort.disconnect(); this.setStatus('Disconnected', 'info'); } catch (error) { this.setStatus(`Disconnect error: ${error.message}`, 'error'); } this.updateUIConnectionState(); } async onReceive(dataView) { this.updateUIConnectionState(); let text = this.textDecoder.decode(dataView); let receivedDataEntry = new ReceivedDataEntry(text); this.appendReceivedData(receivedDataEntry); } async onReceiveError(error) { this.setStatus(`Read error: ${error.message}`, 'error'); await this.disconnectPort(); // Start auto reconnect on error if enabled this.tryAutoReconnect(); } async connectSerialPort() { if (!serial.isWebSerialSupported()) { this.setStatus('Serial not supported on this browser', 'error'); return; } try { this.setStatus('Requesting device...', 'info'); this.currentPort = await serial.requestSerialPort(); this.currentPort.onReceiveError = error => this.onReceiveError(error); this.currentPort.onReceive = dataView => this.onReceive(dataView); await this.currentPort.connect(); this.setStatus('Connected', 'info'); } catch (error) { this.setStatus(`Connection failed: ${error.message}`, 'error'); if (this.currentPort) { await this.currentPort.forgetDevice(); this.currentPort = null; } } } async connectWebUsbSerialPort(initial = false) { if (!serial.isWebUsbSupported()) { this.setStatus('WebUSB not supported on this browser', 'error'); return; } try { let first_time_connection = false; let grantedDevices = await serial.getWebUsbSerialPorts(); if (initial) { if (!uiAutoReconnectCheckbox.checked || grantedDevices.length === 0) { return false; } // Connect to the device that was saved to localStorage otherwise use the first one const savedPortInfo = JSON.parse(localStorage.getItem('webUSBSerialPort')); if (savedPortInfo) { for (const device of grantedDevices) { if (device.device.vendorId === savedPortInfo.vendorId && device.device.productId === savedPortInfo.productId) { this.currentPort = device; break; } } } if (!this.currentPort) { this.currentPort = grantedDevices[0]; } this.setStatus('Connecting to first device...', 'info'); } else { // Prompt the user to select a device this.setStatus('Requesting device...', 'info'); this.currentPort = await serial.requestWebUsbSerialPort(); first_time_connection = true; } this.currentPort.onReceiveError = error => this.onReceiveError(error); this.currentPort.onReceive = dataView => this.onReceive(dataView); try { await this.currentPort.connect(); // save the port to localStorage const portInfo = { vendorId: this.currentPort.device.vendorId, productId: this.currentPort.device.productId, } localStorage.setItem('webUSBSerialPort', JSON.stringify(portInfo)); this.setStatus('Connected', 'info'); } catch (error) { if (first_time_connection) { // Forget the device if a first time connection fails await this.currentPort.forgetDevice(); this.currentPort = null; } throw error; } this.updateUIConnectionState(); } catch (error) { this.setStatus(`Connection failed: ${error.message}`, 'error'); } } async reconnectPort() { if (this.currentPort) { this.setStatus('Reconnecting...', 'info'); try { await this.currentPort.connect(); this.setStatus('Reconnected', 'info'); } catch (error) { this.setStatus(`Reconnect failed: ${error.message}`, 'error'); } } this.updateUIConnectionState(); } async forgetPort() { this.stopAutoReconnect(); if (this.currentPort) { await this.currentPort.forgetDevice(); this.currentPort = null; this.setStatus('Device forgotten', 'info'); } else { this.setStatus('No device to forget', 'error'); } this.updateUIConnectionState(); } async forgetAllPorts() { this.stopAutoReconnect(); await this.forgetPort(); if (serial.isWebUsbSupported()) { let ports = await serial.getWebUsbSerialPorts(); for (const p of ports) { await p.forgetDevice(); } } this.updateUIConnectionState(); } setNewlineMode() { localStorage.setItem('newlineMode', uiNewlineModeSelect.value); } autoReconnectChanged() { if (uiAutoReconnectCheckbox.checked) { this.setStatus('Auto-reconnect enabled', 'info'); this.tryAutoReconnect(); } else { this.setStatus('Auto-reconnect disabled', 'info'); this.stopAutoReconnect(); } localStorage.setItem('autoReconnect', uiAutoReconnectCheckbox.checked); } stopAutoReconnect() { if (this.reconnectTimeoutId !== null) { clearTimeout(this.reconnectTimeoutId); this.reconnectTimeoutId = null; this.setStatus('Auto-reconnect stopped.', 'info'); } } tryAutoReconnect() { this.updateUIConnectionState(); if (!uiAutoReconnectCheckbox.checked) return; if (this.reconnectTimeoutId !== null) return; // already trying this.setStatus('Attempting to auto-reconnect...', 'info'); this.reconnectTimeoutId = setTimeout(async () => { this.reconnectTimeoutId = null; if (!uiAutoReconnectCheckbox.checked) { this.setStatus('Auto-reconnect stopped.', 'info'); return; } if (this.currentPort) { try { await this.currentPort.connect(); } finally { this.updateUIConnectionState(); } } }, 1000); } async handleCommandLineInput(e) { // Instant mode: send key immediately including special keys like Backspace, arrows, enter, etc. if (this.sendMode === 'instant') { e.preventDefault(); // Ignore only pure modifier keys without text representation if (e.key.length === 1 || e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Tab' || e.key === 'Escape' || e.key === 'Delete' ) { let sendText = ''; switch (e.key) { case 'Enter': switch (uiNewlineModeSelect.value) { case 'CR': sendText = '\r'; break; case 'CRLF': sendText = '\r\n'; break; default: sendText = '\n'; break; } break; case 'Backspace': // Usually no straightforward char to send for Backspace, // but often ASCII DEL '\x7F' or '\b' (0x08) is sent. sendText = '\x08'; // backspace break; case 'Tab': sendText = '\t'; break; case 'Escape': // Ignore or send ESC control char if needed sendText = '\x1B'; break; case 'Delete': sendText = '\x7F'; // DEL char break; default: sendText = e.key; } try { await this.currentPort.send(this.textEncoder.encode(sendText)); } catch (error) { this.setStatus(`Send error: ${error.message}`, 'error'); this.tryAutoReconnect(); } } return; } // Command mode: handle up/down arrow keys for history if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); if (this.commandHistory.length === 0) return; if (e.key === 'ArrowUp') { if (this.uiCommandHistoryIndex === -1) this.uiCommandHistoryIndex = this.commandHistory.length - 1; else if (this.uiCommandHistoryIndex > 0) this.uiCommandHistoryIndex--; } else if (e.key === 'ArrowDown') { if (this.uiCommandHistoryIndex !== -1) this.uiCommandHistoryIndex++; if (this.uiCommandHistoryIndex >= this.commandHistory.length) this.uiCommandHistoryIndex = -1; } uiCommandLineInput.value = this.uiCommandHistoryIndex === -1 ? '' : this.commandHistory[this.uiCommandHistoryIndex].text; return; } if (e.key !== 'Enter' || !this.currentPort.isConnected) return; e.preventDefault(); const text = uiCommandLineInput.value; if (!text) return; // Convert to Uint8Array with newline based on config let sendText = text; switch (uiNewlineModeSelect.value) { case 'CR': sendText += '\r'; break; case 'CRLF': sendText += '\r\n'; break; case 'ANY': sendText += '\n'; break; } const data = this.textEncoder.encode(sendText); try { await this.currentPort.send(data); this.uiCommandHistoryIndex = -1; let history_cmd_text = sendText.replace(/[\r\n]+$/, ''); let history_entry = new CommandHistoryEntry(history_cmd_text); this.appendCommandToHistory(history_entry); uiCommandLineInput.value = ''; } catch (error) { this.setStatus(`Send error: ${error.message}`, 'error'); this.tryAutoReconnect(); } } toggleSendMode() { if (this.sendMode === 'instant') { this.setSendMode('command'); } else { this.setSendMode('instant'); } } setSendMode(mode) { this.sendMode = mode; if (mode === 'instant') { uiSendModeBtn.classList.remove('send-mode-command'); uiSendModeBtn.classList.add('send-mode-instant'); uiSendModeBtn.textContent = 'Instant mode'; } else { uiSendModeBtn.classList.remove('send-mode-instant'); uiSendModeBtn.classList.add('send-mode-command'); uiSendModeBtn.textContent = 'Command mode'; } localStorage.setItem('sendMode', this.sendMode); } copyOutput() { let text = ''; for (const entry of this.receivedData) { text += entry.text; if (entry.terminated) { text += '\n'; } } if (text) { navigator.clipboard.writeText(text).then(() => { this.setStatus('Output copied to clipboard', 'info'); }, () => { this.setStatus('Failed to copy output', 'error'); }); } else { this.setStatus('No output to copy', 'error'); } } downloadOutputCsv() { // save , let csvContent = 'data:text/csv;charset=utf-8,'; for (const entry of this.receivedData) { let line = new Date(entry.time).toISOString() + ',"' + entry.text.replace(/[\r\n]+$/, '') + '"'; csvContent += line + '\n'; } const encodedUri = encodeURI(csvContent); const link = document.createElement('a'); link.setAttribute('href', encodedUri); const filename = new Date().toISOString() + '_tinyusb_received_serial_data.csv'; link.setAttribute('download', filename); document.body.appendChild(link); link.click(); document.body.removeChild(link); } async resetAll() { await this.forgetAllPorts(); // Clear localStorage for (const key in localStorage) { localStorage.removeItem(key); } // reload the page window.location.reload(); } } const app = new Application(); })()