firmware-base/mb-script/tests/commons.ts

344 lines
14 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { client as ModbusClient } from 'jsmodbus';
import net from 'net';
import { fileURLToPath } from 'url';
import { Logger, ILogObj } from "tslog";
import WebSocket from 'ws';
import { SerialPort } from 'serialport';
import { ReadlineParser } from '@serialport/parser-readline';
// --- Logger Setup ---
const log: Logger<ILogObj> = new Logger({ name: "ModbusTest" });
// --- Configuration ---
export const ESP32_IP = '192.168.1.250'; // Store IP as constant
export const MODBUS_PORT = 502; // Default Modbus TCP port
export const WEBSOCKET_PATH = '/ws'; // WebSocket path
export const WEBSOCKET_URL = `ws://${ESP32_IP}${WEBSOCKET_PATH}`;
export const SERIAL_BAUD_RATE = 115200; // Baud rate from Python script
export const SERIAL_PORT_MANUAL: string | undefined = 'COM14'; // Define specific port here if needed
export let SERIAL_PORT_PATH: string | undefined = undefined; // To be auto-detected
export const REPORTS_DIR = './tests/reports';
// Helper to resolve __dirname in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Ensure reports directory exists
const reportsPath = path.resolve(__dirname, '..', REPORTS_DIR); // Resolve relative to project root
if (!fs.existsSync(reportsPath)) {
fs.mkdirSync(reportsPath, { recursive: true });
log.info(`Created reports directory: ${reportsPath}`);
}
// --- Serial Port Auto-Detection ---
// Basic auto-detection based on common ESP identifiers
async function findSerialPort(): Promise<string | undefined> {
log.info("Attempting to auto-detect serial port...");
try {
const ports = await SerialPort.list();
log.debug("Available ports:", ports.map(p => ({ path: p.path, manufacturer: p.manufacturer, pnpId: p.pnpId })));
for (const port of ports) {
const pnpId = port.pnpId || '';
const manufacturer = port.manufacturer || '';
// Match common ESP patterns (add more if needed)
if (pnpId.includes('VID_10C4&PID_EA60') || // CP210x
pnpId.includes('VID_303A&PID_1001') || // ESP32-S3 Internal
manufacturer.toLowerCase().includes('espressif') ||
manufacturer.toLowerCase().includes('silicon labs')) {
log.info(`Found potential ESP device: ${port.path}`);
return port.path;
}
}
log.warn("Could not automatically find ESP serial port.");
// Optionally fallback to manual port if detection fails
// return SERIAL_PORT_MANUAL;
return undefined;
} catch (error) {
log.error("Error listing serial ports:", error);
return undefined;
}
}
// --- Serial Port Client Setup ---
export function createSerialClient(path: string): SerialPort {
log.info(`Creating SerialPort client for path: ${path}, Baud: ${SERIAL_BAUD_RATE}`);
// autoOpen: false means we call open() explicitly later
const port = new SerialPort({ path, baudRate: SERIAL_BAUD_RATE, autoOpen: false });
return port;
}
// Utility to connect Serial Port cleanly
export async function connectSerial(port: SerialPort): Promise<void> {
log.info(`Attempting Serial connection to ${port.path}...`);
return new Promise((resolve, reject) => {
port.open((err) => {
if (err) {
log.error(`Serial connection error to ${port.path}:`, err);
reject(err);
} else {
log.info(`Serial port ${port.path} opened successfully.`);
resolve();
}
});
});
}
// Utility to send a command and wait for response data (potentially multi-line)
// Uses ReadlineParser to get lines like the Python script
export async function sendSerialCommandAndReceive(port: SerialPort, command: string, timeout: number = 5000): Promise<string> {
if (!command.endsWith('\n')) {
command += '\n'; // Ensure newline termination
}
log.debug(`Sending Serial command: ${JSON.stringify(command)} to ${port.path}`);
const parser = port.pipe(new ReadlineParser({ delimiter: '\n' }));
let receivedData = '';
let responseResolver: (value: string | PromiseLike<string>) => void;
let responseRejecter: (reason?: any) => void;
const responsePromise = new Promise<string>((resolve, reject) => {
responseResolver = resolve;
responseRejecter = reject;
});
const timer = setTimeout(() => {
log.warn(`Serial response timeout (${timeout}ms) for command: ${JSON.stringify(command)}`);
parser.off('data', onData);
// Resolve with whatever data was received before timeout, or reject if empty?
// For now, resolve with potentially partial data, could be changed to reject.
responseResolver(receivedData);
}, timeout);
const onData = (line: string) => {
const trimmedLine = line.trim();
log.debug(`Received Serial line: ${trimmedLine}`);
receivedData += trimmedLine + '\n';
// Simple approach: Keep collecting lines until timeout.
// More sophisticated logic could be added here to detect end-of-response.
};
parser.on('data', onData);
// Send the command
port.write(command, (err) => {
if (err) {
log.error("Error writing to serial port:", err);
clearTimeout(timer);
parser.off('data', onData);
responseRejecter(err);
} else {
log.debug(`Command ${JSON.stringify(command)} sent successfully.`);
port.drain((drainErr) => { // Ensure data is sent before waiting for response
if (drainErr) {
log.error("Error draining serial port:", drainErr);
// Continue anyway, maybe it still worked
}
});
}
});
return responsePromise;
}
// Utility to disconnect Serial Port cleanly
export function disconnectSerial(port: SerialPort) {
if (port && port.isOpen) {
log.info(`Closing Serial connection to ${port.path}`);
port.close((err) => {
if (err) {
log.error(`Error closing serial port ${port.path}:`, err);
} else {
log.info(`Serial port ${port.path} closed.`);
}
});
} else {
log.warn(`Serial port ${port.path} is not open.`);
}
}
// --- Modbus TCP Client Setup ---
export function createModbusClient() {
const socket = new net.Socket();
// Use correct client factory for TCP
const client = new ModbusClient.TCP(socket);
// Optional: Add error handling for the socket
socket.on('error', (err) => {
log.error("Socket Error:", err);
// Potentially close the socket or handle reconnection here
});
socket.on('close', () => {
log.info('Socket closed');
});
return { socket, client };
}
// --- WebSocket Client Setup ---
export function createWebSocketClient(url: string = WEBSOCKET_URL): WebSocket {
log.info(`Creating WebSocket client for URL: ${url}`);
const ws = new WebSocket(url);
return ws;
}
// --- Test Reporting ---
export function createReport(testName: string): fs.WriteStream {
const now = new Date();
// Format: YYYYMMDD-HHMMSS - Using a more standard timestamp
const timestamp = now.toISOString().replace(/[:T.]/g, '-').split('-').slice(0, 3).join('') + '_' + now.toTimeString().split(' ')[0].replace(/:/g, '');
const reportFileName = `${timestamp}-${testName.replace(/\s+/g, '_')}.md`; // Sanitize test name for filename
const reportPath = path.join(reportsPath, reportFileName);
const reportStream = fs.createWriteStream(reportPath, { encoding: 'utf8' });
reportStream.write(`# Modbus E2E Test Report: ${testName}
`);
reportStream.write(`* **Test:** ${testName}
`);
reportStream.write(`* **Timestamp:** ${now.toISOString()}
`);
// Distinguish target type in report
if (testName.toLowerCase().includes('websocket') || testName.toLowerCase().includes('ws')) {
reportStream.write(`* **Target URL:** ${WEBSOCKET_URL}\n`);
} else if (testName.toLowerCase().includes('serial')) {
// Report the port actually being used (could be auto or manual)
reportStream.write(`* **Target Port:** ${SERIAL_PORT_PATH || SERIAL_PORT_MANUAL || 'Not Found'} @ ${SERIAL_BAUD_RATE}\n`);
} else { // Default to Modbus TCP
reportStream.write(`* **Target IP:** ${ESP32_IP}:${MODBUS_PORT}\n`);
}
reportStream.write(`* **Report File:** ${reportFileName}
`);
reportStream.write(`## Test Log\n\n`);
log.info(`Created report file: ${reportPath}`);
return reportStream;
}
export function logToReport(reportStream: fs.WriteStream, message: string, level: keyof Logger<ILogObj> = 'info') {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}`;
reportStream.write(logMessage + '\n');
if (typeof log[level] === 'function') {
(log[level] as (message: string) => void)(message);
} else {
log.info(message);
}
}
export function closeReport(reportStream: fs.WriteStream) {
logToReport(reportStream, "Test finished.", 'info');
reportStream.end();
}
// Utility to connect and disconnect cleanly
export async function connectModbus(socket: net.Socket, host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
socket.connect({ host, port }, () => {
log.info(`Connected to Modbus server ${host}:${port}`);
resolve();
});
socket.once('error', (err) => {
log.error(`Connection error to ${host}:${port}:`, err);
reject(err);
});
});
}
export function disconnectModbus(socket: net.Socket) {
if (socket && !socket.destroyed) {
socket.destroy();
log.info('Disconnected from Modbus server.');
}
}
// Utility to connect WebSocket cleanly
export async function connectWebSocket(ws: WebSocket): Promise<void> {
log.info(`Attempting WebSocket connection to ${ws.url}...`);
return new Promise((resolve, reject) => {
const onOpen = () => {
log.info(`WebSocket connected to ${ws.url}`);
ws.off('error', onError); // Clean up error listener
resolve();
};
const onError = (err: Error) => {
log.error(`WebSocket connection error to ${ws.url}:`, err);
ws.off('open', onOpen); // Clean up open listener
reject(err);
};
ws.once('open', onOpen);
ws.once('error', onError);
});
}
// Utility to send a message and wait for a specific response type
export async function sendWsMessageAndReceive(ws: WebSocket, message: any, expectedResponseType: string | null = null, timeout: number = 5000): Promise<any> {
const messageString = JSON.stringify(message);
log.debug(`Sending WebSocket message: ${messageString}`);
ws.send(messageString);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
log.error(`WebSocket response timeout (${timeout}ms) waiting for type '${expectedResponseType || 'any'}'`);
ws.off('message', onMessage); // Clean up listener on timeout
reject(new Error(`WebSocket response timeout after ${timeout}ms waiting for type '${expectedResponseType || 'any'}'`));
}, timeout);
const onMessage = (data: WebSocket.RawData) => {
let receivedData: any;
try {
receivedData = JSON.parse(data.toString());
log.debug(`Received WebSocket message: ${JSON.stringify(receivedData, null, 2)}`);
} catch (err: any) {
// Ignore parsing errors? Or reject? For now, log and continue listening.
log.warn("Ignoring non-JSON WebSocket message:", data.toString(), err);
return;
}
// Check if the message type matches the expected type, if provided
if (expectedResponseType === null || (receivedData && receivedData.type === expectedResponseType)) {
clearTimeout(timer);
ws.off('message', onMessage); // Clean up listener after finding the right message
log.debug(`Accepted message with type '${receivedData?.type || 'unknown'}'`);
resolve(receivedData);
} else {
// Log ignored messages if needed
log.debug(`Ignoring message with type '${receivedData?.type || 'unknown'}', waiting for '${expectedResponseType}'`);
// Keep listening for the correct message
}
};
// Use .on instead of .once to handle multiple incoming messages until the expected one arrives
ws.on('message', onMessage);
});
}
// Utility to disconnect WebSocket cleanly
export function disconnectWebSocket(ws: WebSocket) {
if (ws && ws.readyState === WebSocket.OPEN) {
log.info(`Closing WebSocket connection to ${ws.url}`);
ws.close();
log.info(`WebSocket connection closed.`);
}
else if (ws && ws.readyState !== WebSocket.CLOSED) {
log.warn(`WebSocket connection to ${ws.url} is not open (state: ${ws.readyState}), terminating.`);
ws.terminate(); // Force close if not open/closed
}
}
// --- Auto-detect Serial Port on startup ---
(async () => {
// Prioritize manual port if set, otherwise attempt auto-detection
if (SERIAL_PORT_MANUAL) {
log.info(`Using manually configured serial port: ${SERIAL_PORT_MANUAL}`);
SERIAL_PORT_PATH = SERIAL_PORT_MANUAL;
} else {
SERIAL_PORT_PATH = await findSerialPort();
}
})();