802 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			802 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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();
 | ||
| })()
 | 
