Major overhaul and logic cleanup.
Adds support for web serial as well.
This commit is contained in:
@@ -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();
|
||||||
|
})()
|
||||||
|
38
examples/device/webusb_serial/website/divider.js
Normal file
38
examples/device/webusb_serial/website/divider.js
Normal 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
|
||||||
|
}
|
||||||
|
});
|
@@ -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>
|
||||||
|
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -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%;
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user