tcp-serial-relay
Version:
Modular TCP to Serial relay service with comprehensive logging, monitoring and web dashboard
357 lines (298 loc) • 11.8 kB
JavaScript
const net = require('net');
const winston = require('winston');
// Configuration
const PORT = process.env.MAIN_TCP_PORT || 9001;
const HOST = process.env.MAIN_TCP_HOST || '0.0.0.0';
const COMMAND_DELAY = process.env.COMMAND_DELAY || 1000; // Delay before sending command
const AUTO_COMMAND_INTERVAL = process.env.AUTO_COMMAND_INTERVAL || 0; // 0 = disabled, otherwise interval in ms
// --- Configure Winston logger ---
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(({ level, message, timestamp }) => {
return `${timestamp} [MAIN TCP SERVER] ${level.toUpperCase()}: ${message}`;
})
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.printf(({ level, message, timestamp }) => {
return `${timestamp} [MAIN TCP SERVER] ${level}: ${message}`;
})
)
}),
],
});
class MainTcpServer {
constructor() {
this.server = null;
this.activeConnections = new Map();
this.connectionCount = 0;
this.autoCommandIntervals = new Map();
}
start() {
// Create a TCP server for data ingestion
this.server = net.createServer((socket) => {
this.handleConnection(socket);
});
// Handle server errors
this.server.on('error', (err) => {
logger.error(`Server error: ${err.message}`);
process.exit(1);
});
// Start the server
this.server.listen(PORT, HOST, () => {
logger.info(`Main TCP Server running on ${HOST}:${PORT}`);
logger.info('Waiting for relay connections...');
if (AUTO_COMMAND_INTERVAL > 0) {
logger.info(`Auto-command enabled: will send ASCII Ctrl+A + "200" every ${AUTO_COMMAND_INTERVAL}ms when clients connect`);
} else {
logger.info('Auto-command disabled: will send ASCII Ctrl+A + "200" once on connection and when data is received');
}
});
}
handleConnection(socket) {
this.connectionCount++;
const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
const connectionId = `conn-${this.connectionCount}`;
logger.info(`Client connected: ${clientId} (ID: ${connectionId})`);
// Store connection info
const connectionInfo = {
id: connectionId,
clientId: clientId,
socket: socket,
connected: true,
dataReceived: 0,
commandsSent: 0,
lastActivity: new Date(),
aggregatedData: Buffer.alloc(0),
aggregationTimeout: null
};
this.activeConnections.set(socket, connectionInfo);
// Send initial ASCII Ctrl+A + "200" command after a brief delay
setTimeout(() => {
if (!socket.destroyed && socket.writable) {
this.sendAsciiCommand(socket, 'initial connection');
}
}, COMMAND_DELAY);
// Set up auto-command interval if enabled
if (AUTO_COMMAND_INTERVAL > 0) {
const intervalId = setInterval(() => {
if (!socket.destroyed && socket.writable) {
this.sendAsciiCommand(socket, 'auto-interval');
} else {
clearInterval(intervalId);
this.autoCommandIntervals.delete(socket);
}
}, AUTO_COMMAND_INTERVAL);
this.autoCommandIntervals.set(socket, intervalId);
}
// Handle data from client (relay)
socket.on('data', (data) => {
this.handleIncomingData(socket, data);
});
// Handle client disconnection
socket.on('end', () => {
logger.info(`Client ${clientId} disconnected gracefully`);
this.cleanupConnection(socket);
});
// Handle connection close
socket.on('close', (hadError) => {
logger.info(`Connection ${clientId} closed${hadError ? ' with error' : ' normally'}`);
this.cleanupConnection(socket);
});
// Handle errors
socket.on('error', (err) => {
logger.error(`Socket error for ${clientId}: ${err.message}`);
this.cleanupConnection(socket);
});
}
handleIncomingData(socket, data) {
const connectionInfo = this.activeConnections.get(socket);
if (!connectionInfo) return;
connectionInfo.dataReceived++;
connectionInfo.lastActivity = new Date();
// Append new data to the aggregated buffer
connectionInfo.aggregatedData = Buffer.concat([connectionInfo.aggregatedData, data]);
// Just log that we received data, but don't show the content yet
logger.info(`Received ${data.length} bytes from ${connectionInfo.clientId} (total aggregated: ${connectionInfo.aggregatedData.length} bytes)`);
// Reset the aggregation timeout
if (connectionInfo.aggregationTimeout) {
clearTimeout(connectionInfo.aggregationTimeout);
}
// Set a timeout to output final result if no more data comes in
connectionInfo.aggregationTimeout = setTimeout(() => {
this.outputFinalResult(connectionInfo, 'timeout');
}, 3000); // 3 second timeout
// Send another ASCII Ctrl+A + "200" command after receiving data (simulating ongoing requests)
setTimeout(() => {
if (!socket.destroyed && socket.writable && AUTO_COMMAND_INTERVAL === 0) {
this.sendAsciiCommand(socket, 'response to data');
}
}, COMMAND_DELAY);
}
outputFinalResult(connectionInfo, reason) {
if (!connectionInfo.aggregatedData || connectionInfo.aggregatedData.length === 0) {
logger.info(`No data to output for ${connectionInfo.clientId}`);
return;
}
const dataStr = connectionInfo.aggregatedData.toString();
const dataHex = connectionInfo.aggregatedData.toString('hex');
logger.info(`=== FINAL AGGREGATED RESULT (${reason}) ===`);
logger.info(`Connection: ${connectionInfo.clientId}`);
logger.info(`Total bytes received: ${connectionInfo.aggregatedData.length}`);
logger.info(`Commands sent: ${connectionInfo.commandsSent}`);
logger.info(`HEX: ${dataHex}`);
// Log the response in a readable format
if (dataStr.includes('FUEL EXPRESSO') || dataStr.includes('UNLEADED') || dataStr.includes('PREMIUM')) {
logger.info('Final fuel data response:');
console.log('=====================================');
console.log(dataStr);
console.log('=====================================');
} else {
logger.info('Final data content:');
console.log('=====================================');
console.log(`"${dataStr.trim()}"`);
console.log('=====================================');
}
// Clear the aggregated data to prevent duplicate output
connectionInfo.aggregatedData = Buffer.alloc(0);
// Clear the timeout
if (connectionInfo.aggregationTimeout) {
clearTimeout(connectionInfo.aggregationTimeout);
connectionInfo.aggregationTimeout = null;
}
}
sendAsciiCommand(socket, reason) {
const connectionInfo = this.activeConnections.get(socket);
if (!connectionInfo) return;
try {
// Create ASCII command: Ctrl+A (0x01) + "200"
const ctrlA = String.fromCharCode(0x01); // ASCII Control-A character
const command = ctrlA + '200';
const commandBuffer = Buffer.from(command, 'ascii');
socket.write(commandBuffer);
connectionInfo.commandsSent++;
connectionInfo.lastActivity = new Date();
// Log with hex representation for clarity
const commandHex = commandBuffer.toString('hex');
logger.info(`Sent ASCII Ctrl+A + "200" to ${connectionInfo.clientId} (reason: ${reason}, total sent: ${connectionInfo.commandsSent})`);
logger.info(`Command as HEX: ${commandHex}`);
} catch (error) {
logger.error(`Failed to send command to ${connectionInfo.clientId}: ${error.message}`);
}
}
// Legacy method for backward compatibility - now sends ASCII too
sendCommand(socket, command, reason) {
// If the old string format is used, convert it to ASCII
if (command === 'cntl+a 200') {
this.sendAsciiCommand(socket, reason);
return;
}
// For any other commands, send as-is
const connectionInfo = this.activeConnections.get(socket);
if (!connectionInfo) return;
try {
socket.write(command);
connectionInfo.commandsSent++;
connectionInfo.lastActivity = new Date();
logger.info(`Sent "${command}" to ${connectionInfo.clientId} (reason: ${reason}, total sent: ${connectionInfo.commandsSent})`);
} catch (error) {
logger.error(`Failed to send command to ${connectionInfo.clientId}: ${error.message}`);
}
}
cleanupConnection(socket) {
const connectionInfo = this.activeConnections.get(socket);
if (connectionInfo) {
// Output final result before cleanup
this.outputFinalResult(connectionInfo, 'connection closed');
logger.info(`Cleaning up connection ${connectionInfo.clientId} - Commands sent: ${connectionInfo.commandsSent}, Data packets received: ${connectionInfo.dataReceived}`);
// Clear aggregation timeout if it exists
if (connectionInfo.aggregationTimeout) {
clearTimeout(connectionInfo.aggregationTimeout);
}
}
// Clear auto-command interval
const intervalId = this.autoCommandIntervals.get(socket);
if (intervalId) {
clearInterval(intervalId);
this.autoCommandIntervals.delete(socket);
}
// Remove from active connections
this.activeConnections.delete(socket);
// Close socket if not already closed
if (!socket.destroyed) {
socket.destroy();
}
}
getStatus() {
const connections = Array.from(this.activeConnections.values()).map(conn => ({
id: conn.id,
clientId: conn.clientId,
commandsSent: conn.commandsSent,
dataReceived: conn.dataReceived,
lastActivity: conn.lastActivity
}));
return {
listening: this.server ? this.server.listening : false,
address: this.server ? this.server.address() : null,
activeConnections: this.activeConnections.size,
totalConnections: this.connectionCount,
connections: connections
};
}
stop() {
logger.info('Shutting down Main TCP Server...');
// Clear all intervals
this.autoCommandIntervals.forEach(intervalId => clearInterval(intervalId));
this.autoCommandIntervals.clear();
// Close all active connections
this.activeConnections.forEach((connectionInfo, socket) => {
this.cleanupConnection(socket);
});
// Close the server
if (this.server) {
this.server.close(() => {
logger.info('Main TCP Server shut down gracefully');
process.exit(0);
});
} else {
process.exit(0);
}
}
}
// Create and start the server
const mainServer = new MainTcpServer();
// Start the server
mainServer.start();
// Handle graceful shutdown
process.on('SIGINT', () => {
logger.info('Received SIGINT. Shutting down Main TCP Server...');
mainServer.stop();
});
process.on('SIGTERM', () => {
logger.info('Received SIGTERM. Shutting down Main TCP Server...');
mainServer.stop();
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
logger.error(`Uncaught exception: ${err.message}`);
logger.error(err.stack);
mainServer.stop();
});
process.on('unhandledRejection', (reason, promise) => {
logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
mainServer.stop();
});
// Status reporting (optional)
if (process.argv.includes('--status')) {
setInterval(() => {
const status = mainServer.getStatus();
logger.info(`Status: ${status.activeConnections} active connections, ${status.totalConnections} total connections`);
}, 30000);
}
// Export for potential use as a module
module.exports = MainTcpServer;