feat: 添加串口自动探测

This commit is contained in:
2026-06-26 16:34:27 +08:00
parent d1a87556c5
commit 4aa93d8a9d
2 changed files with 295 additions and 10 deletions
+2
View File
@@ -2,6 +2,8 @@
"serial": {
"path": "COM3",
"baudRate": 115200,
"autoDetect": true,
"probeTimeout": 1200,
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
},
"smtp": {
+293 -10
View File
@@ -2,6 +2,8 @@ import { SerialPort } from 'serialport';
import { ReadlineParser } from '@serialport/parser-readline';
import { Submit } from 'node-pdu';
import EventEmitter from 'events';
import fs from 'fs';
import path from 'path';
import logger from './logger.js';
class ModemManager extends EventEmitter {
@@ -23,13 +25,7 @@ class ModemManager extends EventEmitter {
*/
async open() {
try {
// 处理串口路径:Windows支持COMx格式
let portPath = this.config.path;
// 如果是Windows且指定了COM端口号(数字),自动添加COM前缀
if (process.platform === 'win32' && /^\d+$/.test(portPath)) {
portPath = `COM${portPath}`;
}
const portPath = await this.resolvePortPath();
logger.info(`准备打开串口: ${portPath} (${this.config.baudRate})`);
@@ -39,7 +35,8 @@ class ModemManager extends EventEmitter {
baudRate: this.config.baudRate,
dataBits: 8,
stopBits: 1,
parity: 'none'
parity: 'none',
autoOpen: false
});
// 设置行解析器
@@ -61,8 +58,18 @@ class ModemManager extends EventEmitter {
this.setupURCListener();
// 等待串口打开
await new Promise((resolve) => this.port.once('open', resolve));
logger.info(`串口已打开: ${this.config.path}`);
await new Promise((resolve, reject) => {
this.port.open((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
this.config.path = portPath;
logger.info(`串口已打开: ${portPath}`);
// 初始化模组
await this.init();
@@ -73,6 +80,282 @@ class ModemManager extends EventEmitter {
}
}
/**
* 自动探测能响应AT命令的串口;可通过 serial.autoDetect=false 关闭。
*/
async resolvePortPath() {
const manualPath = this.normalizePortPath(this.config.path);
if (this.config.autoDetect === false) {
return manualPath;
}
const candidates = await this.getSerialProbeCandidates(manualPath);
if (candidates.length === 0) {
throw new Error('未发现可探测的串口设备');
}
logger.info(`开始自动探测AT串口: ${this.formatProbeCandidateList(candidates)}`);
const timeout = this.config.probeTimeout || 1200;
const failures = [];
for (const candidate of candidates) {
const result = await this.probeATPort(candidate.path, timeout);
if (result.ok) {
logger.info(`✓ 自动探测到AT串口: ${candidate.path}`);
return candidate.path;
}
failures.push(result);
logger.debug(`串口探测未通过: ${candidate.path} (${this.formatProbeFailure(result)})`);
}
const summary = failures
.slice(0, 8)
.map(item => `${item.path}: ${this.formatProbeFailure(item)}`)
.join('; ');
throw new Error(`未找到可响应AT的串口,已探测 ${failures.length} 个端口${summary ? ` (${summary})` : ''}`);
}
normalizePortPath(portPath) {
if (!portPath) {
return null;
}
const normalized = String(portPath).trim();
if (process.platform === 'win32' && /^\d+$/.test(normalized)) {
return `COM${normalized}`;
}
return normalized;
}
async getSerialProbeCandidates(manualPath) {
const candidates = new Map();
const addCandidate = (path, portInfo = {}, manual = false) => {
if (!path) {
return;
}
const candidate = candidates.get(path) || {
path,
portInfo: {},
manual: false
};
candidate.portInfo = { ...candidate.portInfo, ...portInfo };
candidate.manual = candidate.manual || manual;
candidate.metadata = this.getPortMetadata(path, candidate.portInfo);
candidate.priority = this.getProbeCandidatePriority(path, candidate.portInfo, candidate.manual, candidate.metadata);
candidates.set(path, candidate);
};
let ports = [];
try {
ports = await SerialPort.list();
} catch (err) {
logger.warn(`读取系统串口列表失败: ${err.message}`);
}
for (const portInfo of ports) {
addCandidate(this.normalizePortPath(portInfo.path), portInfo);
}
addCandidate(manualPath, {}, true);
return Array.from(candidates.values())
.sort((a, b) => a.priority - b.priority || a.path.localeCompare(b.path));
}
getProbeCandidatePriority(portPath, portInfo, manual, metadata) {
const interfaceName = (metadata.interfaceName || '').toLowerCase();
const interfaceNumber = metadata.interfaceNumber;
if (interfaceName.includes('at interface') || /\bat\b/.test(interfaceName)) {
return interfaceNumber === null ? 0 : interfaceNumber;
}
if (portInfo.vendorId === '2ecc' && portInfo.productId === '3012') {
// ML307A: if02/if03 are AT interfaces; if04 is Diag; if00/if01 are RNDIS.
if (interfaceNumber === 2 || interfaceNumber === 3) {
return interfaceNumber;
}
return 40 + (interfaceNumber ?? 9);
}
if (interfaceName.includes('diag')) {
return 40;
}
if (interfaceName.includes('rndis') || interfaceName.includes('network')) {
return 50;
}
if (portInfo.vendorId || portInfo.productId || /usb|acm/i.test(portPath)) {
return 20;
}
if (/^COM\d+$/i.test(portPath)) {
return 30;
}
if (manual) {
return 35;
}
return 80;
}
getPortMetadata(portPath, portInfo) {
const metadata = {
interfaceName: '',
interfaceNumber: this.parseUSBInterfaceNumber(portInfo.pnpId)
};
if (process.platform !== 'linux') {
return metadata;
}
const ttyName = this.getTTYName(portPath);
if (!ttyName) {
return metadata;
}
try {
const ttyDevicePath = fs.realpathSync(`/sys/class/tty/${ttyName}/device`);
const usbInterfacePath = path.dirname(ttyDevicePath);
metadata.interfaceName = this.readFirstLine(path.join(usbInterfacePath, 'interface')) || '';
const sysfsInterfaceNumber = this.readFirstLine(path.join(usbInterfacePath, 'bInterfaceNumber'));
if (sysfsInterfaceNumber) {
metadata.interfaceNumber = Number.parseInt(sysfsInterfaceNumber, 16);
}
} catch (err) {
// Some platforms expose serial ports without Linux USB sysfs metadata.
}
return metadata;
}
parseUSBInterfaceNumber(pnpId) {
const match = String(pnpId || '').match(/if(\d+)/i);
if (!match) {
return null;
}
return Number.parseInt(match[1], 10);
}
getTTYName(portPath) {
try {
return path.basename(fs.realpathSync(portPath));
} catch (err) {
return path.basename(portPath);
}
}
readFirstLine(filePath) {
try {
return fs.readFileSync(filePath, 'utf8').split('\n')[0].trim();
} catch (err) {
return '';
}
}
formatProbeCandidateList(candidates) {
const paths = candidates.map(item => item.path);
if (paths.length <= 12) {
return paths.join(', ');
}
return `${paths.slice(0, 12).join(', ')} ... 共${paths.length}`;
}
async probeATPort(path, timeout = 1200) {
return new Promise((resolve) => {
let buffer = '';
let settled = false;
const port = new SerialPort({
path,
baudRate: this.config.baudRate,
dataBits: 8,
stopBits: 1,
parity: 'none',
autoOpen: false
});
const done = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
port.removeListener('data', onData);
port.removeListener('error', onError);
const finalResult = {
path,
...result,
response: buffer
};
if (port.isOpen) {
port.close(() => resolve(finalResult));
return;
}
resolve(finalResult);
};
const onData = (data) => {
buffer += data.toString('utf8');
if (buffer.includes('OK')) {
done({ ok: true, reason: 'ok' });
} else if (buffer.includes('ERROR')) {
done({ ok: false, reason: 'error_response' });
}
};
const onError = (err) => {
done({ ok: false, reason: 'error', error: err.message });
};
const timer = setTimeout(() => {
done({ ok: false, reason: 'timeout' });
}, timeout);
port.on('data', onData);
port.on('error', onError);
port.open((err) => {
if (err) {
done({ ok: false, reason: 'open_error', error: err.message });
return;
}
port.write('AT\r\n', (writeErr) => {
if (writeErr) {
done({ ok: false, reason: 'write_error', error: writeErr.message });
}
});
});
});
}
formatProbeFailure(result) {
if (result.error) {
return `${result.reason}: ${result.error}`;
}
return result.reason;
}
/**
* 设置URC监听器
*/