419 lines
12 KiB
JavaScript
419 lines
12 KiB
JavaScript
// DOM Elements Selection
|
|
const themeToggle = document.getElementById('theme-toggle');
|
|
const connectBtn = document.getElementById('connect-btn');
|
|
const downloadBtn = document.getElementById('download-btn');
|
|
const statusBanner = document.getElementById('status-banner');
|
|
const statusText = document.getElementById('status-text');
|
|
|
|
// Settings Dropdowns
|
|
const baudRateSelect = document.getElementById('baud-rate');
|
|
const dataBitsSelect = document.getElementById('data-bits');
|
|
const stopBitsSelect = document.getElementById('stop-bits');
|
|
const paritySelect = document.getElementById('parity');
|
|
|
|
// Controls & Console
|
|
const scrollModeSelect = document.getElementById('scroll-mode');
|
|
const displayFormatSelect = document.getElementById('display-format');
|
|
const terminalOutput = document.getElementById('terminal-output');
|
|
|
|
// Sending Inputs
|
|
const sendInput = document.getElementById('send-input');
|
|
const sendFormatSelect = document.getElementById('send-format');
|
|
const sendEolSelect = document.getElementById('send-eol');
|
|
const sendBtn = document.getElementById('send-btn');
|
|
const clearBtn = document.getElementById('clear-btn');
|
|
|
|
// App Serial State Variables
|
|
let port = null;
|
|
let reader = null;
|
|
let writer = null;
|
|
let keepReading = false;
|
|
let receivedLogBuffer = []; // Accumulates all received characters/data for download log
|
|
let isTerminalEmpty = true;
|
|
|
|
// Initialize theme and browser support on load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initTheme();
|
|
checkWebSerialSupport();
|
|
});
|
|
|
|
// Toast notification helper
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast-msg show toast-${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 1. Theme Configuration (Dark / Light Mode)
|
|
// -------------------------------------------------------------
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem('theme') || 'dark';
|
|
if (savedTheme === 'light') {
|
|
document.body.classList.add('light-theme');
|
|
} else {
|
|
document.body.classList.remove('light-theme');
|
|
}
|
|
}
|
|
|
|
themeToggle.addEventListener('click', () => {
|
|
document.body.classList.toggle('light-theme');
|
|
const activeTheme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
|
|
localStorage.setItem('theme', activeTheme);
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// 2. Web Serial API Checks & State Updates
|
|
// -------------------------------------------------------------
|
|
function checkWebSerialSupport() {
|
|
if (!('serial' in navigator)) {
|
|
statusBanner.className = 'status-banner error';
|
|
statusText.innerHTML = '<strong>Web Serial API not supported.</strong> Please use a compatible Chromium-based browser (Chrome, Edge, Opera).';
|
|
connectBtn.disabled = true;
|
|
connectBtn.style.opacity = '0.5';
|
|
connectBtn.style.cursor = 'not-allowed';
|
|
showToast('Web Serial API is not supported in this browser.', 'error');
|
|
}
|
|
}
|
|
|
|
function updateConnectionUI(connected, errorMsg = '') {
|
|
if (connected) {
|
|
connectBtn.className = 'connect-btn connected';
|
|
connectBtn.title = 'Disconnect from serial port';
|
|
statusBanner.className = 'status-banner connected';
|
|
|
|
const info = port.getInfo();
|
|
const portLabel = info.usbVendorId ? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})` : 'Serial Device';
|
|
|
|
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${baudRateSelect.value} bps.`;
|
|
} else {
|
|
connectBtn.className = 'connect-btn disconnected';
|
|
connectBtn.title = 'Connect to serial port';
|
|
|
|
if (errorMsg) {
|
|
statusBanner.className = 'status-banner error';
|
|
statusText.textContent = errorMsg;
|
|
} else {
|
|
statusBanner.className = 'status-banner warn';
|
|
statusText.textContent = 'Not connected. Click the connect button to select a port.';
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 3. Serial Port Connection & Reader Loop
|
|
// -------------------------------------------------------------
|
|
async function connectSerial() {
|
|
try {
|
|
// Request a port from the user
|
|
port = await navigator.serial.requestPort();
|
|
|
|
const options = {
|
|
baudRate: parseInt(baudRateSelect.value, 10),
|
|
dataBits: parseInt(dataBitsSelect.value, 10),
|
|
stopBits: parseInt(stopBitsSelect.value, 10),
|
|
parity: paritySelect.value
|
|
};
|
|
|
|
// Open connection
|
|
await port.open(options);
|
|
|
|
updateConnectionUI(true);
|
|
keepReading = true;
|
|
startReadingLoop();
|
|
|
|
} catch (err) {
|
|
console.error('Connection failed:', err);
|
|
updateConnectionUI(false, `Connection failed: ${err.message}`);
|
|
showToast('Failed to open port.', 'error');
|
|
}
|
|
}
|
|
|
|
async function disconnectSerial() {
|
|
keepReading = false;
|
|
|
|
if (reader) {
|
|
try {
|
|
await reader.cancel();
|
|
} catch (e) {
|
|
console.error('Error cancelling reader:', e);
|
|
}
|
|
}
|
|
|
|
// Wait a short moment for loops to clear
|
|
setTimeout(async () => {
|
|
if (port) {
|
|
try {
|
|
await port.close();
|
|
} catch (e) {
|
|
console.error('Error closing port:', e);
|
|
}
|
|
port = null;
|
|
}
|
|
updateConnectionUI(false);
|
|
showToast('Disconnected from port.', 'success');
|
|
}, 100);
|
|
}
|
|
|
|
// Background read loop
|
|
async function startReadingLoop() {
|
|
const decoder = new TextDecoder();
|
|
|
|
while (port && port.readable && keepReading) {
|
|
try {
|
|
reader = port.readable.getReader();
|
|
|
|
while (keepReading) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
handleIncomingData(value, decoder);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Read error:', err);
|
|
updateConnectionUI(false, `Port disconnected: ${err.message}`);
|
|
showToast('Serial device disconnected.', 'error');
|
|
break;
|
|
} finally {
|
|
if (reader) {
|
|
reader.releaseLock();
|
|
reader = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle incoming bytes
|
|
function handleIncomingData(uint8Array, decoder) {
|
|
const format = displayFormatSelect.value;
|
|
let formattedText = '';
|
|
|
|
if (format === 'hex') {
|
|
formattedText = Array.from(uint8Array)
|
|
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
|
.join(' ') + ' ';
|
|
} else {
|
|
// text or auto mode
|
|
formattedText = decoder.decode(uint8Array);
|
|
}
|
|
|
|
// Save to download log buffer
|
|
// Always store string representation
|
|
receivedLogBuffer.push(formattedText);
|
|
|
|
// Display in terminal UI
|
|
appendTerminalText(formattedText, 'received');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 4. Writing Data (Transmission)
|
|
// -------------------------------------------------------------
|
|
async function sendData() {
|
|
if (!port || !port.writable) {
|
|
showToast('Cannot send. Serial port is not connected.', 'error');
|
|
return;
|
|
}
|
|
|
|
const rawInput = sendInput.value;
|
|
if (!rawInput) return;
|
|
|
|
const format = sendFormatSelect.value;
|
|
const eol = sendEolSelect.value;
|
|
let bytesToSend;
|
|
|
|
// Determine EOL bytes/string
|
|
let eolText = '';
|
|
let eolBytes = new Uint8Array(0);
|
|
if (eol === 'lf') {
|
|
eolText = '\n';
|
|
eolBytes = new Uint8Array([10]);
|
|
} else if (eol === 'cr') {
|
|
eolText = '\r';
|
|
eolBytes = new Uint8Array([13]);
|
|
} else if (eol === 'crlf') {
|
|
eolText = '\r\n';
|
|
eolBytes = new Uint8Array([13, 10]);
|
|
}
|
|
|
|
try {
|
|
if (format === 'hex') {
|
|
const parsedBytes = parseHexString(rawInput);
|
|
bytesToSend = new Uint8Array(parsedBytes.length + eolBytes.length);
|
|
bytesToSend.set(parsedBytes);
|
|
bytesToSend.set(eolBytes, parsedBytes.length);
|
|
} else {
|
|
bytesToSend = new TextEncoder().encode(rawInput + eolText);
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
writer = port.writable.getWriter();
|
|
await writer.write(bytesToSend);
|
|
|
|
// Log transmission visually on the terminal
|
|
let displaySent = '';
|
|
if (format === 'hex') {
|
|
displaySent = Array.from(bytesToSend)
|
|
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
|
.join(' ') + ' ';
|
|
} else {
|
|
displaySent = rawInput;
|
|
if (eol === 'lf') displaySent += '\\n';
|
|
else if (eol === 'cr') displaySent += '\\r';
|
|
else if (eol === 'crlf') displaySent += '\\r\\n';
|
|
}
|
|
|
|
appendTerminalText(`\n[Sent] ${displaySent}\n`, 'sent');
|
|
sendInput.value = ''; // clear input field
|
|
|
|
} catch (err) {
|
|
console.error('Send error:', err);
|
|
showToast(`Failed to send: ${err.message}`, 'error');
|
|
} finally {
|
|
if (writer) {
|
|
writer.releaseLock();
|
|
writer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hex Parsing Utility
|
|
function parseHexString(hexString) {
|
|
// Remove all white spaces
|
|
const clean = hexString.replace(/\s+/g, '');
|
|
|
|
if (!/^[0-9a-fA-F]*$/.test(clean)) {
|
|
throw new Error('Hex contains invalid characters (0-9, A-F only)');
|
|
}
|
|
|
|
if (clean.length % 2 !== 0) {
|
|
throw new Error('Hex string must have an even number of characters');
|
|
}
|
|
|
|
const result = new Uint8Array(clean.length / 2);
|
|
for (let i = 0; i < clean.length; i += 2) {
|
|
result[i / 2] = parseInt(clean.substring(i, i + 2), 16);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 5. Terminal Console Operations (Append, Clear, Scroll)
|
|
// -------------------------------------------------------------
|
|
function appendTerminalText(text, className = '') {
|
|
if (isTerminalEmpty) {
|
|
terminalOutput.textContent = '';
|
|
terminalOutput.classList.remove('empty');
|
|
isTerminalEmpty = false;
|
|
downloadBtn.disabled = false;
|
|
}
|
|
|
|
// Create text container
|
|
const span = document.createElement('span');
|
|
if (className) {
|
|
span.className = `terminal-line ${className}`;
|
|
}
|
|
span.textContent = text;
|
|
terminalOutput.appendChild(span);
|
|
|
|
// Auto-scroll handler
|
|
if (scrollModeSelect.value === 'auto') {
|
|
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// Clear terminal function
|
|
function clearTerminal() {
|
|
terminalOutput.innerHTML = 'No data received yet...';
|
|
terminalOutput.classList.add('empty');
|
|
isTerminalEmpty = true;
|
|
receivedLogBuffer = [];
|
|
downloadBtn.disabled = true;
|
|
showToast('Terminal cleared.', 'success');
|
|
}
|
|
|
|
// Download received console logs
|
|
function downloadLog() {
|
|
if (receivedLogBuffer.length === 0) return;
|
|
|
|
const content = receivedLogBuffer.join('');
|
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const dateStr = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `uart_log_${dateStr}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
|
|
setTimeout(() => {
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}, 100);
|
|
|
|
showToast('Download started.', 'success');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 6. Event Listeners & Keyboard Shortcuts
|
|
// -------------------------------------------------------------
|
|
connectBtn.addEventListener('click', () => {
|
|
if (port) {
|
|
disconnectSerial();
|
|
} else {
|
|
connectSerial();
|
|
}
|
|
});
|
|
|
|
sendBtn.addEventListener('click', sendData);
|
|
clearBtn.addEventListener('click', clearTerminal);
|
|
downloadBtn.addEventListener('click', downloadLog);
|
|
|
|
// Input Keyboard Shortcuts
|
|
sendInput.addEventListener('keydown', (e) => {
|
|
// Ctrl + Enter or Enter (standard text input submit) to send
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
sendData();
|
|
}
|
|
// Ctrl + Tab to insert actual tab characters
|
|
if (e.key === 'Tab' && e.ctrlKey) {
|
|
e.preventDefault();
|
|
const start = sendInput.selectionStart;
|
|
const end = sendInput.selectionEnd;
|
|
|
|
// Set input value: [text before tab] + \t + [text after tab]
|
|
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
|
|
|
|
// Put caret in right position
|
|
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
|
|
}
|
|
});
|
|
|
|
// Auto-detect browser USB connect/disconnect events
|
|
if ('serial' in navigator) {
|
|
navigator.serial.addEventListener('connect', (event) => {
|
|
showToast('Serial device connected to port.', 'success');
|
|
});
|
|
|
|
navigator.serial.addEventListener('disconnect', (event) => {
|
|
if (port && event.target === port) {
|
|
disconnectSerial();
|
|
updateConnectionUI(false, 'Selected serial device was disconnected.');
|
|
showToast('Device connection lost.', 'error');
|
|
}
|
|
});
|
|
}
|