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 = {}, appConfig = {}) { super(); this.config = config; this.appConfig = appConfig; this.mobileDataConfig = { cid: this.normalizeMobileDataCid(mobileDataConfig.cid) }; this.smsSending = false; this.port = null; this.parser = null; this.atCommandQueue = Promise.resolve(); this.ready = false; this.modelInfo = { manufacturer: '未知', model: '未知', version: '未知' }; this.sim = { supported: false, canSwitch: false, mode: null, modeLabel: '未知', activeSlot: null, switchSlot: null, bindSlot: null, switching: false, slots: this.createSimSlots(), lastCheckedAt: null, error: '', notice: '直出模式下按模组URC接收短信;URC未携带卡槽时显示未知SIM。' }; this.mobileData = { cid: this.mobileDataConfig.cid, desiredEnabled: false, enabled: false, targetEnabled: false, anyActive: false, status: 'disabled', mode: 'unknown', contexts: [], lastCheckedAt: null, error: '' }; } createSimSlots() { return [0, 1].map(slot => this.createSimSlot(slot)); } createSimSlot(slot) { const phoneNumber = this.getConfiguredSimPhoneNumber(slot); return { slot, name: `SIM${slot + 1}`, active: false, phoneNumber, phoneLabel: phoneNumber || `SIM${slot + 1}`, lastCheckedAt: null }; } /** * 打开串口并初始化模组 */ 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; } getConfiguredSimPhoneNumber(slotNumber) { const slot = this.normalizeSimSlot(slotNumber); if (slot === null) { return ''; } const sources = [ this.appConfig?.simCards, this.appConfig?.simSlots, this.appConfig?.sims, this.appConfig?.sim ]; for (const source of sources) { const phoneNumber = this.extractConfiguredSimPhoneNumber(source, slot); if (phoneNumber) { return phoneNumber; } } return ''; } extractConfiguredSimPhoneNumber(source, slot) { if (!source) { return ''; } let candidate = null; if (Array.isArray(source)) { candidate = source.find(item => this.normalizeSimSlot(item?.slot ?? item?.simSlot ?? item?.sim) === slot) || source[slot]; } else if (typeof source === 'object') { candidate = source[slot] ?? source[String(slot)] ?? source[`sim${slot + 1}`] ?? source[`SIM${slot + 1}`]; } if (typeof candidate === 'string' || typeof candidate === 'number') { return this.normalizePhoneNumber(candidate); } if (candidate && typeof candidate === 'object') { return this.normalizePhoneNumber(candidate.phoneNumber ?? candidate.phone ?? candidate.msisdn ?? candidate.number); } return ''; } normalizePhoneNumber(value) { const text = String(value || '').trim(); if (!text) { return ''; } const normalized = text.replace(/[^\d+]/g, ''); return normalized.length >= 3 ? normalized : ''; } normalizeSimSlot(value) { if (typeof value === 'string') { const normalized = value.trim().toUpperCase(); if (normalized === 'SIM1') { return 0; } if (normalized === 'SIM2') { return 1; } } const slot = Number.parseInt(value, 10); return slot === 0 || slot === 1 ? slot : null; } getCachedSimStatus() { return { ...this.sim, slots: this.sim.slots.map(slot => ({ ...slot })) }; } isSimSwitching() { return Boolean(this.sim.switching); } formatDualSimMode(mode) { if (mode === 0) { return '双卡双待'; } if (mode === 1) { return '双卡单待'; } if (mode === 3) { return '双卡双待(FP)'; } return '未知'; } parseDualSimMode(resp) { const match = String(resp || '').match(/\+DUALSIM:\s*(\d+)/); if (!match) { return null; } const mode = Number.parseInt(match[1], 10); return Number.isInteger(mode) ? mode : null; } parseSimSlotResponse(resp, prefix) { const pattern = new RegExp(`\\+${prefix}:\\s*(\\d+)`, 'i'); const match = String(resp || '').match(pattern); if (!match) { return null; } return this.normalizeSimSlot(match[1]); } markActiveSimSlot(status, activeSlot) { status.activeSlot = activeSlot; status.slots = status.slots.map(slot => ({ ...slot, active: slot.slot === activeSlot })); } getBusinessSimSlot(status = this.sim) { return this.normalizeSimSlot(status.bindSlot) ?? this.normalizeSimSlot(status.switchSlot); } getSimSlotLabel(slotNumber) { const slot = this.normalizeSimSlot(slotNumber); if (slot === null) { return '未知SIM'; } const slotInfo = this.sim.slots.find(item => item.slot === slot); return slotInfo?.phoneLabel || slotInfo?.phoneNumber || `SIM${slot + 1}`; } updateSimSlotPhoneNumber(status, slotNumber, phoneNumber) { const slot = this.normalizeSimSlot(slotNumber); if (slot === null) { return; } const normalized = this.normalizePhoneNumber(phoneNumber); if (!normalized) { return; } status.slots = status.slots.map(item => { if (item.slot !== slot) { return item; } return { ...item, phoneNumber: normalized, phoneLabel: normalized, lastCheckedAt: new Date().toISOString() }; }); } parseOwnNumberResponse(resp) { const lines = String(resp || '') .split('\n') .map(line => line.trim()) .filter(Boolean); for (const line of lines) { if (/^AT/i.test(line) || line === 'OK' || line === 'ERROR') { continue; } const match = line.match(/^\+CNUM:\s*(?:"[^"]*")?\s*,\s*"?([^",\s]*)"?/i); if (match) { return this.normalizePhoneNumber(match[1]); } } return ''; } async queryOwnPhoneNumber() { try { const resp = await this.sendATCommand('AT+CNUM', 2000); return this.parseOwnNumberResponse(resp); } catch (err) { logger.debug(`查询当前SIM手机号失败: ${err.message}`); return ''; } } async readCurrentSimSlotPhoneNumber(slotNumber, context = '当前') { const slot = this.normalizeSimSlot(slotNumber); if (slot === null) { return false; } const phoneNumber = await this.queryOwnPhoneNumber(); if (!phoneNumber) { logger.debug(`${context}SIM${slot + 1}未读取到手机号`); return false; } this.updateSimSlotPhoneNumber(this.sim, slot, phoneNumber); logger.info(`✓ ${context}SIM${slot + 1}手机号: ${phoneNumber}`); return true; } async switchSimForIdentityRead(slotNumber, options = {}) { const targetSlot = this.normalizeSimSlot(slotNumber); if (targetSlot === null) { throw new Error('SIM卡槽参数无效,只支持 SIM1 或 SIM2'); } const { context = '启动探测', purpose = '', retries = 2, retryDelay = 2000, settleDelay = 3000, confirmTimeout = 15000, confirmInterval = 1000 } = options; const currentSlot = await this.querySwitchSimSlot().catch(() => this.sim.switchSlot); this.sim.switchSlot = currentSlot; if (currentSlot === targetSlot) { await this.waitSIMReady(); return targetSlot; } logger.info(`${context}: 切到SIM${targetSlot + 1}${purpose ? purpose : ''}`); let switched = false; for (let attempt = 1; attempt <= retries && !switched; attempt++) { try { const resp = await this.sendATCommand(`AT+SWITCHSIM=${targetSlot}`, 10000); if (!resp.includes('OK')) { logger.warn(`${context}切到SIM${targetSlot + 1}返回非OK(${attempt}/${retries}): ${this.formatATResponse(resp)}`); } } catch (err) { logger.warn(`${context}切到SIM${targetSlot + 1}命令失败(${attempt}/${retries}): ${err.message}`); } switched = await this.waitForSwitchSimSlot(targetSlot, confirmTimeout, confirmInterval); if (!switched && attempt < retries && retryDelay > 0) { await this.sleep(retryDelay); } } if (!switched) { throw new Error(`${context}切到SIM${targetSlot + 1}失败`); } this.sim.switchSlot = targetSlot; this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? targetSlot); await this.sleep(settleDelay); await this.waitSIMReady(); return targetSlot; } async waitForSwitchSimSlot(targetSlot, timeout = 15000, interval = 1000) { const startedAt = Date.now(); while (Date.now() - startedAt <= timeout) { try { const currentSlot = await this.querySwitchSimSlot(); this.sim.switchSlot = currentSlot; if (currentSlot === targetSlot) { return true; } } catch (err) { logger.debug(`等待SWITCHSIM生效失败: ${err.message}`); } await this.sleep(interval); } return false; } async ensureATSimMatchesBinding(context = 'SIM同步') { if (!this.sim.supported || !this.sim.canSwitch) { return null; } const bindSlot = this.normalizeSimSlot(this.sim.bindSlot ?? await this.queryBindSimSlot()); if (bindSlot === null) { return null; } const switchSlot = await this.querySwitchSimSlot(); this.sim.switchSlot = switchSlot; this.markActiveSimSlot(this.sim, bindSlot); if (switchSlot === bindSlot) { return { slot: bindSlot, changed: false }; } const currentLabel = switchSlot === null ? '未知' : `SIM${switchSlot + 1}`; logger.warn(`${context}: 业务绑定为SIM${bindSlot + 1},但AT当前为${currentLabel},准备同步AT卡槽`); await this.switchSimForIdentityRead(bindSlot, { context, purpose: '用于短信发送', retries: 4, retryDelay: 3000, settleDelay: 3000 }); return { slot: bindSlot, changed: true }; } async discoverSimSlotIdentities() { const status = await this.refreshSimStatus({ refreshIdentity: false }); const currentSlot = this.normalizeSimSlot(status.switchSlot) ?? this.normalizeSimSlot(status.activeSlot) ?? 0; logger.info(`启动仅初始化当前SIM${currentSlot + 1},不读取SMSC、不遍历其他卡槽`); await this.waitSIMReady(); await this.readCurrentSimSlotPhoneNumber(currentSlot, '启动当前'); this.sim.lastCheckedAt = new Date().toISOString(); return this.getCachedSimStatus(); } identifyIncomingSms(metadata = {}) { const urcSlot = this.normalizeSimSlot(metadata.simSlot); if (urcSlot !== null) { return { slot: urcSlot, source: metadata.simSource || 'urc' }; } return null; } async queryDualSimMode() { try { const resp = await this.sendATCommand('AT+DUALSIM?', 2000); const mode = this.parseDualSimMode(resp); if (mode === null) { return { supported: false, mode: null, modeLabel: '未检测到双卡能力', error: this.formatATResponse(resp) }; } return { supported: true, mode, modeLabel: this.formatDualSimMode(mode), error: '' }; } catch (err) { return { supported: false, mode: null, modeLabel: '未检测到双卡能力', error: err.message }; } } async querySwitchSimSlot() { const resp = await this.sendATCommand('AT+SWITCHSIM?', 2000); return this.parseSimSlotResponse(resp, 'SWITCHSIM'); } async queryBindSimSlot() { try { const resp = await this.sendATCommand('AT+BINDSIM?', 2000); return this.parseSimSlotResponse(resp, 'BINDSIM'); } catch (err) { logger.debug(`查询BINDSIM失败: ${err.message}`); return null; } } async getSimStatus(options = {}) { return await this.refreshSimStatus(options); } async refreshSimStatus(options = {}) { const refreshIdentity = options.refreshIdentity === true; const status = { ...this.sim, slots: this.sim.slots.map(slot => ({ ...slot })), error: '' }; try { const dualSim = await this.queryDualSimMode(); status.supported = dualSim.supported; status.canSwitch = false; status.mode = dualSim.mode; status.modeLabel = dualSim.modeLabel; if (!dualSim.supported) { status.activeSlot = null; status.switchSlot = null; status.bindSlot = null; status.error = dualSim.error || ''; status.lastCheckedAt = new Date().toISOString(); this.sim = status; return this.getCachedSimStatus(); } const switchSlot = await this.querySwitchSimSlot(); if (switchSlot !== null) { status.canSwitch = true; status.switchSlot = switchSlot; } else { status.switchSlot = null; status.error = '未能读取当前AT SIM卡槽'; } status.bindSlot = await this.queryBindSimSlot(); this.markActiveSimSlot(status, this.getBusinessSimSlot(status)); if (refreshIdentity && switchSlot !== null) { const phoneNumber = await this.queryOwnPhoneNumber(); this.updateSimSlotPhoneNumber(status, switchSlot, phoneNumber); } status.lastCheckedAt = new Date().toISOString(); this.sim = status; return this.getCachedSimStatus(); } catch (err) { logger.warn(`查询SIM状态失败: ${err.message}`); status.error = err.message; status.lastCheckedAt = new Date().toISOString(); this.sim = status; return this.getCachedSimStatus(); } } async switchSim(slotNumber) { const targetSlot = this.normalizeSimSlot(slotNumber); if (targetSlot === null) { throw new Error('SIM卡槽参数无效,只支持 SIM1 或 SIM2'); } if (this.sim.switching) { throw new Error('SIM正在切换中,请稍后再试'); } const before = await this.refreshSimStatus({ refreshIdentity: false }); if (!before.supported || !before.canSwitch) { logger.warn(`绑定SIM前状态不可用: supported=${before.supported}, canSwitch=${before.canSwitch}, bindSlot=${before.bindSlot ?? 'unknown'}, switchSlot=${before.switchSlot ?? 'unknown'}, error=${before.error || 'none'}`); throw new Error(before.error || '当前模组未检测到双卡绑定能力'); } if (this.normalizeSimSlot(before.bindSlot) === targetSlot && this.normalizeSimSlot(before.switchSlot) === targetSlot) { logger.info(`当前已经绑定并切到SIM${targetSlot + 1}`); return { changed: false, status: await this.refreshSimStatus({ refreshIdentity: false }) }; } logger.info(`准备切到并绑定SIM${targetSlot + 1}`); this.sim.switching = true; try { if (this.normalizeSimSlot(before.switchSlot) !== targetSlot) { await this.switchSimForIdentityRead(targetSlot, { context: `切换SIM${targetSlot + 1}`, purpose: '用于业务绑定', retries: 4, retryDelay: 3000, settleDelay: 3000 }); } else { await this.waitSIMReady(); } if (this.normalizeSimSlot(before.bindSlot) !== targetSlot) { await this.sendATWithRetry(`AT+BINDSIM=${targetSlot}`, { timeout: 5000, retries: 2, retryDelay: 1000, label: `绑定到SIM${targetSlot + 1}` }); } this.sim.bindSlot = targetSlot; this.markActiveSimSlot(this.sim, targetSlot); await this.readCurrentSimSlotPhoneNumber(targetSlot, '切换后'); await this.configureSMS(); this.sim.switching = false; const status = await this.refreshSimStatus({ refreshIdentity: false }); const activeSlot = this.normalizeSimSlot(status.activeSlot); const switchSlot = this.normalizeSimSlot(status.switchSlot); if (activeSlot !== targetSlot || switchSlot !== targetSlot) { throw new Error(`切换SIM后状态不一致: activeSlot=${activeSlot ?? 'unknown'}, switchSlot=${switchSlot ?? 'unknown'}`); } logger.info(`✓ 已绑定并切到SIM${targetSlot + 1}`); return { changed: true, status }; } finally { this.sim.switching = false; } } 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(); if (mode === 'mipcall') { return await this.disableMipcallMobileData(options); } return await this.disableCgactMobileData(options); } async disableMipcallMobileData(options = {}) { const before = await this.getMobileDataStatus(); const cids = this.getMobileDataTargetCids(before); logger.info(`${options.reason || '手动操作'}: 断开ML307应用层拨号连接,避免流量消耗`); for (const cid of cids) { try { await this.sendATWithRetry(this.getMobileDataCommand('mipcall', false, cid), { timeout: 8000, retries: 1, label: `断开应用层拨号(CID ${cid})`, required: true }); } catch (err) { err.mobileDataCritical = true; throw err; } } const status = await this.waitForMobileDataState(false, options.timeout || 8000); if (status.anyActive) { const err = new Error(`MIPCALL断开后仍检测到活动连接: ${this.formatMobileDataContexts(status.contexts)}`); err.mobileDataCritical = true; throw err; } if (status.status === 'unknown') { logger.warn('MIPCALL断开命令已成功发送,但状态未确认'); } await this.tryDeactivatePdpContexts(cids); logger.info('✓ 应用层拨号连接已断开'); return status; } async disableCgactMobileData(options = {}) { const before = await this.getMobileDataStatus(); const cids = this.getMobileDataTargetCids(before); let commandSucceeded = false; logger.info(`${options.reason || '手动操作'}: 停用PDP数据连接,避免流量消耗`); for (const cid of cids) { const resp = await this.sendATWithRetry(this.getMobileDataCommand('cgact', false, cid), { timeout: 8000, retries: 1, label: `停用PDP数据连接(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 tryDeactivatePdpContexts(cids) { for (const cid of cids) { const resp = await this.sendATWithRetry(this.getMobileDataCommand('cgact', false, cid), { timeout: 8000, retries: 1, label: `保护性停用PDP(CID ${cid})`, required: false }); if (!resp) { logger.warn(`保护性停用PDP(CID ${cid})未成功,已按MIPCALL断开结果继续`); } } } async forceMobileDataOff(stage) { this.mobileData.desiredEnabled = false; try { return await this.disableMobileData({ reason: stage, required: false, timeout: 5000 }); } catch (err) { if (err.mobileDataCritical) { throw err; } logger.warn(`${stage}: 移动数据关闭未确认: ${err.message}`); return this.getCachedMobileDataStatus(); } } async consumeMobileDataTraffic(options = {}) { const target = this.normalizePingTarget(options.target || '8.8.8.8'); const status = await this.getMobileDataStatus(); if (status.status === 'unknown') { const err = new Error(status.error || '未能确认移动数据状态,无法执行流量消耗测试'); err.statusCode = 503; throw err; } if (!status.anyActive) { const err = new Error('当前未开启流量,请先开启后再执行流量消耗测试'); err.statusCode = 409; throw err; } return await this.runMPing(target, { timeout: options.timeout || 35000, pingTimeoutSeconds: options.pingTimeoutSeconds || 30, count: options.count || 1 }); } normalizePingTarget(value) { const target = String(value || '').trim(); if (!target || !/^[A-Za-z0-9.:-]{1,253}$/.test(target)) { throw new Error('Ping目标地址无效'); } return target; } runMPing(target, options = {}) { if (!this.port || !this.port.isOpen) { throw new Error('串口未打开'); } const timeout = options.timeout || 35000; const pingTimeoutSeconds = Math.min(Math.max(Number(options.pingTimeoutSeconds) || 30, 1), 255); const count = Math.min(Math.max(Number(options.count) || 1, 1), 10); const command = `AT+MPING="${target}",${pingTimeoutSeconds},${count}`; logger.info(`准备通过模组执行MPING,目标: ${target}`); return new Promise((resolve, reject) => { const lines = []; let settled = false; let commandAccepted = false; const finish = (err, result = null) => { if (settled) { return; } settled = true; clearTimeout(timer); this.parser.removeListener('data', handler); if (err) { reject(err); } else { resolve(result); } }; const timer = setTimeout(() => { finish(new Error(`MPING超时,未收到结果: ${target}`)); }, timeout); const handler = (line) => { const trimmed = String(line).trim(); if (!trimmed) { return; } lines.push(trimmed); if (trimmed === 'OK') { commandAccepted = true; return; } if (trimmed.includes('ERROR')) { finish(new Error(`MPING命令失败: ${this.formatATResponse(lines.join('\n'))}`)); return; } if (trimmed.startsWith('+MPING:')) { const result = this.parseMPingResult(trimmed, target); result.commandAccepted = commandAccepted; result.rawResponse = lines.join('\n'); if (result.success) { logger.info(`✓ ${result.message}`); finish(null, result); } else { const err = new Error(result.message); err.statusCode = 502; err.data = result; finish(err); } } }; this.parser.on('data', handler); logger.debug(`>> ${command}`); this.port.write(`${command}\r\n`, (err) => { if (err) { finish(err); } }); }); } parseMPingResult(line, target) { const payload = line.slice('+MPING:'.length).trim(); const params = payload.match(/"[^"]*"|[^,]+/g)?.map(item => { const value = item.trim(); return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; }) || []; const resultCode = Number.parseInt(params[0], 10); const responseTarget = params[1] || target; const packetLength = Number.parseInt(params[2], 10); const latencyMs = Number.parseInt(params[3], 10); const ttl = Number.parseInt(params[4], 10); const hasReplyFields = params.length >= 4; const success = (resultCode === 0 || resultCode === 1) && hasReplyFields; const message = success ? `MPING成功: ${responseTarget}, 延迟 ${Number.isFinite(latencyMs) ? `${latencyMs}ms` : '未知'}, TTL ${Number.isFinite(ttl) ? ttl : '未知'}` : `MPING失败或目标不可达,错误码: ${Number.isFinite(resultCode) ? resultCode : payload}`; return { success, target: responseTarget, resultCode: Number.isFinite(resultCode) ? resultCode : null, packetLength: Number.isFinite(packetLength) ? packetLength : null, latencyMs: Number.isFinite(latencyMs) ? latencyMs : null, ttl: Number.isFinite(ttl) ? ttl : null, message, rawLine: line }; } 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 || '无'; } splitATFields(value) { const fields = []; let current = ''; let quoted = false; for (const char of String(value || '')) { if (char === '"') { quoted = !quoted; continue; } if (char === ',' && !quoted) { fields.push(current.trim()); current = ''; continue; } current += char; } fields.push(current.trim()); return fields; } parseSimSlotHint(value) { const text = String(value || '').trim(); if (!text) { return null; } const explicit = text.match(/\bSIM\s*[:=]?\s*([12])\b/i) || text.match(/\bSLOT\s*[:=]?\s*([12])\b/i); if (explicit) { return Number.parseInt(explicit[1], 10) - 1; } const zeroBased = text.match(/\b(?:SIM|SLOT)\s*[:=]\s*([01])\b/i); if (zeroBased) { return Number.parseInt(zeroBased[1], 10); } return null; } parseIncomingSmsURC(line) { const raw = String(line || '').trim(); const payload = raw.replace(/^\+CMT:\s*/i, ''); const fields = this.splitATFields(payload); const length = Number.parseInt(fields[fields.length - 1], 10); const simSlot = fields .map(field => this.parseSimSlotHint(field)) .find(slot => slot === 0 || slot === 1); return { type: 'CMT', raw, fields, length: Number.isFinite(length) ? length : null, simSlot: simSlot ?? null, receivedAt: new Date().toISOString() }; } parseIncomingSmsIndexURC(line) { const raw = String(line || '').trim(); const payload = raw.replace(/^\+CMTI:\s*/i, ''); const fields = this.splitATFields(payload); const mem = fields[0] || ''; const index = Number.parseInt(fields[1], 10); const simSlot = fields .map(field => this.parseSimSlotHint(field)) .find(slot => slot === 0 || slot === 1); return { type: 'CMTI', raw, mem, index: Number.isFinite(index) ? index : null, simSlot: simSlot ?? null, receivedAt: new Date().toISOString() }; } /** * 设置URC监听器 */ setupURCListener() { let waitingPDU = false; let pendingSms = null; this.parser.on('data', (line) => { line = line.trim(); // 调试输出 if (line.length > 0 && !line.startsWith('AT')) { logger.debug(`<< ${line}`); } // 检测短信URC if (line.startsWith('+CMT:')) { pendingSms = this.parseIncomingSmsURC(line); const slotLabel = pendingSms.simSlot === null ? '未携带卡槽' : `SIM${pendingSms.simSlot + 1}`; logger.info(`检测到短信URC(${slotLabel}),等待PDU数据...`); waitingPDU = true; } else if (line.startsWith('+CMTI:')) { const notification = this.parseIncomingSmsIndexURC(line); const slotLabel = notification.simSlot === null ? '未携带卡槽' : `SIM${notification.simSlot + 1}`; logger.info(`检测到短信存储通知(${slotLabel}): ${notification.mem || '未知存储'} ${notification.index ?? '未知编号'}`); this.emit('sms-notification', notification); this.readSmsFromNotification(notification).catch((err) => { logger.warn(`读取短信存储通知失败: ${err.message}`); }); } else if (waitingPDU && this.isHexString(line)) { logger.info('收到PDU数据'); waitingPDU = false; this.emit('sms', { pdu: line, metadata: pendingSms || { type: 'CMT', raw: '', simSlot: null, receivedAt: new Date().toISOString() } }); pendingSms = null; } else if (waitingPDU && line.length === 0) { // 跳过空行 } else if (waitingPDU) { // 收到非PDU数据,返回等待状态 waitingPDU = false; pendingSms = null; } // 检测网络注册状态变化 if (line.startsWith('+CEREG:')) { this.emit('cereg', line); } }); } parseStoredSmsResponse(resp, notification = {}) { const lines = String(resp || '') .split('\n') .map(line => line.trim()) .filter(Boolean); let header = ''; let pdu = ''; for (const line of lines) { if (line.startsWith('+CMGR:')) { header = line; continue; } if (!pdu && this.isHexString(line) && line.length > 8) { pdu = line; } } if (!pdu) { return null; } const headerFields = header ? this.splitATFields(header.replace(/^\+CMGR:\s*/i, '')) : []; const length = Number.parseInt(headerFields[headerFields.length - 1], 10); return { pdu, metadata: { ...notification, type: 'CMTI', raw: notification.raw || '', cmgrRaw: header, length: Number.isFinite(length) ? length : null, receivedAt: notification.receivedAt || new Date().toISOString() } }; } async readSmsFromNotification(notification = {}) { if (!Number.isInteger(notification.index)) { logger.warn('短信存储通知缺少有效编号,跳过读取'); return false; } const memLabel = notification.mem ? `${notification.mem},` : ''; logger.info(`读取存储短信: ${memLabel}${notification.index}`); const resp = await this.sendATCommand(`AT+CMGR=${notification.index}`, 5000); const sms = this.parseStoredSmsResponse(resp, notification); if (!sms) { logger.warn(`未能从存储短信读取到PDU: ${this.formatATResponse(resp)}`); return false; } logger.info('收到存储短信PDU数据'); this.emit('sms', sms); await this.deleteSmsAtIndex(notification.index, notification.simSlot); return true; } /** * 检查是否为十六进制字符串 */ isHexString(str) { return /^[0-9A-Fa-f]+$/.test(str); } /** * 发送AT命令并等待响应 */ async sendATCommand(cmd, timeout = 2000) { return this.enqueueATCommand(() => this.sendATCommandUnlocked(cmd, timeout)); } enqueueATCommand(task) { const run = this.atCommandQueue.then(task, task); this.atCommandQueue = run.catch(() => {}); return run; } sendATCommandUnlocked(cmd, timeout = 2000) { return new Promise((resolve, reject) => { if (!this.port || !this.port.isOpen || !this.parser) { reject(new Error('串口未打开')); return; } 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. 启动时先重置协议栈,避免上一次双卡切换/短信上报状态残留。 await this.resetProtocolStackForStartup(); // 3.1. 查询双卡能力和当前卡槽;不支持时保持单卡运行。 await this.refreshSimStatus({ refreshIdentity: false }); // 3. 按ML307A文档先确认SIM卡和协议栈状态 await this.waitSIMReady(); // 启动时只初始化当前卡,不遍历其他卡槽。 await this.discoverSimSlotIdentities(); await this.ensureFullFunctionality(); // 4. 启动保护:先断开应用层拨号,再等待短信所需的网络注册。 await this.forceMobileDataOff('启动保护(CFUN=1后)'); // 5. 等待网络注册 this.ready = await this.waitForNetworkRegistration('启动', 30, 2000); // 6. 驻网后再断开一次,防止模组自动拨号在驻网完成后重新拉起。 await this.forceMobileDataOff('启动保护(驻网后)'); // 7. 按文档配置短信功能,并启用本项目需要的PDU模式 await this.configureSMS(); await this.refreshSimStatus({ refreshIdentity: true }); 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)'); } /** * 启动时重置协议栈。避免使用 AT+CFUN=1,1 整机重启导致USB串口断开后反复重启容器。 */ async resetProtocolStackForStartup() { logger.info('启动初始化: 重置协议栈(CFUN=0 -> CFUN=1)'); try { await this.sendATWithRetry('AT+CFUN=0', { timeout: 8000, retries: 2, retryDelay: 1000, label: '启动重置协议栈(CFUN=0)', required: false }); await this.sleep(3000); await this.sendATWithRetry('AT+CFUN=1', { timeout: 10000, retries: 3, retryDelay: 2000, label: '启动恢复协议栈(CFUN=1)' }); await this.sleep(5000); logger.info('✓ 启动协议栈重置完成'); } catch (err) { logger.warn(`启动协议栈重置失败,继续初始化: ${err.message}`); } } /** * 按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('✓ 短信发送参数设置完成'); return null; } async deleteSmsAtIndex(index, slot = null) { try { await this.sendATWithRetry(`AT+CMGD=${index}`, { timeout: 5000, retries: 1, label: `删除已读取短信${slot === null ? '' : `(SIM${slot + 1})`}`, required: false }); } catch (err) { logger.warn(`删除已读取短信失败: ${err.message}`); } } /** * 判断是否为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; } } async waitForNetworkRegistration(label = '网络注册', maxRetries = 30, retryDelay = 2000) { let retries = 0; while (!(await this.waitCEREG()) && retries < maxRetries) { logger.info(`${label}: 等待网络注册...`); retries++; await this.sleep(retryDelay); } if (retries < maxRetries) { logger.info('✓ 网络已注册'); return true; } logger.error(`${label}: 网络注册超时(无SIM卡或信号差)`); return false; } /** * 发送短信(PDU模式) */ async sendSMS(phoneNumber, message) { const sendResult = { success: false, simSlot: null, simLabel: '未知SIM', parts: 0 }; if (this.isSimSwitching()) { logger.warn('SIM切换中,暂不发送短信'); return sendResult; } logger.info(`准备发送短信到: ${phoneNumber}`); logger.info(`短信内容: ${message}`); this.smsSending = true; try { const simSync = await this.ensureATSimMatchesBinding('发送短信前SIM同步'); if (simSync?.changed) { await this.configureSMS(); } const sendSlot = this.normalizeSimSlot(simSync?.slot ?? this.sim.bindSlot ?? await this.queryBindSimSlot().catch(() => null)); sendResult.simSlot = sendSlot; sendResult.simLabel = this.getSimSlotLabel(sendSlot); logger.info(`发送使用SIM: ${sendResult.simLabel}${sendSlot === null ? '' : ` (SIM${sendSlot + 1})`}`); // 编码PDU const submit = new Submit(phoneNumber, message); const parts = submit.getPartStrings(); sendResult.parts = parts.length; 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 sendResult; } } logger.info('✓ 短信发送成功'); sendResult.success = true; return sendResult; } catch (err) { logger.error('发送短信异常:', err); return sendResult; } finally { this.smsSending = 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); logger.warn('等待CMGS发送结果超时'); 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); logger.warn(`CMGS发送返回错误: ${String(line).trim()}`); 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 '未知'; } } /** * 关闭串口 */ 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;