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 { constructor(config, mobileDataConfig = {}) { super(); this.config = config; this.mobileDataConfig = { cid: this.normalizeMobileDataCid(mobileDataConfig.cid) }; this.port = null; this.parser = null; this.ready = false; this.modelInfo = { manufacturer: '未知', model: '未知', version: '未知' }; this.mobileData = { cid: this.mobileDataConfig.cid, desiredEnabled: false, enabled: false, targetEnabled: false, anyActive: false, status: 'disabled', mode: 'unknown', contexts: [], lastCheckedAt: null, error: '' }; } /** * 打开串口并初始化模组 */ async open() { try { const portPath = await this.resolvePortPath(); logger.info(`准备打开串口: ${portPath} (${this.config.baudRate})`); // 打开串口 this.port = new SerialPort({ path: portPath, baudRate: this.config.baudRate, dataBits: 8, stopBits: 1, parity: 'none', autoOpen: false }); // 设置行解析器 this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r\n' })); // 监听串口事件 this.port.on('error', (err) => { logger.error('串口错误:', err); this.emit('error', err); }); this.port.on('close', () => { logger.warn('串口已关闭'); this.ready = false; this.emit('close'); }); // 监听URC(主动上报)消息 this.setupURCListener(); // 等待串口打开 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(); } catch (err) { logger.error('打开串口失败:', err); throw err; } } /** * 自动探测能响应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; } normalizeMobileDataCid(value) { const cid = Number.parseInt(value, 10); if (Number.isInteger(cid) && cid > 0 && cid <= 15 && cid !== 8) { return cid; } return 1; } getMobileDataMode() { return this.isML307Family() ? 'mipcall' : 'cgact'; } getMobileDataCommand(mode = this.getMobileDataMode(), enabled = false, cid = this.mobileDataConfig.cid) { if (mode === 'mipcall') { return `AT+MIPCALL=${enabled ? 1 : 0},${cid}`; } return `AT+CGACT=${enabled ? 1 : 0},${cid}`; } getMobileDataStatusCommand(mode = this.getMobileDataMode()) { return mode === 'mipcall' ? 'AT+MIPCALL?' : 'AT+CGACT?'; } getCachedMobileDataStatus() { return { ...this.mobileData, contexts: this.mobileData.contexts.map(item => ({ ...item })) }; } async getMobileDataStatus() { const mode = this.getMobileDataMode(); const command = this.getMobileDataStatusCommand(mode); try { const resp = await this.sendATCommand(command, 5000); const status = this.parseMobileDataStatusResponse(resp, mode); this.updateMobileDataStatus(status); return this.getCachedMobileDataStatus(); } catch (err) { logger.warn(`查询移动数据状态失败: ${err.message}`); this.updateMobileDataStatus({ mode, cid: this.mobileDataConfig.cid, status: 'unknown', enabled: false, targetEnabled: false, anyActive: false, contexts: [], error: err.message }); return this.getCachedMobileDataStatus(); } } parseMobileDataStatusResponse(resp, mode) { const contexts = []; const lines = resp.split('\n').map(line => line.trim()).filter(Boolean); for (const line of lines) { const payload = this.extractMobileDataPayload(line, mode); if (!payload) { continue; } const match = payload.match(/^(\d+)\s*,\s*(\d+)(.*)$/); if (!match) { continue; } const cid = Number.parseInt(match[1], 10); const state = Number.parseInt(match[2], 10); const addresses = [...match[3].matchAll(/"([^"]+)"/g)].map(item => item[1]).filter(Boolean); contexts.push({ cid, active: state === 1, state, addresses }); } const target = contexts.find(item => item.cid === this.mobileDataConfig.cid); const anyActive = contexts.some(item => item.active); const targetEnabled = target ? target.active : false; return { mode, cid: this.mobileDataConfig.cid, enabled: anyActive, targetEnabled, anyActive, status: anyActive ? 'enabled' : 'disabled', contexts, error: '' }; } extractMobileDataPayload(line, mode) { if (mode === 'mipcall') { if (line.startsWith('+MIPCALL:')) { return line.slice('+MIPCALL:'.length).trim(); } if (/^\d+\s*,\s*\d+/.test(line)) { return line; } } if (line.startsWith('+CGACT:')) { return line.slice('+CGACT:'.length).trim(); } return ''; } updateMobileDataStatus(status) { this.mobileData = { ...this.mobileData, ...status, cid: this.mobileDataConfig.cid, lastCheckedAt: new Date().toISOString(), contexts: Array.isArray(status.contexts) ? status.contexts : [] }; } getMobileDataTargetCids(status) { const cids = new Set([this.mobileDataConfig.cid]); for (const context of status.contexts || []) { if (context.active && context.cid !== 8) { cids.add(context.cid); } } return [...cids].filter(cid => this.normalizeMobileDataCid(cid) === cid); } async setMobileDataEnabled(enabled, options = {}) { this.mobileData.desiredEnabled = Boolean(enabled); if (enabled) { return await this.enableMobileData(options); } return await this.disableMobileData(options); } async enableMobileData(options = {}) { const mode = this.getMobileDataMode(); const cid = this.mobileDataConfig.cid; const command = this.getMobileDataCommand(mode, true, cid); const label = mode === 'mipcall' ? '开启移动数据拨号' : '激活PDP数据连接'; logger.warn(`准备开启移动数据(CID ${cid}),可能产生流量费用`); const resp = await this.sendATWithRetry(command, { timeout: 30000, retries: 1, label, required: false }); const status = await this.waitForMobileDataState(true, options.timeout || 20000); if (!resp || !status.anyActive) { throw new Error('移动数据开启失败,未检测到已激活的数据连接'); } logger.info(`✓ 已开启移动数据(CID ${cid})`); return status; } async disableMobileData(options = {}) { const mode = this.getMobileDataMode(); const before = await this.getMobileDataStatus(); const cids = this.getMobileDataTargetCids(before); const label = mode === 'mipcall' ? '关闭移动数据拨号' : '停用PDP数据连接'; let commandSucceeded = false; logger.info(`${options.reason || '手动操作'}: 关闭移动数据,避免流量消耗`); for (const cid of cids) { const command = this.getMobileDataCommand(mode, false, cid); const resp = await this.sendATWithRetry(command, { timeout: 8000, retries: 1, label: `${label}(CID ${cid})`, required: false }); commandSucceeded = commandSucceeded || Boolean(resp); } const status = await this.waitForMobileDataState(false, options.timeout || 8000); if (status.anyActive) { const message = `移动数据关闭后仍检测到活动连接: ${this.formatMobileDataContexts(status.contexts)}`; if (options.required === false) { logger.warn(message); return status; } throw new Error(message); } if (status.status === 'unknown') { const message = commandSucceeded ? '移动数据关闭命令已发送,但状态未确认' : '移动数据关闭命令未确认成功'; if (options.required === false) { logger.warn(message); return status; } throw new Error(message); } if (!commandSucceeded && before.status === 'unknown' && options.required !== false) { throw new Error('移动数据关闭命令未确认成功'); } logger.info('✓ 移动数据已关闭'); return status; } async forceMobileDataOff(stage) { this.mobileData.desiredEnabled = false; try { return await this.disableMobileData({ reason: stage, required: false, timeout: 5000 }); } catch (err) { logger.warn(`${stage}: 移动数据关闭未确认: ${err.message}`); return this.getCachedMobileDataStatus(); } } async waitForMobileDataState(enabled, timeout = 10000) { const startedAt = Date.now(); let status = await this.getMobileDataStatus(); while (status.status !== 'unknown' && status.anyActive !== enabled && Date.now() - startedAt < timeout) { await this.sleep(1000); status = await this.getMobileDataStatus(); } return status; } formatMobileDataContexts(contexts = []) { const active = contexts .filter(item => item.active) .map(item => `CID ${item.cid}`) .join(', '); return active || '无'; } /** * 设置URC监听器 */ setupURCListener() { let waitingPDU = false; this.parser.on('data', (line) => { line = line.trim(); // 调试输出 if (line.length > 0 && !line.startsWith('AT')) { logger.debug(`<< ${line}`); } // 检测短信URC if (line.startsWith('+CMT:')) { logger.info('检测到短信URC,等待PDU数据...'); waitingPDU = true; } else if (waitingPDU && this.isHexString(line)) { logger.info('收到PDU数据'); waitingPDU = false; this.emit('sms', line); // 发射短信事件 } else if (waitingPDU && line.length === 0) { // 跳过空行 } else if (waitingPDU) { // 收到非PDU数据,返回等待状态 waitingPDU = false; } // 检测网络注册状态变化 if (line.startsWith('+CEREG:')) { this.emit('cereg', line); } }); } /** * 检查是否为十六进制字符串 */ isHexString(str) { return /^[0-9A-Fa-f]+$/.test(str); } /** * 发送AT命令并等待响应 */ async sendATCommand(cmd, timeout = 2000) { return new Promise((resolve, reject) => { let buffer = ''; const timer = setTimeout(() => { this.parser.removeListener('data', handler); reject(new Error(`AT命令超时: ${cmd}`)); }, timeout); const handler = (line) => { buffer += line + '\n'; if (line.includes('OK') || line.includes('ERROR')) { clearTimeout(timer); this.parser.removeListener('data', handler); resolve(buffer); } }; this.parser.on('data', handler); logger.debug(`>> ${cmd}`); this.port.write(cmd + '\r\n'); }); } /** * 发送AT命令并等待OK */ async sendATandWaitOK(cmd, timeout = 2000) { try { const resp = await this.sendATCommand(cmd, timeout); return resp.includes('OK'); } catch (err) { return false; } } /** * 发送AT命令,失败时打印模组原始响应,便于排查型号差异 */ async sendATWithRetry(cmd, options = {}) { const { timeout = 2000, retries = 3, retryDelay = 1000, label = cmd, required = true } = options; for (let attempt = 1; attempt <= retries; attempt++) { try { const resp = await this.sendATCommand(cmd, timeout); if (resp.includes('OK')) { return resp; } logger.warn(`${label}失败(${attempt}/${retries}): ${this.formatATResponse(resp)}`); } catch (err) { logger.warn(`${label}失败(${attempt}/${retries}): ${err.message}`); } if (attempt < retries && retryDelay > 0) { await this.sleep(retryDelay); } } if (required) { throw new Error(`${label}失败`); } return null; } /** * 压缩AT响应,避免日志跨太多行 */ formatATResponse(resp) { return resp .split('\n') .map(line => line.trim()) .filter(Boolean) .join(' | '); } /** * 初始化模组 */ async init() { logger.info('开始初始化4G模组...'); // 1. AT握手 let retries = 0; while (!(await this.sendATandWaitOK('AT', 1000)) && retries < 10) { logger.warn('AT未响应,重试...'); retries++; await this.sleep(1000); } if (retries >= 10) { throw new Error('模组AT握手失败'); } logger.info('✓ 模组AT响应正常'); // 2. 查询模组信息 try { const resp = await this.sendATCommand('ATI', 2000); const lines = resp.split('\n').map(l => l.trim()).filter(l => l && l !== 'ATI' && l !== 'OK'); if (lines.length >= 3) { this.modelInfo.manufacturer = lines[0]; this.modelInfo.model = lines[1]; this.modelInfo.version = lines[2]; logger.info(`模组信息: ${this.modelInfo.manufacturer} ${this.modelInfo.model} ${this.modelInfo.version}`); } } catch (err) { logger.warn('查询模组信息失败'); } // 3. 按ML307A文档先确认SIM卡和协议栈状态 await this.waitSIMReady(); await this.ensureFullFunctionality(); // 4. 启动保护:先关闭移动数据拨号,再等待短信所需的网络注册。 await this.forceMobileDataOff('启动保护(CFUN=1后)'); // 5. 等待网络注册 retries = 0; while (!(await this.waitCEREG()) && retries < 30) { logger.info('等待网络注册...'); retries++; await this.sleep(2000); } if (retries < 30) { logger.info('✓ 网络已注册'); this.ready = true; } else { logger.error('网络注册超时(无SIM卡或信号差)'); this.ready = false; } // 6. 驻网后再关一次,防止模组自动拨号在驻网完成后重新拉起。 await this.forceMobileDataOff('启动保护(驻网后)'); // 7. 按文档配置短信功能,并启用本项目需要的PDU模式 await this.configureSMS(); logger.info('模组初始化完成'); this.emit('ready'); } /** * 等待SIM卡完成初始化 */ async waitSIMReady() { for (let attempt = 1; attempt <= 10; attempt++) { try { const resp = await this.sendATCommand('AT+CPIN?', 2000); if (resp.includes('+CPIN: READY')) { logger.info('✓ SIM卡已就绪'); return; } logger.warn(`SIM卡未就绪(${attempt}/10): ${this.formatATResponse(resp)}`); } catch (err) { logger.warn(`查询SIM卡状态失败(${attempt}/10): ${err.message}`); } await this.sleep(1000); } throw new Error('SIM卡未就绪'); } /** * 确保驻网前协议栈功能模式为CFUN=1 */ async ensureFullFunctionality() { const resp = await this.sendATCommand('AT+CFUN?', 2000); const match = resp.match(/\+CFUN:\s*(\d+)/); if (match && match[1] === '1') { logger.info('✓ 协议栈功能模式正常(CFUN=1)'); return; } logger.warn(`当前CFUN状态不是1: ${this.formatATResponse(resp)}`); await this.sendATWithRetry('AT+CFUN=1', { timeout: 5000, retries: 3, label: '设置CFUN=1' }); // 文档要求CFUN切换后等待模组完成协议栈恢复。 await this.sleep(2000); logger.info('✓ 已设置协议栈功能模式(CFUN=1)'); } /** * 按ML307A文档配置短信功能;项目接收逻辑依赖PDU模式下的+CMT上报。 */ async configureSMS() { if (this.modelInfo.version.includes('ML307A-DL')) { throw new Error('当前ML307A-DL型号不支持短信功能'); } await this.sendATWithRetry('AT+CMGF=0', { timeout: 2000, retries: 3, label: '设置PDU模式' }); logger.info('✓ PDU模式设置完成'); await this.sendATWithRetry('AT+CNMI=2,2,0,2,0', { timeout: 2000, retries: 3, label: '设置CNMI短信上报' }); logger.info('✓ CNMI参数设置完成'); await this.sendATWithRetry('AT+CSCS="IRA"', { timeout: 2000, retries: 3, label: '设置短信字符集' }); logger.info('✓ 短信字符集设置完成'); await this.sendATWithRetry('AT+CSMP=33,167,0,0', { timeout: 2000, retries: 3, label: '设置短信发送参数' }); logger.info('✓ 短信发送参数设置完成'); } /** * 判断是否为ML307系列模组 */ isML307Family() { return this.modelInfo.model.startsWith('ML307') || this.modelInfo.version.startsWith('ML307'); } /** * 检测网络注册状态 */ async waitCEREG() { try { const resp = await this.sendATCommand('AT+CEREG?', 2000); // +CEREG: 0,1 或 +CEREG: 0,5 表示已注册 if (resp.includes('+CEREG:')) { if (resp.includes(',1') || resp.includes(',5')) { return true; } } return false; } catch (err) { return false; } } /** * 发送短信(PDU模式) */ async sendSMS(phoneNumber, message) { logger.info(`准备发送短信到: ${phoneNumber}`); logger.info(`短信内容: ${message}`); try { // 编码PDU const submit = new Submit(phoneNumber, message); const parts = submit.getPartStrings(); if (parts.length === 0) { throw new Error('PDU编码失败'); } for (let i = 0; i < parts.length; i++) { const pduData = parts[i]; const pduLength = this.getSubmitPduLength(pduData); logger.debug(`PDU分段: ${i + 1}/${parts.length}`); logger.debug(`PDU数据: ${pduData}`); logger.debug(`PDU长度: ${pduLength}`); const success = await this.sendPduPart(pduData, pduLength); if (!success) { logger.error(`✗ 短信分段发送失败: ${i + 1}/${parts.length}`); return false; } } logger.info('✓ 短信发送成功'); return true; } catch (err) { logger.error('发送短信异常:', err); return false; } } /** * 计算AT+CMGS需要的TPDU长度,不包含SMSC长度字段和SMSC内容。 */ getSubmitPduLength(pduData) { const smscLength = parseInt(pduData.slice(0, 2), 16); const totalLength = pduData.length / 2; const submitLength = totalLength - smscLength - 1; if (!Number.isFinite(submitLength) || submitLength <= 0) { throw new Error(`PDU长度无效: ${pduData}`); } return submitLength; } /** * 发送单个PDU分段。 */ async sendPduPart(pduData, pduLength) { const cmgsCmd = `AT+CMGS=${pduLength}`; this.port.write(cmgsCmd + '\r\n'); const gotPrompt = await this.waitForPrompt(); if (!gotPrompt) { throw new Error('未收到>提示符'); } logger.debug('收到>提示符,发送PDU数据...'); this.port.write(pduData + String.fromCharCode(0x1A)); return await this.waitForCMGSResult(); } /** * 等待CMGS输入提示符。提示符通常没有行结束符,所以直接监听原始串口数据。 */ waitForPrompt(timeout = 5000) { return new Promise((resolve) => { let buffer = ''; const timer = setTimeout(() => { this.port.removeListener('data', handler); resolve(false); }, timeout); const handler = (chunk) => { buffer += chunk.toString('utf8'); if (buffer.includes('>')) { clearTimeout(timer); this.port.removeListener('data', handler); resolve(true); } }; this.port.on('data', handler); }); } /** * 等待短信发送结果。 */ waitForCMGSResult(timeout = 30000) { return new Promise((resolve) => { const timer = setTimeout(() => { this.parser.removeListener('data', handler); resolve(false); }, timeout); const handler = (line) => { if (line.includes('OK')) { clearTimeout(timer); this.parser.removeListener('data', handler); resolve(true); } else if (line.includes('ERROR')) { clearTimeout(timer); this.parser.removeListener('data', handler); resolve(false); } }; this.parser.on('data', handler); }); } /** * 查询信号强度 */ async getSignalQuality() { try { const resp = await this.sendATCommand('AT+CSQ', 2000); const match = resp.match(/\+CSQ:\s*(\d+),(\d+)/); if (match) { const rssi = parseInt(match[1]); const ber = parseInt(match[2]); let quality = '未知'; if (rssi === 99) { quality = '未知或不可检测'; } else if (rssi >= 20) { quality = '很好'; } else if (rssi >= 15) { quality = '好'; } else if (rssi >= 10) { quality = '一般'; } else { quality = '弱'; } return { rssi, ber, quality }; } return null; } catch (err) { logger.error('查询信号强度失败:', err); return null; } } /** * 查询运营商 */ async getOperator() { try { const resp = await this.sendATCommand('AT+COPS?', 2000); const match = resp.match(/\+COPS:\s*\d+,\d+,"([^"]+)"/); if (match) { return match[1]; } return '未知'; } catch (err) { logger.error('查询运营商失败:', err); return '未知'; } } /** * 查询ICCID */ async getICCID() { try { const resp = await this.sendATCommand('AT+CCID', 2000); const match = resp.match(/\+CCID:\s*(\d+)/); if (match) { return match[1]; } return null; } catch (err) { logger.error('查询ICCID失败:', err); return null; } } /** * 关闭串口 */ async close() { if (this.port && this.port.isOpen) { await new Promise((resolve) => { this.port.close(resolve); }); logger.info('串口已关闭'); } } /** * 辅助函数:延时 */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } export default ModemManager;