Major overhaul and logic cleanup.

Adds support for web serial as well.
This commit is contained in:
raldone01
2025-07-05 19:42:44 +02:00
parent 98b975202c
commit d3f7dff180
5 changed files with 769 additions and 554 deletions

View File

@@ -1,471 +1,512 @@
'use strict'; 'use strict';
(() => { (async () => {
const connectBtn = document.getElementById('connect'); // bind to the html
const resetAllBtn = document.getElementById('reset_all'); const connectWebUsbSerialBtn = document.getElementById('connect_webusb_serial_btn');
const resetOutputBtn = document.getElementById('reset_output'); const connectSerialBtn = document.getElementById('connect_serial_btn');
const senderLines = document.getElementById('sender_lines'); const disconnectBtn = document.getElementById('disconnect_btn');
const receiverLines = document.getElementById('receiver_lines');
const commandLine = document.getElementById('command_line'); const newlineModeSelect = document.getElementById('newline_mode_select');
const status = document.getElementById('status'); const autoReconnectCheckbox = document.getElementById('auto_reconnect_checkbox');
const newlineModeSelect = document.getElementById('newline_mode'); const forgetDeviceBtn = document.getElementById('forget_device_btn');
const sendModeBtn = document.getElementById('send_mode'); const forgetAllDevicesBtn = document.getElementById('forget_all_devices_btn');
const autoReconnectCheckbox = document.getElementById('auto_reconnect'); const resetAllBtn = document.getElementById('reset_all_btn');
const forgetDeviceBtn = document.getElementById('forget_device'); const resetOutputBtn = document.getElementById('reset_output_btn');
const forgetAllDevicesBtn = document.getElementById('forget_all_devices'); 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 const nearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll
let port = null; class Application {
let lastPort = null; // for reconnecting and initial connection constructor() {
this.currentPort = null;
this.textEncoder = new TextEncoder();
this.textDecoder = new TextDecoder();
const history = []; this.reconnectTimeoutId = null;
let historyIndex = -1;
let lastCommand = null;
let lastCommandCount = 0;
let lastCommandButton = null;
let sendMode = localStorage.getItem('sendMode') || 'command'; this.commandHistory = [];
this.commandHistoryIndex = -1;
this.lastCommandCount = 0;
this.lastCommand = null;
this.lastCommandBtn = null;
// track reconnect interval // bind the UI elements
let reconnectIntervalId = null; 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 // restore state from localStorage
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'); // Restore command history
commandEl.className = 'sender-entry'; let savedCommandHistory = JSON.parse(localStorage.getItem('commandHistory') || '[]');
commandEl.type = 'button'; for (const cmd of savedCommandHistory) {
commandEl.textContent = text; this.appendCommandToHistory(cmd);
commandEl.addEventListener('click', () => { }
commandLine.value = text;
commandLine.focus();
});
container.appendChild(commandEl);
lastCommandButton = commandEl;
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) { if (distanceFromBottom < nearTheBottomThreshold) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
commandEl.scrollIntoView({ behavior: 'instant' }); div.scrollIntoView({ behavior: 'instant' });
}); });
} }
} }
};
// Restore command history setStatus(msg, level = 'info') {
history.push(...(JSON.parse(localStorage.getItem('commandHistory') || '[]'))); console.log(msg);
for (const cmd of history) { statusSpan.textContent = msg;
appendCommandToSender(senderLines, cmd); statusSpan.className = 'status status-' + level;
}
// 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 updateUIConnectionState() {
const appendLineToReceiver = (container, text, className = '') => { if (this.currentPort && this.currentPort.isConnected) {
const div = document.createElement('div'); connectWebUsbSerialBtn.style.display = 'none';
if (className) div.className = className; connectSerialBtn.style.display = 'none';
div.textContent = text; disconnectBtn.style.display = 'block';
container.appendChild(div); commandLineInput.disabled = false;
commandLineInput.focus();
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();
} else { } else {
if (lastPort) { if (serial.isWebUsbSupported()) {
// Try to reconnect to the last used port connectWebUsbSerialBtn.style.display = 'block';
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.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(); this.updateUIConnectionState();
// 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;
} }
};
// Start auto reconnect interval if checkbox is checked and not already running async onReceive(dataView) {
const tryAutoReconnect = async () => { this.updateUIConnectionState();
if (!autoReconnectCheckbox.checked) return; let text = this.textDecoder.decode(dataView);
if (reconnectIntervalId !== null) return; // already trying text = this.normalizeNewlines(text);
setStatus('Attempting to auto-reconnect...', 'info'); this.appendLineToReceived(text);
reconnectIntervalId = setInterval(async () => { }
if (!autoReconnectCheckbox.checked) {
clearInterval(reconnectIntervalId); async onReceiveError(error) {
reconnectIntervalId = null; this.setStatus(`Read error: ${error.message}`, 'error');
setStatus('Auto-reconnect stopped.', 'info'); 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; return;
} }
await disconnectPort(); try {
const success = await connectPort(); this.setStatus('Requesting device...', 'info');
if (success) { this.currentPort = await serial.requestSerialPort();
clearInterval(reconnectIntervalId); this.currentPort.onReceiveError = error => this.onReceiveError(error);
reconnectIntervalId = null; this.currentPort.onReceive = dataView => this.onReceive(dataView);
setStatus('Reconnected successfully.', 'info'); await this.currentPort.connect();
} this.setStatus('Connected', 'info');
}, 1000); } catch (error) {
}; this.setStatus(`Connection failed: ${error.message}`, 'error');
if (this.currentPort) {
// Stop auto reconnect immediately await this.currentPort.forgetDevice();
const stopAutoReconnect = () => { this.currentPort = null;
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();
} }
} }
});
sendModeBtn.addEventListener('click', () => { async connectWebUsbSerialPort(initial = false) {
if (sendMode === 'command') { if (!serial.isWebUsbSupported()) {
sendMode = 'instant'; this.setStatus('WebUSB not supported on this browser', 'error');
sendModeBtn.classList.remove('send-mode-command'); return;
sendModeBtn.classList.add('send-mode-instant'); }
sendModeBtn.textContent = 'Instant mode'; try {
// In instant mode, we clear the command line let first_time_connection = false;
commandLine.value = ''; let grantedDevices = await serial.getWebUsbSerialPorts();
} else { if (initial) {
sendMode = 'command'; if (!autoReconnectCheckbox.checked || grantedDevices.length === 0) {
sendModeBtn.classList.remove('send-mode-instant'); return false;
sendModeBtn.classList.add('send-mode-command'); }
sendModeBtn.textContent = 'Command mode';
}
localStorage.setItem('sendMode', sendMode);
});
// Set initial sendMode button state // Connect to the device that was saved to localStorage otherwise use the first one
if (sendMode === 'instant') { const savedPortInfo = JSON.parse(localStorage.getItem('webUSBSerialPort'));
sendModeBtn.classList.remove('send-mode-command'); if (savedPortInfo) {
sendModeBtn.classList.add('send-mode-instant'); for (const device of grantedDevices) {
sendModeBtn.textContent = 'Instant mode'; if (device.device.vendorId === savedPortInfo.vendorId && device.device.productId === savedPortInfo.productId) {
} this.currentPort = device;
break;
// 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': if (!this.currentPort) {
// Usually no straightforward char to send for Backspace, this.currentPort = grantedDevices[0];
// but often ASCII DEL '\x7F' or '\b' (0x08) is sent. }
sendText = '\x08'; // backspace
break; this.setStatus('Connecting to first device...', 'info');
case 'Tab': } else {
sendText = '\t'; // Prompt the user to select a device
break; this.setStatus('Requesting device...', 'info');
case 'Escape': this.currentPort = await serial.requestWebUsbSerialPort();
// Ignore or send ESC control char if needed first_time_connection = true;
sendText = '\x1B';
break;
case 'Delete':
sendText = '\x7F'; // DEL char
break;
default:
sendText = e.key;
} }
const encoder = new TextEncoder(); this.currentPort.onReceiveError = error => this.onReceiveError(error);
this.currentPort.onReceive = dataView => this.onReceive(dataView);
try { 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) { } catch (error) {
setStatus(`Send error: ${error.message}`, 'error'); if (first_time_connection) {
await disconnectPort(); // Forget the device if a first time connection fails
await tryAutoReconnect(); 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');
} }
} }
this.updateUIConnectionState();
return;
} }
// Command mode: handle up/down arrow keys for history async forgetPort() {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { 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(); e.preventDefault();
if (history.length === 0) return; const text = commandLineInput.value;
if (e.key === 'ArrowUp') { if (!text) return;
if (historyIndex === -1) historyIndex = history.length - 1;
else if (historyIndex > 0) historyIndex--; // Convert to Uint8Array with newline based on config
} else if (e.key === 'ArrowDown') { let sendText = text;
if (historyIndex !== -1) historyIndex++; switch (newlineModeSelect.value) {
if (historyIndex >= history.length) historyIndex = -1; case 'CR':
sendText += '\r';
break;
case 'CRLF':
sendText += '\r\n';
break;
case 'ANY':
sendText += '\n';
break;
} }
commandLine.value = historyIndex === -1 ? '' : history[historyIndex]; const data = this.textEncoder.encode(sendText);
return;
}
if (e.key !== 'Enter' || !port) return; try {
e.preventDefault(); await this.currentPort.send(data);
const text = commandLine.value; this.commandHistoryIndex = -1;
if (!text) return; this.appendCommandToHistory(sendText.replace(/[\r\n]+$/, ''));
commandLineInput.value = '';
// Add command to history, ignore duplicate consecutive } catch (error) {
if (history.length === 0 || history[history.length - 1] !== text) { this.setStatus(`Send error: ${error.message}`, 'error');
history.push(text); this.tryAutoReconnect();
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();
} }
setStatus('All devices forgotten', 'info');
} else {
setStatus('No devices to forget', 'error');
} }
});
// Reset output button clears receiver toggleSendMode() {
resetOutputBtn.addEventListener('click', () => { if (this.sendMode === 'instant') {
receiverLines.innerHTML = ''; this.setSendMode('command');
}); } else {
this.setSendMode('instant');
// 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);
} }
});
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 normalizeNewlines(text) {
commandLine.disabled = true; 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 copyOutput() {
if (!serial.isWebUsbSupported()) { const text = receivedDataScrollbox.innerText;
setStatus('WebUSB not supported on this browser', 'error'); if (text) {
} else { navigator.clipboard.writeText(text).then(() => {
// try to connect to any available device this.setStatus('Output copied to clipboard', 'info');
connectPort(true); }, () => {
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();
})()

View File

@@ -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
}
});

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<script defer src="serial.js"></script> <script defer src="serial.js"></script>
<script defer src="application.js"></script> <script defer src="application.js"></script>
<script defer src="divider.js"></script>
</head> </head>
<body> <body>
@@ -20,44 +21,48 @@
</header> </header>
<main> <main>
<section class="controls-section"> <section class="controls-section">
<button id="connect" class="btn good">Connect</button> <button id="connect_webusb_serial_btn" class="controls btn good">Connect WebUSB</button>
<label for="newline_mode"> <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: Newline mode:
<select id="newline_mode"> <select id="newline_mode_select">
<option value="CR">Only \r</option> <option value="CR">Only \r</option>
<option value="CRLF">\r\n</option> <option value="CRLF">\r\n</option>
<option value="ANY" selected>\r, \n or \r\n</option> <option value="ANY" selected>\r, \n or \r\n</option>
</select> </select>
</label> </label>
<label for="auto_reconnect"> <label for="auto_reconnect_checkbox" class="controls">
<input type="checkbox" id="auto_reconnect" /> <input type="checkbox" id="auto_reconnect_checkbox" />
Auto Reconnect Auto Reconnect
</label> </label>
<button id="forget_device" class="btn danger">Forget Device</button> <button id="forget_device_btn" class="controls btn danger">Forget Device</button>
<button id="forget_all_devices" class="btn danger">Forget All Devices</button> <button id="forget_all_devices_btn" class="controls btn danger">Forget All Devices</button>
<button id="reset_all" class="btn danger">Reset All</button> <button id="reset_all_btn" class="controls btn danger">Reset All</button>
<button id="reset_output" class="btn danger">Reset Output</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>
<section class="status-section"> <section class="status-section">
<span id="status" class="status"> <span id="status_span" class="status">
Click "Connect" to start Click "Connect" to start
</span> </span>
</section> </section>
<div class="io-container"> <div class="io-container">
<section class="column sender"> <section class="column sender">
<h2>Sender</h2> <h2>Command History</h2>
<div class="scrollbox-wrapper"> <div class="scrollbox-wrapper">
<div id="sender_lines" class="scrollbox monospaced"></div> <div id="command_history_scrollbox" class="scrollbox monospaced"></div>
</div> </div>
<div class="send-container"> <div class="send-container">
<input id="command_line" class="input" placeholder="Start typing..." autocomplete="off" /> <input id="command_line_input" class="input" placeholder="Start typing..." autocomplete="off" disabled />
<button id="send_mode" class="btn send-mode-command">Command Mode</button> <button id="send_mode_btn" class="btn send-mode-command">Command Mode</button>
</div> </div>
</section> </section>
<div class="resizer" id="resizer"></div>
<section class="column"> <section class="column">
<h2>Receiver</h2> <h2>Received Data</h2>
<div class="scrollbox-wrapper"> <div class="scrollbox-wrapper">
<div id="receiver_lines" class="scrollbox monospaced"></div> <div id="received_data_scrollbox" class="scrollbox monospaced"></div>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1,16 +1,244 @@
'use strict'; '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 = { const serial = {
isWebSerialSupported: () => 'serial' in navigator,
isWebUsbSupported: () => 'usb' in navigator, isWebUsbSupported: () => 'usb' in navigator,
// Returns array of connected devices wrapped as serial.Port instances async getSerialPorts() {
async getPorts() { if (!this.isWebSerialSupported()) return [];
const devices = await navigator.usb.getDevices(); const ports = await navigator.serial.getPorts();
return devices.map(device => new serial.Port(device)); return ports.map(port => new SerialPort(port));
}, },
// Prompts user to select a device matching filters and wraps it in serial.Port async getWebUsbSerialPorts() {
async requestPort() { 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 = [ const filters = [
{ vendorId: 0xcafe }, // TinyUSB { vendorId: 0xcafe }, // TinyUSB
{ vendorId: 0x239a }, // Adafruit { vendorId: 0x239a }, // Adafruit
@@ -19,106 +247,6 @@ const serial = {
{ vendorId: 0x2341 }, // Arduino { vendorId: 0x2341 }, // Arduino
]; ];
const device = await navigator.usb.requestDevice({ filters }); const device = await navigator.usb.requestDevice({ filters });
return new serial.Port(device); return new WebUsbSerialPort(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();
}
} }
}; };

