diff --git a/examples/device/webusb_serial/src/CMakeLists.txt b/examples/device/webusb_serial/src/CMakeLists.txt new file mode 100644 index 000000000..cef2b46ee --- /dev/null +++ b/examples/device/webusb_serial/src/CMakeLists.txt @@ -0,0 +1,4 @@ +# This file is for ESP-IDF only +idf_component_register(SRCS "main.c" "usb_descriptors.c" + INCLUDE_DIRS "." + REQUIRES boards tinyusb_src) diff --git a/examples/device/webusb_serial/website/application.js b/examples/device/webusb_serial/website/application.js new file mode 100644 index 000000000..376faec44 --- /dev/null +++ b/examples/device/webusb_serial/website/application.js @@ -0,0 +1,801 @@ +'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 = ` + ${escapeHtml(time_str)} + ${escapeHtml(commandHistoryEntry.text)} + ×${escapeHtml(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); + } + } + + 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 = ` + ${escapeHtml(new Date(entry.time).toLocaleString())} + ${escapeHtml(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); + } + } + + 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 , + 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(); +})() diff --git a/examples/device/webusb_serial/website/divider.js b/examples/device/webusb_serial/website/divider.js new file mode 100644 index 000000000..b67d0e429 --- /dev/null +++ b/examples/device/webusb_serial/website/divider.js @@ -0,0 +1,47 @@ +(async () => { + + const uiResizer = document.getElementById('resizer'); + const uiLeftColumn = uiResizer.previousElementSibling; + const uiRightColumn = uiResizer.nextElementSibling; + const uiParent = uiResizer.parentElement; + + let isResizing = false; + let abortSignal = null; + + function onMouseMove(e) { + // we resize the columns by applying felx: to the columns + + // compute the percentage the mouse is in the parent + const percentage = (e.clientX - uiParent.offsetLeft) / uiParent.clientWidth; + // clamp the percentage between 0.1 and 0.9 + const clampedPercentage = Math.max(0.1, Math.min(0.9, percentage)); + // set the flex property of the columns + uiLeftColumn.style.flex = `${clampedPercentage}`; + uiRightColumn.style.flex = `${1 - clampedPercentage}`; + } + + function onMouseUp(e) { + // restore user selection + document.body.style.userSelect = ''; + + // remove the mousemove and mouseup events + if (abortSignal) { + abortSignal.abort(); + abortSignal = null; + } + } + + uiResizer.addEventListener('mousedown', e => { + e.preventDefault(); + isResizing = true; + + // prevent text selection + document.body.style.userSelect = 'none'; + + // register the mousemove and mouseup events + abortSignal = new AbortController(); + document.addEventListener('mousemove', onMouseMove, { signal: abortSignal.signal }); + document.addEventListener('mouseup', onMouseUp, { signal: abortSignal.signal }); + }); + +})(); diff --git a/examples/device/webusb_serial/website/index.html b/examples/device/webusb_serial/website/index.html new file mode 100644 index 000000000..60002ff0a --- /dev/null +++ b/examples/device/webusb_serial/website/index.html @@ -0,0 +1,79 @@ + + + + + + + TinyUSB WebUSB Serial + + + + + + + +
+

TinyUSB - WebUSB Serial

+ + + Find my source on GitHub + +
+
+
+ + + + + + + + + + +
+
+ + Click "Connect" to start + +
+
+
+
+

Command History

+ +
+
+
+
+
+ + +
+
+
+
+
+

Received Data

