762 lines
23 KiB
JavaScript
762 lines
23 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 terminalContainer = document.getElementById('terminal-container');
|
|
|
|
// 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');
|
|
const addSessionBtn = document.getElementById('add-session-btn');
|
|
const sessionListContainer = document.getElementById('session-list');
|
|
const downloadTypeSelect = document.getElementById('download-type');
|
|
|
|
// PortSession Class definition
|
|
class PortSession {
|
|
constructor(id, name) {
|
|
this.id = id;
|
|
this.name = name;
|
|
|
|
// Web Serial State
|
|
this.port = null;
|
|
this.reader = null;
|
|
this.writer = null;
|
|
this.keepReading = false;
|
|
this.rxLogBuffer = [];
|
|
this.txLogBuffer = [];
|
|
this.combinedLogBuffer = [];
|
|
this.downloadType = 'rx_tx';
|
|
this.commandHistory = [];
|
|
this.historyIndex = -1;
|
|
this.isTerminalEmpty = true;
|
|
this.deviceInfo = '';
|
|
|
|
// Settings state
|
|
this.baudRate = '115200';
|
|
this.dataBits = '8';
|
|
this.stopBits = '1';
|
|
this.parity = 'none';
|
|
|
|
// Console controls
|
|
this.scrollMode = 'auto';
|
|
this.displayFormat = 'auto';
|
|
this.sendFormat = 'ascii';
|
|
this.sendEol = 'crlf';
|
|
this.sendText = '';
|
|
|
|
// Dedicated Terminal Element
|
|
this.terminalOutputElement = document.createElement('div');
|
|
this.terminalOutputElement.id = `terminal-output-${this.id}`;
|
|
this.terminalOutputElement.className = 'terminal-output empty';
|
|
this.terminalOutputElement.tabIndex = 0;
|
|
this.terminalOutputElement.textContent = 'No data received yet...';
|
|
}
|
|
}
|
|
|
|
// Global Application State
|
|
let sessions = [];
|
|
let activeSession = null;
|
|
|
|
// Initialize theme and browser support on load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initTheme();
|
|
checkWebSerialSupport();
|
|
|
|
// Wire up main event listeners
|
|
addSessionBtn.addEventListener('click', addSession);
|
|
|
|
baudRateSelect.addEventListener('change', () => {
|
|
if (activeSession) {
|
|
activeSession.baudRate = baudRateSelect.value;
|
|
renderSessionsList();
|
|
}
|
|
});
|
|
|
|
dataBitsSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
|
|
});
|
|
|
|
stopBitsSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
|
|
});
|
|
|
|
paritySelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.parity = paritySelect.value;
|
|
});
|
|
|
|
scrollModeSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
|
|
});
|
|
|
|
displayFormatSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
|
|
});
|
|
|
|
sendFormatSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
|
|
});
|
|
|
|
sendEolSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.sendEol = sendEolSelect.value;
|
|
});
|
|
|
|
sendInput.addEventListener('input', () => {
|
|
if (activeSession) activeSession.sendText = sendInput.value;
|
|
});
|
|
|
|
downloadTypeSelect.addEventListener('change', () => {
|
|
if (activeSession) activeSession.downloadType = downloadTypeSelect.value;
|
|
});
|
|
|
|
// Initialize with a default session
|
|
addSession();
|
|
});
|
|
|
|
// 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';
|
|
addSessionBtn.disabled = true;
|
|
addSessionBtn.style.opacity = '0.5';
|
|
addSessionBtn.style.cursor = 'not-allowed';
|
|
showToast('Web Serial API is not supported in this browser.', 'error');
|
|
}
|
|
}
|
|
|
|
function updateConnectionUI(connected, errorMsg = '') {
|
|
if (!activeSession) return;
|
|
|
|
if (connected && activeSession.port) {
|
|
connectBtn.className = 'connect-btn connected';
|
|
connectBtn.title = 'Disconnect from serial port';
|
|
statusBanner.className = 'status-banner connected';
|
|
|
|
const info = activeSession.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 ${activeSession.baudRate} 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 Session Management
|
|
// -------------------------------------------------------------
|
|
function addSession() {
|
|
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
|
const name = `Session ${sessions.length + 1}`;
|
|
const newSession = new PortSession(id, name);
|
|
|
|
sessions.push(newSession);
|
|
terminalContainer.appendChild(newSession.terminalOutputElement);
|
|
|
|
selectSession(newSession);
|
|
showToast(`${name} created.`, 'success');
|
|
}
|
|
|
|
function selectSession(session) {
|
|
if (activeSession) {
|
|
activeSession.sendText = sendInput.value;
|
|
}
|
|
|
|
activeSession = session;
|
|
renderSessionsList();
|
|
|
|
// Show only active session's terminal DOM element
|
|
const terminals = terminalContainer.querySelectorAll('.terminal-output');
|
|
terminals.forEach(el => {
|
|
if (el.id === activeSession.terminalOutputElement.id) {
|
|
el.classList.remove('hidden');
|
|
} else {
|
|
el.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Sync dropdown UI values
|
|
baudRateSelect.value = activeSession.baudRate;
|
|
dataBitsSelect.value = activeSession.dataBits;
|
|
stopBitsSelect.value = activeSession.stopBits;
|
|
paritySelect.value = activeSession.parity;
|
|
|
|
scrollModeSelect.value = activeSession.scrollMode;
|
|
displayFormatSelect.value = activeSession.displayFormat;
|
|
sendFormatSelect.value = activeSession.sendFormat;
|
|
sendEolSelect.value = activeSession.sendEol;
|
|
sendInput.value = activeSession.sendText;
|
|
downloadTypeSelect.value = activeSession.downloadType;
|
|
|
|
downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0);
|
|
|
|
// Sync status banner and buttons
|
|
const isConnected = activeSession.port !== null && activeSession.port.readable;
|
|
updateConnectionUI(isConnected);
|
|
}
|
|
|
|
async function deleteSession(session, event) {
|
|
if (event) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
if (sessions.length <= 1) {
|
|
showToast('Cannot delete the last remaining session.', 'error');
|
|
return;
|
|
}
|
|
|
|
// Disconnect if connected
|
|
if (session.port) {
|
|
await disconnectSession(session);
|
|
}
|
|
|
|
// Remove terminal from DOM
|
|
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
|
|
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
|
|
}
|
|
|
|
const index = sessions.indexOf(session);
|
|
if (index > -1) {
|
|
sessions.splice(index, 1);
|
|
}
|
|
|
|
if (activeSession === session) {
|
|
const nextActive = sessions[Math.max(0, index - 1)];
|
|
selectSession(nextActive);
|
|
} else {
|
|
renderSessionsList();
|
|
}
|
|
|
|
showToast(`${session.name} deleted.`, 'success');
|
|
}
|
|
|
|
function renderSessionsList() {
|
|
sessionListContainer.innerHTML = '';
|
|
|
|
sessions.forEach(session => {
|
|
const item = document.createElement('div');
|
|
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
|
|
item.addEventListener('click', () => selectSession(session));
|
|
|
|
const leftGroup = document.createElement('div');
|
|
leftGroup.className = 'session-item-left';
|
|
|
|
// Status dot
|
|
const dot = document.createElement('span');
|
|
const isConnected = session.port !== null && session.port.readable;
|
|
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
|
|
leftGroup.appendChild(dot);
|
|
|
|
// Session labels
|
|
const info = document.createElement('div');
|
|
info.className = 'session-info';
|
|
|
|
const nameEl = document.createElement('span');
|
|
nameEl.className = 'session-name';
|
|
nameEl.textContent = session.name;
|
|
|
|
// Double click to rename
|
|
nameEl.addEventListener('dblclick', (e) => {
|
|
e.stopPropagation();
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.value = session.name;
|
|
input.className = 'session-rename-input';
|
|
|
|
const saveRename = () => {
|
|
const val = input.value.trim();
|
|
if (val) {
|
|
session.name = val;
|
|
}
|
|
renderSessionsList();
|
|
};
|
|
|
|
input.addEventListener('keydown', (evt) => {
|
|
if (evt.key === 'Enter') {
|
|
saveRename();
|
|
} else if (evt.key === 'Escape') {
|
|
renderSessionsList();
|
|
}
|
|
});
|
|
|
|
input.addEventListener('blur', saveRename);
|
|
|
|
info.innerHTML = '';
|
|
info.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
});
|
|
|
|
const detailEl = document.createElement('span');
|
|
detailEl.className = 'session-detail';
|
|
if (isConnected) {
|
|
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
|
|
} else {
|
|
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
|
|
}
|
|
|
|
info.appendChild(nameEl);
|
|
info.appendChild(detailEl);
|
|
leftGroup.appendChild(info);
|
|
item.appendChild(leftGroup);
|
|
|
|
// Close / Delete Session button
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'session-delete-btn';
|
|
deleteBtn.title = 'Delete Session';
|
|
deleteBtn.innerHTML = `
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
`;
|
|
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
|
|
item.appendChild(deleteBtn);
|
|
|
|
sessionListContainer.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 4. Serial Port Connection & Reader Loop
|
|
// -------------------------------------------------------------
|
|
async function connectSession(session) {
|
|
try {
|
|
session.port = await navigator.serial.requestPort();
|
|
|
|
const options = {
|
|
baudRate: parseInt(session.baudRate, 10),
|
|
dataBits: parseInt(session.dataBits, 10),
|
|
stopBits: parseInt(session.stopBits, 10),
|
|
parity: session.parity
|
|
};
|
|
|
|
await session.port.open(options);
|
|
|
|
const info = session.port.getInfo();
|
|
if (info.usbVendorId) {
|
|
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
|
|
} else {
|
|
session.deviceInfo = 'Serial Device';
|
|
}
|
|
|
|
if (session === activeSession) {
|
|
updateConnectionUI(true);
|
|
}
|
|
renderSessionsList();
|
|
|
|
session.keepReading = true;
|
|
startSessionReadingLoop(session);
|
|
|
|
showToast(`${session.name} connected.`, 'success');
|
|
} catch (err) {
|
|
console.error('Connection failed:', err);
|
|
session.port = null;
|
|
if (session === activeSession) {
|
|
updateConnectionUI(false, `Connection failed: ${err.message}`);
|
|
}
|
|
showToast(`Failed to open port for ${session.name}.`, 'error');
|
|
}
|
|
}
|
|
|
|
async function disconnectSession(session) {
|
|
session.keepReading = false;
|
|
|
|
if (session.reader) {
|
|
try {
|
|
await session.reader.cancel();
|
|
} catch (e) {
|
|
console.error('Error cancelling reader:', e);
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
setTimeout(async () => {
|
|
if (session.port) {
|
|
try {
|
|
await session.port.close();
|
|
} catch (e) {
|
|
console.error('Error closing port:', e);
|
|
}
|
|
session.port = null;
|
|
}
|
|
session.deviceInfo = '';
|
|
if (session === activeSession) {
|
|
updateConnectionUI(false);
|
|
}
|
|
renderSessionsList();
|
|
showToast(`${session.name} disconnected.`, 'success');
|
|
resolve();
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
async function startSessionReadingLoop(session) {
|
|
const decoder = new TextDecoder();
|
|
|
|
while (session.port && session.port.readable && session.keepReading) {
|
|
try {
|
|
session.reader = session.port.readable.getReader();
|
|
|
|
while (session.keepReading) {
|
|
const { value, done } = await session.reader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
handleIncomingSessionData(session, value, decoder);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Read error:', err);
|
|
if (session === activeSession) {
|
|
updateConnectionUI(false, `Port disconnected: ${err.message}`);
|
|
}
|
|
showToast(`${session.name} disconnected unexpectedly.`, 'error');
|
|
session.port = null;
|
|
renderSessionsList();
|
|
break;
|
|
} finally {
|
|
if (session.reader) {
|
|
session.reader.releaseLock();
|
|
session.reader = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleIncomingSessionData(session, uint8Array, decoder) {
|
|
const format = session.displayFormat;
|
|
let formattedText = '';
|
|
|
|
if (format === 'hex') {
|
|
formattedText = Array.from(uint8Array)
|
|
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
|
.join(' ') + ' ';
|
|
} else {
|
|
formattedText = decoder.decode(uint8Array);
|
|
}
|
|
|
|
session.rxLogBuffer.push(formattedText);
|
|
session.combinedLogBuffer.push(formattedText);
|
|
appendSessionTerminalText(session, formattedText, 'received');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 5. Writing Data (Transmission)
|
|
// -------------------------------------------------------------
|
|
async function sendSessionData() {
|
|
if (!activeSession) return;
|
|
const session = activeSession;
|
|
|
|
if (!session.port || !session.port.writable) {
|
|
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
|
|
return;
|
|
}
|
|
|
|
const rawInput = sendInput.value;
|
|
if (!rawInput) return;
|
|
|
|
// Add command to history (maximum 10 items)
|
|
const trimmed = rawInput.trim();
|
|
if (trimmed) {
|
|
const history = session.commandHistory;
|
|
if (history.length === 0 || history[history.length - 1] !== trimmed) {
|
|
history.push(trimmed);
|
|
if (history.length > 10) {
|
|
history.shift();
|
|
}
|
|
}
|
|
session.historyIndex = history.length;
|
|
}
|
|
|
|
const format = session.sendFormat;
|
|
const eol = session.sendEol;
|
|
let bytesToSend;
|
|
|
|
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 {
|
|
session.writer = session.port.writable.getWriter();
|
|
await session.writer.write(bytesToSend);
|
|
|
|
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';
|
|
}
|
|
|
|
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
|
|
|
|
// Save sent log
|
|
session.txLogBuffer.push(`[Sent] ${displaySent}\n`);
|
|
session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`);
|
|
|
|
sendInput.value = '';
|
|
session.sendText = '';
|
|
|
|
} catch (err) {
|
|
console.error('Send error:', err);
|
|
showToast(`Failed to send: ${err.message}`, 'error');
|
|
} finally {
|
|
if (session.writer) {
|
|
session.writer.releaseLock();
|
|
session.writer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseHexString(hexString) {
|
|
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;
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 6. Terminal Console Operations
|
|
// -------------------------------------------------------------
|
|
function appendSessionTerminalText(session, text, className = '') {
|
|
if (session.isTerminalEmpty) {
|
|
session.terminalOutputElement.textContent = '';
|
|
session.terminalOutputElement.classList.remove('empty');
|
|
session.isTerminalEmpty = false;
|
|
if (session === activeSession) {
|
|
downloadBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
const span = document.createElement('span');
|
|
if (className) {
|
|
span.className = `terminal-line ${className}`;
|
|
}
|
|
span.textContent = text;
|
|
session.terminalOutputElement.appendChild(span);
|
|
|
|
if (session.scrollMode === 'auto') {
|
|
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function clearTerminal() {
|
|
if (!activeSession) return;
|
|
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
|
|
activeSession.terminalOutputElement.classList.add('empty');
|
|
activeSession.isTerminalEmpty = true;
|
|
activeSession.rxLogBuffer = [];
|
|
activeSession.txLogBuffer = [];
|
|
activeSession.combinedLogBuffer = [];
|
|
downloadBtn.disabled = true;
|
|
showToast('Terminal cleared.', 'success');
|
|
}
|
|
|
|
function downloadLog() {
|
|
if (!activeSession) return;
|
|
|
|
let content = '';
|
|
let filenameSuffix = '';
|
|
|
|
if (activeSession.downloadType === 'rx') {
|
|
content = activeSession.rxLogBuffer.join('');
|
|
filenameSuffix = 'rx';
|
|
} else if (activeSession.downloadType === 'tx') {
|
|
content = activeSession.txLogBuffer.join('');
|
|
filenameSuffix = 'tx';
|
|
} else {
|
|
content = activeSession.combinedLogBuffer.join('');
|
|
filenameSuffix = 'combined';
|
|
}
|
|
|
|
if (!content) {
|
|
showToast('No logs to download for the selected type.', 'error');
|
|
return;
|
|
}
|
|
|
|
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_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
|
|
setTimeout(() => {
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}, 100);
|
|
|
|
showToast('Download started.', 'success');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 7. Event Listeners & Keyboard Shortcuts
|
|
// -------------------------------------------------------------
|
|
connectBtn.addEventListener('click', () => {
|
|
if (activeSession) {
|
|
if (activeSession.port) {
|
|
disconnectSession(activeSession);
|
|
} else {
|
|
connectSession(activeSession);
|
|
}
|
|
}
|
|
});
|
|
|
|
sendBtn.addEventListener('click', sendSessionData);
|
|
clearBtn.addEventListener('click', clearTerminal);
|
|
downloadBtn.addEventListener('click', downloadLog);
|
|
|
|
sendInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
sendSessionData();
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (activeSession && activeSession.commandHistory.length > 0) {
|
|
if (activeSession.historyIndex > 0) {
|
|
activeSession.historyIndex--;
|
|
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
|
|
activeSession.sendText = sendInput.value;
|
|
}
|
|
}
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (activeSession && activeSession.commandHistory.length > 0) {
|
|
if (activeSession.historyIndex < activeSession.commandHistory.length - 1) {
|
|
activeSession.historyIndex++;
|
|
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
|
|
activeSession.sendText = sendInput.value;
|
|
} else {
|
|
activeSession.historyIndex = activeSession.commandHistory.length;
|
|
sendInput.value = '';
|
|
activeSession.sendText = '';
|
|
}
|
|
}
|
|
}
|
|
if (e.key === 'Tab' && e.ctrlKey) {
|
|
e.preventDefault();
|
|
const start = sendInput.selectionStart;
|
|
const end = sendInput.selectionEnd;
|
|
|
|
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
|
|
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
|
|
if (activeSession) activeSession.sendText = sendInput.value;
|
|
}
|
|
});
|
|
|
|
// Auto-detect browser USB disconnect events globally
|
|
if ('serial' in navigator) {
|
|
navigator.serial.addEventListener('disconnect', async (event) => {
|
|
const disconnectedSession = sessions.find(s => s.port === event.target);
|
|
if (disconnectedSession) {
|
|
await disconnectSession(disconnectedSession);
|
|
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
|
|
}
|
|
});
|
|
}
|