summaryrefslogtreecommitdiff
path: root/examples
diff options
context:
space:
mode:
Diffstat (limited to 'examples')
-rw-r--r--examples/device/webusb_serial/website/application.js431
-rw-r--r--examples/device/webusb_serial/website/index.html67
-rw-r--r--examples/device/webusb_serial/website/serial.js124
-rw-r--r--examples/device/webusb_serial/website/style.css188
4 files changed, 810 insertions, 0 deletions
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 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>TinyUSB WebUSB Serial</title>
+ <link rel="stylesheet" href="style.css" />
+ <script defer src="serial.js"></script>
+ <script defer src="application.js"></script>
+</head>
+
+<body>
+ <header class="header">
+ <h1 class="title">TinyUSB - WebUSB Serial</h1>
+ <a class="github-link" href="https://github.com/hathach/tinyusb/tree/master/examples/device/webusb_serial/website"
+ target="_blank">
+ Find my source on GitHub
+ </a>
+ </header>
+ <main>
+ <section class="controls-section">
+ <button id="connect" class="btn good">Connect</button>
+ <label for="newline_mode">
+ Newline mode:
+ <select id="newline_mode">
+ <option value="CR">Only \r</option>
+ <option value="CRLF">\r\n</option>
+ <option value="ANY" selected>\r, \n or \r\n</option>
+ </select>
+ </label>
+ <label for="auto_reconnect">
+ <input type="checkbox" id="auto_reconnect" />
+ Auto Reconnect
+ </label>
+ <button id="forget_device" class="btn danger">Forget Device</button>
+ <button id="forget_all_devices" class="btn danger">Forget All Devices</button>
+ <button id="reset_all" class="btn danger">Reset All</button>
+ <button id="reset_output" class="btn danger">Reset Output</button>
+ </section>
+ <section class="status-section">
+ <span id="status" class="status">
+ Click "Connect" to start
+ </span>
+ </section>
+ <div class="io-container">
+ <section class="column sender">
+ <h2>Sender</h2>
+ <div class="scrollbox-wrapper">
+ <div id="sender_lines" class="scrollbox monospaced"></div>
+ </div>
+ <div class="send-container">
+ <input id="command_line" class="input" placeholder="Start typing..." />
+ <button id="send_mode" class="btn send-mode-command">Command Mode</button>
+ </div>
+ </section>
+ <section class="column">
+ <h2>Receiver</h2>
+ <div class="scrollbox-wrapper">
+ <div id="receiver_lines" class="scrollbox monospaced"></div>
+ </div>
+ </section>
+ </div>
+ </main>
+</body>
+
+</html>
diff --git a/examples/device/webusb_serial/website/serial.js b/examples/device/webusb_serial/website/serial.js
new file mode 100644
index 000000000..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;
+}