summaryrefslogtreecommitdiff
path: root/examples/device
diff options
context:
space:
mode:
authorHa Thach <[email protected]>2025-08-02 22:34:33 +0700
committerGitHub <[email protected]>2025-08-02 22:34:33 +0700
commit87d9e05efa951d6e1b1c63cbb7f4261be96741e1 (patch)
treee10d031cc364acc256cf9bc2320380c617025e3b /examples/device
parent8f46021eba3a93dd95e8272ab0ac223fdfe6c881 (diff)
parent9c1115e066f70ca72895bfd9bc4b06a113d32651 (diff)
Merge pull request #3091 from raldone01/fix/web_serial
Rewrite of the web_serial example website.
Diffstat (limited to 'examples/device')
-rw-r--r--examples/device/webusb_serial/src/CMakeLists.txt4
-rw-r--r--examples/device/webusb_serial/website/application.js801
-rw-r--r--examples/device/webusb_serial/website/divider.js47
-rw-r--r--examples/device/webusb_serial/website/index.html79
-rw-r--r--examples/device/webusb_serial/website/serial.js306
-rw-r--r--examples/device/webusb_serial/website/style.css296
6 files changed, 1533 insertions, 0 deletions
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("&", "&amp;")
+ .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();
+})()
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: <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 });
+ });
+
+})();
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 @@
+<!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>
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;
+}