UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

532 lines (472 loc) 20.5 kB
const { FUNCTION_CODES, EXCEPTION_CODES } = require('../constants/constants'); const logger = require('../logger'); /** * Class that collects and analyzes statistics about Modbus communication. * @class * @alias Diagnostics */ class Diagnostics { /** * @param {Object} [options={}] - Configuration options * @param {number} [options.notificationThreshold=10] - Threshold for error notifications * @param {number} [options.errorRateThreshold=10] - Threshold for error rate notifications (%) * @param {number|number[]} [options.slaveId=1] - Slave ID(s) for metrics and logs * @param {string} [options.loggerName='diagnostics'] - Name for categorical logger */ constructor(options = {}) { this.notificationThreshold = options.notificationThreshold || 10; this.errorRateThreshold = options.errorRateThreshold || 10; // % error rate this.slaveIds = Array.isArray(options.slaveId) ? options.slaveId : [options.slaveId || 1]; this.logger = logger.createLogger(options.loggerName || 'diagnostics'); this.reset(); } /** * Resets all statistics and counters to their initial state. * @private */ reset() { this.startTime = Date.now(); this.totalRequests = 0; this.successfulResponses = 0; this.errorResponses = 0; this.timeouts = 0; this.crcErrors = 0; this.modbusExceptions = 0; this.exceptionCodeCounts = {}; this.totalRetries = 0; this.totalRetrySuccesses = 0; this.lastResponseTime = null; this.minResponseTime = null; this.maxResponseTime = null; this._totalResponseTime = 0; this._totalResponseTimeAll = 0; this.lastErrorMessage = null; this.lastErrors = []; this.functionCallCounts = {}; this.errorMessageCounts = {}; this.lastSuccessDetails = null; this._lastFuncCode = null; this.lastExceptionCode = null; this.totalDataSent = 0; this.totalDataReceived = 0; this.lastRequestTimestamp = null; this.lastSuccessTimestamp = null; this.lastErrorTimestamp = null; this.totalSessions ??= 0; this.totalSessions++; this.requestTimestamps = []; // Для расчёта requests per second } /** * Resets specific statistics. * @param {string[]} [metrics] - Metrics to reset (e.g., ['errors', 'retries']) */ resetStats(metrics = []) { const allMetrics = [ 'requests', 'successes', 'errors', 'timeouts', 'crcErrors', 'modbusExceptions', 'retries', 'retrySuccesses', 'responseTimes', 'errorsList', 'functionCalls', 'errorMessages', 'dataSent', 'dataReceived', 'timestamps', 'exceptionCodes' ]; const toReset = metrics.length > 0 ? metrics : allMetrics; if (toReset.includes('requests')) this.totalRequests = 0; if (toReset.includes('successes')) this.successfulResponses = 0; if (toReset.includes('errors')) this.errorResponses = 0; if (toReset.includes('timeouts')) this.timeouts = 0; if (toReset.includes('crcErrors')) this.crcErrors = 0; if (toReset.includes('modbusExceptions')) this.modbusExceptions = 0; if (toReset.includes('exceptionCodes')) this.exceptionCodeCounts = {}; if (toReset.includes('retries')) this.totalRetries = 0; if (toReset.includes('retrySuccesses')) this.totalRetrySuccesses = 0; if (toReset.includes('responseTimes')) { this.lastResponseTime = null; this.minResponseTime = null; this.maxResponseTime = null; this._totalResponseTime = 0; this._totalResponseTimeAll = 0; } if (toReset.includes('errorsList')) { this.lastErrorMessage = null; this.lastErrors = []; } if (toReset.includes('functionCalls')) this.functionCallCounts = {}; if (toReset.includes('errorMessages')) this.errorMessageCounts = {}; if (toReset.includes('dataSent')) this.totalDataSent = 0; if (toReset.includes('dataReceived')) this.totalDataReceived = 0; if (toReset.includes('timestamps')) { this.lastRequestTimestamp = null; this.lastSuccessTimestamp = null; this.lastErrorTimestamp = null; this.requestTimestamps = []; } } /** * Destroys the diagnostics instance, clearing resources. */ destroy() { this.reset(); this.logger.pause(); // Отключаем логгер } /** * Outputs a notification if error count or error rate exceeds thresholds. * @private */ sendNotification() { if (this.errorResponses <= this.notificationThreshold && this.errorRate <= this.errorRateThreshold) return; const notification = { timestamp: new Date().toISOString(), slaveIds: this.slaveIds, errorCount: this.errorResponses, errorRate: this.errorRate?.toFixed(2) || 'N/A', lastError: this.lastErrorMessage, lastErrors: this.lastErrors }; this.logger.warn('Excessive errors detected', { slaveId: this.slaveIds.join(','), errorCount: notification.errorCount, errorRate: notification.errorRate, lastError: notification.lastError }); } /** * Records a request event. * @param {number} [slaveId] - Slave ID for the request * @param {number} [funcCode] - Modbus function code */ recordRequest(slaveId, funcCode) { this.totalRequests++; this.lastRequestTimestamp = new Date().toISOString(); this.requestTimestamps.push(Date.now()); if (this.requestTimestamps.length > 1000) this.requestTimestamps.shift(); // Ограничиваем историю this.logger.trace('Request sent', { slaveId: slaveId || this.slaveIds[0], funcCode }); } /** * Records a retry event. * @param {number} attempts - Number of retry attempts * @param {number} [slaveId] - Slave ID * @param {number} [funcCode] - Modbus function code */ recordRetry(attempts, slaveId, funcCode) { this.totalRetries += attempts; this.logger.debug(`Retry attempt #${attempts}`, { slaveId: slaveId || this.slaveIds[0], funcCode }); } /** * Records a successful retry event. * @param {number} [slaveId] - Slave ID * @param {number} [funcCode] - Modbus function code */ recordRetrySuccess(slaveId, funcCode) { this.totalRetrySuccesses++; this.logger.debug('Retry successful', { slaveId: slaveId || this.slaveIds[0], funcCode }); } /** * Records a function call event. * @param {number} funcCode - Modbus function code * @param {number} [slaveId] - Slave ID */ recordFunctionCall(funcCode, slaveId) { if (funcCode == null) return; this._lastFuncCode = funcCode; this.functionCallCounts[funcCode] ??= 0; this.functionCallCounts[funcCode]++; this.logger.trace('Function called', { slaveId: slaveId || this.slaveIds[0], funcCode, funcName: Object.keys(FUNCTION_CODES).find(k => FUNCTION_CODES[k] === funcCode) || 'Unknown' }); } /** * Records a successful response event. * @param {number} responseTimeMs - Response time in milliseconds * @param {number} [slaveId] - Slave ID * @param {number} [funcCode] - Modbus function code */ recordSuccess(responseTimeMs, slaveId, funcCode) { this.successfulResponses++; this.lastResponseTime = responseTimeMs; this.minResponseTime = this.minResponseTime == null ? responseTimeMs : Math.min(this.minResponseTime, responseTimeMs); this.maxResponseTime = this.maxResponseTime == null ? responseTimeMs : Math.max(this.maxResponseTime, responseTimeMs); this._totalResponseTime += responseTimeMs; this._totalResponseTimeAll += responseTimeMs; this.lastSuccessTimestamp = new Date().toISOString(); this.lastSuccessDetails = { responseTime: responseTimeMs, timestamp: this.lastSuccessTimestamp, funcCode, slaveId: slaveId || this.slaveIds[0] }; this.logger.info('Response received', { slaveId: slaveId || this.slaveIds[0], funcCode, responseTime: responseTimeMs }); } /** * Records an error event. * @param {Error} error - Error object * @param {Object} [options] - Optional parameters * @param {string} [options.code] - Error code (e.g., 'timeout', 'crc') * @param {number} [options.responseTimeMs=0] - Response time in milliseconds * @param {number} [options.exceptionCode] - Modbus exception code * @param {number} [options.slaveId] - Slave ID * @param {number} [options.funcCode] - Modbus function code */ recordError(error, { code = null, responseTimeMs = 0, exceptionCode = null, slaveId, funcCode } = {}) { this.errorResponses++; this.lastErrorMessage = error.message || String(error); this._totalResponseTimeAll += responseTimeMs; this.lastErrorTimestamp = new Date().toISOString(); this.lastErrors.push(this.lastErrorMessage); if (this.lastErrors.length > 10) this.lastErrors.shift(); const msg = (error.message || '').toLowerCase(); if (code === 'timeout' || msg.includes('timeout')) { this.timeouts++; } else if (code === 'crc' || msg.includes('crc')) { this.crcErrors++; } else if (code === 'modbus-exception' || msg.includes('modbus exception')) { this.modbusExceptions++; if (typeof exceptionCode === 'number') { this.lastExceptionCode = exceptionCode; this.exceptionCodeCounts[exceptionCode] ??= 0; this.exceptionCodeCounts[exceptionCode]++; } } this.errorMessageCounts[this.lastErrorMessage] ??= 0; this.errorMessageCounts[this.lastErrorMessage]++; this.logger.error(this.lastErrorMessage, { slaveId: slaveId || this.slaveIds[0], funcCode, exceptionCode, responseTime: responseTimeMs }); this.sendNotification(); } /** * Records the amount of outgoing data in bytes. * @param {number} byteLength - Number of bytes sent * @param {number} [slaveId] - Slave ID * @param {number} [funcCode] - Modbus function code */ recordDataSent(byteLength, slaveId, funcCode) { this.totalDataSent += byteLength; this.logger.trace(`Data sent: ${byteLength} bytes`, { slaveId: slaveId || this.slaveIds[0], funcCode }); } /** * Records the amount of incoming data in bytes. * @param {number} byteLength - Number of bytes received * @param {number} [slaveId] - Slave ID * @param {number} [funcCode] - Modbus function code */ recordDataReceived(byteLength, slaveId, funcCode) { this.totalDataReceived += byteLength; this.logger.trace(`Data received: ${byteLength} bytes`, { slaveId: slaveId || this.slaveIds[0], funcCode }); } /** * Returns the average response time in milliseconds for successful responses. * @returns {number|null} Average response time in milliseconds */ get averageResponseTime() { return this.successfulResponses === 0 ? null : this._totalResponseTime / this.successfulResponses; } /** * Returns the average response time including errors. * @returns {number|null} Average response time in milliseconds */ get averageResponseTimeAll() { const total = this.successfulResponses + this.errorResponses; return total === 0 ? null : this._totalResponseTimeAll / total; } /** * Calculates the error rate as a percentage of total requests. * @returns {number|null} Error rate percentage or null if totalRequests is zero */ get errorRate() { return this.totalRequests === 0 ? null : (this.errorResponses / this.totalRequests) * 100; } /** * Calculates the requests per second based on recent activity. * @returns {number|null} Requests per second or null if insufficient data */ get requestsPerSecond() { if (this.requestTimestamps.length < 2) return null; const timeSpan = (this.requestTimestamps[this.requestTimestamps.length - 1] - this.requestTimestamps[0]) / 1000; return timeSpan === 0 ? null : this.requestTimestamps.length / timeSpan; } /** * Returns the uptime in seconds. * @returns {number} Uptime in seconds */ get uptimeSeconds() { return Math.floor((Date.now() - this.startTime) / 1000); } /** * Analyzes statistics and returns potential issues. * @returns {Object} Analysis results with warnings */ analyze() { const warnings = []; if (this.errorRate > this.errorRateThreshold) { warnings.push(`High error rate: ${this.errorRate.toFixed(2)}% (threshold: ${this.errorRateThreshold}%)`); } if (this.timeouts > this.notificationThreshold) { warnings.push(`High timeout count: ${this.timeouts}`); } if (this.crcErrors > this.notificationThreshold) { warnings.push(`High CRC error count: ${this.crcErrors}`); } if (this.modbusExceptions > this.notificationThreshold) { warnings.push(`High Modbus exception count: ${this.modbusExceptions}`); } if (this.maxResponseTime > 1000) { warnings.push(`High max response time: ${this.maxResponseTime}ms`); } return { warnings, isHealthy: warnings.length === 0, stats: this.getStats() }; } /** * Returns a JSON object containing all statistics and counters. * @returns {Object} Object containing all statistics and counters */ getStats() { return { uptimeSeconds: this.uptimeSeconds, totalSessions: this.totalSessions, totalRequests: this.totalRequests, successfulResponses: this.successfulResponses, errorResponses: this.errorResponses, timeouts: this.timeouts, crcErrors: this.crcErrors, modbusExceptions: this.modbusExceptions, exceptionCodeCounts: Object.entries(this.exceptionCodeCounts).reduce((acc, [code, count]) => { acc[`${code}/${EXCEPTION_CODES[code] || 'Unknown'}`] = count; return acc; }, {}), totalRetries: this.totalRetries, totalRetrySuccesses: this.totalRetrySuccesses, lastResponseTime: this.lastResponseTime, minResponseTime: this.minResponseTime, maxResponseTime: this.maxResponseTime, averageResponseTime: this.averageResponseTime, averageResponseTimeAll: this.averageResponseTimeAll, requestsPerSecond: this.requestsPerSecond, errorRate: this.errorRate, lastErrorMessage: this.lastErrorMessage, lastErrors: [...this.lastErrors], lastSuccessDetails: this.lastSuccessDetails, functionCallCounts: Object.entries(this.functionCallCounts).reduce((acc, [code, count]) => { acc[`${code}/${Object.keys(FUNCTION_CODES).find(k => FUNCTION_CODES[k] === parseInt(code)) || 'Unknown'}`] = count; return acc; }, {}), commonErrors: Object.entries(this.errorMessageCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([message, count]) => ({ message, count })), dataSent: this.totalDataSent, dataReceived: this.totalDataReceived, lastRequestTimestamp: this.lastRequestTimestamp, lastSuccessTimestamp: this.lastSuccessTimestamp, lastErrorTimestamp: this.lastErrorTimestamp, slaveIds: [...this.slaveIds] }; } /** * Prints formatted statistics to the console. */ printStats() { const stats = this.getStats(); this.logger.info('=== Modbus Diagnostics ==='); this.logger.info(`Slave IDs: ${stats.slaveIds.join(', ')}`); this.logger.info(`Uptime: ${stats.uptimeSeconds} seconds`); this.logger.info(`Total Sessions: ${stats.totalSessions}`); this.logger.info(`Total Requests: ${stats.totalRequests}`); this.logger.info(`Successful Responses: ${stats.successfulResponses}`); this.logger.info(`Error Responses: ${stats.errorResponses} (Rate: ${stats.errorRate?.toFixed(2) || 'N/A'}%)`); this.logger.info(`Timeouts: ${stats.timeouts}`); this.logger.info(`CRC Errors: ${stats.crcErrors}`); this.logger.info(`Modbus Exceptions: ${stats.modbusExceptions}`); this.logger.info(`Exception Codes: ${JSON.stringify(stats.exceptionCodeCounts, null, 2)}`); this.logger.info(`Total Retries: ${stats.totalRetries}`); this.logger.info(`Successful Retries: ${stats.totalRetrySuccesses}`); this.logger.info(`Last Response Time: ${stats.lastResponseTime || 'N/A'} ms`); this.logger.info(`Min Response Time: ${stats.minResponseTime || 'N/A'} ms`); this.logger.info(`Max Response Time: ${stats.maxResponseTime || 'N/A'} ms`); this.logger.info(`Average Response Time (Success): ${stats.averageResponseTime?.toFixed(2) || 'N/A'} ms`); this.logger.info(`Average Response Time (All): ${stats.averageResponseTimeAll?.toFixed(2) || 'N/A'} ms`); this.logger.info(`Requests per Second: ${stats.requestsPerSecond?.toFixed(2) || 'N/A'}`); this.logger.info(`Data Sent: ${stats.dataSent} bytes`); this.logger.info(`Data Received: ${stats.dataReceived} bytes`); this.logger.info(`Last Request: ${stats.lastRequestTimestamp || 'N/A'}`); this.logger.info(`Last Success: ${stats.lastSuccessTimestamp || 'N/A'}`); this.logger.info(`Last Error: ${stats.lastErrorTimestamp || 'N/A'}`); this.logger.info('Function Calls:', JSON.stringify(stats.functionCallCounts, null, 2)); this.logger.info('Common Errors:', JSON.stringify(stats.commonErrors, null, 2)); this.logger.info('========================='); } /** * Returns a JSON string containing all statistics and counters. * @returns {string} JSON string containing all statistics and counters */ serialize() { return JSON.stringify(this.getStats(), null, 2); } /** * Returns an array of objects containing metric names and their values. * @returns {Array<Object>} Array of objects containing metric names and their values */ toTable() { const stats = this.getStats(); return Object.entries(stats).map(([metric, value]) => ({ metric, value })); } /** * Merges another Diagnostics object into this one. * @param {Diagnostics} other - Diagnostics object to merge with */ mergeWith(other) { if (!(other instanceof Diagnostics)) return; this.totalRequests += other.totalRequests; this.successfulResponses += other.successfulResponses; this.errorResponses += other.errorResponses; this.timeouts += other.timeouts; this.crcErrors += other.crcErrors; this.modbusExceptions += other.modbusExceptions; for (const [code, count] of Object.entries(other.exceptionCodeCounts)) { this.exceptionCodeCounts[code] ??= 0; this.exceptionCodeCounts[code] += count; } this.totalRetries += other.totalRetries; this.totalRetrySuccesses += other.totalRetrySuccesses; this._totalResponseTime += other._totalResponseTime; this._totalResponseTimeAll += other._totalResponseTimeAll; if (other.minResponseTime != null) { this.minResponseTime = this.minResponseTime == null ? other.minResponseTime : Math.min(this.minResponseTime, other.minResponseTime); } if (other.maxResponseTime != null) { this.maxResponseTime = this.maxResponseTime == null ? other.maxResponseTime : Math.max(this.maxResponseTime, other.maxResponseTime); } this.totalDataSent += other.totalDataSent; this.totalDataReceived += other.totalDataReceived; other.lastErrors.forEach(err => this.lastErrors.push(err)); if (this.lastErrors.length > 10) this.lastErrors = this.lastErrors.slice(-10); for (const [code, count] of Object.entries(other.functionCallCounts)) { this.functionCallCounts[code] ??= 0; this.functionCallCounts[code] += count; } for (const [msg, count] of Object.entries(other.errorMessageCounts)) { this.errorMessageCounts[msg] ??= 0; this.errorMessageCounts[msg] += count; } this.totalSessions += other.totalSessions ?? 0; this.slaveIds = [...new Set([...this.slaveIds, ...other.slaveIds])]; this.requestTimestamps.push(...other.requestTimestamps); if (this.requestTimestamps.length > 1000) this.requestTimestamps = this.requestTimestamps.slice(-1000); this.logger.info('Merged diagnostics', { slaveIds: other.slaveIds }); } } module.exports = { Diagnostics };