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上报。 */