feat: 添加移动数据控制

This commit is contained in:
2026-06-26 16:48:40 +08:00
parent 4aa93d8a9d
commit ae27984fb0
7 changed files with 462 additions and 50 deletions
+4
View File
@@ -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,
+13
View File
@@ -54,6 +54,10 @@
<dt>信号</dt>
<dd id="signalQuality">--</dd>
</div>
<div>
<dt>移动数据</dt>
<dd id="mobileDataSummary">--</dd>
</div>
<div>
<dt>运行时长</dt>
<dd id="uptime">--</dd>
@@ -80,6 +84,15 @@
<h3 id="signalValue">--</h3>
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p>
</article>
<article class="feature-card feature-card--control">
<span class="eyebrow">mobile data</span>
<h3 id="mobileDataState">--</h3>
<p id="mobileDataHint">启动时会自动关闭移动数据。</p>
<div class="feature-card__actions">
<button class="button button--secondary button--compact" id="mobileDataToggle" type="button" data-action="disable">强制关闭流量</button>
</div>
<p class="form-status mobile-data-status" id="mobileDataStatus" role="status"></p>
</article>
<article class="feature-card">
<span class="eyebrow">module</span>
<h3 id="moduleModel">--</h3>
+15 -1
View File
@@ -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;
+90
View File
@@ -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);
+44
View File
@@ -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 {
+17 -9
View File
@@ -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);
});
});
// 捕获未处理的异常
+279 -40
View File
@@ -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上报。
*/