summaryrefslogtreecommitdiff
path: root/examples/device
diff options
context:
space:
mode:
authorraldone01 <[email protected]>2025-07-05 19:42:44 +0200
committerraldone01 <[email protected]>2025-07-05 19:42:44 +0200
commitd3f7dff180507161d0277617edbf423792a6b21e (patch)
tree48464112dbc0c3eee886186f833a5c24629590d1 /examples/device
parent98b975202ced1940e642796f28a868e96dc492cc (diff)
Major overhaul and logic cleanup.
Adds support for web serial as well.
Diffstat (limited to 'examples/device')
-rw-r--r--examples/device/webusb_serial/website/application.js815
-rw-r--r--examples/device/webusb_serial/website/divider.js38
-rw-r--r--examples/device/webusb_serial/website/index.html37
-rw-r--r--examples/device/webusb_serial/website/serial.js302
-rw-r--r--examples/device/webusb_serial/website/style.css25
5 files changed, 716 insertions, 501 deletions
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();
+
+ this.reconnectTimeoutId = null;
+
+ this.commandHistory = [];
+ this.commandHistoryIndex = -1;
+ this.lastCommandCount = 0;
+ this.lastCommand = null;
+ this.lastCommandBtn = null;
+
+ // bind the UI elements
+ connectWebUsbSerialBtn.addEventListener('click', () => this.connectWebUsbSerialPort());
+ connectSerialBtn.addEventListener('click', () => this.connectSerialPort());
+ disconnectBtn.addEventListener('click', () => this.disconnectPort());
+ newlineModeSelect.addEventListener('change', () => this.setNewlineMode());
+ autoReconnectCheckbox.addEventListener('change', () => this.autoReconnectChanged());
+ forgetDeviceBtn.addEventListener('click', () => this.forgetPort());
+ forgetAllDevicesBtn.addEventListener('click', () => this.forgetAllPorts());
+ resetAllBtn.addEventListener('click', () => this.resetAll());
+ resetOutputBtn.addEventListener('click', () => this.resetOutput());
+ copyOutputBtn.addEventListener('click', () => this.copyOutput());
+ commandLineInput.addEventListener('keydown', (e) => this.handleCommandLineInput(e));
+ sendModeBtn.addEventListener('click', () => this.toggleSendMode());
+
+ // restore state from localStorage
+
+ // Restore command history
+ let savedCommandHistory = JSON.parse(localStorage.getItem('commandHistory') || '[]');
+ for (const cmd of savedCommandHistory) {
+ this.appendCommandToHistory(cmd);
+ }
+
+ this.sendMode = localStorage.getItem('sendMode') || 'command';
+ this.setSendMode(this.sendMode);
+
+ autoReconnectCheckbox.checked = localStorage.getItem('autoReconnect') === 'true';
+
+ let savedNewlineMode = localStorage.getItem('newlineMode');
+ if (savedNewlineMode) {
+ newlineModeSelect.value = savedNewlineMode;
+ }
- const history = [];
- let historyIndex = -1;
- let lastCommand = null;
- let lastCommandCount = 0;
- let lastCommandButton = null;
+ this.connectWebUsbSerialPort(true);
+ }
- let sendMode = localStorage.getItem('sendMode') || 'command';
+ 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;
- // track reconnect interval
- let reconnectIntervalId = null;
+ 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);
- // 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;
+ this.lastCommand = text;
+ this.lastCommandBtn = commandHistoryEntryBtn;
- 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;
+ // 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' });
+ });
+ }
+ }
+ }
- const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight);
+ 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);
- }
+ setStatus(msg, level = 'info') {
+ console.log(msg);
+ statusSpan.textContent = msg;
+ statusSpan.className = 'status status-' + level;
+ }
+
+ updateUIConnectionState() {
+ if (this.currentPort && this.currentPort.isConnected) {
+ connectWebUsbSerialBtn.style.display = 'none';
+ connectSerialBtn.style.display = 'none';
+ disconnectBtn.style.display = 'block';
+ commandLineInput.disabled = false;
+ commandLineInput.focus();
+ } else {
+ if (serial.isWebUsbSupported()) {
+ connectWebUsbSerialBtn.style.display = 'block';
+ }
+ if (serial.isWebSerialSupported()) {
+ connectSerialBtn.style.display = 'block';
+ }
+ if (!serial.isWebUsbSupported() && !serial.isWebSerialSupported()) {
+ this.setStatus('Your browser does not support WebUSB or WebSerial', 'error');
+ }
+ disconnectBtn.style.display = 'none';
+ commandLineInput.disabled = true;
+ commandLineInput.value = '';
+ commandLineInput.blur();
+ }
+ }
- // Restore auto reconnect checkbox
- autoReconnectCheckbox.checked = localStorage.getItem('autoReconnect') === 'true';
- // Restore newline mode
- const savedNewlineMode = localStorage.getItem('newlineMode');
- if (savedNewlineMode) newlineModeSelect.value = savedNewlineMode;
+ async disconnectPort() {
+ this.stopAutoReconnect();
+ if (!this.currentPort) return;
- // Format incoming data
- const decodeData = (() => {
- const decoder = new TextDecoder();
- return dataView => decoder.decode(dataView);
- })();
+ try {
+ await this.currentPort.disconnect();
+ }
+ catch (error) {
+ this.setStatus(`Disconnect error: ${error.message}`, 'error');
+ }
- 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;
+ this.updateUIConnectionState();
}
- };
- // 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" });
- });
+ async onReceive(dataView) {
+ this.updateUIConnectionState();
+ let text = this.textDecoder.decode(dataView);
+ text = this.normalizeNewlines(text);
+ this.appendLineToReceived(text);
}
- };
- // Update status text and style
- const setStatus = (msg, level = 'info') => {
- console.log(msg);
- status.textContent = msg;
- status.className = 'status status-' + level;
- };
+ async onReceiveError(error) {
+ this.setStatus(`Read error: ${error.message}`, 'error');
+ await this.disconnectPort();
+ // Start auto reconnect on error if enabled
+ this.tryAutoReconnect();
+ }
- // Disconnect helper
- const disconnectPort = async () => {
- if (port) {
+ async connectSerialPort() {
+ if (!serial.isWebSerialSupported()) {
+ this.setStatus('Serial not supported on this browser', 'error');
+ return;
+ }
try {
- await port.disconnect();
+ 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) {
- setStatus(`Disconnect error: ${error.message}`, 'error');
+ this.setStatus(`Connection failed: ${error.message}`, 'error');
+ if (this.currentPort) {
+ await this.currentPort.forgetDevice();
+ this.currentPort = null;
+ }
}
- 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;
+ async connectWebUsbSerialPort(initial = false) {
+ if (!serial.isWebUsbSupported()) {
+ this.setStatus('WebUSB not supported on this browser', 'error');
+ return;
}
- 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 {
+ try {
+ let first_time_connection = false;
+ let grantedDevices = await serial.getWebUsbSerialPorts();
+ if (initial) {
+ if (!autoReconnectCheckbox.checked || grantedDevices.length === 0) {
return false;
}
+
+ // Connect to the device that was saved to localStorage otherwise use the first one
+ const savedPortInfo = JSON.parse(localStorage.getItem('webUSBSerialPort'));
+ if (savedPortInfo) {
+ for (const device of grantedDevices) {
+ if (device.device.vendorId === savedPortInfo.vendorId && device.device.productId === savedPortInfo.productId) {
+ this.currentPort = device;
+ break;
+ }
+ }
+ }
+ if (!this.currentPort) {
+ this.currentPort = grantedDevices[0];
+ }
+
+ this.setStatus('Connecting to first device...', 'info');
} else {
- // No last port, just use the first available
- port = grantedDevices[0];
- setStatus('Connecting to first device...', 'info');
+ // Prompt the user to select a device
+ this.setStatus('Requesting device...', 'info');
+ this.currentPort = await serial.requestWebUsbSerialPort();
+ first_time_connection = true;
}
- }
- await port.connect();
- // save for reconnecting
- lastPort = port;
+ this.currentPort.onReceiveError = error => this.onReceiveError(error);
+ this.currentPort.onReceive = dataView => this.onReceive(dataView);
- setStatus(`Connected to ${port.device.productName || 'device'}`, 'info');
- connectBtn.textContent = 'Disconnect';
- commandLine.disabled = false;
- commandLine.focus();
+ try {
+ await this.currentPort.connect();
- port.onReceiveError = async error => {
- setStatus(`Read error: ${error.message}`, 'error');
- await disconnectPort();
- // Start auto reconnect on error if enabled
- await tryAutoReconnect();
- };
+ // save the port to localStorage
+ const portInfo = {
+ vendorId: this.currentPort.device.vendorId,
+ productId: this.currentPort.device.productId,
+ }
+ localStorage.setItem('webUSBSerialPort', JSON.stringify(portInfo));
- port.onReceive = dataView => {
- let text = decodeData(dataView);
- text = normalizeNewlines(text, newlineModeSelect.value);
- appendLineToReceiver(receiverLines, text, 'received');
- };
+ this.setStatus('Connected', 'info');
+ } catch (error) {
+ if (first_time_connection) {
+ // Forget the device if a first time connection fails
+ await this.currentPort.forgetDevice();
+ this.currentPort = null;
+ }
+ throw error;
+ }
- return true;
- } catch (error) {
- setStatus(`Connection failed: ${error.message}`, 'error');
- port = null;
- connectBtn.textContent = 'Connect';
- commandLine.disabled = true;
- return false;
+ this.updateUIConnectionState();
+ } catch (error) {
+ this.setStatus(`Connection failed: ${error.message}`, 'error');
+ }
}
- };
- // 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');
- return;
- }
- await disconnectPort();
- const success = await connectPort();
- if (success) {
- clearInterval(reconnectIntervalId);
- reconnectIntervalId = null;
- setStatus('Reconnected successfully.', 'info');
+ 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');
+ }
}
- }, 1000);
- };
+ this.updateUIConnectionState();
+ }
- // Stop auto reconnect immediately
- const stopAutoReconnect = () => {
- if (reconnectIntervalId !== null) {
- clearInterval(reconnectIntervalId);
- reconnectIntervalId = null;
- setStatus('Auto-reconnect stopped.', 'info');
+ 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();
}
- };
- // Connect button click handler
- connectBtn.addEventListener('click', async () => {
- if (!serial.isWebUsbSupported()) {
- setStatus('WebUSB not supported on this browser', 'error');
- return;
+ 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();
}
- if (port) {
- // Disconnect
- stopAutoReconnect();
- await disconnectPort();
- setStatus('Disconnected', 'info');
- return;
+ setNewlineMode() {
+ localStorage.setItem('newlineMode', newlineModeSelect.value);
}
- stopAutoReconnect();
- try {
- // Connect
- const success = await connectPort();
- if (success) {
- setStatus('Connected', 'info');
+ autoReconnectChanged() {
+ if (autoReconnectCheckbox.checked) {
+ this.setStatus('Auto-reconnect enabled', 'info');
+ this.tryAutoReconnect();
+ } else {
+ this.setStatus('Auto-reconnect disabled', 'info');
+ this.stopAutoReconnect();
}
- } catch (error) {
- setStatus(`Connection failed: ${error.message}`, 'error');
- port = null;
- connectBtn.textContent = 'Connect';
- commandLine.disabled = true;
+ localStorage.setItem('autoReconnect', autoReconnectCheckbox.checked);
}
- });
- // 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();
+ stopAutoReconnect() {
+ if (this.reconnectTimeoutId !== null) {
+ clearTimeout(this.reconnectTimeoutId);
+ this.reconnectTimeoutId = null;
+ this.setStatus('Auto-reconnect stopped.', 'info');
}
}
- });
- 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';
+ 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);
}
- 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;
-
- // Instant mode: send key immediately including special keys like Backspace, arrows, enter, etc.
- if (sendMode === 'instant') {
- e.preventDefault();
+ 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' ) {
+ // 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;
+ 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();
+ }
}
- const encoder = new TextEncoder();
- try {
- await port.send(encoder.encode(sendText));
- } catch (error) {
- setStatus(`Send error: ${error.message}`, 'error');
- await disconnectPort();
- await tryAutoReconnect();
- }
+ return;
}
- 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;
+ }
- // Command mode: handle up/down arrow keys for history
- if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+ 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;
- }
- commandLine.value = historyIndex === -1 ? '' : history[historyIndex];
- return;
- }
+ const text = commandLineInput.value;
+ if (!text) return;
- if (e.key !== 'Enter' || !port) return;
- e.preventDefault();
- const text = commandLine.value;
- if (!text) return;
+ // Convert to Uint8Array with newline based on config
+ let sendText = text;
+ switch (newlineModeSelect.value) {
+ case 'CR':
+ sendText += '\r';
+ break;
+ case 'CRLF':
+ sendText += '\r\n';
+ break;
+ case 'ANY':
+ sendText += '\n';
+ break;
+ }
+ const data = this.textEncoder.encode(sendText);
- // 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));
+ 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();
+ }
}
- 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;
+ toggleSendMode() {
+ if (this.sendMode === 'instant') {
+ this.setSendMode('command');
+ } else {
+ this.setSendMode('instant');
+ }
}
- 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();
+ 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);
}
- });
-
- 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');
+ 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;
+ }
}
- });
- // 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();
+ 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');
}
- 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);
+ resetOutput() {
+ receivedDataScrollbox.innerHTML = '';
}
- });
+ async resetAll() {
+ await this.forgetAllPorts();
- // Disable input on load
- commandLine.disabled = true;
+ // Clear localStorage
+ for (const key in localStorage) {
+ localStorage.removeItem(key);
+ }
- // 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);
+ // 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 @@
<link rel="stylesheet" href="style.css" />
<script defer src="serial.js"></script>
<script defer src="application.js"></script>
+ <script defer src="divider.js"></script>
</head>
<body>
@@ -20,44 +21,48 @@
</header>
<main>
<section class="controls-section">
- <button id="connect" class="btn good">Connect</button>
- <label for="newline_mode">
+ <button id="connect_webusb_serial_btn" class="controls btn good">Connect WebUSB</button>
+ <button id="connect_serial_btn" class="controls btn good">Connect Serial</button>
+ <button id="disconnect_btn" class="controls btn danger">Disconnect</button>
+ <label for="newline_mode_select" class="controls">
Newline mode:
- <select id="newline_mode">
+ <select id="newline_mode_select">
<option value="CR">Only \r</option>
<option value="CRLF">\r\n</option>
<option value="ANY" selected>\r, \n or \r\n</option>
</select>
</label>
- <label for="auto_reconnect">
- <input type="checkbox" id="auto_reconnect" />
+ <label for="auto_reconnect_checkbox" class="controls">
+ <input type="checkbox" id="auto_reconnect_checkbox" />
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>
+ <button id="forget_device_btn" class="controls btn danger">Forget Device</button>
+ <button id="forget_all_devices_btn" class="controls btn danger">Forget All Devices</button>
+ <button id="reset_all_btn" class="controls btn danger">Reset All</button>
+ <button id="reset_output_btn" class="controls btn danger">Reset Output</button>
+ <button id="copy_output_btn" class="controls btn good">Copy Output</button>
</section>
<section class="status-section">
- <span id="status" class="status">
+ <span id="status_span" class="status">
Click "Connect" to start
</span>
</section>
<div class="io-container">
<section class="column sender">
- <h2>Sender</h2>
+ <h2>Command History</h2>
<div class="scrollbox-wrapper">
- <div id="sender_lines" class="scrollbox monospaced"></div>
+ <div id="command_history_scrollbox" class="scrollbox monospaced"></div>
</div>
<div class="send-container">
- <input id="command_line" class="input" placeholder="Start typing..." autocomplete="off" />
- <button id="send_mode" class="btn send-mode-command">Command Mode</button>
+ <input id="command_line_input" class="input" placeholder="Start typing..." autocomplete="off" disabled />
+ <button id="send_mode_btn" class="btn send-mode-command">Command Mode</button>
</div>
</section>
+ <div class="resizer" id="resizer"></div>
<section class="column">
- <h2>Receiver</h2>
+ <h2>Received Data</h2>
<div class="scrollbox-wrapper">
- <div id="receiver_lines" class="scrollbox monospaced"></div>
+ <div id="received_data_scrollbox" class="scrollbox monospaced"></div>
</div>
</section>
</div>
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,104 +1,197 @@
'use strict';
-const serial = {
- isWebUsbSupported: () => 'usb' in navigator,
+/// 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;
+ }
- // 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));
- },
+ /// Connect and start reading loop
+ async connect(options = { baudRate: 9600 }) {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+ await this.port.open(options);
- // 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);
- },
+ this.readableStreamClosed = this.port.readable;
+ this.reader = this.port.readable.getReader();
+
+ this.writer = this.port.writable.getWriter();
+ this.isConnected = true;
+ this.readLoop = this._readLoop();
+ }
- Port: class {
- constructor(device) {
- this.device = device;
- this.interfaceNumber = 0;
- this.endpointIn = 0;
- this.endpointOut = 0;
- this.readLoopActive = false;
+ /// 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);
+ }
}
+ }
- 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;
+ async _waitForReadLoopToFinish() {
+ if (this.readLoop) {
+ try {
+ await this.readLoop;
+ } catch (error) {}
+ this.readLoop = null;
}
+ }
- // Connect and start reading loop
- async connect() {
- await this.device.open();
+ /// Stop reading and release port
+ async disconnect() {
+ this.isConnected = false;
+ await this._waitForReadLoopToFinish();
- if (!this.device.configuration) {
- await this.device.selectConfiguration(1);
+ 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) { }
- // 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.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.');
- }
+ if (this.interfaceNumber === undefined) {
+ throw new Error('No suitable interface found.');
+ }
- await this.device.claimInterface(this.interfaceNumber);
- await this.device.selectAlternateInterface(this.interfaceNumber, 0);
+ 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,
- });
+ // 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();
+ }
- this.readLoopActive = true;
- this._readLoop();
+ async _waitForReadLoopToFinish() {
+ if (this.readLoop) {
+ try {
+ await this.readLoop;
+ } catch (error) {}
+ this.readLoop = null;
}
+ }
- // 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);
- }
+ /// 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.readLoopActive = false;
+ /// Stop reading and release device
+ async disconnect() {
+ this.isConnected = false;
+ await this._waitForReadLoopToFinish();
+ try {
await this.device.controlTransferOut({
requestType: 'class',
recipient: 'interface',
@@ -106,19 +199,54 @@ const serial = {
value: 0x00,
index: this.interfaceNumber,
});
- await this.device.close();
+ } catch (error) {
+ console.log(error);
}
+ await this.device.close();
+ }
- // Send data to device
- send(data) {
- return this.device.transferOut(this.endpointOut, data);
- }
+ /// 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();
- }
+ async forgetDevice() {
+ await this.disconnect();
+ await this.device.forget();
+ }
+}
+
+// Utility Functions
+const serial = {
+ isWebSerialSupported: () => 'serial' in navigator,
+ isWebUsbSupported: () => 'usb' in navigator,
+
+ async getSerialPorts() {
+ if (!this.isWebSerialSupported()) return [];
+ const ports = await navigator.serial.getPorts();
+ return ports.map(port => new SerialPort(port));
+ },
+
+ async getWebUsbSerialPorts() {
+ if (!this.isWebUsbSupported()) return [];
+ const devices = await navigator.usb.getDevices();
+ return devices.map(device => new WebUsbSerialPort(device));
+ },
+
+ async requestSerialPort() {
+ const port = await navigator.serial.requestPort();
+ return new SerialPort(port);
+ },
+
+ async requestWebUsbSerialPort() {
+ const filters = [
+ { vendorId: 0xcafe }, // TinyUSB
+ { vendorId: 0x239a }, // Adafruit
+ { vendorId: 0x2e8a }, // Raspberry Pi
+ { vendorId: 0x303a }, // Espressif
+ { vendorId: 0x2341 }, // Arduino
+ ];
+ const device = await navigator.usb.requestDevice({ filters });
+ return new WebUsbSerialPort(device);
}
};
diff --git a/examples/device/webusb_serial/website/style.css b/examples/device/webusb_serial/website/style.css
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%;
+}