| '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']; |
| |
| /// https://stackoverflow.com/a/6234804/4479969 |
| const escapeHtml = unsafe => { |
| if (typeof unsafe !== 'string') unsafe = String(unsafe); |
| return unsafe |
| .replaceAll("&", "&") |
| .replaceAll("<", "<") |
| .replaceAll(">", ">") |
| .replaceAll('"', """) |
| .replaceAll("'", "'"); |
| }; |
| |
| 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()); |
| |
| window.addEventListener('beforeunload', () => this.beforeUnloadHandler()); |
| |
| // 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); |
| } |
| |
| beforeUnloadHandler() { |
| // Save the scroll position of the command history and received data |
| localStorage.setItem('commandHistoryScrollTop', uiCommandHistoryScrollbox.scrollTop); |
| localStorage.setItem('receivedDataScrollTop', uiReceivedDataScrollbox.scrollTop); |
| } |
| |
| 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.addCommandToHistoryUI(cmd); |
| } |
| // Restore scroll position for command history |
| const commandHistoryScrollTop = localStorage.getItem('commandHistoryScrollTop'); |
| if (commandHistoryScrollTop) { |
| uiCommandHistoryScrollbox.scrollTop = parseInt(commandHistoryScrollTop, 10); |
| } |
| |
| // Restore received data |
| let savedReceivedData = JSON.parse(localStorage.getItem('receivedData') || '[]'); |
| for (let line of savedReceivedData) { |
| line.terminated = true; |
| this.addReceivedDataEntryUI(line); |
| } |
| // Restore scroll position for received data |
| const receivedDataScrollTop = localStorage.getItem('receivedDataScrollTop'); |
| if (receivedDataScrollTop) { |
| uiReceivedDataScrollbox.scrollTop = parseInt(receivedDataScrollTop, 10); |
| } |
| |
| 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); |
| } |
| |
| addCommandToHistoryUI(commandHistoryEntry) { |
| 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 = ` |
| <span class="command-history-entry-time">${escapeHtml(time_str)}</span> |
| <span class="command-history-entry-text">${escapeHtml(commandHistoryEntry.text)}</span> |
| <span class="command-history-entry-count">×${escapeHtml(commandHistoryEntry.count)}</span> |
| `; |
| 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); |
| } |
| } |
| |
| appendNewCommandToHistory(commandHistoryEntry) { |
| const wasNearBottom = this.isNearBottom(uiCommandHistoryScrollbox); |
| |
| this.addCommandToHistoryUI(commandHistoryEntry); |
| |
| // Save the command history to localStorage |
| localStorage.setItem('commandHistory', JSON.stringify(this.commandHistory)); |
| |
| // Scroll to the new entry if near the bottom |
| if (wasNearBottom) { |
| this.scrollToBottom(uiCommandHistoryScrollbox); |
| } |
| } |
| |
| clearCommandHistory() { |
| this.commandHistory = []; |
| uiCommandHistoryScrollbox.textContent = ''; |
| localStorage.removeItem('commandHistory'); |
| this.setStatus('Command history cleared', 'info'); |
| } |
| |
| isNearBottom(container) { |
| return container.scrollHeight - container.scrollTop <= container.clientHeight + uiNearTheBottomThreshold; |
| } |
| |
| scrollToBottom(container) { |
| requestAnimationFrame(() => { |
| container.scrollTop = container.scrollHeight; |
| }); |
| } |
| |
| addReceivedDataEntryUI(receivedDataEntry) { |
| 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 = ` |
| <span class="received-data-entry-time">${escapeHtml(new Date(entry.time).toLocaleString())}</span> |
| <span class="received-data-entry-text">${escapeHtml(entry.text)}</span> |
| `; |
| documentFragment.appendChild(receivedDataEntryBtn); |
| } |
| uiReceivedDataScrollbox.appendChild(documentFragment); |
| |
| // Limit the received data length |
| while (this.receivedData.length > maxReceivedDataLength) { |
| this.receivedData.shift(); |
| uiReceivedDataScrollbox.removeChild(uiReceivedDataScrollbox.firstElementChild); |
| } |
| } |
| |
| appendNewReceivedData(receivedDataEntry) { |
| const wasNearBottom = this.isNearBottom(uiReceivedDataScrollbox); |
| |
| this.addReceivedDataEntryUI(receivedDataEntry); |
| |
| // Save the received data to localStorage |
| localStorage.setItem('receivedData', JSON.stringify(this.receivedData)); |
| |
| // Scroll to the new entry if near the bottom |
| if (wasNearBottom) { |
| this.scrollToBottom(uiReceivedDataScrollbox); |
| } |
| } |
| |
| clearReceivedData() { |
| this.receivedData = []; |
| uiReceivedDataScrollbox.textContent = ''; |
| localStorage.removeItem('receivedData'); |
| this.setStatus('Received data cleared', 'info'); |
| } |
| |
| setStatus(msg, level = 'info') { |
| console.error(msg); |
| uiStatusSpan.textContent = msg; |
| uiStatusSpan.className = 'status status-' + level; |
| } |
| |
| /// force_connected is used to instantly change the UI to the connected state while the device is still connecting |
| /// Otherwise we would have to wait for the connection to be established. |
| /// This can take until the device sends the first data packet. |
| updateUIConnectionState(force_connected = false) { |
| if (force_connected || (this.currentPort && this.currentPort.isConnected)) { |
| uiConnectWebUsbSerialBtn.style.display = 'none'; |
| uiConnectSerialBtn.style.display = 'none'; |
| uiDisconnectBtn.style.display = 'block'; |
| uiCommandLineInput.disabled = false; |
| |
| if (this.currentPort instanceof SerialPort) { |
| uiDisconnectBtn.textContent = 'Disconnect from WebSerial'; |
| } else if (this.currentPort instanceof WebUsbSerialPort) { |
| uiDisconnectBtn.textContent = 'Disconnect from WebUSB'; |
| } else { |
| uiDisconnectBtn.textContent = 'Disconnect'; |
| } |
| } 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.appendNewReceivedData(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.updateUIConnectionState(true); |
| 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; |
| } |
| } finally { |
| this.updateUIConnectionState(); |
| } |
| } |
| |
| 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 { |
| this.updateUIConnectionState(true); |
| 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'); |
| uiCommandLineInput.focus(); |
| } catch (error) { |
| if (first_time_connection) { |
| // Forget the device if a first time connection fails |
| await this.currentPort.forgetDevice(); |
| this.currentPort = null; |
| } |
| throw error; |
| } finally { |
| this.updateUIConnectionState(); |
| } |
| |
| 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'); |
| } finally { |
| this.updateUIConnectionState(); |
| } |
| } |
| 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'); |
| } |
| } |
| |
| async autoReconnectTimeout() { |
| this.reconnectTimeoutId = null; |
| if (!uiAutoReconnectCheckbox.checked) { |
| this.setStatus('Auto-reconnect stopped.', 'info'); |
| return; |
| } |
| if (this.currentPort && !this.currentPort.isConnected) { |
| try { |
| await this.currentPort.connect(); |
| this.setStatus('Reconnected successfully', 'info'); |
| } catch (error) { |
| this.setStatus(`Reconnect failed: ${error.message}`, 'error'); |
| // Try again after a delay |
| this.tryAutoReconnect(); |
| } finally { |
| this.updateUIConnectionState(); |
| } |
| } |
| } |
| |
| 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 () => { |
| await this.autoReconnectTimeout(); |
| }, 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.appendNewCommandToHistory(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 <iso_date_time>,<received_line> |
| let csvContent = 'data:text/csv;charset=utf-8,'; |
| for (const entry of this.receivedData) { |
| let sanitizedText = entry.text.replace(/"/g, '""').replace(/[\r\n]+$/, ''); |
| let line = new Date(entry.time).toISOString() + ',"' + sanitizedText + '"'; |
| csvContent += line + '\n'; |
| } |
| |
| const encodedUri = encodeURI(csvContent); |
| const link = document.createElement('a'); |
| link.setAttribute('href', encodedUri); |
| const filename = new Date().toISOString().replace(/:/g, '-') + '_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 |
| localStorage.clear(); |
| |
| // reload the page |
| window.location.reload(); |
| } |
| } |
| |
| const app = new Application(); |
| })() |