diff --git a/public/assets/admin.css b/public/assets/admin.css index 453f70a..3702095 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -70,6 +70,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + html { scroll-behavior: smooth; scroll-padding-top: 80px; @@ -726,6 +730,10 @@ h3 { gap: var(--space-md); } +.sim-slot-grid.is-single { + grid-template-columns: minmax(0, 1fr); +} + .sim-slot { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -737,6 +745,11 @@ h3 { padding: 12px 14px; } +.sim-slot.is-single { + grid-template-columns: minmax(0, 1fr); + min-height: 76px; +} + .sim-slot.is-active { border-color: rgba(93, 184, 114, 0.55); box-shadow: inset 3px 0 0 var(--success); @@ -1592,4 +1605,4 @@ body.modal-open { .field-row--wide { grid-column: auto; } -} \ No newline at end of file +} diff --git a/public/assets/admin.js b/public/assets/admin.js index bdb0409..de31eda 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -50,6 +50,7 @@ const elements = { simStatus: document.querySelector('#simStatus'), simSlot0: document.querySelector('#simSlot0'), simSlot1: document.querySelector('#simSlot1'), + simSlotGrid: document.querySelector('.sim-slot-grid'), simSlot0State: document.querySelector('#simSlot0State'), simSlot1State: document.querySelector('#simSlot1State'), simSlot0Phone: document.querySelector('#simSlot0Phone'), @@ -281,7 +282,18 @@ function renderSimSlot(slot, card, stateNode, phoneNode) { } card.classList.toggle('is-active', Boolean(slot.active)); - setText(stateNode, slot.active ? '当前使用' : '未选中'); + if (slot.active && slot.present === false) { + setText(stateNode, '当前未就绪'); + } else if (slot.active) { + setText(stateNode, '当前使用'); + } else if (slot.present === true) { + setText(stateNode, '可用'); + } else if (slot.present === false) { + setText(stateNode, '未检测到SIM'); + } else { + setText(stateNode, '未确认'); + } + const phoneText = formatSimSlotPhone(slot); if (phoneNode) { phoneNode.textContent = phoneText; @@ -289,29 +301,81 @@ function renderSimSlot(slot, card, stateNode, phoneNode) { } } +function getVisibleSimSlots(sim = {}, activeSlot = null) { + const slots = sim.slots || []; + const presentSlots = slots.filter(slot => slot.present === true); + + if (presentSlots.length >= 2) { + return presentSlots; + } + + if (activeSlot?.present === true) { + return [activeSlot]; + } + + return slots.filter(slot => slot.active || slot.present !== false); +} + +function getSimModeLabel(sim = {}, activeSlot = null) { + const hardwareSwitchable = Boolean(sim.supported && sim.canSwitch); + if (!hardwareSwitchable) { + return '单卡/未检测到双卡'; + } + + const presentSlots = (sim.slots || []).filter(slot => slot.present === true); + if (presentSlots.length >= 2) { + return sim.modeLabel || '双卡'; + } + + if (activeSlot?.present === true) { + return `仅检测到 ${getSimDisplayLabel(activeSlot)}`; + } + + return '未检测到可用SIM'; +} + function renderSimStatus(sim = {}) { state.currentSim = sim; const supported = Boolean(sim.supported && sim.canSwitch); const activeSlot = getActiveSimSlot(sim); - const modeLabel = supported ? (sim.modeLabel || '双卡') : '单卡/未检测到双卡'; + const modeLabel = getSimModeLabel(sim, activeSlot); setText(elements.simMode, modeLabel); - renderSimSlot(getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone); - renderSimSlot(getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone); + const visibleSlots = getVisibleSimSlots(sim, activeSlot); + const singleSlot = visibleSlots.length === 1; + const visibleSlotNumbers = new Set(visibleSlots.map(slot => slot.slot)); + + elements.simSlotGrid?.classList.toggle('is-single', singleSlot); + + [ + [getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone], + [getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone] + ].forEach(([slot, card, stateNode, phoneNode]) => { + if (card) { + card.hidden = !visibleSlotNumbers.has(slot.slot); + card.classList.toggle('is-single', singleSlot && visibleSlotNumbers.has(slot.slot)); + } + renderSimSlot(slot, card, stateNode, phoneNode); + }); elements.simSwitchButtons.forEach((button) => { const slot = Number.parseInt(button.dataset.simSwitch, 10); + const slotInfo = getSimSlot(sim, slot); const isActive = activeSlot?.slot === slot; - button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive; + const unavailable = slotInfo.present === false; + button.hidden = singleSlot; + button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive || unavailable; button.textContent = isActive ? '正在使用' : `切到SIM${slot + 1}`; }); if (state.simBusy || sim.switching) { setStatusMessage(elements.simStatus, '正在切换SIM...'); - } else if (supported && activeSlot) { + } else if (supported && activeSlot?.present === true) { setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`, 'success'); + } else if (supported && activeSlot?.present === false) { + setStatusMessage(elements.simStatus, `${getSimDisplayLabel(activeSlot)} 未就绪,请检查是否插卡`, 'error'); } else if (!supported) { setStatusMessage(elements.simStatus, '双卡不可用'); } else { diff --git a/src/modem.js b/src/modem.js index c0da879..351cd3a 100644 --- a/src/modem.js +++ b/src/modem.js @@ -33,6 +33,7 @@ class ModemManager extends EventEmitter { switchSlot: null, bindSlot: null, switching: false, + probing: false, slots: this.createSimSlots(), lastCheckedAt: null, error: '', @@ -63,6 +64,7 @@ class ModemManager extends EventEmitter { slot, name: `SIM${slot + 1}`, active: false, + present: null, phoneNumber, phoneLabel: phoneNumber || `SIM${slot + 1}`, lastCheckedAt: null @@ -538,6 +540,25 @@ class ModemManager extends EventEmitter { })); } + updateSimSlotPresence(status, slotNumber, present) { + const slot = this.normalizeSimSlot(slotNumber); + if (slot === null) { + return; + } + + status.slots = status.slots.map(item => { + if (item.slot !== slot) { + return item; + } + + return { + ...item, + present: Boolean(present), + lastCheckedAt: new Date().toISOString() + }; + }); + } + getBusinessSimSlot(status = this.sim) { return this.normalizeSimSlot(status.bindSlot) ?? this.normalizeSimSlot(status.switchSlot); } @@ -637,14 +658,27 @@ class ModemManager extends EventEmitter { retryDelay = 2000, settleDelay = 3000, confirmTimeout = 15000, - confirmInterval = 1000 + confirmInterval = 1000, + simReadyAttempts = 10, + simReadyDelay = 1000 } = options; const currentSlot = await this.querySwitchSimSlot().catch(() => this.sim.switchSlot); this.sim.switchSlot = currentSlot; if (currentSlot === targetSlot) { - await this.waitSIMReady(); + try { + await this.waitSIMReady({ + attempts: simReadyAttempts, + delay: simReadyDelay, + label: `SIM${targetSlot + 1}` + }); + this.updateSimSlotPresence(this.sim, targetSlot, true); + } catch (err) { + this.updateSimSlotPresence(this.sim, targetSlot, false); + throw err; + } + return targetSlot; } @@ -674,11 +708,155 @@ class ModemManager extends EventEmitter { this.sim.switchSlot = targetSlot; this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? targetSlot); await this.sleep(settleDelay); - await this.waitSIMReady(); + + try { + await this.waitSIMReady({ + attempts: simReadyAttempts, + delay: simReadyDelay, + label: `SIM${targetSlot + 1}` + }); + this.updateSimSlotPresence(this.sim, targetSlot, true); + } catch (err) { + this.updateSimSlotPresence(this.sim, targetSlot, false); + await this.restoreSwitchSimSlot(currentSlot, `${context}失败后回退`).catch((restoreErr) => { + logger.warn(`${context}失败后回退SIM失败: ${restoreErr.message}`); + }); + throw err; + } return targetSlot; } + async restoreSwitchSimSlot(slotNumber, context = 'SIM回退', timeout = 20000) { + const slot = this.normalizeSimSlot(slotNumber); + if (slot === null) { + return false; + } + + logger.warn(`${context}: 恢复到SIM${slot + 1}`); + await this.sendATCommand(`AT+SWITCHSIM=${slot}`, 10000).catch((err) => { + logger.warn(`${context}命令失败: ${err.message}`); + }); + + const restored = await this.waitForSwitchSimSlot(slot, timeout, 1000); + if (restored) { + this.sim.switchSlot = slot; + this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? slot); + return true; + } + + logger.warn(`${context}: 未能确认恢复到SIM${slot + 1}`); + return false; + } + + async restoreBindSimSlot(slotNumber, context = '业务SIM回退') { + const slot = this.normalizeSimSlot(slotNumber); + if (slot === null) { + return false; + } + + await this.sendATWithRetry(`AT+BINDSIM=${slot}`, { + timeout: 5000, + retries: 1, + retryDelay: 500, + label: `${context}到SIM${slot + 1}` + }).catch((err) => { + logger.warn(`${context}到SIM${slot + 1}失败: ${err.message}`); + }); + + return true; + } + + async restoreSimAfterProbe(switchSlot, bindSlot, context = 'SIM探测后恢复') { + const targetSwitchSlot = this.normalizeSimSlot(switchSlot); + const targetBindSlot = this.normalizeSimSlot(bindSlot) ?? targetSwitchSlot; + + if (targetSwitchSlot !== null) { + await this.restoreSwitchSimSlot(targetSwitchSlot, context).catch((err) => { + logger.warn(`${context}切回SIM${targetSwitchSlot + 1}失败: ${err.message}`); + }); + } + + if (targetBindSlot !== null) { + await this.restoreBindSimSlot(targetBindSlot, context); + } + } + + async probeInactiveSimSlot(status, slotNumber, originalSlot, originalBindSlot = originalSlot) { + const targetSlot = this.normalizeSimSlot(slotNumber); + const restoreSlot = this.normalizeSimSlot(originalSlot); + const restoreBindSlot = this.normalizeSimSlot(originalBindSlot) ?? restoreSlot; + if (targetSlot === null || restoreSlot === null || targetSlot === restoreSlot) { + return false; + } + + logger.info(`探测SIM${targetSlot + 1}是否可用`); + let switched = false; + + try { + const resp = await this.sendATCommand(`AT+SWITCHSIM=${targetSlot}`, 10000); + if (!resp.includes('OK')) { + logger.warn(`探测SIM${targetSlot + 1}切换返回非OK: ${this.formatATResponse(resp)}`); + } + + switched = await this.waitForSwitchSimSlot(targetSlot, 15000, 1000); + if (!switched) { + this.updateSimSlotPresence(status, targetSlot, false); + return false; + } + + await this.sleep(1000); + await this.waitSIMReady({ + attempts: 5, + delay: 1000, + label: `SIM${targetSlot + 1}` + }); + this.updateSimSlotPresence(status, targetSlot, true); + return true; + } catch (err) { + logger.warn(`探测SIM${targetSlot + 1}失败: ${err.message}`); + this.updateSimSlotPresence(status, targetSlot, false); + return false; + } finally { + if (switched) { + await this.restoreSimAfterProbe(restoreSlot, restoreBindSlot, `探测SIM${targetSlot + 1}后恢复`); + } + } + } + + async probeSimSlotPresence(status, currentSlot) { + const originalSlot = this.normalizeSimSlot(currentSlot); + if (!status.supported || !status.canSwitch || originalSlot === null) { + return status; + } + + if (this.sim.switching || this.sim.probing) { + return status; + } + + const originalBindSlot = this.normalizeSimSlot(status.bindSlot) ?? originalSlot; + + this.sim.probing = true; + try { + for (const slot of status.slots) { + if (slot.slot !== originalSlot) { + await this.probeInactiveSimSlot(status, slot.slot, originalSlot, originalBindSlot); + } + } + + await this.restoreSimAfterProbe(originalSlot, originalBindSlot, 'SIM探测结束恢复'); + + const restoredSlot = await this.querySwitchSimSlot().catch(() => originalSlot); + status.switchSlot = this.normalizeSimSlot(restoredSlot) ?? originalSlot; + status.bindSlot = await this.queryBindSimSlot(); + this.markActiveSimSlot(status, this.getBusinessSimSlot(status)); + return status; + } finally { + this.sim.probing = false; + status.probing = false; + } + } + async waitForSwitchSimSlot(targetSlot, timeout = 15000, interval = 1000) { const startedAt = Date.now(); @@ -806,12 +984,27 @@ class ModemManager extends EventEmitter { } } + isSimReadyResponse(resp) { + return String(resp || '').includes('+CPIN: READY'); + } + + async isCurrentSimReady(timeout = 2000) { + try { + const resp = await this.sendATCommand('AT+CPIN?', timeout); + return this.isSimReadyResponse(resp); + } catch (err) { + logger.debug(`查询当前SIM就绪状态失败: ${err.message}`); + return false; + } + } + async getSimStatus(options = {}) { return await this.refreshSimStatus(options); } async refreshSimStatus(options = {}) { const refreshIdentity = options.refreshIdentity === true; + const probeSlots = options.probeSlots === true; const status = { ...this.sim, slots: this.sim.slots.map(slot => ({ ...slot })), @@ -839,6 +1032,8 @@ class ModemManager extends EventEmitter { if (switchSlot !== null) { status.canSwitch = true; status.switchSlot = switchSlot; + const currentSimReady = await this.isCurrentSimReady(); + this.updateSimSlotPresence(status, switchSlot, currentSimReady); } else { status.switchSlot = null; status.error = '未能读取当前AT SIM卡槽'; @@ -852,6 +1047,10 @@ class ModemManager extends EventEmitter { this.updateSimSlotPhoneNumber(status, switchSlot, phoneNumber); } + if (probeSlots && status.canSwitch && switchSlot !== null) { + await this.probeSimSlotPresence(status, switchSlot); + } + status.lastCheckedAt = new Date().toISOString(); this.sim = status; return this.getCachedSimStatus(); @@ -896,12 +1095,20 @@ class ModemManager extends EventEmitter { await this.switchSimForIdentityRead(targetSlot, { context: `切换SIM${targetSlot + 1}`, purpose: '用于业务绑定', - retries: 4, - retryDelay: 3000, - settleDelay: 3000 + retries: 2, + retryDelay: 1000, + settleDelay: 1000, + confirmTimeout: 5000, + confirmInterval: 1000, + simReadyAttempts: 3, + simReadyDelay: 1000 }); } else { - await this.waitSIMReady(); + await this.waitSIMReady({ + attempts: 3, + delay: 1000, + label: `SIM${targetSlot + 1}` + }); } if (this.normalizeSimSlot(before.bindSlot) !== targetSlot) { @@ -1753,7 +1960,7 @@ class ModemManager extends EventEmitter { // 7. 按文档配置短信功能,并启用本项目需要的PDU模式 await this.configureSMS(); - await this.refreshSimStatus({ refreshIdentity: true }); + await this.refreshSimStatus({ refreshIdentity: true, probeSlots: true }); logger.info('模组初始化完成'); this.emit('ready'); @@ -1762,24 +1969,29 @@ class ModemManager extends EventEmitter { /** * 等待SIM卡完成初始化 */ - async waitSIMReady() { - for (let attempt = 1; attempt <= 10; attempt++) { + async waitSIMReady(options = {}) { + const attempts = options.attempts ?? 10; + const delay = options.delay ?? 1000; + const timeout = options.timeout ?? 2000; + const label = options.label || 'SIM卡'; + + for (let attempt = 1; attempt <= attempts; attempt++) { try { - const resp = await this.sendATCommand('AT+CPIN?', 2000); - if (resp.includes('+CPIN: READY')) { - logger.info('✓ SIM卡已就绪'); + const resp = await this.sendATCommand('AT+CPIN?', timeout); + if (this.isSimReadyResponse(resp)) { + logger.info(`✓ ${label}已就绪`); return; } - logger.warn(`SIM卡未就绪(${attempt}/10): ${this.formatATResponse(resp)}`); + logger.warn(`${label}未就绪(${attempt}/${attempts}): ${this.formatATResponse(resp)}`); } catch (err) { - logger.warn(`查询SIM卡状态失败(${attempt}/10): ${err.message}`); + logger.warn(`查询${label}状态失败(${attempt}/${attempts}): ${err.message}`); } - await this.sleep(1000); + await this.sleep(delay); } - throw new Error('SIM卡未就绪'); + throw new Error(`${label}未就绪,可能未插卡或SIM初始化失败`); } /**