Merge pull request #3091 from raldone01/fix/web_serial

Rewrite of the web_serial example website.
This commit is contained in:
Ha Thach
2025-08-02 22:34:33 +07:00
committed by GitHub
6 changed files with 1533 additions and 0 deletions

View File

@@ -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)

View File

@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
};
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();
})()

View File

@@ -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: <ratio> 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 });
});
})();

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TinyUSB WebUSB Serial</title>
<link rel="stylesheet" href="style.css" />
<script defer src="serial.js"></script>
<script defer src="application.js"></script>
<script defer src="divider.js"></script>
</head>
<body>
<header class="header">
<h1 class="app-title">TinyUSB - WebUSB Serial</h1>
<button id="theme-toggle" class="btn btn-theme">Theme: Auto</button>
<a class="github-link" href="https://github.com/hathach/tinyusb/tree/master/examples/device/webusb_serial/website"
target="_blank">
Find my source on GitHub
</a>
</header>
<main>
<section class="controls-section">
<button id="connect_webusb_serial_btn" class="controls btn good">Connect WebUSB</button>
<button id="connect_serial_btn" class="controls btn good">Connect Serial</button>
<button id="disconnect_btn" class="controls btn danger">Disconnect</button>
<label for="newline_mode_select" class="controls">
Command newline mode:
<select id="newline_mode_select">
<option value="CR">Only \r</option>
<option value="CRLF">\r\n</option>
<option value="ANY" selected>\r, \n or \r\n</option>
</select>
</label>
<label for="auto_reconnect_checkbox" class="controls">
<input type="checkbox" id="auto_reconnect_checkbox" />
Auto Reconnect WebUSB
</label>
<button id="forget_device_btn" class="controls btn danger">Forget Device</button>
<button id="forget_all_devices_btn" class="controls btn danger">Forget All Devices</button>
<button id="reset_all_btn" class="controls btn danger">Reset All</button>
<button id="copy_output_btn" class="controls btn good">Copy Output</button>
<button id="download_csv_output_btn" class="controls btn good">Download CSV</button>
</section>
<section class="status-section">
<span id="status_span" class="status">
Click "Connect" to start
</span>
</section>
<div class="io-container">
<section class="column">
<div class="heading-with-controls">
<h2>Command History</h2>
<button id="clear_command_history_btn" class="controls btn danger">Clear History</button>
</div>
<div class="scrollbox-wrapper">
<div id="command_history_scrollbox" class="scrollbox monospaced"></div>
</div>
<div class="send-container">
<input id="command_line_input" class="input" placeholder="Start typing..." autocomplete="off" disabled />
<button id="send_mode_btn" class="btn send-mode-command">Command Mode</button>
</div>
</section>
<div class="resizer" id="resizer"></div>
<section class="column">
<div class="heading-with-controls">
<h2>Received Data</h2>
<button id="clear_received_data_btn" class="controls btn danger">Clear Received</button>
</div>
<div class="scrollbox-wrapper">
<div id="received_data_scrollbox" class="scrollbox monospaced"></div>
</div>
</section>
</div>
</main>
</body>
</html>

View File

@@ -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);
}
};

View File

@@ -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;
}