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