View File

@@ -43,6 +43,10 @@ main {
.controls-section, .status-section { .controls-section, .status-section {
padding: 1rem; padding: 1rem;
flex-shrink: 0; flex-shrink: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
} }
/* Container for the two columns */ /* Container for the two columns */
@@ -51,6 +55,7 @@ main {
flex: 1; flex: 1;
/* fill remaining vertical space */ /* fill remaining vertical space */
width: 100%; width: 100%;
overflow: hidden;
} }
/* Both columns flex equally and full height */ /* Both columns flex equally and full height */
@@ -67,7 +72,7 @@ main {
flex-direction: column; flex-direction: column;
} }
.sender-entry { .command-history-entry {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -85,7 +90,7 @@ main {
color: inherit; color: inherit;
} }
.sender-entry:hover { .command-history-entry:hover {
background-color: #f0f0f0; background-color: #f0f0f0;
} }
@@ -133,15 +138,6 @@ main {
background-color: blue; background-color: blue;
} }
/* UI Styles */
.controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
flex: 1
}
.btn { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 1rem; font-size: 1rem;
@@ -186,3 +182,10 @@ main {
box-shadow: 0 0 6px rgba(0, 120, 215, 0.5); box-shadow: 0 0 6px rgba(0, 120, 215, 0.5);
background-color: #fff; background-color: #fff;
} }
.resizer {
width: 5px;
background-color: #ccc;
cursor: col-resize;
height: 100%;
}