UNPKG

serialconsole

Version:

Cross-platform serial port monitor and console with TUI interface, auto-reconnect, and hex viewer. Ideal for embedded systems debugging.

1,374 lines (1,171 loc) โ€ข 43.1 kB
#!/usr/bin/env node import { SerialPort } from 'serialport'; import { ReadlineParser } from '@serialport/parser-readline'; import chalk from 'chalk'; import { Command } from 'commander'; import blessed from 'blessed'; import readline from 'readline'; // Command: Responsive TUI Monitor with adaptive layout async function tuiMonitor(portPath, options) { let port; let parser; let bytesReceived = 0; let bytesSent = 0; let messagesReceived = 0; let startTime = Date.now(); // Settings let showHex = true; let showStats = true; let pauseLogging = false; let autoReconnect = true; // Reconnection state let isReconnecting = false; let reconnectAttempts = 0; let reconnectTimer = null; let maxReconnectDelay = 30000; let reconnectStartTime = null; let currentReconnectDelay = 0; // UI State let screenWidth = 80; let screenHeight = 24; let compactMode = false; let isShuttingDown = false; let updateTimer = null; let renderScheduled = false; // Create the screen with proper key handling const screen = blessed.screen({ smartCSR: true, title: `ByteStream Monitor - ${portPath}`, fullUnicode: true, dockBorders: true, autoPadding: true }); // UI State management let components = {}; let keyHandlers = []; let screenHandlersAttached = false; // Optimized rendering function function scheduleRender() { if (!renderScheduled && !isShuttingDown) { renderScheduled = true; setImmediate(() => { if (!isShuttingDown) { try { screen.render(); } catch (error) { // Ignore rendering errors during shutdown } } renderScheduled = false; }); } } // Dynamic layout calculator function calculateLayout() { screenWidth = screen.width || 80; screenHeight = screen.height || 24; compactMode = screenWidth < 100 || screenHeight < 20; const layout = { // Header takes 1 row header: { top: 0, height: 1 }, // Status bar: 2-4 rows depending on space and mode status: { top: 1, height: compactMode ? 2 : (showStats ? 4 : 3) }, // Main content area (accounts for 2 help rows at bottom) content: { top: compactMode ? 3 : (showStats ? 5 : 4), height: Math.max(3, screenHeight - (compactMode ? 5 : (showStats ? 7 : 6))) }, // Help bar: 2 rows at bottom (like nano) help: { bottom: 0, height: 2 }, // Data panel width (responsive) dataWidth: showHex ? (compactMode ? '60%' : '70%') : '100%', hexWidth: showHex ? (compactMode ? '40%' : '30%') : '0%' }; return layout; } // Enhanced cleanup function for components function cleanupComponents() { Object.values(components).forEach(component => { if (component) { try { // Remove all event listeners before destroying if (component.removeAllListeners) { component.removeAllListeners(); } if (component.destroy) { component.destroy(); } } catch (error) { // Ignore cleanup errors } } }); components = {}; } // Create UI components with proper error handling function createComponents() { if (isShuttingDown) return; try { const layout = calculateLayout(); // Header with gradient background components.header = blessed.box({ parent: screen, top: layout.header.top, left: 0, width: '100%', height: layout.header.height, content: `{center}{bold}{white-fg}{blue-bg} ๐Ÿš€ ByteStream Monitor - ${portPath} {/}{/}{/}`, tags: true, style: { bg: 'blue' } }); // Responsive status panel components.statusPanel = blessed.box({ parent: screen, top: layout.status.top, left: 0, width: '100%', height: layout.status.height, border: { type: 'line' }, label: ' Connection & Statistics ', tags: true, style: { fg: 'white', border: { fg: 'cyan' } } }); // Main data area with responsive width components.dataArea = blessed.log({ parent: screen, top: layout.content.top, left: 0, width: layout.dataWidth, height: layout.content.height, border: { type: 'line' }, label: ` ๐Ÿ“ก Serial Data ${compactMode ? '' : '(ASCII)'} `, tags: true, scrollable: true, alwaysScroll: true, mouse: true, style: { fg: 'white', border: { fg: 'yellow' } } }); // Hex area (collapsible) if (showHex) { components.hexArea = blessed.log({ parent: screen, top: layout.content.top, left: layout.dataWidth, width: layout.hexWidth, height: layout.content.height, border: { type: 'line' }, label: ` ๐Ÿ” Hex ${compactMode ? '' : 'View'} `, tags: true, scrollable: true, alwaysScroll: true, mouse: true, style: { fg: 'white', border: { fg: 'magenta' } } }); } // Permanent help bar with working keys (2 rows) components.helpBar1 = blessed.box({ parent: screen, bottom: 1, left: 0, width: '100%', height: 1, content: compactMode ? '{white-fg}{black-bg} Q{/} Exit {white-fg}{black-bg} I{/} Send {white-fg}{black-bg} H{/} Hex {white-fg}{black-bg} S{/} Stats {white-fg}{black-bg} P{/} Pause {white-fg}{black-bg} C{/} Clear {/}' : '{white-fg}{black-bg} Q{/} Exit {white-fg}{black-bg} I{/} Send Msg {white-fg}{black-bg} H{/} Hex View {white-fg}{black-bg} S{/} Statistics {white-fg}{black-bg} P{/} Pause {white-fg}{black-bg} C{/} Clear {/}', tags: true, style: { fg: 'white', bg: 'black' } }); components.helpBar2 = blessed.box({ parent: screen, bottom: 0, left: 0, width: '100%', height: 1, content: compactMode ? '{white-fg}{black-bg} R{/} Reconnect {white-fg}{black-bg} ?{/} Help {/}' : '{white-fg}{black-bg} R{/} Auto-Reconnect {white-fg}{black-bg} ?{/} Help {/}', tags: true, style: { fg: 'white', bg: 'black' } }); } catch (error) { if (!isShuttingDown) { addMessage(`โš ๏ธ UI creation error: ${error.message}`, 'yellow'); } } } // Handle terminal resize with better error handling function handleResize() { if (isShuttingDown) return; try { // Cleanup old components properly cleanupComponents(); // Recreate with new layout createComponents(); updateStatusDisplay(); scheduleRender(); } catch (error) { if (!isShuttingDown) { console.error('Resize error:', error.message); } } } // Enhanced message display with better null checking function addMessage(message, color = 'white') { if (isShuttingDown || !components.dataArea) return; try { // Always show system messages (like pause notifications) even when paused const isSystemMessage = color === 'yellow' || color === 'cyan' || color === 'red'; if (pauseLogging && !isSystemMessage) { return; // Skip regular messages when paused } // Create responsive timestamp const now = new Date(); const timeStr = now.toTimeString().substring(0, 8); const ms = String(now.getMilliseconds()).padStart(3, '0'); const [hours, minutes, seconds] = timeStr.split(':'); // Adaptive timestamp format const timestamp = compactMode ? `{#666666-fg}[{/}{#4169E1-fg}${hours.substring(1)}{/}{#666666-fg}:{/}{#1E90FF-fg}${minutes}{/}{#666666-fg}:{/}{#00BFFF-fg}${seconds}{/}{#666666-fg}]{/}` : `{#666666-fg}[{/}{#4169E1-fg}${hours}{/}{#666666-fg}:{/}{#1E90FF-fg}${minutes}{/}{#666666-fg}:{/}{#00BFFF-fg}${seconds}{/}{#666666-fg}.{/}{#87CEEB-fg}${ms}{/}{#666666-fg}]{/}`; // Ensure message is a string and handle null/undefined const messageStr = String(message || ''); const coloredMsg = color === 'white' ? `{white-fg}${messageStr}{/white-fg}` : `{${color}-fg}${messageStr}{/${color}-fg}`; // Responsive message formatting const maxLength = Math.max(20, screenWidth - (compactMode ? 15 : 25)); const truncatedMsg = messageStr.length > maxLength ? messageStr.substring(0, maxLength - 3) + '...' : messageStr; const finalMsg = compactMode ? `{${color}-fg}${truncatedMsg}{/${color}-fg}` : coloredMsg; components.dataArea.log(`${timestamp} ${finalMsg}`); scheduleRender(); } catch (error) { // Ignore message display errors to prevent cascading failures } } // Enhanced hex display with better error handling function addHexData(data) { if (isShuttingDown || !showHex || pauseLogging || !components.hexArea || !data) return; try { const hex = Array.from(data) .map(byte => byte.toString(16).padStart(2, '0').toUpperCase()) .join(' '); // Responsive hex formatting const now = new Date(); const timeStr = now.toTimeString().substring(0, 8); const ms = String(now.getMilliseconds()).padStart(3, '0'); const [hours, minutes, seconds] = timeStr.split(':'); const timestamp = compactMode ? `{#666666-fg}[{/}{#4169E1-fg}${seconds}{/}{#666666-fg}]{/}` : `{#666666-fg}[{/}{#4169E1-fg}${hours}{/}{#666666-fg}:{/}{#1E90FF-fg}${minutes}{/}{#666666-fg}:{/}{#00BFFF-fg}${seconds}{/}{#666666-fg}.{/}{#87CEEB-fg}${ms}{/}{#666666-fg}]{/}`; // Responsive hex length const maxHexLength = Math.max(10, Math.floor((screenWidth * 0.3) / 3)); const displayHex = hex.length > maxHexLength * 3 ? hex.substring(0, maxHexLength * 3 - 3) + '...' : hex; components.hexArea.log(`${timestamp} {#CCCCCC-fg}${displayHex}{/}`); scheduleRender(); } catch (error) { // Ignore hex display errors } } // Smart status display with better error handling function updateStatusDisplay() { if (isShuttingDown || !components.statusPanel) return; try { const uptime = Math.floor((Date.now() - startTime) / 1000); const hours = Math.floor(uptime / 3600); const minutes = Math.floor((uptime % 3600) / 60); const seconds = uptime % 60; const uptimeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; const rxRate = (bytesReceived / Math.max(1, uptime)).toFixed(1); const txRate = (bytesSent / Math.max(1, uptime)).toFixed(1); // Status with countdown let status; if (isReconnecting && reconnectStartTime) { const elapsed = Date.now() - reconnectStartTime; const remaining = Math.max(0, Math.ceil((currentReconnectDelay - elapsed) / 1000)); status = `{yellow-fg}๐Ÿ”„ Reconnecting... #${reconnectAttempts} (${remaining}s){/yellow-fg}`; } else if (port && port.isOpen) { status = '{green-fg}๐ŸŸข Connected{/green-fg}'; } else { status = '{red-fg}๐Ÿ”ด Disconnected{/red-fg}'; } // Responsive status content let content; if (compactMode) { // Compact single-line status content = `{white-fg}${portPath}@${options.baud} | ${status} | โ†“${bytesReceived} โ†‘${bytesSent} | ${autoReconnect ? '๐Ÿ”„' : 'โธ๏ธ'} | Press ? for help{/}`; } else if (showStats) { // Full statistics display content = `{white-fg}๐Ÿ“ก Port:{/} {bold}{cyan-fg}${portPath}{/}{/} | {white-fg}โšก Baud:{/} {bold}{cyan-fg}${options.baud}{/}{/} | {white-fg}๐Ÿ”— Status:{/} ${status} | {white-fg}โฑ๏ธ Uptime:{/} {bold}{cyan-fg}${uptimeStr}{/}{/} | Press ? for help {white-fg}๐Ÿ“ฅ RX:{/} {bold}{green-fg}${bytesReceived.toLocaleString()}{/}{/} {white-fg}bytes ({/}{bold}{green-fg}${rxRate}{/}{/} {white-fg}B/s) | ๐Ÿ“ค TX:{/} {bold}{blue-fg}${bytesSent.toLocaleString()}{/}{/} {white-fg}bytes ({/}{bold}{blue-fg}${txRate}{/}{/} {white-fg}B/s) | ๐Ÿ“Š Messages:{/} {bold}{cyan-fg}${messagesReceived.toLocaleString()}{/}{/} {white-fg}๐Ÿ“ Log:{/} ${pauseLogging ? '{red-fg}โธ๏ธ PAUSED{/}' : '{green-fg}โ–ถ๏ธ ACTIVE{/}'} | {white-fg}๐Ÿ” Hex:{/} ${showHex ? '{green-fg}๐Ÿ‘๏ธ ON{/}' : '{gray-fg}๐Ÿ‘๏ธ OFF{/}'} | {white-fg}๐Ÿ”„ Auto-reconnect:{/} ${autoReconnect ? '{green-fg}โœ… ON{/}' : '{red-fg}โŒ OFF{/}'}`; } else { // Medium detail status content = `{white-fg}๐Ÿ“ก{/} {bold}{cyan-fg}${portPath}{/}{/} {white-fg}@ {/}{bold}{cyan-fg}${options.baud}{/}{/} | ${status} | {white-fg}โฑ๏ธ{/} {bold}{cyan-fg}${uptimeStr}{/}{/} | {white-fg}๐Ÿ“Š{/} {bold}{cyan-fg}${messagesReceived}{/}{/} {white-fg}msgs{/} | Press ? for help {white-fg}๐Ÿ“ฅ{/} {bold}{green-fg}${(bytesReceived/1024).toFixed(1)}KB{/}{/} {white-fg}๐Ÿ“ค{/} {bold}{blue-fg}${(bytesSent/1024).toFixed(1)}KB{/}{/} | ${pauseLogging ? '{red-fg}โธ๏ธ{/}' : '{green-fg}โ–ถ๏ธ{/}'} | ${showHex ? '{green-fg}๐Ÿ”{/}' : '{gray-fg}๐Ÿ”{/}'} | ${autoReconnect ? '{green-fg}๐Ÿ”„{/}' : '{red-fg}๐Ÿ”„{/}'}`; } components.statusPanel.setContent(content); } catch (error) { // Ignore status display errors } } // Enhanced input dialog with better cleanup function showInputDialog() { if (isShuttingDown || !port || !port.isOpen) { addMessage('โš ๏ธ Port not connected. Cannot send data.', 'yellow'); return; } // Disable screen shortcuts while dialog is open disableScreenKeys(); const dialogWidth = Math.min(60, Math.max(30, screenWidth - 10)); const dialogHeight = compactMode ? 3 : 5; const input = blessed.textbox({ parent: screen, top: 'center', left: 'center', width: dialogWidth, height: dialogHeight, border: { type: 'line' }, label: ` ๐Ÿ’ฌ Send Data `, inputOnFocus: true, style: { fg: 'white', bg: 'black', border: { fg: 'green' } } }); function closeDialog() { if (input && input.destroy) { input.removeAllListeners(); input.destroy(); } enableScreenKeys(); // Re-enable shortcuts when dialog closes scheduleRender(); } // Set up event handlers with proper cleanup const submitHandler = (value) => { if (value && port && port.isOpen && !isShuttingDown) { const dataToSend = value + '\n'; // Always use LF line ending try { port.write(dataToSend); addMessage(`โ†’ ${value}`, 'blue'); bytesSent += Buffer.from(dataToSend).length; } catch (error) { addMessage(`โŒ Send failed: ${error.message}`, 'red'); } } closeDialog(); }; const cancelHandler = () => { closeDialog(); }; const escapeHandler = () => { closeDialog(); }; input.on('submit', submitHandler); input.on('cancel', cancelHandler); input.key(['escape'], escapeHandler); try { input.focus(); scheduleRender(); } catch (error) { closeDialog(); } } // Enhanced filter dialog with better cleanup function showFilterDialog() { if (isShuttingDown) return; // Disable screen shortcuts while dialog is open disableScreenKeys(); const dialogWidth = Math.min(50, Math.max(25, screenWidth - 20)); const filter = blessed.textbox({ parent: screen, top: 'center', left: 'center', width: dialogWidth, height: 3, border: { type: 'line' }, label: ' ๐ŸŽฏ Filter Text (empty to disable) ', inputOnFocus: true, content: filterText, style: { fg: 'white', bg: 'black', border: { fg: 'cyan' } } }); function closeDialog() { if (filter && filter.destroy) { filter.removeAllListeners(); filter.destroy(); } enableScreenKeys(); // Re-enable shortcuts when dialog closes scheduleRender(); } const submitHandler = (value) => { filterText = value || ''; filterEnabled = Boolean(filterText); addMessage(`๐ŸŽฏ Filter ${filterEnabled ? 'enabled' : 'disabled'}: "${filterText}"`, 'yellow'); closeDialog(); }; const cancelHandler = () => { closeDialog(); }; const escapeHandler = () => { closeDialog(); }; filter.on('submit', submitHandler); filter.on('cancel', cancelHandler); filter.key(['escape'], escapeHandler); try { filter.focus(); scheduleRender(); } catch (error) { closeDialog(); } } // Auto-reconnect logic with better error handling and race condition prevention function attemptReconnect() { if (!autoReconnect || isReconnecting || isShuttingDown) return; isReconnecting = true; reconnectAttempts++; currentReconnectDelay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempts - 1, 5)), maxReconnectDelay); reconnectStartTime = Date.now(); addMessage(`๐Ÿ”„ Reconnect attempt #${reconnectAttempts} starting in ${currentReconnectDelay/1000}s...`, 'cyan'); reconnectTimer = setTimeout(async () => { if (isShuttingDown || !autoReconnect) { isReconnecting = false; return; } try { addMessage(`๐Ÿ”Œ Attempting to open ${portPath}...`, 'cyan'); // Cleanup existing port if (port) { try { if (parser) { parser.removeAllListeners(); parser = null; } port.removeAllListeners(); if (port.isOpen) { await new Promise(resolve => { port.close(resolve); }); } } catch (e) { // Ignore close errors } } // Create new port port = new SerialPort({ path: portPath, baudRate: parseInt(options.baud), autoOpen: false }); setupPortHandlers(); // Open with timeout await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Connection timeout')), 5000); port.open((error) => { clearTimeout(timeout); error ? reject(error) : resolve(); }); }); const attemptCount = reconnectAttempts; isReconnecting = false; reconnectAttempts = 0; reconnectStartTime = null; currentReconnectDelay = 0; addMessage(`โœ… Reconnected successfully after ${attemptCount} attempt${attemptCount > 1 ? 's' : ''}!`, 'green'); } catch (error) { if (isShuttingDown) return; addMessage(`โŒ Reconnect #${reconnectAttempts} failed: ${error.message}`, 'red'); const permanentErrors = ['Access denied', 'Permission denied', 'EACCES', 'EPERM', 'Resource busy', 'EBUSY']; const isPermanentError = permanentErrors.some(errorType => error.message.includes(errorType)); if (isPermanentError) { addMessage('โš ๏ธ Permanent error - disabling auto-reconnect', 'yellow'); autoReconnect = false; isReconnecting = false; reconnectAttempts = 0; } else { isReconnecting = false; // Schedule next attempt with a small delay setTimeout(() => { if (autoReconnect && (!port || !port.isOpen) && !isShuttingDown) { attemptReconnect(); } }, 1000); } } }, currentReconnectDelay); } function cancelReconnect() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } isReconnecting = false; reconnectAttempts = 0; reconnectStartTime = null; currentReconnectDelay = 0; } // Enhanced port setup with better error handling function setupPortHandlers() { if (!port || isShuttingDown) return; try { // Remove any existing listeners to prevent duplicates port.removeAllListeners(); parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); parser.on('data', (data) => { if (isShuttingDown) return; const cleanData = data.trim(); if (cleanData) { addMessage(`โ† ${cleanData}`, 'green'); messagesReceived++; } bytesReceived += Buffer.from(data).length; }); port.on('data', (data) => { if (isShuttingDown) return; addHexData(data); }); port.on('error', (error) => { if (isShuttingDown) return; addMessage(`โŒ Port error: ${error.message}`, 'red'); }); port.on('close', () => { if (isShuttingDown) return; addMessage('๐Ÿ“ด Port disconnected', 'yellow'); if (autoReconnect && !isReconnecting) { setTimeout(() => { if (autoReconnect && (!port || !port.isOpen) && !isShuttingDown) { attemptReconnect(); } }, 500); } }); } catch (error) { if (!isShuttingDown) { addMessage(`โš ๏ธ Error setting up port handlers: ${error.message}`, 'yellow'); } } } // Help dialog function with better cleanup function showHelpDialog() { if (isShuttingDown) return; // Disable screen shortcuts while dialog is open disableScreenKeys(); const helpDialog = blessed.box({ parent: screen, top: 'center', left: 'center', width: '80%', height: '70%', border: { type: 'line' }, label: ' ๐ŸŽฎ Keyboard Shortcuts Help ', content: `{center}{bold}SerialConsole Keyboard Shortcuts{/bold}{/center} {yellow-fg}{bold}Main Controls:{/bold}{/yellow-fg} {white-fg}Q{/white-fg} - Exit application {white-fg}I{/white-fg} - Send message to serial port {white-fg}H{/white-fg} - Toggle hex viewer on/off {white-fg}S{/white-fg} - Toggle statistics panel {white-fg}P{/white-fg} or {white-fg}Space{/white-fg} - Pause/resume logging {white-fg}C{/white-fg} - Clear screen {white-fg}R{/white-fg} - Toggle auto-reconnect {yellow-fg}{bold}Navigation:{/bold}{/yellow-fg} {white-fg}Escape{/white-fg} - Close dialogs {white-fg}?{/white-fg} - Show this help {white-fg}Ctrl+C{/white-fg} - Force exit {yellow-fg}{bold}Current Status:{/bold}{/yellow-fg} โ€ข Hex View: ${showHex ? '{green-fg}ON{/green-fg}' : '{red-fg}OFF{/red-fg}'} โ€ข Statistics: ${showStats ? '{green-fg}ON{/green-fg}' : '{red-fg}OFF{/red-fg}'} โ€ข Logging: ${pauseLogging ? '{red-fg}PAUSED{/red-fg}' : '{green-fg}ACTIVE{/green-fg}'} โ€ข Auto-Reconnect: ${autoReconnect ? '{green-fg}ON{/green-fg}' : '{red-fg}OFF{/red-fg}'} {center}{gray-fg}Press any key to close this help{/gray-fg}{/center}`, tags: true, scrollable: true, style: { fg: 'white', bg: 'black', border: { fg: 'cyan' } } }); function closeHelp() { if (helpDialog && helpDialog.destroy) { helpDialog.removeAllListeners(); helpDialog.destroy(); } enableScreenKeys(); // Re-enable shortcuts when dialog closes scheduleRender(); } helpDialog.key(['escape', 'enter', 'q', 'space', '?'], closeHelp); helpDialog.on('keypress', closeHelp); try { helpDialog.focus(); scheduleRender(); } catch (error) { closeHelp(); } } // Enhanced keyboard handler management function setupKeyHandlers() { if (screenHandlersAttached) return; // Store all key handlers so we can disable them during dialogs const handlers = [ // Exit - Q key and Ctrl+Q { keys: ['q', 'Q', 'C-c'], handler: () => { cleanup(); process.exit(0); }}, // Send message - I key { keys: ['i', 'I'], handler: () => { showInputDialog(); }}, // Toggle hex view - H key { keys: ['h', 'H'], handler: () => { showHex = !showHex; handleResize(); addMessage(`๐Ÿ” Hex view ${showHex ? 'enabled' : 'disabled'}`, 'yellow'); }}, // Toggle statistics - S key { keys: ['s', 'S'], handler: () => { showStats = !showStats; handleResize(); addMessage(`๐Ÿ“Š Statistics ${showStats ? 'enabled' : 'disabled'}`, 'yellow'); }}, // Pause logging - P key and Space { keys: ['p', 'P', 'space'], handler: () => { pauseLogging = !pauseLogging; const status = pauseLogging ? 'paused' : 'resumed'; addMessage(`๐Ÿ“ Logging ${status}`, 'yellow'); updateStatusDisplay(); }}, // Clear screen - C key { keys: ['c', 'C'], handler: () => { if (components.dataArea) { components.dataArea.setContent(''); } if (components.hexArea) { components.hexArea.setContent(''); } addMessage('๐Ÿงน Logs cleared', 'yellow'); scheduleRender(); }}, // Toggle auto-reconnect - R key { keys: ['r', 'R'], handler: () => { if (!autoReconnect) { autoReconnect = true; cancelReconnect(); addMessage('๐Ÿ”„ Auto-reconnect enabled', 'green'); if (!port || !port.isOpen) { setTimeout(() => attemptReconnect(), 500); } } else if (isReconnecting) { addMessage('๐Ÿ”„ Resetting reconnection attempts...', 'cyan'); cancelReconnect(); setTimeout(() => attemptReconnect(), 500); } else if (!port || !port.isOpen) { addMessage('๐Ÿš€ Starting reconnection...', 'cyan'); setTimeout(() => attemptReconnect(), 500); } else { autoReconnect = false; addMessage('โธ๏ธ Auto-reconnect disabled', 'yellow'); cancelReconnect(); } }}, // Help - ? key { keys: ['?'], handler: () => { showHelpDialog(); }} ]; // Bind all handlers try { handlers.forEach(({keys, handler}) => { screen.key(keys, handler); keyHandlers.push({keys, handler}); }); screenHandlersAttached = true; addMessage('๐ŸŽฎ Keyboard shortcuts loaded. Press ? for help', 'cyan'); } catch (error) { addMessage(`โš ๏ธ Error setting up keyboard shortcuts: ${error.message}`, 'yellow'); } } // Function to temporarily disable screen shortcuts function disableScreenKeys() { try { keyHandlers.forEach(({keys, handler}) => { screen.unkey(keys, handler); }); } catch (error) { // Ignore unkey errors } } // Function to re-enable screen shortcuts function enableScreenKeys() { try { keyHandlers.forEach(({keys, handler}) => { screen.key(keys, handler); }); } catch (error) { // Ignore key binding errors } } // Enhanced cleanup function function cleanup() { if (isShuttingDown) return; isShuttingDown = true; try { // Cancel all timers cancelReconnect(); if (updateTimer) { clearInterval(updateTimer); updateTimer = null; } // Cleanup port if (port) { try { if (parser) { parser.removeAllListeners(); } port.removeAllListeners(); if (port.isOpen) { port.close(); } } catch (e) { // Ignore close errors } } // Cleanup components and screen cleanupComponents(); if (screen) { try { screen.removeAllListeners(); screen.destroy(); } catch (e) { // Ignore screen cleanup errors } } } catch (error) { // Ignore cleanup errors } } // Handle process termination process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); process.on('exit', cleanup); // Handle screen resize screen.on('resize', handleResize); try { // Initialize port port = new SerialPort({ path: portPath, baudRate: parseInt(options.baud), autoOpen: false }); setupPortHandlers(); await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Connection timeout')), 5000); port.open((error) => { clearTimeout(timeout); error ? reject(error) : resolve(); }); }); // Create initial UI createComponents(); // Setup keyboard shortcuts AFTER components are created setupKeyHandlers(); addMessage(`โœ… Connected to ${portPath} at ${options.baud} baud`, 'green'); updateStatusDisplay(); // Timer for updates with error handling updateTimer = setInterval(() => { if (!isShuttingDown) { try { updateStatusDisplay(); scheduleRender(); } catch (error) { // Ignore update errors } } }, 1000); scheduleRender(); } catch (error) { cleanup(); console.error(chalk.red('โŒ Error in TUI monitor:'), error.message); process.exit(1); } } // Global error handlers with better logging process.on('uncaughtException', (error) => { console.error(chalk.red('Fatal error:'), error.message); process.exit(1); }); process.on('unhandledRejection', (reason) => { console.error(chalk.red('Unhandled rejection:'), reason); process.exit(1); }); // Utility function to format port info function formatPortInfo(port) { const info = [`${chalk.cyan(port.path)}`]; if (port.manufacturer) { info.push(chalk.gray(`(${port.manufacturer})`)); } if (port.serialNumber) { info.push(chalk.dim(`SN: ${port.serialNumber}`)); } if (port.productId && port.vendorId) { info.push(chalk.dim(`PID: ${port.productId} VID: ${port.vendorId}`)); } return info.join(' '); } // Helper function to prompt user input with validation function askQuestion(question, validator = null) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { const ask = () => { rl.question(question, (answer) => { const trimmedAnswer = answer.trim(); if (validator) { const validation = validator(trimmedAnswer); if (validation !== true) { console.log(chalk.red(validation)); return ask(); } } rl.close(); resolve(trimmedAnswer); }); }; ask(); }); } // Enhanced Command: List available serial ports with interactive selection async function listPorts() { try { console.log(chalk.blue('๐Ÿ“‹ Scanning for serial ports...\n')); const ports = await SerialPort.list(); if (ports.length === 0) { console.log(chalk.yellow('โš ๏ธ No serial ports found')); console.log(chalk.gray('Make sure your device is connected and drivers are installed.')); return; } console.log(chalk.green(`โœ… Found ${ports.length} serial port(s):\n`)); ports.forEach((port, index) => { console.log(`${chalk.white(`${index + 1}.`)} ${formatPortInfo(port)}`); }); console.log(); // Interactive port selection with validation while (true) { const selection = await askQuestion( chalk.cyan('๐Ÿ”— Select a port to monitor (1-' + ports.length + '), or press Enter to exit: '), (input) => { if (!input) return true; // Allow empty for exit const num = parseInt(input); if (isNaN(num) || num < 1 || num > ports.length) { return `Invalid selection. Please enter a number between 1 and ${ports.length}`; } return true; } ); // Allow exit with empty input if (!selection) { console.log(chalk.gray('๐Ÿ‘‹ Goodbye!')); return; } const portIndex = parseInt(selection) - 1; const selectedPort = ports[portIndex]; console.log(chalk.green(`โœ… Selected: ${formatPortInfo(selectedPort)}\n`)); // Baud rate selection with validation const commonBaudRates = [ '9600', '19200', '38400', '57600', '115200', '230400', '460800', '921600' ]; console.log(chalk.blue('โšก Common baud rates:')); commonBaudRates.forEach((rate, index) => { const marker = rate === '9600' ? chalk.green(' (default)') : ''; console.log(` ${index + 1}. ${rate}${marker}`); }); console.log(` ${commonBaudRates.length + 1}. Custom rate`); console.log(); let baudRate = '9600'; // default const baudSelection = await askQuestion( chalk.cyan('โšก Select baud rate (1-' + (commonBaudRates.length + 1) + '), or press Enter for 9600: '), (input) => { if (!input) return true; // Allow empty for default const num = parseInt(input); if (isNaN(num) || num < 1 || num > (commonBaudRates.length + 1)) { return `Invalid selection. Please enter a number between 1 and ${commonBaudRates.length + 1}`; } return true; } ); if (baudSelection) { const baudIndex = parseInt(baudSelection) - 1; if (baudIndex >= 0 && baudIndex < commonBaudRates.length) { baudRate = commonBaudRates[baudIndex]; } else if (baudIndex === commonBaudRates.length) { // Custom rate baudRate = await askQuestion( chalk.cyan('โšก Enter custom baud rate: '), (input) => { const rate = parseInt(input); if (isNaN(rate) || rate <= 0) { return 'Invalid baud rate. Please enter a positive number.'; } return true; } ); } } console.log(chalk.green(`โœ… Baud rate: ${baudRate}\n`)); console.log(chalk.blue('๐Ÿš€ Starting TUI monitor...\n')); // Launch the TUI monitor try { await tuiMonitor(selectedPort.path, { baud: baudRate }); } catch (error) { console.error(chalk.red('โŒ Failed to start monitor:'), error.message); console.log(chalk.yellow('๐Ÿ”„ Returning to port selection...\n')); continue; } break; } } catch (error) { console.error(chalk.red('โŒ Error listing ports:'), error.message); process.exit(1); } } // Command: Read from serial port with enhanced error handling async function readFromPort(portPath, options) { let port; let parser; try { console.log(chalk.blue(`๐Ÿ“– Opening ${portPath} at ${options.baud} baud...`)); port = new SerialPort({ path: portPath, baudRate: parseInt(options.baud), autoOpen: false }); parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); // Handle port events port.on('error', (error) => { console.error(chalk.red('โŒ Port error:'), error.message); process.exit(1); }); port.on('close', () => { console.log(chalk.yellow('\n๐Ÿ“ด Port closed')); process.exit(0); }); parser.on('data', (data) => { const timestamp = new Date().toISOString().substring(11, 23); console.log(`${chalk.gray(timestamp)} ${chalk.green('โ†')} ${data.trim()}`); }); // Open the port with timeout await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Connection timeout')), 5000); port.open((error) => { clearTimeout(timeout); if (error) reject(error); else resolve(); }); }); console.log(chalk.green('โœ… Connected! Press Ctrl+C to exit\n')); // Handle graceful shutdown process.on('SIGINT', () => { console.log(chalk.yellow('\n๐Ÿ›‘ Shutting down...')); if (parser) { parser.removeAllListeners(); } if (port && port.isOpen) { port.close(); } }); } catch (error) { console.error(chalk.red('โŒ Error reading from port:'), error.message); if (parser) { parser.removeAllListeners(); } if (port && port.isOpen) { port.close(); } process.exit(1); } } // Command: Write to serial port with enhanced error handling async function writeToPort(portPath, data, options) { let port; try { console.log(chalk.blue(`โœ๏ธ Opening ${portPath} at ${options.baud} baud...`)); port = new SerialPort({ path: portPath, baudRate: parseInt(options.baud), autoOpen: false }); // Handle port events port.on('error', (error) => { console.error(chalk.red('โŒ Port error:'), error.message); process.exit(1); }); // Open the port with timeout await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Connection timeout')), 5000); port.open((error) => { clearTimeout(timeout); if (error) reject(error); else resolve(); }); }); // Write data const writeData = data + (options.newline ? '\n' : ''); await new Promise((resolve, reject) => { port.write(writeData, (error) => { if (error) reject(error); else resolve(); }); }); console.log(chalk.green(`โœ… Sent: ${chalk.white(JSON.stringify(writeData))}`)); // Close the port port.close(); } catch (error) { console.error(chalk.red('โŒ Error writing to port:'), error.message); if (port && port.isOpen) { port.close(); } process.exit(1); } } // CLI Program setup const program = new Command(); program .name('serialconsole') .description('Tiny cross-platform serial port console') .version('1.0.0'); // Enhanced List command with interactive selection program .command('list') .alias('ls') .description('List available serial ports and interactively select one to monitor') .action(listPorts); // Read command program .command('read <port>') .alias('r') .description('Read data from serial port') .option('-b, --baud <rate>', 'Baud rate', '9600') .action(readFromPort); // Write command program .command('write <port> <data>') .alias('w') .description('Write data to serial port') .option('-b, --baud <rate>', 'Baud rate', '9600') .option('-n, --newline', 'Append newline to data', false) .action(writeToPort); // Interactive mode command with enhanced error handling program .command('interactive <port>') .alias('i') .description('Interactive mode (read and write)') .option('-b, --baud <rate>', 'Baud rate', '9600') .action(async (portPath, options) => { let port; let parser; try { console.log(chalk.blue(`๐Ÿ”„ Opening ${portPath} in interactive mode at ${options.baud} baud...`)); port = new SerialPort({ path: portPath, baudRate: parseInt(options.baud), autoOpen: false }); parser = port.pipe(new ReadlineParser({ delimiter: '\n' })); // Handle incoming data parser.on('data', (data) => { const timestamp = new Date().toISOString().substring(11, 23); console.log(`${chalk.gray(timestamp)} ${chalk.green('โ†')} ${data.trim()}`); }); // Handle port events port.on('error', (error) => { console.error(chalk.red('โŒ Port error:'), error.message); process.exit(1); }); port.on('close', () => { console.log(chalk.yellow('\n๐Ÿ“ด Port closed')); process.exit(0); }); // Open the port with timeout await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Connection timeout')), 5000); port.open((error) => { clearTimeout(timeout); if (error) reject(error); else resolve(); }); }); console.log(chalk.green('โœ… Connected! Type messages and press Enter. Press Ctrl+C to exit\n')); // Handle stdin for interactive input process.stdin.setEncoding('utf8'); process.stdin.on('data', (input) => { const message = input.trim(); if (message) { port.write(message + '\n'); const timestamp = new Date().toISOString().substring(11, 23); console.log(`${chalk.gray(timestamp)} ${chalk.blue('โ†’')} ${message}`); } }); // Handle graceful shutdown process.on('SIGINT', () => { console.log(chalk.yellow('\n๐Ÿ›‘ Shutting down...')); if (parser) { parser.removeAllListeners(); } if (port && port.isOpen) { port.close(); } }); } catch (error) { console.error(chalk.red('โŒ Error in interactive mode:'), error.message); if (parser) { parser.removeAllListeners(); } if (port && port.isOpen) { port.close(); } process.exit(1); } }); // TUI Monitor command program .command('monitor <port>') .alias('mon') .alias('m') .description('Responsive full-screen TUI monitor') .option('-b, --baud <rate>', 'Baud rate', '9600') .action(tuiMonitor); // Show help if no command provided if (process.argv.length <= 2) { program.help(); } // Parse command line arguments program.parse();