diff --git a/config.example.json b/config.example.json
index e4b48ef..372d0c0 100644
--- a/config.example.json
+++ b/config.example.json
@@ -6,6 +6,10 @@
"probeTimeout": 1200,
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
},
+ "mobileData": {
+ "cid": 1,
+ "_comment": "每次启动都会强制关闭移动数据,管理台开关只影响当前运行期; ML307 cid8为IMS专用,请勿使用"
+ },
"smtp": {
"server": "smtp.qq.com",
"port": 465,
diff --git a/public/admin.html b/public/admin.html
index f93cac7..ef336b0 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -54,6 +54,10 @@
信号
--
+
+
移动数据
+ --
+
运行时长
--
@@ -80,6 +84,15 @@
--
RSSI 与误码率将在刷新后显示。
+
+ mobile data
+ --
+ 启动时会自动关闭移动数据。
+
+
+
+
+
module
--
diff --git a/public/assets/admin.css b/public/assets/admin.css
index 134c458..604ce79 100644
--- a/public/assets/admin.css
+++ b/public/assets/admin.css
@@ -363,7 +363,7 @@ h3 {
.status-grid {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
@@ -378,12 +378,26 @@ h3 {
min-height: 190px;
}
+.feature-card--control {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+}
+
.feature-card p {
margin-top: 14px;
color: var(--muted);
font-size: 14px;
}
+.feature-card__actions {
+ margin-top: 18px;
+}
+
+.mobile-data-status {
+ min-height: 20px;
+ margin-top: 10px;
+}
+
.eyebrow {
display: block;
margin-bottom: 18px;
diff --git a/public/assets/admin.js b/public/assets/admin.js
index d507977..a2c0193 100644
--- a/public/assets/admin.js
+++ b/public/assets/admin.js
@@ -1,5 +1,6 @@
const state = {
busy: false,
+ mobileDataBusy: false,
pushChannels: [],
selectedPushChannel: -1,
pushLoaded: false
@@ -29,11 +30,16 @@ const elements = {
modelName: document.querySelector('#modelName'),
operator: document.querySelector('#operator'),
signalQuality: document.querySelector('#signalQuality'),
+ mobileDataSummary: document.querySelector('#mobileDataSummary'),
uptime: document.querySelector('#uptime'),
connectionState: document.querySelector('#connectionState'),
connectionHint: document.querySelector('#connectionHint'),
signalValue: document.querySelector('#signalValue'),
signalHint: document.querySelector('#signalHint'),
+ mobileDataState: document.querySelector('#mobileDataState'),
+ mobileDataHint: document.querySelector('#mobileDataHint'),
+ mobileDataToggle: document.querySelector('#mobileDataToggle'),
+ mobileDataStatus: document.querySelector('#mobileDataStatus'),
moduleModel: document.querySelector('#moduleModel'),
moduleVersion: document.querySelector('#moduleVersion'),
iccid: document.querySelector('#iccid'),
@@ -152,6 +158,55 @@ function formatModel(model = {}) {
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
}
+function formatMobileDataMode(mode) {
+ if (mode === 'mipcall') {
+ return 'MIPCALL';
+ }
+ if (mode === 'cgact') {
+ return 'CGACT';
+ }
+ return 'AT';
+}
+
+function formatMobileDataContexts(contexts = []) {
+ const active = contexts
+ .filter(item => item.active)
+ .map(item => `CID ${item.cid}`)
+ .join('、');
+
+ return active || '';
+}
+
+function renderMobileData(mobileData = {}) {
+ const unknown = mobileData.status === 'unknown';
+ const enabled = Boolean(mobileData.enabled || mobileData.anyActive);
+ const cid = mobileData.cid || 1;
+ const mode = formatMobileDataMode(mobileData.mode);
+ const activeContexts = formatMobileDataContexts(mobileData.contexts || []);
+
+ if (unknown) {
+ setText(elements.mobileDataSummary, '未知');
+ setText(elements.mobileDataState, '状态未知');
+ setText(elements.mobileDataHint, mobileData.error || '未能确认移动数据状态,可先强制关闭。');
+ elements.mobileDataToggle.textContent = '强制关闭流量';
+ elements.mobileDataToggle.dataset.action = 'disable';
+ } else if (enabled) {
+ setText(elements.mobileDataSummary, '已开启');
+ setText(elements.mobileDataState, '流量已开启');
+ setText(elements.mobileDataHint, activeContexts ? `${mode} 检测到 ${activeContexts} 处于活动状态。` : `CID ${cid} 处于活动状态。`);
+ elements.mobileDataToggle.textContent = '关闭流量';
+ elements.mobileDataToggle.dataset.action = 'disable';
+ } else {
+ setText(elements.mobileDataSummary, '已关闭');
+ setText(elements.mobileDataState, '流量已关闭');
+ setText(elements.mobileDataHint, `${mode} CID ${cid} 未建立移动数据连接,重启后也会先保持关闭。`);
+ elements.mobileDataToggle.textContent = '开启流量';
+ elements.mobileDataToggle.dataset.action = 'enable';
+ }
+
+ elements.mobileDataToggle.disabled = state.mobileDataBusy;
+}
+
function renderStatus(status, info) {
const data = status.data || {};
const infoData = info.data || {};
@@ -165,6 +220,7 @@ function renderStatus(status, info) {
setText(elements.operator, data.operator || '未知');
setText(elements.signalQuality, signal.quality || '未知');
setText(elements.uptime, formatUptime(data.uptime));
+ renderMobileData(data.mobileData || {});
setText(elements.connectionState, ready ? '运行中' : '未就绪');
setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
@@ -594,6 +650,38 @@ async function refreshMessages() {
renderMessages(messages.data || []);
}
+async function toggleMobileData() {
+ if (state.mobileDataBusy) {
+ return;
+ }
+
+ const action = elements.mobileDataToggle.dataset.action || 'disable';
+ const enabled = action === 'enable';
+
+ if (enabled && !window.confirm('开启移动数据可能产生流量费用,确定要开启吗?')) {
+ return;
+ }
+
+ state.mobileDataBusy = true;
+ elements.mobileDataToggle.disabled = true;
+ setStatusMessage(elements.mobileDataStatus, enabled ? '正在开启...' : '正在关闭...');
+
+ try {
+ const result = await requestJSON('/api/modem/mobile-data', {
+ method: 'POST',
+ body: JSON.stringify({ enabled })
+ });
+ renderMobileData(result.data || {});
+ setStatusMessage(elements.mobileDataStatus, result.message || (enabled ? '移动数据已开启。' : '移动数据已关闭。'), 'success');
+ await refreshLogs();
+ } catch (err) {
+ setStatusMessage(elements.mobileDataStatus, err.message, 'error');
+ } finally {
+ state.mobileDataBusy = false;
+ elements.mobileDataToggle.disabled = false;
+ }
+}
+
async function refreshAll() {
if (state.busy) {
return;
@@ -607,6 +695,7 @@ async function refreshAll() {
const status = await requestJSON('/api/status');
const info = await requestJSON('/api/modem/info');
renderStatus(status, info);
+ setStatusMessage(elements.mobileDataStatus, '');
await refreshMessages();
await refreshLogs();
} catch (err) {
@@ -694,6 +783,7 @@ elements.pushChannelForm.addEventListener('submit', savePushChannels);
elements.pushTestButton.addEventListener('click', testPushChannel);
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
elements.refreshButton.addEventListener('click', refreshAll);
+elements.mobileDataToggle.addEventListener('click', toggleMobileData);
elements.logsButton.addEventListener('click', refreshLogs);
elements.messagesButton.addEventListener('click', refreshMessages);
elements.smsForm.addEventListener('submit', sendSMS);
diff --git a/src/api.js b/src/api.js
index 4780221..6460cd3 100644
--- a/src/api.js
+++ b/src/api.js
@@ -364,6 +364,7 @@ class APIServer {
try {
const signal = await this.modem.getSignalQuality();
const operator = await this.modem.getOperator();
+ const mobileData = await this.modem.getMobileDataStatus();
res.json({
success: true,
@@ -372,6 +373,7 @@ class APIServer {
model: this.modem.modelInfo,
signal,
operator,
+ mobileData,
uptime: process.uptime()
}
});
@@ -466,6 +468,48 @@ class APIServer {
}
});
+ // 查询移动数据连接状态
+ this.app.get('/api/modem/mobile-data', async (req, res) => {
+ try {
+ const mobileData = await this.modem.getMobileDataStatus();
+ res.json({
+ success: true,
+ data: mobileData
+ });
+ } catch (err) {
+ logger.error('查询移动数据状态失败:', err);
+ res.status(500).json({
+ success: false,
+ error: err.message
+ });
+ }
+ });
+
+ // 开启或关闭移动数据连接
+ this.app.post('/api/modem/mobile-data', async (req, res) => {
+ try {
+ if (typeof req.body?.enabled !== 'boolean') {
+ return res.status(400).json({
+ success: false,
+ error: '缺少必要参数: enabled'
+ });
+ }
+
+ const mobileData = await this.modem.setMobileDataEnabled(req.body.enabled);
+ res.json({
+ success: true,
+ message: req.body.enabled ? '移动数据已开启' : '移动数据已关闭',
+ data: mobileData
+ });
+ } catch (err) {
+ logger.error('设置移动数据状态失败:', err);
+ res.status(500).json({
+ success: false,
+ error: err.message
+ });
+ }
+ });
+
// 发送AT命令
this.app.post('/api/modem/at', async (req, res) => {
try {
diff --git a/src/index.js b/src/index.js
index a053abc..f445d98 100644
--- a/src/index.js
+++ b/src/index.js
@@ -46,7 +46,7 @@ async function main() {
logger.info('配置加载完成');
// 创建模组管理器
- const modem = new ModemManager(config.serial);
+ const modem = new ModemManager(config.serial, config.mobileData);
// 创建长短信管理器
const concatManager = new ConcatManager();
@@ -111,19 +111,27 @@ async function main() {
process.exit(1);
}
- // 优雅退出
- process.on('SIGINT', async () => {
- logger.info('\n收到 SIGINT 信号,正在关闭...');
+ async function shutdown(signal) {
+ logger.info(`\n收到 ${signal} 信号,正在关闭...`);
concatManager.stopTimeoutChecker();
+ await modem.forceMobileDataOff(`${signal}退出保护`);
await modem.close();
process.exit(0);
+ }
+
+ // 优雅退出
+ process.on('SIGINT', () => {
+ shutdown('SIGINT').catch((err) => {
+ logger.error('关闭失败:', err);
+ process.exit(1);
+ });
});
- process.on('SIGTERM', async () => {
- logger.info('\n收到 SIGTERM 信号,正在关闭...');
- concatManager.stopTimeoutChecker();
- await modem.close();
- process.exit(0);
+ process.on('SIGTERM', () => {
+ shutdown('SIGTERM').catch((err) => {
+ logger.error('关闭失败:', err);
+ process.exit(1);
+ });
});
// 捕获未处理的异常
diff --git a/src/modem.js b/src/modem.js
index 53a1302..3edce74 100644
--- a/src/modem.js
+++ b/src/modem.js
@@ -7,9 +7,12 @@ import path from 'path';
import logger from './logger.js';
class ModemManager extends EventEmitter {
- constructor(config) {
+ constructor(config, mobileDataConfig = {}) {
super();
this.config = config;
+ this.mobileDataConfig = {
+ cid: this.normalizeMobileDataCid(mobileDataConfig.cid)
+ };
this.port = null;
this.parser = null;
this.ready = false;
@@ -18,6 +21,18 @@ class ModemManager extends EventEmitter {
model: '未知',
version: '未知'
};
+ this.mobileData = {
+ cid: this.mobileDataConfig.cid,
+ desiredEnabled: false,
+ enabled: false,
+ targetEnabled: false,
+ anyActive: false,
+ status: 'disabled',
+ mode: 'unknown',
+ contexts: [],
+ lastCheckedAt: null,
+ error: ''
+ };
}
/**
@@ -356,6 +371,262 @@ class ModemManager extends EventEmitter {
return result.reason;
}
+ normalizeMobileDataCid(value) {
+ const cid = Number.parseInt(value, 10);
+ if (Number.isInteger(cid) && cid > 0 && cid <= 15 && cid !== 8) {
+ return cid;
+ }
+
+ return 1;
+ }
+
+ getMobileDataMode() {
+ return this.isML307Family() ? 'mipcall' : 'cgact';
+ }
+
+ getMobileDataCommand(mode = this.getMobileDataMode(), enabled = false, cid = this.mobileDataConfig.cid) {
+ if (mode === 'mipcall') {
+ return `AT+MIPCALL=${enabled ? 1 : 0},${cid}`;
+ }
+
+ return `AT+CGACT=${enabled ? 1 : 0},${cid}`;
+ }
+
+ getMobileDataStatusCommand(mode = this.getMobileDataMode()) {
+ return mode === 'mipcall' ? 'AT+MIPCALL?' : 'AT+CGACT?';
+ }
+
+ getCachedMobileDataStatus() {
+ return {
+ ...this.mobileData,
+ contexts: this.mobileData.contexts.map(item => ({ ...item }))
+ };
+ }
+
+ async getMobileDataStatus() {
+ const mode = this.getMobileDataMode();
+ const command = this.getMobileDataStatusCommand(mode);
+
+ try {
+ const resp = await this.sendATCommand(command, 5000);
+ const status = this.parseMobileDataStatusResponse(resp, mode);
+ this.updateMobileDataStatus(status);
+ return this.getCachedMobileDataStatus();
+ } catch (err) {
+ logger.warn(`查询移动数据状态失败: ${err.message}`);
+ this.updateMobileDataStatus({
+ mode,
+ cid: this.mobileDataConfig.cid,
+ status: 'unknown',
+ enabled: false,
+ targetEnabled: false,
+ anyActive: false,
+ contexts: [],
+ error: err.message
+ });
+ return this.getCachedMobileDataStatus();
+ }
+ }
+
+ parseMobileDataStatusResponse(resp, mode) {
+ const contexts = [];
+ const lines = resp.split('\n').map(line => line.trim()).filter(Boolean);
+
+ for (const line of lines) {
+ const payload = this.extractMobileDataPayload(line, mode);
+ if (!payload) {
+ continue;
+ }
+
+ const match = payload.match(/^(\d+)\s*,\s*(\d+)(.*)$/);
+ if (!match) {
+ continue;
+ }
+
+ const cid = Number.parseInt(match[1], 10);
+ const state = Number.parseInt(match[2], 10);
+ const addresses = [...match[3].matchAll(/"([^"]+)"/g)].map(item => item[1]).filter(Boolean);
+
+ contexts.push({
+ cid,
+ active: state === 1,
+ state,
+ addresses
+ });
+ }
+
+ const target = contexts.find(item => item.cid === this.mobileDataConfig.cid);
+ const anyActive = contexts.some(item => item.active);
+ const targetEnabled = target ? target.active : false;
+
+ return {
+ mode,
+ cid: this.mobileDataConfig.cid,
+ enabled: anyActive,
+ targetEnabled,
+ anyActive,
+ status: anyActive ? 'enabled' : 'disabled',
+ contexts,
+ error: ''
+ };
+ }
+
+ extractMobileDataPayload(line, mode) {
+ if (mode === 'mipcall') {
+ if (line.startsWith('+MIPCALL:')) {
+ return line.slice('+MIPCALL:'.length).trim();
+ }
+
+ if (/^\d+\s*,\s*\d+/.test(line)) {
+ return line;
+ }
+ }
+
+ if (line.startsWith('+CGACT:')) {
+ return line.slice('+CGACT:'.length).trim();
+ }
+
+ return '';
+ }
+
+ updateMobileDataStatus(status) {
+ this.mobileData = {
+ ...this.mobileData,
+ ...status,
+ cid: this.mobileDataConfig.cid,
+ lastCheckedAt: new Date().toISOString(),
+ contexts: Array.isArray(status.contexts) ? status.contexts : []
+ };
+ }
+
+ getMobileDataTargetCids(status) {
+ const cids = new Set([this.mobileDataConfig.cid]);
+ for (const context of status.contexts || []) {
+ if (context.active && context.cid !== 8) {
+ cids.add(context.cid);
+ }
+ }
+
+ return [...cids].filter(cid => this.normalizeMobileDataCid(cid) === cid);
+ }
+
+ async setMobileDataEnabled(enabled, options = {}) {
+ this.mobileData.desiredEnabled = Boolean(enabled);
+ if (enabled) {
+ return await this.enableMobileData(options);
+ }
+
+ return await this.disableMobileData(options);
+ }
+
+ async enableMobileData(options = {}) {
+ const mode = this.getMobileDataMode();
+ const cid = this.mobileDataConfig.cid;
+ const command = this.getMobileDataCommand(mode, true, cid);
+ const label = mode === 'mipcall' ? '开启移动数据拨号' : '激活PDP数据连接';
+
+ logger.warn(`准备开启移动数据(CID ${cid}),可能产生流量费用`);
+ const resp = await this.sendATWithRetry(command, {
+ timeout: 30000,
+ retries: 1,
+ label,
+ required: false
+ });
+
+ const status = await this.waitForMobileDataState(true, options.timeout || 20000);
+ if (!resp || !status.anyActive) {
+ throw new Error('移动数据开启失败,未检测到已激活的数据连接');
+ }
+
+ logger.info(`✓ 已开启移动数据(CID ${cid})`);
+ return status;
+ }
+
+ async disableMobileData(options = {}) {
+ const mode = this.getMobileDataMode();
+ const before = await this.getMobileDataStatus();
+ const cids = this.getMobileDataTargetCids(before);
+ const label = mode === 'mipcall' ? '关闭移动数据拨号' : '停用PDP数据连接';
+ let commandSucceeded = false;
+
+ logger.info(`${options.reason || '手动操作'}: 关闭移动数据,避免流量消耗`);
+
+ for (const cid of cids) {
+ const command = this.getMobileDataCommand(mode, false, cid);
+ const resp = await this.sendATWithRetry(command, {
+ timeout: 8000,
+ retries: 1,
+ label: `${label}(CID ${cid})`,
+ required: false
+ });
+
+ commandSucceeded = commandSucceeded || Boolean(resp);
+ }
+
+ const status = await this.waitForMobileDataState(false, options.timeout || 8000);
+ if (status.anyActive) {
+ const message = `移动数据关闭后仍检测到活动连接: ${this.formatMobileDataContexts(status.contexts)}`;
+ if (options.required === false) {
+ logger.warn(message);
+ return status;
+ }
+
+ throw new Error(message);
+ }
+
+ if (status.status === 'unknown') {
+ const message = commandSucceeded ? '移动数据关闭命令已发送,但状态未确认' : '移动数据关闭命令未确认成功';
+ if (options.required === false) {
+ logger.warn(message);
+ return status;
+ }
+
+ throw new Error(message);
+ }
+
+ if (!commandSucceeded && before.status === 'unknown' && options.required !== false) {
+ throw new Error('移动数据关闭命令未确认成功');
+ }
+
+ logger.info('✓ 移动数据已关闭');
+ return status;
+ }
+
+ async forceMobileDataOff(stage) {
+ this.mobileData.desiredEnabled = false;
+ try {
+ return await this.disableMobileData({
+ reason: stage,
+ required: false,
+ timeout: 5000
+ });
+ } catch (err) {
+ logger.warn(`${stage}: 移动数据关闭未确认: ${err.message}`);
+ return this.getCachedMobileDataStatus();
+ }
+ }
+
+ async waitForMobileDataState(enabled, timeout = 10000) {
+ const startedAt = Date.now();
+ let status = await this.getMobileDataStatus();
+
+ while (status.status !== 'unknown' && status.anyActive !== enabled && Date.now() - startedAt < timeout) {
+ await this.sleep(1000);
+ status = await this.getMobileDataStatus();
+ }
+
+ return status;
+ }
+
+ formatMobileDataContexts(contexts = []) {
+ const active = contexts
+ .filter(item => item.active)
+ .map(item => `CID ${item.cid}`)
+ .join(', ');
+
+ return active || '无';
+ }
+
/**
* 设置URC监听器
*/
@@ -521,7 +792,10 @@ class ModemManager extends EventEmitter {
await this.waitSIMReady();
await this.ensureFullFunctionality();
- // 4. 等待网络注册
+ // 4. 启动保护:先关闭移动数据拨号,再等待短信所需的网络注册。
+ await this.forceMobileDataOff('启动保护(CFUN=1后)');
+
+ // 5. 等待网络注册
retries = 0;
while (!(await this.waitCEREG()) && retries < 30) {
logger.info('等待网络注册...');
@@ -536,10 +810,10 @@ class ModemManager extends EventEmitter {
this.ready = false;
}
- // 5. 数据连接处理。ML307文档不建议用CGACT做PDP激活/去激活。
- await this.disableDataConnection();
+ // 6. 驻网后再关一次,防止模组自动拨号在驻网完成后重新拉起。
+ await this.forceMobileDataOff('启动保护(驻网后)');
- // 6. 按文档配置短信功能,并启用本项目需要的PDU模式
+ // 7. 按文档配置短信功能,并启用本项目需要的PDU模式
await this.configureSMS();
logger.info('模组初始化完成');
@@ -593,41 +867,6 @@ class ModemManager extends EventEmitter {
logger.info('✓ 已设置协议栈功能模式(CFUN=1)');
}
- /**
- * 按模组型号处理数据连接,避免ML307系列使用文档不推荐的CGACT。
- */
- async disableDataConnection() {
- if (this.isML307Family()) {
- logger.info('ML307系列跳过AT+CGACT,按文档尝试断开应用层拨号(AT+MIPCALL=0,1)');
- const resp = await this.sendATWithRetry('AT+MIPCALL=0,1', {
- timeout: 5000,
- retries: 1,
- label: '断开应用层拨号',
- required: false
- });
-
- if (resp) {
- logger.info('✓ 已断开应用层拨号连接');
- } else {
- logger.warn('应用层拨号未断开或当前未激活,继续启动');
- }
- return;
- }
-
- const resp = await this.sendATWithRetry('AT+CGACT=0,1', {
- timeout: 5000,
- retries: 3,
- label: '禁用数据连接',
- required: false
- });
-
- if (resp) {
- logger.info('✓ 已禁用数据连接(AT+CGACT=0,1),防止流量消耗');
- } else {
- logger.warn('设置CGACT失败,可能会消耗流量');
- }
- }
-
/**
* 按ML307A文档配置短信功能;项目接收逻辑依赖PDU模式下的+CMT上报。
*/