Files
sms_forwarding/src/modem.js
T
2026-06-27 16:57:48 +08:00

2154 lines
58 KiB
JavaScript

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;