+ +
+
+
+
+
+
+
+ + + diff --git a/examples/device/webusb_serial/website/serial.js b/examples/device/webusb_serial/website/serial.js new file mode 100644 index 000000000..19827f016 --- /dev/null +++ b/examples/device/webusb_serial/website/serial.js @@ -0,0 +1,306 @@ +'use strict'; + +/// Web Serial API Implementation +/// https://developer.mozilla.org/en-US/docs/Web/API/SerialPort +class SerialPort { + constructor(port) { + this._port = port; + this._readLoopPromise = null; + this._reader = null; + this._writer = null; + this._initialized = false; + this._keepReading = true; + this.isConnected = false; + } + + /// Connect and start reading loop + async connect(options = { baudRate: 9600 }) { + if (this._initialized) { + try { + await this.disconnect(); + } catch (error) { + console.error('Error disconnecting previous port:', error); + } + + if (this._readLoopPromise) { + try { + await this._readLoopPromise; + } catch (error) { + console.error('Error in read loop:', error); + } + } + this._readLoopPromise = null; + } + this._initialized = true; + + this.isConnected = true; + this._keepReading = true; + + try { + await this._port.open(options); + } catch (error) { + this.isConnected = false; + throw error; + } + + this._readLoopPromise = this._readLoop(); + } + + /// Internal continuous read loop + async _readLoop() { + try { + while (this._port.readable && this._keepReading) { + this._reader = this._port.readable.getReader(); + try { + while (true) { + const { value, done } = await this._reader.read(); + if (done) { + // |reader| has been canceled. + break; + } + if (this.onReceive) { + this.onReceive(value); + } + } + } catch (error) { + if (this.onReceiveError) this.onReceiveError(error); + } finally { + this._reader.releaseLock(); + } + } + } finally { + this.isConnected = false; + await this._port.close(); + } + } + + /// Stop reading and release port + async disconnect() { + this._keepReading = false; + + if (this._reader) { + try { + await this._reader.cancel(); + } catch (error) { + console.error('Error cancelling reader:', error); + } + this._reader.releaseLock(); + } + + if (this._writer) { + try { + await this._writer.abort(); + } catch (error) { + console.error('Error closing writer:', error); + } + this._writer.releaseLock(); + } + + try { + await this._port.close(); + } catch (error) { + console.error('Error closing port:', error); + } + + if (this._readLoopPromise) { + try { + await this._readLoopPromise; + } catch (error) { + console.error('Error in read loop:', error); + } + } + } + + /// Send data to port + send(data) { + if (!this._port.writable) { + throw new Error('Port is not writable'); + } + this._writer = this._port.writable.getWriter(); + if (!this._writer) { + throw new Error('Failed to get writer from port'); + } + try { + return this._writer.write(data); + } finally { + this._writer.releaseLock(); + } + } + + async forgetDevice() {} +} + +/// WebUSB Implementation +class WebUsbSerialPort { + constructor(device) { + this._device = device; + this._interfaceNumber = 0; + this._endpointIn = 0; + this._endpointOut = 0; + this.isConnected = false; + this._readLoopPromise = null; + this._initialized = false; + this._keepReading = true; + + this._vendorId = device.vendorId; + this._productId = device.productId; + } + + _isSameWebUsbSerialPort(webUsbSerialPort) { + return this._vendorId === webUsbSerialPort._vendorId && this._productId === webUsbSerialPort._productId; + } + + /// Connect and start reading loop + async connect() { + if (this._initialized) { + try { + await this.disconnect(); + } catch (error) { + console.error('Error disconnecting previous device:', error); + } + + const webUsbSerialPorts = await serial.getWebUsbSerialPorts(); + const webUsbSerialPort = webUsbSerialPorts.find(serialPort => this._isSameWebUsbSerialPort(serialPort)); + this._device = webUsbSerialPort ? webUsbSerialPort._device : this._device; + } + this._initialized = true; + + this.isConnected = true; + this._keepReading = true; + try { + await this._device.open(); + + if (!this._device.configuration) { + await this._device.selectConfiguration(1); + } + + // Find interface with vendor-specific class (0xFF) and endpoints + for (const iface of this._device.configuration.interfaces) { + for (const alternate of iface.alternates) { + if (alternate.interfaceClass === 0xff) { + this._interfaceNumber = iface.interfaceNumber; + for (const endpoint of alternate.endpoints) { + if (endpoint.direction === 'out') this._endpointOut = endpoint.endpointNumber; + else if (endpoint.direction === 'in') this._endpointIn = endpoint.endpointNumber; + } + } + } + } + + if (this._interfaceNumber === undefined) { + throw new Error('No suitable interface found.'); + } + + await this._device.claimInterface(this._interfaceNumber); + await this._device.selectAlternateInterface(this._interfaceNumber, 0); + + // Set device to ENABLE (0x22 = SET_CONTROL_LINE_STATE, value 0x01 = activate) + await this._device.controlTransferOut({ + requestType: 'class', + recipient: 'interface', + request: 0x22, + value: 0x01, + index: this._interfaceNumber, + }); + } catch (error) { + this.isConnected = false; + throw error; + } + + this._readLoopPromise = this._readLoop(); + } + + /// Internal continuous read loop + async _readLoop() { + try { + while (this._keepReading && this.isConnected) { + try { + const result = await this._device.transferIn(this._endpointIn, 16384); + if (result.data && this.onReceive) { + this.onReceive(result.data); + } + } catch (error) { + this.isConnected = false; + if (this.onReceiveError) { + this.onReceiveError(error); + } + } + } + } finally { + this.isConnected = false; + await this._device.close(); + } + } + + /// Stop reading and release device + async disconnect() { + this._keepReading = false; + + try { + await this._device.controlTransferOut({ + requestType: 'class', + recipient: 'interface', + request: 0x22, + value: 0x00, + index: this._interfaceNumber, + }); + } catch (error) { + console.error('Error sending control transfer:', error); + } + + await this._device.releaseInterface(this._interfaceNumber); + + if (this._readLoopPromise) { + try { + await this._readLoopPromise; + } catch (error) { + console.error('Error in read loop:', error); + } + } + } + + /// Send data to device + send(data) { + return this._device.transferOut(this._endpointOut, data); + } + + async forgetDevice() { + await this.disconnect(); + await this._device.forget(); + } +} + +// Utility Functions +const serial = { + isWebSerialSupported: () => 'serial' in navigator, + isWebUsbSupported: () => 'usb' in navigator, + + async getSerialPorts() { + if (!this.isWebSerialSupported()) return []; + const ports = await navigator.serial.getPorts(); + return ports.map(port => new SerialPort(port)); + }, + + async getWebUsbSerialPorts() { + if (!this.isWebUsbSupported()) return []; + const devices = await navigator.usb.getDevices(); + return devices.map(device => new WebUsbSerialPort(device)); + }, + + async requestSerialPort() { + const port = await navigator.serial.requestPort(); + return new SerialPort(port); + }, + + async requestWebUsbSerialPort() { + const filters = [ + { vendorId: 0xcafe }, // TinyUSB + { vendorId: 0x239a }, // Adafruit + { vendorId: 0x2e8a }, // Raspberry Pi + { vendorId: 0x303a }, // Espressif + { vendorId: 0x2341 }, // Arduino + ]; + const device = await navigator.usb.requestDevice({ filters }); + return new WebUsbSerialPort(device); + } +}; diff --git a/examples/device/webusb_serial/website/style.css b/examples/device/webusb_serial/website/style.css new file mode 100644 index 000000000..7b8b6029d --- /dev/null +++ b/examples/device/webusb_serial/website/style.css @@ -0,0 +1,296 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Reset default margins and make html, body full height */ +html, +body { + height: 100%; + font-family: sans-serif; + background: #f5f5f5; + color: #333; +} + +body { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header row styling */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 1em; + gap: 1em; + flex-shrink: 0; +} + +h1, +h2 { + margin: 0; +} + +.app-title { + flex-grow: 1; +} + +.btn-theme { + background-color: #6b6b6b; + color: #fff; +} + +.github-link { + font-weight: 600; +} + +/* Main is flex column */ +main { + display: flex; + flex-direction: column; + flex: 1; + width: 100%; +} + +/* Controls top row in main*/ +.controls-section, +.status-section { + padding: 1rem; + flex-shrink: 0; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Container for the two columns */ +.io-container { + display: flex; + flex: 1; + /* fill remaining vertical space */ + width: 100%; + overflow: hidden; +} + +/* Both columns flex equally and full height */ +.column { + flex: 1; + padding: 1rem; + display: flex; + flex-direction: column; +} + +.heading-with-controls { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} + +.command-history-entry { + all: unset; + display: flex; + flex-direction: row; + gap: 0.5rem; + background: none; + border: none; + border-bottom: 1px solid #ccc; + /* light gray line */ + padding: 0.5rem 1rem; + margin: 0; + text-align: left; + cursor: pointer; +} + +.command-history-entry:hover { + background-color: #f0f0f0; +} + +.monospaced { + font-family: 'Courier New', Courier, monospace; + font-size: 1rem; + color: #333; +} + +.scrollbox-wrapper { + position: relative; + padding: 0.5rem; + flex: 1; + + display: block; + overflow: hidden; +} + +.scrollbox { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; + overflow-x: auto; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + background-color: #fff; + border-radius: 0.5rem; + white-space: nowrap; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.send-container { + display: flex; + flex-direction: row; + gap: 0.5rem; +} + +.send-mode-command { + background-color: lightgray; + /* light-gray */ +} + +.send-mode-instant { + background-color: blue; +} + +.btn { + padding: 0.5rem 1rem; + font-size: 1rem; + border: none; + border-radius: 0.3rem; + cursor: pointer; +} + +.good { + background-color: #2ecc71; + /* green */ + color: #fff; +} + +.danger { + background-color: #e74c3c; + /* red */ + color: #fff; +} + +.input { + width: 100%; + padding: 12px 16px; + font-size: 1rem; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + border: 2px solid #ddd; + border-radius: 8px; + background-color: #fafafa; + color: #333; + transition: border-color 0.3s ease, box-shadow 0.3s ease; + outline: none; + box-sizing: border-box; +} + +.input::placeholder { + color: #aaa; + font-style: italic; +} + +.input:focus { + border-color: #0078d7; + box-shadow: 0 0 6px rgba(0, 120, 215, 0.5); + background-color: #fff; +} + +.resizer { + width: 5px; + background-color: #ccc; + cursor: col-resize; + height: 100%; +} + +/* +================================ +Togglable Dark Mode +================================ +*/ +/* This class will be added to the body element by JavaScript */ +body.dark-mode { + /* Invert base background and text colors */ + background: #1e1e1e; + color: #d4d4d4; +} + +body.dark-mode input[type="checkbox"] { + border-color: #888; + accent-color: #2e2e2e; + opacity: 0.8; +} + +body.dark-mode .btn-theme { + background-color: #b0b0b0; + color: #000; +} + +body.dark-mode .github-link { + color: #58a6ff; +} + +body.dark-mode .resizer { + background-color: #444; +} + +body.dark-mode .input { + background-color: #3c3c3c; + color: #f0f0f0; + border: 2px solid #555; +} + +body.dark-mode .input::placeholder { + color: #888; +} + +body.dark-mode .input:focus { + background-color: #2a2d2e; + border-color: #0078d7; +} + +body.dark-mode .scrollbox { + background-color: #252526; + scrollbar-color: #555 #2e2e2e; + border: 1px solid #444; +} + +body.dark-mode .monospaced { + color: #d4d4d4; +} + +body.dark-mode .command-history-entry { + border-bottom: 1px solid #444; +} + +body.dark-mode .command-history-entry:hover { + background-color: #3c3c3c; +} + +body.dark-mode .send-mode-command { + background-color: #555; + color: #f5f5f5; +} + +body.dark-mode select { + background-color: #3c3c3c; + color: #f0f0f0; + border: 2px solid #555; +} + +body.dark-mode select:focus { + background-color: #2a2d2e; + border-color: #0078d7; + outline: none; +} + +body.dark-mode option { + background-color: #3c3c3c; + color: #f0f0f0; +}