Files
tinyUSB/examples/device/webusb_serial/website/application.js
raldone01 d3f7dff180 Major overhaul and logic cleanup.
Adds support for web serial as well.
2025-07-05 19:42:44 +02:00

513 lines
17 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 connectWebUsbSerialBtn = document.getElementById('connect_webusb_serial_btn');
const connectSerialBtn = document.getElementById('connect_serial_btn');
const disconnectBtn = document.getElementById('disconnect_btn');
const newlineModeSelect = document.getElementById('newline_mode_select');
const autoReconnectCheckbox = document.getElementById('auto_reconnect_checkbox');
const forgetDeviceBtn = document.getElementById('forget_device_btn');
const forgetAllDevicesBtn = document.getElementById('forget_all_devices_btn');
const resetAllBtn = document.getElementById('reset_all_btn');
const resetOutputBtn = document.getElementById('reset_output_btn');
const copyOutputBtn = document.getElementById('copy_output_btn');
const statusSpan = document.getElementById('status_span');
const commandHistoryScrollbox = document.getElementById('command_history_scrollbox');
const commandLineInput = document.getElementById('command_line_input');
const sendModeBtn = document.getElementById('send_mode_btn');
const receivedDataScrollbox = document.getElementById('received_data_scrollbox');
const nearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll
class Application {
constructor() {
this.currentPort = null;
this.textEncoder = new TextEncoder();
this.textDecoder = new TextDecoder();
this.reconnectTimeoutId = null;
this.commandHistory = [];
this.commandHistoryIndex = -1;
this.lastCommandCount = 0;
this.lastCommand = null;
this.lastCommandBtn = null;
// bind the UI elements
connectWebUsbSerialBtn.addEventListener('click', () => this.connectWebUsbSerialPort());
connectSerialBtn.addEventListener('click', () => this.connectSerialPort());
disconnectBtn.addEventListener('click', () => this.disconnectPort());
newlineModeSelect.addEventListener('change', () => this.setNewlineMode());
autoReconnectCheckbox.addEventListener('change', () => this.autoReconnectChanged());
forgetDeviceBtn.addEventListener('click', () => this.forgetPort());
forgetAllDevicesBtn.addEventListener('click', () => this.forgetAllPorts());
resetAllBtn.addEventListener('click', () => this.resetAll());
resetOutputBtn.addEventListener('click', () => this.resetOutput());
copyOutputBtn.addEventListener('click', () => this.copyOutput());
commandLineInput.addEventListener('keydown', (e) => this.handleCommandLineInput(e));
sendModeBtn.addEventListener('click', () => this.toggleSendMode());
// restore state from localStorage
// Restore command history
let savedCommandHistory = JSON.parse(localStorage.getItem('commandHistory') || '[]');
for (const cmd of savedCommandHistory) {
this.appendCommandToHistory(cmd);
}
this.sendMode = localStorage.getItem('sendMode') || 'command';
this.setSendMode(this.sendMode);
autoReconnectCheckbox.checked = localStorage.getItem('autoReconnect') === 'true';
let savedNewlineMode = localStorage.getItem('newlineMode');
if (savedNewlineMode) {
newlineModeSelect.value = savedNewlineMode;
}
this.connectWebUsbSerialPort(true);
}
appendCommandToHistory(text) {
if (text === this.lastCommand) {
// Increment count and update button
this.lastCommandCount++;
this.lastCommandBtn.textContent = `${text} ×${this.lastCommandCount}`;
} else {
// Add a new entry to the command history
this.commandHistory.push(text);
localStorage.setItem('commandHistory', JSON.stringify(this.commandHistory));
this.commandHistoryIndex = -1;
const commandHistoryEntryBtn = document.createElement('button');
commandHistoryEntryBtn.className = 'command-history-entry';
commandHistoryEntryBtn.type = 'button';
commandHistoryEntryBtn.textContent = text;
commandHistoryEntryBtn.addEventListener('click', () => {
if (commandLineInput.disabled) return;
commandLineInput.value = text;
commandLineInput.focus();
});
commandHistoryScrollbox.appendChild(commandHistoryEntryBtn);
this.lastCommand = text;
this.lastCommandBtn = commandHistoryEntryBtn;
// Scroll to the new entry if near the bottom
const distanceFromBottom = commandHistoryScrollbox.scrollHeight - (commandHistoryScrollbox.scrollTop + commandHistoryScrollbox.clientHeight);
if (distanceFromBottom < nearTheBottomThreshold) {
requestAnimationFrame(() => {
commandHistoryEntryBtn.scrollIntoView({ behavior: 'instant' });
});
}
}
}
appendLineToReceived(text) {
const div = document.createElement('div');
div.textContent = text;
receivedDataScrollbox.appendChild(div);
// Scroll to the new entry if near the bottom
const distanceFromBottom = receivedDataScrollbox.scrollHeight - (receivedDataScrollbox.scrollTop + receivedDataScrollbox.clientHeight);
if (distanceFromBottom < nearTheBottomThreshold) {
requestAnimationFrame(() => {
div.scrollIntoView({ behavior: 'instant' });
});
}
}
setStatus(msg, level = 'info') {
console.log(msg);
statusSpan.textContent = msg;
statusSpan.className = 'status status-' + level;
}
updateUIConnectionState() {
if (this.currentPort && this.currentPort.isConnected) {
connectWebUsbSerialBtn.style.display = 'none';
connectSerialBtn.style.display = 'none';
disconnectBtn.style.display = 'block';
commandLineInput.disabled = false;
commandLineInput.focus();
} else {
if (serial.isWebUsbSupported()) {
connectWebUsbSerialBtn.style.display = 'block';
}
if (serial.isWebSerialSupported()) {
connectSerialBtn.style.display = 'block';
}
if (!serial.isWebUsbSupported() && !serial.isWebSerialSupported()) {
this.setStatus('Your browser does not support WebUSB or WebSerial', 'error');
}
disconnectBtn.style.display = 'none';
commandLineInput.disabled = true;
commandLineInput.value = '';
commandLineInput.blur();
}
}
async disconnectPort() {
this.stopAutoReconnect();
if (!this.currentPort) return;
try {
await this.currentPort.disconnect();
}
catch (error) {
this.setStatus(`Disconnect error: ${error.message}`, 'error');
}
this.updateUIConnectionState();
}
async onReceive(dataView) {
this.updateUIConnectionState();
let text = this.textDecoder.decode(dataView);
text = this.normalizeNewlines(text);
this.appendLineToReceived(text);
}
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 (!autoReconnectCheckbox.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', newlineModeSelect.value);
}
autoReconnectChanged() {
if (autoReconnectCheckbox.checked) {
this.setStatus('Auto-reconnect enabled', 'info');
this.tryAutoReconnect();
} else {
this.setStatus('Auto-reconnect disabled', 'info');
this.stopAutoReconnect();
}
localStorage.setItem('autoReconnect', autoReconnectCheckbox.checked);
}
stopAutoReconnect() {
if (this.reconnectTimeoutId !== null) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = null;
this.setStatus('Auto-reconnect stopped.', 'info');
}
}
tryAutoReconnect() {
this.updateUIConnectionState();
if (!autoReconnectCheckbox.checked) return;
if (this.reconnectTimeoutId !== null) return; // already trying
this.setStatus('Attempting to auto-reconnect...', 'info');
this.reconnectTimeoutId = setTimeout(async () => {
this.reconnectTimeoutId = null;
if (!autoReconnectCheckbox.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 (newlineModeSelect.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 port.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.commandHistoryIndex === -1) this.commandHistoryIndex = this.commandHistory.length - 1;
else if (this.commandHistoryIndex > 0) this.commandHistoryIndex--;
} else if (e.key === 'ArrowDown') {
if (this.commandHistoryIndex !== -1) this.commandHistoryIndex++;
if (this.commandHistoryIndex >= this.commandHistory.length) this.commandHistoryIndex = -1;
}
commandLineInput.value = this.commandHistoryIndex === -1 ? '' : this.commandHistory[this.commandHistoryIndex];
return;
}
if (e.key !== 'Enter' || !this.currentPort.isConnected) return;
e.preventDefault();
const text = commandLineInput.value;
if (!text) return;
// Convert to Uint8Array with newline based on config
let sendText = text;
switch (newlineModeSelect.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.commandHistoryIndex = -1;
this.appendCommandToHistory(sendText.replace(/[\r\n]+$/, ''));
commandLineInput.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') {
sendModeBtn.classList.remove('send-mode-command');
sendModeBtn.classList.add('send-mode-instant');
sendModeBtn.textContent = 'Instant mode';
} else {
sendModeBtn.classList.remove('send-mode-instant');
sendModeBtn.classList.add('send-mode-command');
sendModeBtn.textContent = 'Command mode';
}
localStorage.setItem('sendMode', this.sendMode);
}
normalizeNewlines(text) {
switch (newlineModeSelect.value) {
case 'CR':
return text.replace(/\r?\n/g, '\r');
case 'CRLF':
return text.replace(/\r\n|[\r\n]/g, '\r\n');
case 'ANY':
return text.replace(/\r\n|\r/g, '\n');
default:
return text;
}
}
copyOutput() {
const text = receivedDataScrollbox.innerText;
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');
}
}
resetOutput() {
receivedDataScrollbox.innerHTML = '';
}
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();
})()