472 lines
14 KiB
JavaScript
472 lines
14 KiB
JavaScript
'use strict';
|
||
|
||
(() => {
|
||
const connectBtn = document.getElementById('connect');
|
||
const resetAllBtn = document.getElementById('reset_all');
|
||
const resetOutputBtn = document.getElementById('reset_output');
|
||
const senderLines = document.getElementById('sender_lines');
|
||
const receiverLines = document.getElementById('receiver_lines');
|
||
const commandLine = document.getElementById('command_line');
|
||
const status = document.getElementById('status');
|
||
const newlineModeSelect = document.getElementById('newline_mode');
|
||
const sendModeBtn = document.getElementById('send_mode');
|
||
const autoReconnectCheckbox = document.getElementById('auto_reconnect');
|
||
const forgetDeviceBtn = document.getElementById('forget_device');
|
||
const forgetAllDevicesBtn = document.getElementById('forget_all_devices');
|
||
|
||
const nearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll
|
||
|
||
let port = null;
|
||
let lastPort = null; // for reconnecting and initial connection
|
||
|
||
const history = [];
|
||
let historyIndex = -1;
|
||
let lastCommand = null;
|
||
let lastCommandCount = 0;
|
||
let lastCommandButton = null;
|
||
|
||
let sendMode = localStorage.getItem('sendMode') || 'command';
|
||
|
||
// track reconnect interval
|
||
let reconnectIntervalId = null;
|
||
|
||
// Append sent command to sender container as a clickable element
|
||
const appendCommandToSender = (container, text) => {
|
||
if (text === lastCommand) {
|
||
// Increment count and update button
|
||
lastCommandCount++;
|
||
lastCommandButton.textContent = `${text} ×${lastCommandCount}`;
|
||
} else {
|
||
// Reset count and add new button
|
||
lastCommand = text;
|
||
lastCommandCount = 1;
|
||
|
||
const commandEl = document.createElement('button');
|
||
commandEl.className = 'sender-entry';
|
||
commandEl.type = 'button';
|
||
commandEl.textContent = text;
|
||
commandEl.addEventListener('click', () => {
|
||
commandLine.value = text;
|
||
commandLine.focus();
|
||
});
|
||
container.appendChild(commandEl);
|
||
lastCommandButton = commandEl;
|
||
|
||
const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||
if (distanceFromBottom < nearTheBottomThreshold) {
|
||
requestAnimationFrame(() => {
|
||
commandEl.scrollIntoView({ behavior: 'instant' });
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// Restore command history
|
||
history.push(...(JSON.parse(localStorage.getItem('commandHistory') || '[]')));
|
||
for (const cmd of history) {
|
||
appendCommandToSender(senderLines, cmd);
|
||
}
|
||
|
||
// 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
|
||
const appendLineToReceiver = (container, text, className = '') => {
|
||
const div = document.createElement('div');
|
||
if (className) div.className = className;
|
||
div.textContent = text;
|
||
container.appendChild(div);
|
||
|
||
const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||
if (distanceFromBottom < nearTheBottomThreshold) {
|
||
requestAnimationFrame(() => {
|
||
div.scrollIntoView({ behavior: "instant" });
|
||
});
|
||
}
|
||
};
|
||
|
||
// Update status text and style
|
||
const setStatus = (msg, level = 'info') => {
|
||
console.log(msg);
|
||
status.textContent = msg;
|
||
status.className = 'status status-' + level;
|
||
};
|
||
|
||
// Disconnect helper
|
||
const disconnectPort = async () => {
|
||
if (port) {
|
||
try {
|
||
await port.disconnect();
|
||
} catch (error) {
|
||
setStatus(`Disconnect error: ${error.message}`, 'error');
|
||
}
|
||
port = null;
|
||
connectBtn.textContent = 'Connect';
|
||
commandLine.disabled = true;
|
||
}
|
||
};
|
||
|
||
// Connect helper
|
||
const connectPort = async (initial = false) => {
|
||
try {
|
||
let grantedDevices = await serial.getPorts();
|
||
if (grantedDevices.length === 0 && initial) {
|
||
return false;
|
||
}
|
||
if (grantedDevices.length === 0) {
|
||
// No previously granted devices, request a new one
|
||
setStatus('Requesting device...', 'info');
|
||
port = await serial.requestPort();
|
||
} else {
|
||
if (lastPort) {
|
||
// Try to reconnect to the last used port
|
||
const matchingPort = grantedDevices.find(p => p.portPointToSameDevice(lastPort));
|
||
if (matchingPort) {
|
||
port = matchingPort;
|
||
setStatus('Reconnecting to last device...', 'info');
|
||
} else {
|
||
return false;
|
||
}
|
||
} else {
|
||
// No last port, just use the first available
|
||
port = grantedDevices[0];
|
||
setStatus('Connecting to first device...', 'info');
|
||
}
|
||
}
|
||
|
||
await port.connect();
|
||
// 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
|
||
const tryAutoReconnect = async () => {
|
||
if (!autoReconnectCheckbox.checked) return;
|
||
if (reconnectIntervalId !== null) return; // already trying
|
||
setStatus('Attempting to auto-reconnect...', 'info');
|
||
reconnectIntervalId = setInterval(async () => {
|
||
if (!autoReconnectCheckbox.checked) {
|
||
clearInterval(reconnectIntervalId);
|
||
reconnectIntervalId = null;
|
||
setStatus('Auto-reconnect stopped.', 'info');
|
||
return;
|
||
}
|
||
await disconnectPort();
|
||
const success = await connectPort();
|
||
if (success) {
|
||
clearInterval(reconnectIntervalId);
|
||
reconnectIntervalId = null;
|
||
setStatus('Reconnected successfully.', 'info');
|
||
}
|
||
}, 1000);
|
||
};
|
||
|
||
// Stop auto reconnect immediately
|
||
const stopAutoReconnect = () => {
|
||
if (reconnectIntervalId !== null) {
|
||
clearInterval(reconnectIntervalId);
|
||
reconnectIntervalId = null;
|
||
setStatus('Auto-reconnect stopped.', 'info');
|
||
}
|
||
};
|
||
|
||
// Connect button click handler
|
||
connectBtn.addEventListener('click', async () => {
|
||
if (!serial.isWebUsbSupported()) {
|
||
setStatus('WebUSB not supported on this browser', 'error');
|
||
return;
|
||
}
|
||
|
||
if (port) {
|
||
// Disconnect
|
||
stopAutoReconnect();
|
||
await disconnectPort();
|
||
setStatus('Disconnected', 'info');
|
||
return;
|
||
}
|
||
|
||
stopAutoReconnect();
|
||
try {
|
||
// Connect
|
||
const success = await connectPort();
|
||
if (success) {
|
||
setStatus('Connected', 'info');
|
||
}
|
||
} catch (error) {
|
||
setStatus(`Connection failed: ${error.message}`, 'error');
|
||
port = null;
|
||
connectBtn.textContent = 'Connect';
|
||
commandLine.disabled = true;
|
||
}
|
||
});
|
||
|
||
// Checkbox toggle stops auto reconnect if unchecked
|
||
autoReconnectCheckbox.addEventListener('change', 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', () => {
|
||
if (sendMode === 'command') {
|
||
sendMode = 'instant';
|
||
sendModeBtn.classList.remove('send-mode-command');
|
||
sendModeBtn.classList.add('send-mode-instant');
|
||
sendModeBtn.textContent = 'Instant mode';
|
||
// In instant mode, we clear the command line
|
||
commandLine.value = '';
|
||
} else {
|
||
sendMode = 'command';
|
||
sendModeBtn.classList.remove('send-mode-instant');
|
||
sendModeBtn.classList.add('send-mode-command');
|
||
sendModeBtn.textContent = 'Command mode';
|
||
}
|
||
localStorage.setItem('sendMode', sendMode);
|
||
});
|
||
|
||
// Set initial sendMode button state
|
||
if (sendMode === 'instant') {
|
||
sendModeBtn.classList.remove('send-mode-command');
|
||
sendModeBtn.classList.add('send-mode-instant');
|
||
sendModeBtn.textContent = 'Instant mode';
|
||
}
|
||
|
||
// Send command line input on Enter
|
||
commandLine.addEventListener('keydown', async e => {
|
||
if (!port) return;
|
||
|
||
// Instant mode: send key immediately including special keys like Backspace, arrows, enter, etc.
|
||
if (sendMode === 'instant') {
|
||
e.preventDefault();
|
||
|
||
// Ignore only pure modifier keys without text representation
|
||
if (e.key.length === 1 ||
|
||
e.key === 'Enter' ||
|
||
e.key === 'Backspace' ||
|
||
e.key === 'Tab' ||
|
||
e.key === 'Escape' ||
|
||
e.key === 'Delete' ) {
|
||
|
||
let sendText = '';
|
||
switch (e.key) {
|
||
case 'Enter':
|
||
switch (newlineModeSelect.value) {
|
||
case 'CR': sendText = '\r'; break;
|
||
case 'CRLF': sendText = '\r\n'; break;
|
||
default: sendText = '\n'; break;
|
||
}
|
||
break;
|
||
case 'Backspace':
|
||
// Usually no straightforward char to send for Backspace,
|
||
// but often ASCII DEL '\x7F' or '\b' (0x08) is sent.
|
||
sendText = '\x08'; // backspace
|
||
break;
|
||
case 'Tab':
|
||
sendText = '\t';
|
||
break;
|
||
case 'Escape':
|
||
// Ignore or send ESC control char if needed
|
||
sendText = '\x1B';
|
||
break;
|
||
case 'Delete':
|
||
sendText = '\x7F'; // DEL char
|
||
break;
|
||
default:
|
||
sendText = e.key;
|
||
}
|
||
|
||
const encoder = new TextEncoder();
|
||
try {
|
||
await port.send(encoder.encode(sendText));
|
||
} catch (error) {
|
||
setStatus(`Send error: ${error.message}`, 'error');
|
||
await disconnectPort();
|
||
await tryAutoReconnect();
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// Command mode: handle up/down arrow keys for history
|
||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (history.length === 0) return;
|
||
if (e.key === 'ArrowUp') {
|
||
if (historyIndex === -1) historyIndex = history.length - 1;
|
||
else if (historyIndex > 0) historyIndex--;
|
||
} else if (e.key === 'ArrowDown') {
|
||
if (historyIndex !== -1) historyIndex++;
|
||
if (historyIndex >= history.length) historyIndex = -1;
|
||
}
|
||
commandLine.value = historyIndex === -1 ? '' : history[historyIndex];
|
||
return;
|
||
}
|
||
|
||
if (e.key !== 'Enter' || !port) return;
|
||
e.preventDefault();
|
||
const text = commandLine.value;
|
||
if (!text) return;
|
||
|
||
// Add command to history, ignore duplicate consecutive
|
||
if (history.length === 0 || history[history.length - 1] !== text) {
|
||
history.push(text);
|
||
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
|
||
resetOutputBtn.addEventListener('click', () => {
|
||
receiverLines.innerHTML = '';
|
||
});
|
||
|
||
// Reset button clears sender and receiver
|
||
resetAllBtn.addEventListener('click', () => {
|
||
senderLines.innerHTML = '';
|
||
receiverLines.innerHTML = '';
|
||
lastCommand = null;
|
||
lastCommandCount = 0;
|
||
lastCommandButton = null;
|
||
history.length = 0;
|
||
historyIndex = -1;
|
||
|
||
// iterate and delete localStorage items
|
||
for (const key in localStorage) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
});
|
||
|
||
|
||
// Disable input on load
|
||
commandLine.disabled = true;
|
||
|
||
// Show warning if no WebUSB support
|
||
if (!serial.isWebUsbSupported()) {
|
||
setStatus('WebUSB not supported on this browser', 'error');
|
||
} else {
|
||
// try to connect to any available device
|
||
connectPort(true);
|
||
}
|
||
})();
|