From ff18dbd2382d0e24e10cbf4e3f5d7a4feb9d3ce7 Mon Sep 17 00:00:00 2001 From: raldone01 Date: Sat, 5 Jul 2025 19:42:44 +0200 Subject: [PATCH 01/16] Rewrite of the web_serial example website. Fixes: #2632 --- .../webusb_serial/website/application.js | 431 ++++++++++++++++++ .../device/webusb_serial/website/index.html | 67 +++ .../device/webusb_serial/website/serial.js | 124 +++++ .../device/webusb_serial/website/style.css | 188 ++++++++ 4 files changed, 810 insertions(+) create mode 100644 examples/device/webusb_serial/website/application.js create mode 100644 examples/device/webusb_serial/website/index.html create mode 100644 examples/device/webusb_serial/website/serial.js create mode 100644 examples/device/webusb_serial/website/style.css diff --git a/examples/device/webusb_serial/website/application.js b/examples/device/webusb_serial/website/application.js new file mode 100644 index 000000000..a94a96714 --- /dev/null +++ b/examples/device/webusb_serial/website/application.js @@ -0,0 +1,431 @@ +'use strict'; + +(() => { + const connectBtn = document.getElementById('connect'); + const resetAllBtn = document.getElementById('reset_all'); + const resetOutputBtn = document.getElementById('reset_output'); + const senderLines = document.getElementById('sender_lines'); + const receiverLines = document.getElementById('receiver_lines'); + const commandLine = document.getElementById('command_line'); + const status = document.getElementById('status'); + const newlineModeSelect = document.getElementById('newline_mode'); + const sendModeBtn = document.getElementById('send_mode'); + const autoReconnectCheckbox = document.getElementById('auto_reconnect'); + const forgetDeviceBtn = document.getElementById('forget_device'); + const forgetAllDevicesBtn = document.getElementById('forget_all_devices'); + + const nearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll + + let port = null; + let lastPort = null; // for reconnecting and initial connection + + const history = []; + let historyIndex = -1; + let lastCommand = null; + let lastCommandCount = 0; + let lastCommandButton = null; + + let sendMode = 'command'; // default mode + + let reconnectIntervalId = null; // track reconnect interval + + // Format incoming data to string based on newline mode + const decodeData = (() => { + const decoder = new TextDecoder(); + return dataView => decoder.decode(dataView); + })(); + + // Normalize newline if mode is ANY + const normalizeNewlines = (text, mode) => { + switch (mode) { + case 'CR': + // Only \r: Replace all \n with \r + return text.replace(/\r?\n/g, '\r'); + case 'CRLF': + // Replace lone \r or \n with \r\n + return text.replace(/\r\n|[\r\n]/g, '\r\n'); + case 'ANY': + // Accept any \r, \n, \r\n. Normalize as \n for display + return text.replace(/\r\n|\r/g, '\n'); + default: + return text; + } + }; + + // Append line to container, optionally scroll to bottom + const appendLineToReceiver = (container, text, className = '') => { + const div = document.createElement('div'); + if (className) div.className = className; + div.textContent = text; + container.appendChild(div); + + const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight); + if (distanceFromBottom < nearTheBottomThreshold) { + requestAnimationFrame(() => { + div.scrollIntoView({ behavior: "instant" }); + }); + } + }; + + // Append sent command to sender container as a clickable element + const appendCommandToSender = (container, text) => { + if (text === lastCommand) { + // Increment count and update button + lastCommandCount++; + lastCommandButton.textContent = `${text} ×${lastCommandCount}`; + } else { + // Reset count and add new button + lastCommand = text; + lastCommandCount = 1; + + const commandEl = document.createElement('button'); + commandEl.className = 'sender-entry'; + commandEl.type = 'button'; + commandEl.textContent = text; + commandEl.addEventListener('click', () => { + commandLine.value = text; + commandLine.focus(); + }); + container.appendChild(commandEl); + lastCommandButton = commandEl; + + const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight); + if (distanceFromBottom < nearTheBottomThreshold) { + requestAnimationFrame(() => { + commandEl.scrollIntoView({ behavior: 'instant' }); + }); + } + } + }; + + // Update status text and style + const setStatus = (msg, level = 'info') => { + console.log(msg); + status.textContent = msg; + status.className = 'status status-' + level; + }; + + // Disconnect helper + const disconnectPort = async () => { + if (port) { + try { + await port.disconnect(); + } catch (error) { + setStatus(`Disconnect error: ${error.message}`, 'error'); + } + port = null; + connectBtn.textContent = 'Connect'; + commandLine.disabled = true; + } + }; + + // Connect helper + const connectPort = async (initial=false) => { + try { + let grantedDevices = await serial.getPorts(); + if (grantedDevices.length === 0 && initial) { + return false; + } + if (grantedDevices.length === 0) { + // No previously granted devices, request a new one + setStatus('Requesting device...', 'info'); + port = await serial.requestPort(); + } else { + if (lastPort) { + // Try to reconnect to the last used port + const matchingPort = grantedDevices.find(p => p.portPointToSameDevice(lastPort)); + if (matchingPort) { + port = matchingPort; + setStatus('Reconnecting to last device...', 'info'); + } else { + return false; + } + } else { + // No last port, just use the first available + port = grantedDevices[0]; + setStatus('Connecting to first device...', 'info'); + } + } + + await port.connect(); + lastPort = port; // save for reconnecting + + setStatus(`Connected to ${port.device.productName || 'device'}`, 'info'); + connectBtn.textContent = 'Disconnect'; + commandLine.disabled = false; + commandLine.focus(); + + port.onReceive = dataView => { + let text = decodeData(dataView); + text = normalizeNewlines(text, newlineModeSelect.value); + appendLineToReceiver(receiverLines, text, 'received'); + }; + + port.onReceiveError = error => { + setStatus(`Read error: ${error.message}`, 'error'); + // Start auto reconnect on error if enabled + tryAutoReconnect(); + }; + return true; + } catch (error) { + setStatus(`Connection failed: ${error.message}`, 'error'); + port = null; + connectBtn.textContent = 'Connect'; + commandLine.disabled = true; + return false; + } + }; + + // Start auto reconnect interval if checkbox is checked and not already running + const tryAutoReconnect = () => { + if (!autoReconnectCheckbox.checked) return; + if (reconnectIntervalId !== null) return; // already trying + setStatus('Attempting to auto-reconnect...', 'info'); + reconnectIntervalId = setInterval(async () => { + if (!autoReconnectCheckbox.checked) { + clearInterval(reconnectIntervalId); + reconnectIntervalId = null; + setStatus('Auto-reconnect stopped.', 'info'); + return; + } + await disconnectPort(); + const success = await connectPort(); + if (success) { + clearInterval(reconnectIntervalId); + reconnectIntervalId = null; + setStatus('Reconnected successfully.', 'info'); + } + }, 1000); + }; + + // Stop auto reconnect immediately + const stopAutoReconnect = () => { + if (reconnectIntervalId !== null) { + clearInterval(reconnectIntervalId); + reconnectIntervalId = null; + setStatus('Auto-reconnect stopped.', 'info'); + } + }; + + // Connect button click handler + connectBtn.addEventListener('click', async () => { + if (!serial.isWebUsbSupported()) { + setStatus('WebUSB not supported on this browser', 'error'); + return; + } + + if (port) { + // Disconnect + stopAutoReconnect(); + await disconnectPort(); + setStatus('Disconnected', 'info'); + return; + } + + stopAutoReconnect(); + try { + // Connect + const success = await connectPort(); + if (success) { + setStatus('Connected', 'info'); + } + } catch (error) { + setStatus(`Connection failed: ${error.message}`, 'error'); + port = null; + connectBtn.textContent = 'Connect'; + commandLine.disabled = true; + } + }); + + // Checkbox toggle stops auto reconnect if unchecked + autoReconnectCheckbox.addEventListener('change', () => { + if (!autoReconnectCheckbox.checked) { + stopAutoReconnect(); + } else { + // Start auto reconnect immediately if not connected + if (!port) { + tryAutoReconnect(); + } + } + }); + + sendModeBtn.addEventListener('click', () => { + if (sendMode === 'command') { + sendMode = 'instant'; + sendModeBtn.classList.remove('send-mode-command'); + sendModeBtn.classList.add('send-mode-instant'); + sendModeBtn.textContent = 'Instant mode'; + // In instant mode, we clear the command line + commandLine.value = ''; + } else { + sendMode = 'command'; + sendModeBtn.classList.remove('send-mode-instant'); + sendModeBtn.classList.add('send-mode-command'); + sendModeBtn.textContent = 'Command mode'; + } + }); + + // Send command line input on Enter + commandLine.addEventListener('keydown', async e => { + if (!port) return; + + // Instant mode: send key immediately including special keys like Backspace, arrows, enter, etc. + if (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; + } + + const encoder = new TextEncoder(); + try { + await port.send(encoder.encode(sendText)); + } catch (error) { + setStatus(`Send error: ${error.message}`, 'error'); + tryAutoReconnect(); + } + } + + return; + } + + // Command mode: handle up/down arrow keys for history + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + if (history.length === 0) return; + if (e.key === 'ArrowUp') { + if (historyIndex === -1) historyIndex = history.length - 1; + else if (historyIndex > 0) historyIndex--; + } else if (e.key === 'ArrowDown') { + if (historyIndex !== -1) historyIndex++; + if (historyIndex >= history.length) historyIndex = -1; + } + commandLine.value = historyIndex === -1 ? '' : history[historyIndex]; + return; + } + + if (e.key !== 'Enter' || !port) return; + e.preventDefault(); + const text = commandLine.value; + if (!text) return; + + // Add command to history, ignore duplicate consecutive + if (history.length === 0 || history[history.length - 1] !== text) { + history.push(text); + } + historyIndex = -1; + + // 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 encoder = new TextEncoder(); + const data = encoder.encode(sendText); + + try { + await port.send(data); + appendCommandToSender(senderLines, sendText.replace(/[\r\n]+$/, '')); + commandLine.value = ''; + } catch (error) { + setStatus(`Send error: ${error.message}`, 'error'); + tryAutoReconnect(); + } + }); + + // Forget device button clears stored device info + forgetDeviceBtn.addEventListener('click', async () => { + if (port) { + // Disconnect first + await port.disconnect(); + await port.forgetDevice(); + stopAutoReconnect(); + await disconnectPort(); + + setStatus('Device forgotten', 'info'); + } else { + setStatus('No device to forget', 'error'); + } + }); + + // Forget all devices button clears all stored device info + forgetAllDevicesBtn.addEventListener('click', async () => { + stopAutoReconnect(); + await disconnectPort(); + let ports = await serial.getPorts(); + if (ports.length > 0) { + for (const p of ports) { + await p.forgetDevice(); + } + setStatus('All devices forgotten', 'info'); + } else { + setStatus('No devices to forget', 'error'); + } + }); + + // Reset output button clears receiver + resetOutputBtn.addEventListener('click', () => { + receiverLines.innerHTML = ''; + }); + + // Reset button clears sender and receiver + resetAllBtn.addEventListener('click', () => { + senderLines.innerHTML = ''; + receiverLines.innerHTML = ''; + lastCommand = null; + lastCommandCount = 0; + lastCommandButton = null; + }); + + + // Disable input on load + commandLine.disabled = true; + + // Show warning if no WebUSB support + if (!serial.isWebUsbSupported()) { + setStatus('WebUSB not supported on this browser', 'error'); + } else { + // try to connect to any available device + connectPort(true); + } +})(); diff --git a/examples/device/webusb_serial/website/index.html b/examples/device/webusb_serial/website/index.html new file mode 100644 index 000000000..91d6e4fba --- /dev/null +++ b/examples/device/webusb_serial/website/index.html @@ -0,0 +1,67 @@ + + + + + + + TinyUSB WebUSB Serial + + + + + + +
+

