Files
tinyUSB/examples/device/webusb_serial/website/application.js
2025-07-05 19:42:44 +02:00

732 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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