TinyUSB - WebUSB Serial

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

Sender

+
+
+
+
+ + +
+
+
+

Receiver

+
+
+
+
+
+
+ + + diff --git a/examples/device/webusb_serial/website/serial.js b/examples/device/webusb_serial/website/serial.js new file mode 100644 index 000000000..2b60f2cc7 --- /dev/null +++ b/examples/device/webusb_serial/website/serial.js @@ -0,0 +1,124 @@ +'use strict'; + +const serial = { + isWebUsbSupported: () => 'usb' in navigator, + + // Returns array of connected devices wrapped as serial.Port instances + async getPorts() { + const devices = await navigator.usb.getDevices(); + return devices.map(device => new serial.Port(device)); + }, + + // Prompts user to select a device matching filters and wraps it in serial.Port + async requestPort() { + 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 serial.Port(device); + }, + + Port: class { + constructor(device) { + this.device = device; + this.interfaceNumber = 0; + this.endpointIn = 0; + this.endpointOut = 0; + this.readLoopActive = false; + } + + portPointToSameDevice(port) { + if (this.device.vendorId !== port.device.vendorId) return false; + if (this.device.productId !== port.device.productId) return false; + if (this.device.serialNumber !== port.device.serialNumber) return false; + return true; + } + + // Connect and start reading loop + async connect() { + 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, + }); + + this.readLoopActive = true; + this._readLoop(); + } + + // Internal continuous read loop + async _readLoop() { + while (this.readLoopActive) { + try { + const result = await this.device.transferIn(this.endpointIn, 64); + if (result.data && this.onReceive) { + this.onReceive(result.data); + } + } catch (error) { + this.readLoopActive = false; + if (this.onReceiveError) { + this.onReceiveError(error); + } + } + } + } + + // Stop reading and release device + async disconnect() { + this.readLoopActive = false; + await this.device.controlTransferOut({ + requestType: 'class', + recipient: 'interface', + request: 0x22, + value: 0x00, + index: this.interfaceNumber, + }); + await this.device.close(); + } + + // Send data to device + send(data) { + return this.device.transferOut(this.endpointOut, data); + } + + async forgetDevice() { + if (this.device.opened) { + await this.device.close(); + } + await this.device.forget(); + } + } +}; diff --git a/examples/device/webusb_serial/website/style.css b/examples/device/webusb_serial/website/style.css new file mode 100644 index 000000000..b73735d13 --- /dev/null +++ b/examples/device/webusb_serial/website/style.css @@ -0,0 +1,188 @@ +/* Reset default margins and make html, body full height */ +html, +body { + height: 100%; + margin: 0; + 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; + flex-shrink: 0; +} + +h1, h2 { + margin: 0; +} + +.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; +} + +/* Container for the two columns */ +.io-container { + display: flex; + flex: 1; + /* fill remaining vertical space */ + width: 100%; +} + +/* Both columns flex equally and full height */ +.column { + flex: 1; + padding: 1rem; + overflow: auto; + display: flex; + flex-direction: column; +} + +.sender { + display: flex; + flex-direction: column; +} + +.sender-entry { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + background: none; + border: none; + border-bottom: 1px solid #ccc; + /* light gray line */ + padding: 0.5rem 1rem; + margin: 0; + gap: 0; + text-align: left; + cursor: pointer; + font-size: 1rem; + color: inherit; +} + +.sender-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; +} + +.send-container { + display: flex; + flex-direction: row; + gap: 0.5rem; +} + +.send-mode-command { + background-color: light-gray; +} + +.send-mode-instant { + background-color: blue; +} + +/* UI Styles */ +.controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + flex: 1 +} + +.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; +} From 98b975202ced1940e642796f28a868e96dc492cc Mon Sep 17 00:00:00 2001 From: raldone01 Date: Sat, 5 Jul 2025 19:42:44 +0200 Subject: [PATCH 02/16] Minor bug fixes. Persist settings. --- .../webusb_serial/website/application.js | 146 +++++++++++------- .../device/webusb_serial/website/index.html | 2 +- 2 files changed, 94 insertions(+), 54 deletions(-) diff --git a/examples/device/webusb_serial/website/application.js b/examples/device/webusb_serial/website/application.js index a94a96714..8c01ce111 100644 --- a/examples/device/webusb_serial/website/application.js +++ b/examples/device/webusb_serial/website/application.js @@ -25,47 +25,10 @@ let lastCommandCount = 0; let lastCommandButton = null; - let sendMode = 'command'; // default mode + let sendMode = localStorage.getItem('sendMode') || 'command'; - let reconnectIntervalId = null; // track reconnect interval - - // Format incoming data to string based on newline mode - const decodeData = (() => { - const decoder = new TextDecoder(); - return dataView => decoder.decode(dataView); - })(); - - // Normalize newline if mode is ANY - const normalizeNewlines = (text, mode) => { - switch (mode) { - case 'CR': - // Only \r: Replace all \n with \r - return text.replace(/\r?\n/g, '\r'); - case 'CRLF': - // Replace lone \r or \n with \r\n - return text.replace(/\r\n|[\r\n]/g, '\r\n'); - case 'ANY': - // Accept any \r, \n, \r\n. Normalize as \n for display - return text.replace(/\r\n|\r/g, '\n'); - default: - return text; - } - }; - - // Append line to container, optionally scroll to bottom - const appendLineToReceiver = (container, text, className = '') => { - const div = document.createElement('div'); - if (className) div.className = className; - div.textContent = text; - container.appendChild(div); - - const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight); - if (distanceFromBottom < nearTheBottomThreshold) { - requestAnimationFrame(() => { - div.scrollIntoView({ behavior: "instant" }); - }); - } - }; + // track reconnect interval + let reconnectIntervalId = null; // Append sent command to sender container as a clickable element const appendCommandToSender = (container, text) => { @@ -98,6 +61,55 @@ } }; + // Restore command history + history.push(...(JSON.parse(localStorage.getItem('commandHistory') || '[]'))); + for (const cmd of history) { + appendCommandToSender(senderLines, cmd); + } + + // Restore auto reconnect checkbox + autoReconnectCheckbox.checked = localStorage.getItem('autoReconnect') === 'true'; + // Restore newline mode + const savedNewlineMode = localStorage.getItem('newlineMode'); + if (savedNewlineMode) newlineModeSelect.value = savedNewlineMode; + + // Format incoming data + const decodeData = (() => { + const decoder = new TextDecoder(); + return dataView => decoder.decode(dataView); + })(); + + const normalizeNewlines = (text, mode) => { + switch (mode) { + case 'CR': + // Only \r: Replace all \n with \r + return text.replace(/\r?\n/g, '\r'); + case 'CRLF': + // Replace lone \r or \n with \r\n + return text.replace(/\r\n|[\r\n]/g, '\r\n'); + case 'ANY': + // Accept any \r, \n, \r\n. Normalize as \n for display + return text.replace(/\r\n|\r/g, '\n'); + default: + return text; + } + }; + + // Append line to container, optionally scroll to bottom + const appendLineToReceiver = (container, text, className = '') => { + const div = document.createElement('div'); + if (className) div.className = className; + div.textContent = text; + container.appendChild(div); + + const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight); + if (distanceFromBottom < nearTheBottomThreshold) { + requestAnimationFrame(() => { + div.scrollIntoView({ behavior: "instant" }); + }); + } + }; + // Update status text and style const setStatus = (msg, level = 'info') => { console.log(msg); @@ -120,7 +132,7 @@ }; // Connect helper - const connectPort = async (initial=false) => { + const connectPort = async (initial = false) => { try { let grantedDevices = await serial.getPorts(); if (grantedDevices.length === 0 && initial) { @@ -148,24 +160,27 @@ } await port.connect(); - lastPort = port; // save for reconnecting + // save for reconnecting + lastPort = port; setStatus(`Connected to ${port.device.productName || 'device'}`, 'info'); connectBtn.textContent = 'Disconnect'; commandLine.disabled = false; commandLine.focus(); + port.onReceiveError = async error => { + setStatus(`Read error: ${error.message}`, 'error'); + await disconnectPort(); + // Start auto reconnect on error if enabled + await tryAutoReconnect(); + }; + port.onReceive = dataView => { let text = decodeData(dataView); text = normalizeNewlines(text, newlineModeSelect.value); appendLineToReceiver(receiverLines, text, 'received'); }; - port.onReceiveError = error => { - setStatus(`Read error: ${error.message}`, 'error'); - // Start auto reconnect on error if enabled - tryAutoReconnect(); - }; return true; } catch (error) { setStatus(`Connection failed: ${error.message}`, 'error'); @@ -177,7 +192,7 @@ }; // Start auto reconnect interval if checkbox is checked and not already running - const tryAutoReconnect = () => { + const tryAutoReconnect = async () => { if (!autoReconnectCheckbox.checked) return; if (reconnectIntervalId !== null) return; // already trying setStatus('Attempting to auto-reconnect...', 'info'); @@ -238,13 +253,16 @@ }); // Checkbox toggle stops auto reconnect if unchecked - autoReconnectCheckbox.addEventListener('change', () => { + autoReconnectCheckbox.addEventListener('change', async () => { + localStorage.setItem('autoReconnect', autoReconnectCheckbox.checked); if (!autoReconnectCheckbox.checked) { stopAutoReconnect(); } else { // Start auto reconnect immediately if not connected - if (!port) { - tryAutoReconnect(); + console.log(port); + console.log(lastPort); + if (!port && lastPort) { + await tryAutoReconnect(); } } }); @@ -263,8 +281,16 @@ sendModeBtn.classList.add('send-mode-command'); sendModeBtn.textContent = 'Command mode'; } + localStorage.setItem('sendMode', sendMode); }); + // Set initial sendMode button state + if (sendMode === 'instant') { + sendModeBtn.classList.remove('send-mode-command'); + sendModeBtn.classList.add('send-mode-instant'); + sendModeBtn.textContent = 'Instant mode'; + } + // Send command line input on Enter commandLine.addEventListener('keydown', async e => { if (!port) return; @@ -314,7 +340,8 @@ await port.send(encoder.encode(sendText)); } catch (error) { setStatus(`Send error: ${error.message}`, 'error'); - tryAutoReconnect(); + await disconnectPort(); + await tryAutoReconnect(); } } @@ -344,6 +371,7 @@ // Add command to history, ignore duplicate consecutive if (history.length === 0 || history[history.length - 1] !== text) { history.push(text); + localStorage.setItem('commandHistory', JSON.stringify(history)); } historyIndex = -1; @@ -369,10 +397,15 @@ commandLine.value = ''; } catch (error) { setStatus(`Send error: ${error.message}`, 'error'); - tryAutoReconnect(); + await disconnectPort(); + await tryAutoReconnect(); } }); + newlineModeSelect.addEventListener('change', () => { + localStorage.setItem('newlineMode', newlineModeSelect.value); + }); + // Forget device button clears stored device info forgetDeviceBtn.addEventListener('click', async () => { if (port) { @@ -415,6 +448,13 @@ lastCommand = null; lastCommandCount = 0; lastCommandButton = null; + history.length = 0; + historyIndex = -1; + + // iterate and delete localStorage items + for (const key in localStorage) { + localStorage.removeItem(key); + } }); diff --git a/examples/device/webusb_serial/website/index.html b/examples/device/webusb_serial/website/index.html index 91d6e4fba..8d18ff8cf 100644 --- a/examples/device/webusb_serial/website/index.html +++ b/examples/device/webusb_serial/website/index.html @@ -50,7 +50,7 @@
- +
From d3f7dff180507161d0277617edbf423792a6b21e Mon Sep 17 00:00:00 2001 From: raldone01 Date: Sat, 5 Jul 2025 19:42:44 +0200 Subject: [PATCH 03/16] Major overhaul and logic cleanup. Adds support for web serial as well. --- .../webusb_serial/website/application.js | 881 +++++++++--------- .../device/webusb_serial/website/divider.js | 38 + .../device/webusb_serial/website/index.html | 37 +- .../device/webusb_serial/website/serial.js | 342 ++++--- .../device/webusb_serial/website/style.css | 25 +- 5 files changed, 769 insertions(+), 554 deletions(-) create mode 100644 examples/device/webusb_serial/website/divider.js diff --git a/examples/device/webusb_serial/website/application.js b/examples/device/webusb_serial/website/application.js index 8c01ce111..9e28c0204 100644 --- a/examples/device/webusb_serial/website/application.js +++ b/examples/device/webusb_serial/website/application.js @@ -1,471 +1,512 @@ 'use strict'; -(() => { - const connectBtn = document.getElementById('connect'); - const resetAllBtn = document.getElementById('reset_all'); - const resetOutputBtn = document.getElementById('reset_output'); - const senderLines = document.getElementById('sender_lines'); - const receiverLines = document.getElementById('receiver_lines'); - const commandLine = document.getElementById('command_line'); - const status = document.getElementById('status'); - const newlineModeSelect = document.getElementById('newline_mode'); - const sendModeBtn = document.getElementById('send_mode'); - const autoReconnectCheckbox = document.getElementById('auto_reconnect'); - const forgetDeviceBtn = document.getElementById('forget_device'); - const forgetAllDevicesBtn = document.getElementById('forget_all_devices'); +(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 - let port = null; - let lastPort = null; // for reconnecting and initial connection + class Application { + constructor() { + this.currentPort = null; + this.textEncoder = new TextEncoder(); + this.textDecoder = new TextDecoder(); - const history = []; - let historyIndex = -1; - let lastCommand = null; - let lastCommandCount = 0; - let lastCommandButton = null; + this.reconnectTimeoutId = null; - let sendMode = localStorage.getItem('sendMode') || 'command'; + this.commandHistory = []; + this.commandHistoryIndex = -1; + this.lastCommandCount = 0; + this.lastCommand = null; + this.lastCommandBtn = null; - // track reconnect interval - let reconnectIntervalId = 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()); - // Append sent command to sender container as a clickable element - const appendCommandToSender = (container, text) => { - if (text === lastCommand) { - // Increment count and update button - lastCommandCount++; - lastCommandButton.textContent = `${text} ×${lastCommandCount}`; - } else { - // Reset count and add new button - lastCommand = text; - lastCommandCount = 1; + // restore state from localStorage - const commandEl = document.createElement('button'); - commandEl.className = 'sender-entry'; - commandEl.type = 'button'; - commandEl.textContent = text; - commandEl.addEventListener('click', () => { - commandLine.value = text; - commandLine.focus(); - }); - container.appendChild(commandEl); - lastCommandButton = commandEl; + // Restore command history + let savedCommandHistory = JSON.parse(localStorage.getItem('commandHistory') || '[]'); + for (const cmd of savedCommandHistory) { + this.appendCommandToHistory(cmd); + } - const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight); + 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(() => { - commandEl.scrollIntoView({ behavior: 'instant' }); + div.scrollIntoView({ behavior: 'instant' }); }); } } - }; - // Restore command history - history.push(...(JSON.parse(localStorage.getItem('commandHistory') || '[]'))); - for (const cmd of history) { - appendCommandToSender(senderLines, cmd); - } - - // Restore auto reconnect checkbox - autoReconnectCheckbox.checked = localStorage.getItem('autoReconnect') === 'true'; - // Restore newline mode - const savedNewlineMode = localStorage.getItem('newlineMode'); - if (savedNewlineMode) newlineModeSelect.value = savedNewlineMode; - - // Format incoming data - const decodeData = (() => { - const decoder = new TextDecoder(); - return dataView => decoder.decode(dataView); - })(); - - const normalizeNewlines = (text, mode) => { - switch (mode) { - case 'CR': - // Only \r: Replace all \n with \r - return text.replace(/\r?\n/g, '\r'); - case 'CRLF': - // Replace lone \r or \n with \r\n - return text.replace(/\r\n|[\r\n]/g, '\r\n'); - case 'ANY': - // Accept any \r, \n, \r\n. Normalize as \n for display - return text.replace(/\r\n|\r/g, '\n'); - default: - return text; + setStatus(msg, level = 'info') { + console.log(msg); + statusSpan.textContent = msg; + statusSpan.className = 'status status-' + level; } - }; - // Append line to container, optionally scroll to bottom - const appendLineToReceiver = (container, text, className = '') => { - const div = document.createElement('div'); - if (className) div.className = className; - div.textContent = text; - container.appendChild(div); - - const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight); - if (distanceFromBottom < nearTheBottomThreshold) { - requestAnimationFrame(() => { - div.scrollIntoView({ behavior: "instant" }); - }); - } - }; - - // Update status text and style - const setStatus = (msg, level = 'info') => { - console.log(msg); - status.textContent = msg; - status.className = 'status status-' + level; - }; - - // Disconnect helper - const disconnectPort = async () => { - if (port) { - try { - await port.disconnect(); - } catch (error) { - setStatus(`Disconnect error: ${error.message}`, 'error'); - } - port = null; - connectBtn.textContent = 'Connect'; - commandLine.disabled = true; - } - }; - - // Connect helper - const connectPort = async (initial = false) => { - try { - let grantedDevices = await serial.getPorts(); - if (grantedDevices.length === 0 && initial) { - return false; - } - if (grantedDevices.length === 0) { - // No previously granted devices, request a new one - setStatus('Requesting device...', 'info'); - port = await serial.requestPort(); + 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 (lastPort) { - // Try to reconnect to the last used port - const matchingPort = grantedDevices.find(p => p.portPointToSameDevice(lastPort)); - if (matchingPort) { - port = matchingPort; - setStatus('Reconnecting to last device...', 'info'); - } else { - return false; - } - } else { - // No last port, just use the first available - port = grantedDevices[0]; - setStatus('Connecting to first device...', 'info'); + 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'); } - await port.connect(); - // save for reconnecting - lastPort = port; - - setStatus(`Connected to ${port.device.productName || 'device'}`, 'info'); - connectBtn.textContent = 'Disconnect'; - commandLine.disabled = false; - commandLine.focus(); - - port.onReceiveError = async error => { - setStatus(`Read error: ${error.message}`, 'error'); - await disconnectPort(); - // Start auto reconnect on error if enabled - await tryAutoReconnect(); - }; - - port.onReceive = dataView => { - let text = decodeData(dataView); - text = normalizeNewlines(text, newlineModeSelect.value); - appendLineToReceiver(receiverLines, text, 'received'); - }; - - return true; - } catch (error) { - setStatus(`Connection failed: ${error.message}`, 'error'); - port = null; - connectBtn.textContent = 'Connect'; - commandLine.disabled = true; - return false; + this.updateUIConnectionState(); } - }; - // Start auto reconnect interval if checkbox is checked and not already running - const tryAutoReconnect = async () => { - if (!autoReconnectCheckbox.checked) return; - if (reconnectIntervalId !== null) return; // already trying - setStatus('Attempting to auto-reconnect...', 'info'); - reconnectIntervalId = setInterval(async () => { - if (!autoReconnectCheckbox.checked) { - clearInterval(reconnectIntervalId); - reconnectIntervalId = null; - setStatus('Auto-reconnect stopped.', 'info'); + 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; } - await disconnectPort(); - const success = await connectPort(); - if (success) { - clearInterval(reconnectIntervalId); - reconnectIntervalId = null; - setStatus('Reconnected successfully.', 'info'); - } - }, 1000); - }; - - // Stop auto reconnect immediately - const stopAutoReconnect = () => { - if (reconnectIntervalId !== null) { - clearInterval(reconnectIntervalId); - reconnectIntervalId = null; - setStatus('Auto-reconnect stopped.', 'info'); - } - }; - - // Connect button click handler - connectBtn.addEventListener('click', async () => { - if (!serial.isWebUsbSupported()) { - setStatus('WebUSB not supported on this browser', 'error'); - return; - } - - if (port) { - // Disconnect - stopAutoReconnect(); - await disconnectPort(); - setStatus('Disconnected', 'info'); - return; - } - - stopAutoReconnect(); - try { - // Connect - const success = await connectPort(); - if (success) { - setStatus('Connected', 'info'); - } - } catch (error) { - setStatus(`Connection failed: ${error.message}`, 'error'); - port = null; - connectBtn.textContent = 'Connect'; - commandLine.disabled = true; - } - }); - - // Checkbox toggle stops auto reconnect if unchecked - autoReconnectCheckbox.addEventListener('change', async () => { - localStorage.setItem('autoReconnect', autoReconnectCheckbox.checked); - if (!autoReconnectCheckbox.checked) { - stopAutoReconnect(); - } else { - // Start auto reconnect immediately if not connected - console.log(port); - console.log(lastPort); - if (!port && lastPort) { - await tryAutoReconnect(); + 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; + } } } - }); - sendModeBtn.addEventListener('click', () => { - if (sendMode === 'command') { - sendMode = 'instant'; - sendModeBtn.classList.remove('send-mode-command'); - sendModeBtn.classList.add('send-mode-instant'); - sendModeBtn.textContent = 'Instant mode'; - // In instant mode, we clear the command line - commandLine.value = ''; - } else { - sendMode = 'command'; - sendModeBtn.classList.remove('send-mode-instant'); - sendModeBtn.classList.add('send-mode-command'); - sendModeBtn.textContent = 'Command mode'; - } - localStorage.setItem('sendMode', sendMode); - }); + 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; + } - // Set initial sendMode button state - if (sendMode === 'instant') { - sendModeBtn.classList.remove('send-mode-command'); - sendModeBtn.classList.add('send-mode-instant'); - sendModeBtn.textContent = 'Instant mode'; - } - - // Send command line input on Enter - commandLine.addEventListener('keydown', async e => { - if (!port) return; - - // Instant mode: send key immediately including special keys like Backspace, arrows, enter, etc. - if (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; + // 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; + } } - 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; + } + 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; } - const encoder = new TextEncoder(); + this.currentPort.onReceiveError = error => this.onReceiveError(error); + this.currentPort.onReceive = dataView => this.onReceive(dataView); + try { - await port.send(encoder.encode(sendText)); + 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) { - setStatus(`Send error: ${error.message}`, 'error'); - await disconnectPort(); - await tryAutoReconnect(); + 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'); } } - - return; + this.updateUIConnectionState(); } - // Command mode: handle up/down arrow keys for history - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + 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(); - if (history.length === 0) return; - if (e.key === 'ArrowUp') { - if (historyIndex === -1) historyIndex = history.length - 1; - else if (historyIndex > 0) historyIndex--; - } else if (e.key === 'ArrowDown') { - if (historyIndex !== -1) historyIndex++; - if (historyIndex >= history.length) historyIndex = -1; + 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; } - commandLine.value = historyIndex === -1 ? '' : history[historyIndex]; - return; - } + const data = this.textEncoder.encode(sendText); - if (e.key !== 'Enter' || !port) return; - e.preventDefault(); - const text = commandLine.value; - if (!text) return; - - // Add command to history, ignore duplicate consecutive - if (history.length === 0 || history[history.length - 1] !== text) { - history.push(text); - localStorage.setItem('commandHistory', JSON.stringify(history)); - } - historyIndex = -1; - - // 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 encoder = new TextEncoder(); - const data = encoder.encode(sendText); - - try { - await port.send(data); - appendCommandToSender(senderLines, sendText.replace(/[\r\n]+$/, '')); - commandLine.value = ''; - } catch (error) { - setStatus(`Send error: ${error.message}`, 'error'); - await disconnectPort(); - await tryAutoReconnect(); - } - }); - - newlineModeSelect.addEventListener('change', () => { - localStorage.setItem('newlineMode', newlineModeSelect.value); - }); - - // Forget device button clears stored device info - forgetDeviceBtn.addEventListener('click', async () => { - if (port) { - // Disconnect first - await port.disconnect(); - await port.forgetDevice(); - stopAutoReconnect(); - await disconnectPort(); - - setStatus('Device forgotten', 'info'); - } else { - setStatus('No device to forget', 'error'); - } - }); - - // Forget all devices button clears all stored device info - forgetAllDevicesBtn.addEventListener('click', async () => { - stopAutoReconnect(); - await disconnectPort(); - let ports = await serial.getPorts(); - if (ports.length > 0) { - for (const p of ports) { - await p.forgetDevice(); + 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(); } - setStatus('All devices forgotten', 'info'); - } else { - setStatus('No devices to forget', 'error'); } - }); - // Reset output button clears receiver - resetOutputBtn.addEventListener('click', () => { - receiverLines.innerHTML = ''; - }); - - // Reset button clears sender and receiver - resetAllBtn.addEventListener('click', () => { - senderLines.innerHTML = ''; - receiverLines.innerHTML = ''; - lastCommand = null; - lastCommandCount = 0; - lastCommandButton = null; - history.length = 0; - historyIndex = -1; - - // iterate and delete localStorage items - for (const key in localStorage) { - localStorage.removeItem(key); + 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); + } - // Disable input on load - commandLine.disabled = true; + 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; + } + } - // Show warning if no WebUSB support - if (!serial.isWebUsbSupported()) { - setStatus('WebUSB not supported on this browser', 'error'); - } else { - // try to connect to any available device - connectPort(true); + 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(); +})() diff --git a/examples/device/webusb_serial/website/divider.js b/examples/device/webusb_serial/website/divider.js new file mode 100644 index 000000000..e52278005 --- /dev/null +++ b/examples/device/webusb_serial/website/divider.js @@ -0,0 +1,38 @@ +const resizer = document.getElementById('resizer'); +const leftColumn = resizer.previousElementSibling; +const rightColumn = resizer.nextElementSibling; +const container = resizer.parentNode; + +// Minimum and maximum width for left column in px +const minLeftWidth = 100; +const maxLeftWidth = container.clientWidth - 100; + +let isResizing = false; + +resizer.addEventListener('mousedown', e => { + e.preventDefault(); + isResizing = true; + document.body.style.userSelect = 'none'; // prevent text selection +}); + +document.addEventListener('mousemove', e => { + if (!isResizing) return; + + // Calculate new width of left column relative to container + const containerRect = container.getBoundingClientRect(); + let newLeftWidth = e.clientX - containerRect.left; + + // Clamp the width + newLeftWidth = Math.max(minLeftWidth, Math.min(newLeftWidth, containerRect.width - minLeftWidth)); + + // Set the left column's flex-basis (fixed width) + leftColumn.style.flex = '0 0 ' + newLeftWidth + 'px'; + rightColumn.style.flex = '1 1 0'; // fill remaining space +}); + +document.addEventListener('mouseup', e => { + if (isResizing) { + isResizing = false; + document.body.style.userSelect = ''; // restore user selection + } +}); diff --git a/examples/device/webusb_serial/website/index.html b/examples/device/webusb_serial/website/index.html index 8d18ff8cf..95bf20bf1 100644 --- a/examples/device/webusb_serial/website/index.html +++ b/examples/device/webusb_serial/website/index.html @@ -8,6 +8,7 @@ + @@ -20,44 +21,48 @@
- -
- + Click "Connect" to start
-

Sender

+

Command History

-
+
- - + +
+
-

Receiver

+

Received Data

-
+
diff --git a/examples/device/webusb_serial/website/serial.js b/examples/device/webusb_serial/website/serial.js index 2b60f2cc7..893dedbc7 100644 --- a/examples/device/webusb_serial/website/serial.js +++ b/examples/device/webusb_serial/website/serial.js @@ -1,16 +1,244 @@ 'use strict'; +/// Web Serial API Implementation +class SerialPort { + constructor(port) { + this.port = port; + this.reader = null; + this.writer = null; + this.readableStreamClosed = null; + this.isConnected = false; + this.readLoop = null; + this.initialized = false; + } + + /// Connect and start reading loop + async connect(options = { baudRate: 9600 }) { + if (this.initialized) { + return; + } + this.initialized = true; + await this.port.open(options); + + this.readableStreamClosed = this.port.readable; + this.reader = this.port.readable.getReader(); + + this.writer = this.port.writable.getWriter(); + this.isConnected = true; + this.readLoop = this._readLoop(); + } + + /// Internal continuous read loop + async _readLoop() { + while (this.isConnected) { + try { + const { value, done } = await this.reader.read(); + if (done || !this.isConnected) break; + if (value && this.onReceive) this.onReceive(value); + } catch (error) { + this.isConnected = false; + if (this.onReceiveError) this.onReceiveError(error); + } + } + } + + async _waitForReadLoopToFinish() { + if (this.readLoop) { + try { + await this.readLoop; + } catch (error) {} + this.readLoop = null; + } + } + + /// Stop reading and release port + async disconnect() { + this.isConnected = false; + await this._waitForReadLoopToFinish(); + + if (this.reader) { + try { + await this.reader.cancel(); + } catch (error) {} + this.reader.releaseLock(); + } + + if (this.writer) { + try { + await this.writer.close(); + } catch (error) {} + } + + if (this.readableStreamClosed) { + try { + await this.readableStreamClosed; + } catch (error) {} + } + + try { + await this.port.close(); + } catch (error) {} + } + + /// Send data to port + send(data) { + if (!this.writer) throw new Error('Port not connected'); + const encoder = new TextEncoder(); + return this.writer.write(encoder.encode(data)); + } + + async forgetDevice() {} +} + +/// WebUSB Implementation +class WebUsbSerialPort { + constructor(device) { + this.device = device; + this.interfaceNumber = 0; + this.endpointIn = 0; + this.endpointOut = 0; + this.isConnected = false; + this.readLoop = null; + this.initialized = false; + } + + isSameDevice(device) { + return this.device.vendorId === device.vendorId && this.device.productId === device.productId; + } + + /// Connect and start reading loop + async connect() { + if (this.initialized) { + const devices = await serial.getWebUsbSerialPorts(); + const device = devices.find(d => this.isSameDevice(d.device)); + if (device) { + this.device = device.device; + } else { + return false; + } + await this.device.open(); + } + this.initialized = true; + await this.device.open(); + try { + await this.device.reset(); + } catch (error) { } + + 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, + }); + + this.isConnected = true; + this.readLoop = this._readLoop(); + } + + async _waitForReadLoopToFinish() { + if (this.readLoop) { + try { + await this.readLoop; + } catch (error) {} + this.readLoop = null; + } + } + + /// Internal continuous read loop + async _readLoop() { + while (this.isConnected) { + try { + const result = await this.device.transferIn(this.endpointIn, 64); + if (result.data && this.onReceive) { + this.onReceive(result.data); + } + } catch (error) { + this.isConnected = false; + if (this.onReceiveError) { + this.onReceiveError(error); + } + } + } + } + + /// Stop reading and release device + async disconnect() { + this.isConnected = false; + await this._waitForReadLoopToFinish(); + try { + await this.device.controlTransferOut({ + requestType: 'class', + recipient: 'interface', + request: 0x22, + value: 0x00, + index: this.interfaceNumber, + }); + } catch (error) { + console.log(error); + } + await this.device.close(); + } + + /// 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, - // Returns array of connected devices wrapped as serial.Port instances - async getPorts() { - const devices = await navigator.usb.getDevices(); - return devices.map(device => new serial.Port(device)); + async getSerialPorts() { + if (!this.isWebSerialSupported()) return []; + const ports = await navigator.serial.getPorts(); + return ports.map(port => new SerialPort(port)); }, - // Prompts user to select a device matching filters and wraps it in serial.Port - async requestPort() { + 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 @@ -19,106 +247,6 @@ const serial = { { vendorId: 0x2341 }, // Arduino ]; const device = await navigator.usb.requestDevice({ filters }); - return new serial.Port(device); - }, - - Port: class { - constructor(device) { - this.device = device; - this.interfaceNumber = 0; - this.endpointIn = 0; - this.endpointOut = 0; - this.readLoopActive = false; - } - - portPointToSameDevice(port) { - if (this.device.vendorId !== port.device.vendorId) return false; - if (this.device.productId !== port.device.productId) return false; - if (this.device.serialNumber !== port.device.serialNumber) return false; - return true; - } - - // Connect and start reading loop - async connect() { - 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, - }); - - this.readLoopActive = true; - this._readLoop(); - } - - // Internal continuous read loop - async _readLoop() { - while (this.readLoopActive) { - try { - const result = await this.device.transferIn(this.endpointIn, 64); - if (result.data && this.onReceive) { - this.onReceive(result.data); - } - } catch (error) { - this.readLoopActive = false; - if (this.onReceiveError) { - this.onReceiveError(error); - } - } - } - } - - // Stop reading and release device - async disconnect() { - this.readLoopActive = false; - await this.device.controlTransferOut({ - requestType: 'class', - recipient: 'interface', - request: 0x22, - value: 0x00, - index: this.interfaceNumber, - }); - await this.device.close(); - } - - // Send data to device - send(data) { - return this.device.transferOut(this.endpointOut, data); - } - - async forgetDevice() { - if (this.device.opened) { - await this.device.close(); - } - await this.device.forget(); - } + return new WebUsbSerialPort(device); } }; diff --git a/examples/device/webusb_serial/website/style.css b/examples/device/webusb_serial/website/style.css index b73735d13..038a037c0 100644 --- a/examples/device/webusb_serial/website/style.css +++ b/examples/device/webusb_serial/website/style.css @@ -43,6 +43,10 @@ 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 */ @@ -51,6 +55,7 @@ main { flex: 1; /* fill remaining vertical space */ width: 100%; + overflow: hidden; } /* Both columns flex equally and full height */ @@ -67,7 +72,7 @@ main { flex-direction: column; } -.sender-entry { +.command-history-entry { width: 100%; display: flex; flex-direction: row; @@ -85,7 +90,7 @@ main { color: inherit; } -.sender-entry:hover { +.command-history-entry:hover { background-color: #f0f0f0; } @@ -133,15 +138,6 @@ main { background-color: blue; } -/* UI Styles */ -.controls { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - flex: 1 -} - .btn { padding: 0.5rem 1rem; font-size: 1rem; @@ -186,3 +182,10 @@ main { 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%; +} From eef5b92c9b168e11e29e8369b2f4d104fab42347 Mon Sep 17 00:00:00 2001 From: raldone01 Date: Sat, 5 Jul 2025 19:42:44 +0200 Subject: [PATCH 04/16] Choose a larger buffer. --- examples/device/webusb_serial/website/serial.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/device/webusb_serial/website/serial.js b/examples/device/webusb_serial/website/serial.js index 893dedbc7..2a07f6a0f 100644 --- a/examples/device/webusb_serial/website/serial.js +++ b/examples/device/webusb_serial/website/serial.js @@ -174,7 +174,7 @@ class WebUsbSerialPort { async _readLoop() { while (this.isConnected) { try { - const result = await this.device.transferIn(this.endpointIn, 64); + const result = await this.device.transferIn(this.endpointIn, 16384); if (result.data && this.onReceive) { this.onReceive(result.data); } From 28ded62c1c2034b29c7f3d4301386ce82e0c5ee5 Mon Sep 17 00:00:00 2001 From: raldone01 Date: Sat, 5 Jul 2025 19:42:44 +0200 Subject: [PATCH 05/16] Improve performance slightly --- .../webusb_serial/website/application.js | 415 ++++++++++++------ .../device/webusb_serial/website/divider.js | 75 ++-- .../device/webusb_serial/website/index.html | 16 +- .../device/webusb_serial/website/serial.js | 1 + .../device/webusb_serial/website/style.css | 33 +- 5 files changed, 363 insertions(+), 177 deletions(-) diff --git a/examples/device/webusb_serial/website/application.js b/examples/device/webusb_serial/website/application.js index 9e28c0204..0886f3441 100644 --- a/examples/device/webusb_serial/website/application.js +++ b/examples/device/webusb_serial/website/application.js @@ -2,27 +2,48 @@ (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 uiConnectWebUsbSerialBtn = document.getElementById('connect_webusb_serial_btn'); + const uiConnectSerialBtn = document.getElementById('connect_serial_btn'); + const uiDisconnectBtn = 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 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 statusSpan = document.getElementById('status_span'); + const uiStatusSpan = 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 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 receivedDataScrollbox = document.getElementById('received_data_scrollbox'); + const uiReceivedDataClearBtn = document.getElementById('clear_received_data_btn'); + const uiReceivedDataScrollbox = document.getElementById('received_data_scrollbox'); - const nearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll + 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 + + 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() { @@ -33,122 +54,251 @@ this.reconnectTimeoutId = null; this.commandHistory = []; - this.commandHistoryIndex = -1; - this.lastCommandCount = 0; - this.lastCommand = null; - this.lastCommandBtn = null; + this.uiCommandHistoryIndex = -1; + + this.receivedData = []; // 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()); + 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 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); - autoReconnectCheckbox.checked = localStorage.getItem('autoReconnect') === 'true'; + uiAutoReconnectCheckbox.checked = !(localStorage.getItem('autoReconnect') === 'false'); let savedNewlineMode = localStorage.getItem('newlineMode'); if (savedNewlineMode) { - newlineModeSelect.value = savedNewlineMode; + uiNewlineModeSelect.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; + appendCommandToHistory(commandHistoryEntry) { + const wasNearBottom = uiCommandHistoryScrollbox.scrollHeight - uiCommandHistoryScrollbox.scrollTop <= uiCommandHistoryScrollbox.clientHeight + uiNearTheBottomThreshold; - 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); + let commandHistoryEntryBtn = null; - this.lastCommand = text; - this.lastCommandBtn = commandHistoryEntryBtn; + 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; - // 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' }); - }); + // 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); - appendLineToReceived(text) { - const div = document.createElement('div'); - div.textContent = text; - receivedDataScrollbox.appendChild(div); + // 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 = ` + ${time_str} + ${commandHistoryEntry.text} + ×${commandHistoryEntry.count} + `; + commandHistoryEntryBtn.addEventListener('click', () => { + if (uiCommandLineInput.disabled) return; + uiCommandLineInput.value = commandHistoryEntry.text; + uiCommandLineInput.focus(); + }); + + uiCommandHistoryScrollbox.appendChild(commandHistoryEntryBtn); + } + + // Limit the command history length + while (this.commandHistory.length > maxCommandHistoryLength) { + this.commandHistory.shift(); + uiCommandHistoryScrollbox.removeChild(uiCommandHistoryScrollbox.firstElementChild); + } + + // Save the command history to localStorage + localStorage.setItem('commandHistory', JSON.stringify(this.commandHistory)); // Scroll to the new entry if near the bottom - const distanceFromBottom = receivedDataScrollbox.scrollHeight - (receivedDataScrollbox.scrollTop + receivedDataScrollbox.clientHeight); - if (distanceFromBottom < nearTheBottomThreshold) { + if (wasNearBottom) { requestAnimationFrame(() => { - div.scrollIntoView({ behavior: 'instant' }); + 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 = ` + ${new Date(entry.time).toLocaleString()} + ${entry.text} + `; + documentFragment.appendChild(receivedDataEntryBtn); + } + uiReceivedDataScrollbox.appendChild(documentFragment); + + // Limit the received data length + while (this.receivedData.length > maxReceivedDataLength) { + this.receivedData.shift(); + uiReceivedDataScrollbox.removeChild(uiReceivedDataScrollbox.firstElementChild); + } + + // 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); - statusSpan.textContent = msg; - statusSpan.className = 'status status-' + level; + uiStatusSpan.textContent = msg; + uiStatusSpan.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(); + uiConnectWebUsbSerialBtn.style.display = 'none'; + uiConnectSerialBtn.style.display = 'none'; + uiDisconnectBtn.style.display = 'block'; + uiCommandLineInput.disabled = false; + uiCommandLineInput.focus(); } else { if (serial.isWebUsbSupported()) { - connectWebUsbSerialBtn.style.display = 'block'; + uiConnectWebUsbSerialBtn.style.display = 'block'; } if (serial.isWebSerialSupported()) { - connectSerialBtn.style.display = 'block'; + uiConnectSerialBtn.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(); + uiDisconnectBtn.style.display = 'none'; + uiCommandLineInput.disabled = true; + uiCommandLineInput.value = ''; + uiCommandLineInput.blur(); } } @@ -168,9 +318,10 @@ async onReceive(dataView) { this.updateUIConnectionState(); + let text = this.textDecoder.decode(dataView); - text = this.normalizeNewlines(text); - this.appendLineToReceived(text); + let receivedDataEntry = new ReceivedDataEntry(text); + this.appendReceivedData(receivedDataEntry); } async onReceiveError(error) { @@ -210,7 +361,7 @@ let first_time_connection = false; let grantedDevices = await serial.getWebUsbSerialPorts(); if (initial) { - if (!autoReconnectCheckbox.checked || grantedDevices.length === 0) { + if (!uiAutoReconnectCheckbox.checked || grantedDevices.length === 0) { return false; } @@ -303,18 +454,18 @@ } setNewlineMode() { - localStorage.setItem('newlineMode', newlineModeSelect.value); + localStorage.setItem('newlineMode', uiNewlineModeSelect.value); } autoReconnectChanged() { - if (autoReconnectCheckbox.checked) { + if (uiAutoReconnectCheckbox.checked) { this.setStatus('Auto-reconnect enabled', 'info'); this.tryAutoReconnect(); } else { this.setStatus('Auto-reconnect disabled', 'info'); this.stopAutoReconnect(); } - localStorage.setItem('autoReconnect', autoReconnectCheckbox.checked); + localStorage.setItem('autoReconnect', uiAutoReconnectCheckbox.checked); } stopAutoReconnect() { @@ -327,12 +478,12 @@ tryAutoReconnect() { this.updateUIConnectionState(); - if (!autoReconnectCheckbox.checked) return; + 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 (!autoReconnectCheckbox.checked) { + if (!uiAutoReconnectCheckbox.checked) { this.setStatus('Auto-reconnect stopped.', 'info'); return; } @@ -362,7 +513,7 @@ let sendText = ''; switch (e.key) { case 'Enter': - switch (newlineModeSelect.value) { + switch (uiNewlineModeSelect.value) { case 'CR': sendText = '\r'; break; case 'CRLF': sendText = '\r\n'; break; default: sendText = '\n'; break; @@ -387,7 +538,7 @@ sendText = e.key; } try { - await port.send(this.textEncoder.encode(sendText)); + await this.currentPort.send(this.textEncoder.encode(sendText)); } catch (error) { this.setStatus(`Send error: ${error.message}`, 'error'); this.tryAutoReconnect(); @@ -402,24 +553,24 @@ 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--; + if (this.uiCommandHistoryIndex === -1) this.uiCommandHistoryIndex = this.commandHistory.length - 1; + else if (this.uiCommandHistoryIndex > 0) this.uiCommandHistoryIndex--; } else if (e.key === 'ArrowDown') { - if (this.commandHistoryIndex !== -1) this.commandHistoryIndex++; - if (this.commandHistoryIndex >= this.commandHistory.length) this.commandHistoryIndex = -1; + if (this.uiCommandHistoryIndex !== -1) this.uiCommandHistoryIndex++; + if (this.uiCommandHistoryIndex >= this.commandHistory.length) this.uiCommandHistoryIndex = -1; } - commandLineInput.value = this.commandHistoryIndex === -1 ? '' : this.commandHistory[this.commandHistoryIndex]; + uiCommandLineInput.value = this.uiCommandHistoryIndex === -1 ? '' : this.commandHistory[this.uiCommandHistoryIndex].text; return; } if (e.key !== 'Enter' || !this.currentPort.isConnected) return; e.preventDefault(); - const text = commandLineInput.value; + const text = uiCommandLineInput.value; if (!text) return; // Convert to Uint8Array with newline based on config let sendText = text; - switch (newlineModeSelect.value) { + switch (uiNewlineModeSelect.value) { case 'CR': sendText += '\r'; break; @@ -434,9 +585,11 @@ try { await this.currentPort.send(data); - this.commandHistoryIndex = -1; - this.appendCommandToHistory(sendText.replace(/[\r\n]+$/, '')); - commandLineInput.value = ''; + 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(); @@ -454,32 +607,26 @@ setSendMode(mode) { this.sendMode = mode; if (mode === 'instant') { - sendModeBtn.classList.remove('send-mode-command'); - sendModeBtn.classList.add('send-mode-instant'); - sendModeBtn.textContent = 'Instant mode'; + uiSendModeBtn.classList.remove('send-mode-command'); + uiSendModeBtn.classList.add('send-mode-instant'); + uiSendModeBtn.textContent = 'Instant mode'; } else { - sendModeBtn.classList.remove('send-mode-instant'); - sendModeBtn.classList.add('send-mode-command'); - sendModeBtn.textContent = 'Command mode'; + uiSendModeBtn.classList.remove('send-mode-instant'); + uiSendModeBtn.classList.add('send-mode-command'); + uiSendModeBtn.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; + 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'); @@ -491,8 +638,22 @@ } } - resetOutput() { - receivedDataScrollbox.innerHTML = ''; + downloadOutputCsv() { + // save , + 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() { diff --git a/examples/device/webusb_serial/website/divider.js b/examples/device/webusb_serial/website/divider.js index e52278005..b67d0e429 100644 --- a/examples/device/webusb_serial/website/divider.js +++ b/examples/device/webusb_serial/website/divider.js @@ -1,38 +1,47 @@ -const resizer = document.getElementById('resizer'); -const leftColumn = resizer.previousElementSibling; -const rightColumn = resizer.nextElementSibling; -const container = resizer.parentNode; +(async () => { -// Minimum and maximum width for left column in px -const minLeftWidth = 100; -const maxLeftWidth = container.clientWidth - 100; + const uiResizer = document.getElementById('resizer'); + const uiLeftColumn = uiResizer.previousElementSibling; + const uiRightColumn = uiResizer.nextElementSibling; + const uiParent = uiResizer.parentElement; -let isResizing = false; + let isResizing = false; + let abortSignal = null; -resizer.addEventListener('mousedown', e => { - e.preventDefault(); - isResizing = true; - document.body.style.userSelect = 'none'; // prevent text selection -}); + function onMouseMove(e) { + // we resize the columns by applying felx: to the columns -document.addEventListener('mousemove', e => { - if (!isResizing) return; - - // Calculate new width of left column relative to container - const containerRect = container.getBoundingClientRect(); - let newLeftWidth = e.clientX - containerRect.left; - - // Clamp the width - newLeftWidth = Math.max(minLeftWidth, Math.min(newLeftWidth, containerRect.width - minLeftWidth)); - - // Set the left column's flex-basis (fixed width) - leftColumn.style.flex = '0 0 ' + newLeftWidth + 'px'; - rightColumn.style.flex = '1 1 0'; // fill remaining space -}); - -document.addEventListener('mouseup', e => { - if (isResizing) { - isResizing = false; - document.body.style.userSelect = ''; // restore user selection + // 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 index 95bf20bf1..87fcfd6aa 100644 --- a/examples/device/webusb_serial/website/index.html +++ b/examples/device/webusb_serial/website/index.html @@ -25,7 +25,7 @@ diff --git a/examples/device/webusb_serial/website/serial.js b/examples/device/webusb_serial/website/serial.js index 55035c81f..19827f016 100644 --- a/examples/device/webusb_serial/website/serial.js +++ b/examples/device/webusb_serial/website/serial.js @@ -116,7 +116,7 @@ class SerialPort { if (!this._port.writable) { throw new Error('Port is not writable'); } - this._writer = port.writeable.getWriter(); + this._writer = this._port.writable.getWriter(); if (!this._writer) { throw new Error('Failed to get writer from port'); } @@ -141,6 +141,13 @@ class WebUsbSerialPort { 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 @@ -152,14 +159,9 @@ class WebUsbSerialPort { console.error('Error disconnecting previous device:', error); } - if (this._readLoopPromise) { - try { - await this._readLoopPromise; - } catch (error) { - console.error('Error in read loop:', error); - } - } - this._readLoopPromise = null; + const webUsbSerialPorts = await serial.getWebUsbSerialPorts(); + const webUsbSerialPort = webUsbSerialPorts.find(serialPort => this._isSameWebUsbSerialPort(serialPort)); + this._device = webUsbSerialPort ? webUsbSerialPort._device : this._device; } this._initialized = true; diff --git a/examples/device/webusb_serial/website/style.css b/examples/device/webusb_serial/website/style.css index 3af2668e0..7b8b6029d 100644 --- a/examples/device/webusb_serial/website/style.css +++ b/examples/device/webusb_serial/website/style.css @@ -221,6 +221,12 @@ body.dark-mode { 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; @@ -251,6 +257,7 @@ body.dark-mode .input:focus { body.dark-mode .scrollbox { background-color: #252526; + scrollbar-color: #555 #2e2e2e; border: 1px solid #444; } @@ -287,7 +294,3 @@ body.dark-mode option { background-color: #3c3c3c; color: #f0f0f0; } - -body.dark-mode .scrollbox { - scrollbar-color: #555 #2e2e2e; -}