From a47a027178d64685c1e99ec2f681008f8aa4d6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Wed, 15 Jul 2026 12:12:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota)=EF=BC=9A=E5=AE=9E=E7=8E=B0ESP32?= =?UTF-8?q?=E5=92=8CGD32=E7=9A=84OTA=E4=B8=8A=E4=BC=A0=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了upload-esp32.js用于处理ESP32的OTA上传,包括文件上传、SHA-256验证和设备重启。 - 引入了upload-gd32.js,用于触发GD32 OTA中继并跟踪中继进度。 - 针对OTA流程,增强了会话管理和错误处理能力。 - 通过在上传过程中显示吐司通知和进度更新,改进了用户反馈。 --- .../split-tools-into-modules/checklist.md | 81 + .trae/specs/split-tools-into-modules/spec.md | 136 + .trae/specs/split-tools-into-modules/tasks.md | 101 + main/include/version.h | 8 +- sdkconfig.defaults | 4 +- tools/app.js | 2371 ----------------- tools/ble_ota.js | 1658 ------------ tools/can.html | 8 +- tools/can.js | 1526 ----------- tools/can_monitor.html | 8 +- tools/can_monitor.js | 244 -- tools/css/base.css | 54 + tools/css/can.css | 1029 +++++++ tools/css/components.css | 658 +++++ tools/css/layout.css | 156 ++ tools/css/responsive.css | 250 ++ tools/index.html | 265 +- tools/js/app.js | 245 ++ tools/js/ble/constants.js | 57 + tools/js/ble/file.js | 211 ++ tools/js/ble/gatt.js | 546 ++++ tools/js/ble/session.js | 123 + tools/js/ble/stages.js | 66 + tools/js/ble/state.js | 188 ++ tools/js/ble/transfer.js | 489 ++++ tools/js/ble/verify.js | 26 + tools/js/ble_ota.js | 58 + tools/js/can.js | 265 ++ tools/js/can/dbc.js | 256 ++ tools/js/can/feedback.js | 19 + tools/js/can/messages.js | 356 +++ tools/js/can/send.js | 383 +++ tools/js/can/signals.js | 384 +++ tools/js/can_monitor.js | 232 ++ tools/js/common/connection.js | 270 ++ tools/js/common/log.js | 70 + tools/js/common/polling.js | 363 +++ tools/js/common/toast.js | 33 + tools/js/common/utils.js | 41 + tools/js/ota/console-mode.js | 273 ++ tools/js/ota/file-handle.js | 263 ++ tools/js/ota/progress.js | 210 ++ tools/js/ota/session.js | 244 ++ tools/js/ota/spi.js | 105 + tools/js/ota/stages.js | 279 ++ tools/js/ota/upload-esp32.js | 443 +++ tools/js/ota/upload-gd32.js | 212 ++ tools/style.css | 2146 --------------- 48 files changed, 9201 insertions(+), 8212 deletions(-) create mode 100644 .trae/specs/split-tools-into-modules/checklist.md create mode 100644 .trae/specs/split-tools-into-modules/spec.md create mode 100644 .trae/specs/split-tools-into-modules/tasks.md delete mode 100644 tools/app.js delete mode 100644 tools/ble_ota.js delete mode 100644 tools/can.js delete mode 100644 tools/can_monitor.js create mode 100644 tools/css/base.css create mode 100644 tools/css/can.css create mode 100644 tools/css/components.css create mode 100644 tools/css/layout.css create mode 100644 tools/css/responsive.css create mode 100644 tools/js/app.js create mode 100644 tools/js/ble/constants.js create mode 100644 tools/js/ble/file.js create mode 100644 tools/js/ble/gatt.js create mode 100644 tools/js/ble/session.js create mode 100644 tools/js/ble/stages.js create mode 100644 tools/js/ble/state.js create mode 100644 tools/js/ble/transfer.js create mode 100644 tools/js/ble/verify.js create mode 100644 tools/js/ble_ota.js create mode 100644 tools/js/can.js create mode 100644 tools/js/can/dbc.js create mode 100644 tools/js/can/feedback.js create mode 100644 tools/js/can/messages.js create mode 100644 tools/js/can/send.js create mode 100644 tools/js/can/signals.js create mode 100644 tools/js/can_monitor.js create mode 100644 tools/js/common/connection.js create mode 100644 tools/js/common/log.js create mode 100644 tools/js/common/polling.js create mode 100644 tools/js/common/toast.js create mode 100644 tools/js/common/utils.js create mode 100644 tools/js/ota/console-mode.js create mode 100644 tools/js/ota/file-handle.js create mode 100644 tools/js/ota/progress.js create mode 100644 tools/js/ota/session.js create mode 100644 tools/js/ota/spi.js create mode 100644 tools/js/ota/stages.js create mode 100644 tools/js/ota/upload-esp32.js create mode 100644 tools/js/ota/upload-gd32.js delete mode 100644 tools/style.css diff --git a/.trae/specs/split-tools-into-modules/checklist.md b/.trae/specs/split-tools-into-modules/checklist.md new file mode 100644 index 0000000..59028a9 --- /dev/null +++ b/.trae/specs/split-tools-into-modules/checklist.md @@ -0,0 +1,81 @@ +# Checklist + +## CSS 拆分验证 +- [x] `css/base.css` 包含 `:root` 变量定义、`.container` 样式、背景纹理,无其他职责段混入 +- [x] `css/layout.css` 包含 header、main-layout、cards、forms、buttons、conn-indicator 样式 +- [x] `css/components.css` 包含 upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast 样式 +- [x] `css/can.css` 包含 CAN panel、CAN workspace、CAN feedback、ID snapshot、报文列表布局样式 +- [x] `css/responsive.css` 包含所有 `@media` 查询和 `@keyframes` 动画定义 +- [x] 三个 HTML 页面的 `` 标签引用了全部 5 个 CSS 文件,页面视觉效果与原 `style.css` 完全一致 +- [x] 原 `tools/style.css` 已删除 + +## 公共模块验证 +- [x] `js/common/utils.js` 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml` +- [x] `js/common/toast.js` 导出 `showToast`,支持 info/success/warning/error 类型 +- [x] `js/common/log.js` 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll`,环形缓冲与去重逻辑完整 +- [x] `js/common/connection.js` 导出 `testConnection`、连接状态指示器更新函数 +- [x] `js/common/polling.js` 导出轮询调度、`pollStatus`、`pollLogs`、Page Visibility 补偿逻辑 + +## app.js 拆分验证 +- [x] `js/ota/session.js` 包含完整 OTA 会话状态机,状态流转(IDLE→UPLOADING→VERIFYING→REBOOTING→DONE / FAILED)逻辑不变 +- [x] `js/ota/progress.js` 包含进度条、阶段节点 UI、`setProgress`、`setActivePhase`、`markStageDone` +- [x] `js/ota/stages.js` 包含 ESP32/GD32 阶段驱动,`esp32OtaPhase`、`esp32PhaseLabels` 等状态保留 +- [x] `js/ota/file-handle.js` 包含文件选择、FSAPI、SHA-256 计算、Intel HEX→BIN 转换、文件信息 UI +- [x] `js/ota/upload-esp32.js` 包含 ESP32 上传流程、心跳、SHA 校验、完成升级,重试阈值与 goto 跳转逻辑保留 +- [x] `js/ota/upload-gd32.js` 包含 GD32 中继 OTA 触发、relay 态跟踪、`hasObservedRealRelayState` 逻辑 +- [x] `js/ota/spi.js` 包含 `sendSpiFrame`、`clearSpiFields` +- [x] `js/ota/console-mode.js` 包含从 index.html 抽出的全部内联脚本函数 + +## ble_ota.js 拆分验证 +- [x] `js/ble/constants.js` 导出 `UUIDS`、所有 `BLE_OTA_*` 常量、`TARGETS` 配置 +- [x] `js/ble/state.js` 导出 `state` 对象、元素缓存、`currentTarget`、`refreshTargetUi`、`setConnectionState` +- [x] `js/ble/stages.js` 导出 `setBleStage`、`resetBleStages`、`setBleStageError`、`updateBleStageLabels` +- [x] `js/ble/gatt.js` 导出 BLE 连接、断开、服务发现、通知使能逻辑 +- [x] `js/ble/file.js` 导出 `notifyFileChange`、`clearFile`、分包计算 +- [x] `js/ble/transfer.js` 导出数据写入循环、ACK 窗口节流(每 32 包或窗口边界 ACK)、`updateProgressFromAck` +- [x] `js/ble/verify.js` 导出 `showBleVerifyInfo`、`hideBleVerifyInfo` +- [x] `js/ble/session.js` 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、abort 逻辑 + +## can.js 拆分验证 +- [x] `js/can/dbc.js` 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary` +- [x] `js/can/messages.js` 导出报文列表渲染、过滤、节点过滤逻辑 +- [x] `js/can/signals.js` 导出信号值编辑器、编解码、批量添加 +- [x] `js/can/send.js` 导出帧构建、发送、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、`buildSendResponseText` +- [ ] `js/can/feedback.js` 导出 1Hz 反馈轮询、ID 快照表渲染 + **注:原 can.js 中不存在 1Hz 反馈轮询/ID 快照表代码(该功能在 can_monitor.js 中)。feedback.js 当前重导出 common/connection.js 的连接管理函数供入口使用。如后续 can 页面需要 1Hz `/can/feedback` 轮询,可在 feedback.js 实现。** + +## can_monitor.js 验证 +- [x] `js/can_monitor.js` 转为 ESM,引用 `js/common/*` 公共模块替换重复工具函数 +- [x] CAN 监控页面功能正常(连接、报文接收)— HTTP 200 验证通过,语法检查通过 + +## 入口与 HTML 验证 +- [x] `js/app.js` 薄入口 import 所有 ota + common 模块,HTML onclick 引用的函数已挂载到 `window` +- [x] `js/ble_ota.js` 薄入口 import 所有 ble 模块,导出 `window.bleOta` API 并调用 `init()` +- [x] `js/can.js` 薄入口 import 所有 can 模块并初始化 +- [x] `index.html` 使用 ` + diff --git a/tools/can.js b/tools/can.js deleted file mode 100644 index bd5e2ad..0000000 --- a/tools/can.js +++ /dev/null @@ -1,1526 +0,0 @@ -(function () { - 'use strict'; - - var state = { - dbcFile: null, - dbcText: '', - dbc: null, - filteredMessages: [], - selectedMessageKey: '', - signalValues: {}, - checkedKeys: {}, - expandedKeys: {}, - transmitterFilter: Object.create(null), - transmitterFilterEmpty: true, - generatedData: new Uint8Array(0), - history: [], - latestStatus: null, - batchInFlight: false - }; - - var el = {}; - - function cacheElements() { - [ - 'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData', - 'testConnBtn', 'connIndicator', 'connIndicatorTxt', 'connectionNote', - 'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount', - 'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcSearch', 'dbcHint', 'clearDbcBtn', - 'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint', - 'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn', - 'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco', - 'toastMsg', 'canConnPill', 'canConnTxt', 'canVersionPill', 'canChannelPill', - 'dbcSummaryPill', 'messageSummaryPill', - 'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn', - 'nodeFilterWrap', 'nodeFilterBtn', 'nodeFilterLabel', 'nodeFilterPanel', 'nodeFilterSearch', - 'nodeFilterAllBtn', 'nodeFilterNoneBtn', 'nodeFilterList' - ].forEach(function (id) { - el[id] = document.getElementById(id); - }); - } - - function showToast(message, type) { - type = type || 'success'; - if (!el.toast) return; - el.toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show'; - el.toastIco.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0'; - el.toastMsg.textContent = message; - clearTimeout(showToast._timer); - showToast._timer = setTimeout(function () { - el.toast.classList.remove('show'); - }, 3000); - } - - function formatBytes(bytes) { - if (!Number.isFinite(bytes) || bytes < 0) return '--'; - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - } - - function formatTime(date) { - var d = date instanceof Date ? date : new Date(); - var hh = String(d.getHours()).padStart(2, '0'); - var mm = String(d.getMinutes()).padStart(2, '0'); - var ss = String(d.getSeconds()).padStart(2, '0'); - return hh + ':' + mm + ':' + ss; - } - - function sanitizeHtml(text) { - return String(text == null ? '' : text) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function getBaseUrl() { - var ip = (el.toolIp && el.toolIp.value ? el.toolIp.value.trim() : '') || '192.168.31.127'; - return 'http://' + ip; - } - - function parseNumericId(input) { - var raw = String(input == null ? '' : input).trim(); - if (!raw) return NaN; - if (/^0x/i.test(raw)) return parseInt(raw, 16); - if (/^[0-9]+$/.test(raw)) return parseInt(raw, 10); - if (/^[0-9a-fA-F]+h$/i.test(raw)) return parseInt(raw.slice(0, -1), 16); - if (/^[0-9a-fA-F]+$/.test(raw)) return parseInt(raw, 16); - return NaN; - } - - function padHex(value, width) { - return value.toString(16).toUpperCase().padStart(width, '0'); - } - - function bytesToHex(bytes) { - if (!bytes || !bytes.length) return ''; - var parts = []; - for (var i = 0; i < bytes.length; i++) { - parts.push(padHex(bytes[i], 2)); - } - return parts.join(''); - } - - function setConnectionState(connected, text, buildVersion, buildTime) { - if (el.canConnPill) { - el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline'); - } - if (el.canConnTxt) el.canConnTxt.textContent = text; - if (el.connIndicator) { - el.connIndicator.className = 'conn-indicator ' + (connected ? 'ok' : 'err'); - } - if (el.connIndicatorTxt) el.connIndicatorTxt.textContent = text; - if (el.canVersionPill) { - el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--'; - el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : ''; - } - } - - async function testConnection() { - var baseUrl = getBaseUrl(); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note'; - el.connectionNote.innerHTML = '正在请求 /system/status 检查设备状态...'; - } - try { - var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) }); - if (!resp.ok) { - setConnectionState(false, 'HTTP ' + resp.status, '', ''); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note err'; - el.connectionNote.innerHTML = '状态接口返回 HTTP ' + resp.status + ',请确认设备地址和 HTTP 服务。'; - } - showToast('设备响应异常: HTTP ' + resp.status, 'error'); - return; - } - var data = await resp.json(); - state.latestStatus = data; - var wifi = data.wifi || {}; - var connected = !!wifi.connected; - var mode = wifi.mode || 'STA'; - var text = connected ? (mode + ' 已连接') : (mode + ' 未连接'); - setConnectionState(connected, text, data.version, data.build_time); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note ' + (connected ? 'ok' : 'warn'); - el.connectionNote.innerHTML = - 'IP: ' + sanitizeHtml(wifi.ip || '--') + ',RSSI: ' + - sanitizeHtml(wifi.rssi !== undefined && wifi.rssi !== -127 ? (wifi.rssi + ' dBm') : '--') + - ',运行: ' + sanitizeHtml(formatUptime(data.uptime_sec)) + ''; - } - showToast(connected ? 'ESP32 当前在线' : '设备已响应,但 WiFi 未连接', connected ? 'success' : 'warning'); - } catch (err) { - setConnectionState(false, '连接失败', '', ''); - if (el.connectionNote) { - el.connectionNote.className = 'helper-note err'; - el.connectionNote.innerHTML = '无法访问 /system/status:' + sanitizeHtml(err.message); - } - showToast('连接失败: ' + err.message, 'error'); - } - } - - function formatUptime(sec) { - if (!Number.isFinite(sec) || sec < 0) return '--'; - var s = Math.floor(sec); - var days = Math.floor(s / 86400); - s %= 86400; - var hours = Math.floor(s / 3600); - s %= 3600; - var mins = Math.floor(s / 60); - s %= 60; - if (days > 0) return days + 'd ' + hours + 'h'; - if (hours > 0) return hours + 'h ' + mins + 'm'; - if (mins > 0) return mins + 'm ' + s + 's'; - return s + 's'; - } - - function normalizeWhitespace(text) { - return String(text || '').replace(/\r/g, '').trim(); - } - - function parseDbc(text) { - var lines = String(text || '').replace(/\r/g, '').split('\n'); - var messages = []; - var messageMap = {}; - var comments = { messages: {}, signals: {} }; - var valueTables = {}; - var sigDefaults = {}; - var nodeNames = []; - var current = null; - - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (!line) continue; - - var nodeMatch = line.match(/^BU_\s*:\s*(.*)$/); - if (nodeMatch) { - nodeNames = nodeMatch[1].trim() ? nodeMatch[1].trim().split(/\s+/) : []; - continue; - } - - var messageMatch = line.match(/^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+(.+)$/); - if (messageMatch) { - current = { - idRaw: parseInt(messageMatch[1], 10), - name: messageMatch[2], - dlc: parseInt(messageMatch[3], 10), - transmitter: messageMatch[4].trim(), - signals: [], - comment: '' - }; - current.id = current.idRaw & 0x1FFFFFFF; - current.isExtended = current.idRaw > 0x7FF; - current.key = String(current.idRaw) + ':' + current.name; - messages.push(current); - messageMap[current.idRaw] = current; - continue; - } - - var signalMatch = line.match(/^SG_\s+([A-Za-z0-9_]+)(?:\s+((?:M|m-?\d+)))?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s+\(([^\),]+),([^\)]+)\)\s+\[([^\|]*)\|([^\]]*)\]\s+"([^"]*)"\s+(.+)$/); - if (signalMatch && current) { - var muxInfo = parseMultiplexToken(signalMatch[2]); - current.signals.push({ - name: signalMatch[1], - startBit: parseInt(signalMatch[3], 10), - length: parseInt(signalMatch[4], 10), - byteOrder: signalMatch[5] === '1' ? 'little' : 'big', - isSigned: signalMatch[6] === '-', - factor: parseFloat(signalMatch[7]), - offset: parseFloat(signalMatch[8]), - min: signalMatch[9].trim() === '' ? null : parseFloat(signalMatch[9]), - max: signalMatch[10].trim() === '' ? null : parseFloat(signalMatch[10]), - unit: signalMatch[11], - receivers: signalMatch[12].split(/\s*,\s*/), - comment: '', - valueType: 'int', - enumMap: {}, - defaultValue: null, - multiplexRole: muxInfo.role, - multiplexValue: muxInfo.value - }); - continue; - } - - var messageCommentMatch = line.match(/^CM_\s+BO_\s+(\d+)\s+"([\s\S]*)";$/); - if (messageCommentMatch) { - comments.messages[messageCommentMatch[1]] = messageCommentMatch[2]; - continue; - } - - var signalCommentMatch = line.match(/^CM_\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+"([\s\S]*)";$/); - if (signalCommentMatch) { - comments.signals[signalCommentMatch[1] + ':' + signalCommentMatch[2]] = signalCommentMatch[3]; - continue; - } - - var valueMatch = line.match(/^VAL_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(.+);$/); - if (valueMatch) { - var table = {}; - var pairRe = /(-?\d+)\s+"([^"]*)"/g; - var m; - while ((m = pairRe.exec(valueMatch[3])) !== null) { - table[m[1]] = m[2]; - } - valueTables[valueMatch[1] + ':' + valueMatch[2]] = table; - continue; - } - - var valTypeMatch = line.match(/^SIG_VALTYPE_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s*;$/); - if (valTypeMatch) { - var msg = messageMap[valTypeMatch[1]]; - if (!msg) continue; - var signal = findSignal(msg, valTypeMatch[2]); - if (signal) { - signal.valueType = valTypeMatch[3] === '1' ? 'float' : valTypeMatch[3] === '2' ? 'double' : 'int'; - } - continue; - } - - /* BA_ "GenSigDefaultValue" SG_ ; - * 注意该值是物理值(已应用 factor/offset),与 GenSigStartValue(raw)不同 */ - var sigDefaultMatch = line.match(/^BA_\s+"GenSigDefaultValue"\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*;$/); - if (sigDefaultMatch) { - sigDefaults[sigDefaultMatch[1] + ':' + sigDefaultMatch[2]] = sigDefaultMatch[3]; - continue; - } - } - - messages.forEach(function (msg) { - msg.comment = comments.messages[String(msg.idRaw)] || ''; - msg.signals.forEach(function (signal) { - signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || ''; - signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {}; - var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name]; - signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? dv : null; - }); - }); - - return { - messages: messages, - nodeNames: nodeNames, - messageMap: messageMap - }; - } - - function findSignal(message, signalName) { - for (var i = 0; i < message.signals.length; i++) { - if (message.signals[i].name === signalName) return message.signals[i]; - } - return null; - } - - function parseMultiplexToken(token) { - if (!token) return { role: null, value: null }; - if (token === 'M') return { role: 'mux', value: null }; - var match = token.match(/^m(-?\d+)$/); - if (match) { - return { role: 'value', value: parseInt(match[1], 10) }; - } - return { role: null, value: null }; - } - - function buildSummary() { - if (!state.dbc) { - if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC'; - if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文'; - return; - } - if (el.dbcSummaryPill) { - el.dbcSummaryPill.textContent = (state.dbc.nodeNames.length || 0) + ' 节点'; - } - if (el.messageSummaryPill) { - el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文'; - } - } - - function updateDbcFileInfo(file, dbc) { - if (!el.dbcFileInfo) return; - if (!file || !dbc) { - el.dbcFileInfo.classList.remove('show'); - el.dbcFileName.textContent = '-'; - el.dbcMessageCount.textContent = '0'; - el.dbcSignalCount.textContent = '0'; - el.dbcNodeCount.textContent = '0'; - el.dbcFileSize.textContent = '0 B'; - return; - } - var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0); - el.dbcFileInfo.classList.add('show'); - el.dbcFileName.textContent = file.name || '-'; - el.dbcMessageCount.textContent = String(dbc.messages.length); - el.dbcSignalCount.textContent = String(signalCount); - el.dbcNodeCount.textContent = String(dbc.nodeNames.length); - el.dbcFileSize.textContent = formatBytes(file.size || 0); - } - - function filterMessages() { - var keyword = normalizeWhitespace(el.dbcSearch ? el.dbcSearch.value : '').toLowerCase(); - var messages = state.dbc ? state.dbc.messages.slice() : []; - if (!state.transmitterFilterEmpty) { - messages = messages.filter(function (msg) { - var tx = msg.transmitter || 'Vector__XXX'; - return !!state.transmitterFilter[tx]; - }); - } - if (keyword) { - messages = messages.filter(function (msg) { - var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); - if (msg.name.toLowerCase().indexOf(keyword) >= 0) return true; - if (idHex.toLowerCase().indexOf(keyword) >= 0) return true; - if (String(msg.idRaw).indexOf(keyword) >= 0) return true; - if ((msg.transmitter || '').toLowerCase().indexOf(keyword) >= 0) return true; - for (var i = 0; i < msg.signals.length; i++) { - if (msg.signals[i].name.toLowerCase().indexOf(keyword) >= 0) return true; - } - return false; - }); - } - state.filteredMessages = messages; - renderMessageList(); - updateNodeFilterLabel(); - } - - function collectTransmitters() { - var set = Object.create(null); - var list = []; - if (!state.dbc) return list; - state.dbc.messages.forEach(function (msg) { - var tx = msg.transmitter || 'Vector__XXX'; - if (!set[tx]) { set[tx] = 1; list.push(tx); } - }); - list.sort(function (a, b) { return a.localeCompare(b); }); - return list; - } - - function renderNodeFilterList() { - if (!el.nodeFilterList) return; - var txs = collectTransmitters(); - var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase(); - if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; }); - if (!txs.length) { - el.nodeFilterList.innerHTML = '
无匹配节点
'; - return; - } - var counts = Object.create(null); - if (state.dbc) { - state.dbc.messages.forEach(function (msg) { - var tx = msg.transmitter || 'Vector__XXX'; - counts[tx] = (counts[tx] || 0) + 1; - }); - } - el.nodeFilterList.innerHTML = txs.map(function (name) { - var checked = !!state.transmitterFilter[name] ? ' checked' : ''; - return ''; - }).join(''); - var inputs = el.nodeFilterList.querySelectorAll('input[type="checkbox"]'); - for (var i = 0; i < inputs.length; i++) { - inputs[i].addEventListener('change', function () { - var name = this.getAttribute('data-node'); - if (this.checked) state.transmitterFilter[name] = true; - else delete state.transmitterFilter[name]; - recomputeTransmitterFilterEmpty(); - filterMessages(); - }); - } - } - - function recomputeTransmitterFilterEmpty() { - var any = false; - for (var k in state.transmitterFilter) { - if (state.transmitterFilter[k]) { any = true; break; } - } - state.transmitterFilterEmpty = !any; - } - - function updateNodeFilterLabel() { - if (!el.nodeFilterLabel) return; - if (state.transmitterFilterEmpty) { - el.nodeFilterLabel.textContent = '发送节点:全部'; - if (el.nodeFilterBtn) el.nodeFilterBtn.classList.remove('active'); - } else { - var picked = Object.keys(state.transmitterFilter).filter(function (k) { return state.transmitterFilter[k]; }); - var preview = picked.length === 1 ? picked[0] : ('已选 ' + picked.length + ' 个'); - el.nodeFilterLabel.textContent = '发送节点:' + preview; - if (el.nodeFilterBtn) el.nodeFilterBtn.classList.add('active'); - } - } - - function setNodeFilterPanelOpen(open) { - if (!el.nodeFilterPanel || !el.nodeFilterBtn) return; - if (open) { - renderNodeFilterList(); - el.nodeFilterPanel.hidden = false; - el.nodeFilterBtn.setAttribute('aria-expanded', 'true'); - } else { - el.nodeFilterPanel.hidden = true; - el.nodeFilterBtn.setAttribute('aria-expanded', 'false'); - } - } - - function bindNodeFilterEvents() { - if (!el.nodeFilterBtn) return; - el.nodeFilterBtn.addEventListener('click', function (event) { - event.stopPropagation(); - setNodeFilterPanelOpen(el.nodeFilterPanel.hidden); - }); - document.addEventListener('click', function (event) { - if (!el.nodeFilterWrap || el.nodeFilterPanel.hidden) return; - if (!el.nodeFilterWrap.contains(event.target)) setNodeFilterPanelOpen(false); - }); - document.addEventListener('keydown', function (event) { - if (event.key === 'Escape' && el.nodeFilterPanel && !el.nodeFilterPanel.hidden) { - setNodeFilterPanelOpen(false); - } - }); - if (el.nodeFilterSearch) { - el.nodeFilterSearch.addEventListener('input', renderNodeFilterList); - } - if (el.nodeFilterAllBtn) el.nodeFilterAllBtn.addEventListener('click', function () { - collectTransmitters().forEach(function (n) { state.transmitterFilter[n] = true; }); - recomputeTransmitterFilterEmpty(); - renderNodeFilterList(); - filterMessages(); - }); - if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () { - state.transmitterFilter = Object.create(null); - state.transmitterFilterEmpty = true; - renderNodeFilterList(); - filterMessages(); - }); - } - - function renderMessageList() { - if (!el.messageList) return; - if (!state.dbc) { - el.messageList.innerHTML = '
等待导入 DBC...
'; - updateBatchUi(); - return; - } - if (!state.filteredMessages.length) { - el.messageList.innerHTML = '
没有匹配的报文。
'; - updateBatchUi(); - return; - } - var channelTxt = el.defaultChannel ? el.defaultChannel.value : '0'; - var rows = state.filteredMessages.map(function (msg, index) { - var active = msg.key === state.selectedMessageKey ? ' active' : ''; - var checked = state.checkedKeys[msg.key] ? ' checked' : ''; - var expanded = !!state.expandedKeys[msg.key]; - var caret = expanded ? '\u25BE' : '\u25B8'; - var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); - var comment = msg.comment || ''; - if (comment.length > 36) comment = comment.slice(0, 36) + '...'; - var rowHtml = '' + - '
' + - '' + - '' + - '' + (index + 1) + '' + - '' + sanitizeHtml(msg.name) + '' + - '' + sanitizeHtml(idHex) + '' + - 'CH' + sanitizeHtml(channelTxt) + '' + - '' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '' + - '' + sanitizeHtml(msg.dlc) + '' + - '' + sanitizeHtml(msg.signals.length) + '' + - '' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '' + - '' + sanitizeHtml(comment || '-') + '' + - '
'; - var expandHtml = '
' + - '
' + - '
'; - return rowHtml + expandHtml; - }).join(''); - el.messageList.innerHTML = - '
' + - '
' + - '' + - '' + - '#' + - '报文名称' + - '标识符' + - '通道' + - '类型' + - 'DLC' + - '信号' + - '发送节点' + - '注释' + - '
' + - '
' + rows + '
' + - '
'; - - var rowEls = el.messageList.querySelectorAll('.message-row'); - for (var i = 0; i < rowEls.length; i++) { - rowEls[i].addEventListener('click', handleMessageRowClick); - rowEls[i].addEventListener('keydown', function (event) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - selectMessage(this.getAttribute('data-key')); - } - }); - } - var checks = el.messageList.querySelectorAll('.message-check'); - for (var j = 0; j < checks.length; j++) { - checks[j].addEventListener('click', function (event) { event.stopPropagation(); }); - checks[j].addEventListener('change', function () { - var k = this.getAttribute('data-key'); - if (this.checked) state.checkedKeys[k] = true; - else delete state.checkedKeys[k]; - updateBatchUi(); - }); - } - var toggles = el.messageList.querySelectorAll('.message-toggle'); - for (var t = 0; t < toggles.length; t++) { - toggles[t].addEventListener('click', function (event) { - event.stopPropagation(); - toggleMessageExpand(this.getAttribute('data-key')); - }); - } - - state.filteredMessages.forEach(function (msg) { - if (state.expandedKeys[msg.key]) { - var host = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(msg.key) + '"] .signal-table-host'); - if (host) renderInlineSignalGrid(msg, host); - } - }); - updateBatchUi(); - } - - function handleMessageRowClick(event) { - if (event.target.closest('.message-check') || event.target.closest('.message-toggle')) return; - var key = this.getAttribute('data-key'); - selectMessage(key); - } - - function toggleMessageExpand(key) { - if (!state.dbc) return; - var msg = null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === key) { msg = state.dbc.messages[i]; break; } - } - if (!msg) return; - var willExpand = !state.expandedKeys[key]; - if (willExpand) { - state.expandedKeys[key] = true; - ensureMessageDefaults(msg); - } else { - delete state.expandedKeys[key]; - } - var panel = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"]'); - var toggle = el.messageList.querySelector('.message-toggle[data-key="' + cssEscape(key) + '"]'); - if (panel) { - if (willExpand) { - panel.hidden = false; - var host = panel.querySelector('.signal-table-host'); - if (host) renderInlineSignalGrid(msg, host); - } else { - panel.hidden = true; - } - } - if (toggle) { - toggle.classList.toggle('expanded', willExpand); - toggle.textContent = willExpand ? '\u25BE' : '\u25B8'; - } - } - - function renderInlineSignalGrid(message, host) { - if (!host) return; - ensureMessageDefaults(message); - var activeSignals = getActiveSignals(message); - if (!activeSignals.length) { - host.innerHTML = '
当前报文没有信号定义。
'; - return; - } - var rows = activeSignals.map(function (signal, index) { - return renderSignalRow(message, signal, index); - }).join(''); - host.innerHTML = - '
' + - '
' + - '#' + - '信号名称' + - '输入值' + - '范围 / 枚举' + - '位定义' + - '注释' + - '
' + - '
' + rows + '
' + - '
'; - bindSignalInputs(host, message); - } - - function updateBatchUi() { - var keys = Object.keys(state.checkedKeys); - var count = keys.length; - if (el.selectedCountPill) el.selectedCountPill.textContent = '已勾选 ' + count; - if (el.sendCheckedBtn) { - el.sendCheckedBtn.disabled = count === 0 || state.batchInFlight; - el.sendCheckedBtn.textContent = state.batchInFlight ? '发送中...' : ('批量发送勾选 (' + count + ')'); - } - if (el.selectAllBox) { - var visible = state.filteredMessages || []; - var allChecked = visible.length > 0 && visible.every(function (m) { return !!state.checkedKeys[m.key]; }); - var someChecked = visible.some(function (m) { return !!state.checkedKeys[m.key]; }); - el.selectAllBox.checked = allChecked; - el.selectAllBox.indeterminate = !allChecked && someChecked; - } - } - function selectMessage(key) { - if (!state.dbc) return; - var msg = null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === key) { - msg = state.dbc.messages[i]; - break; - } - } - if (!msg) return; - state.selectedMessageKey = key; - ensureMessageDefaults(msg); - renderMessageList(); - renderSelectedMessage(); - updateEncodedPreview(); - } - - function ensureMessageDefaults(message) { - if (!message) return null; - var bag = state.signalValues[message.key]; - if (!bag) { - bag = {}; - state.signalValues[message.key] = bag; - } - for (var i = 0; i < message.signals.length; i++) { - var sig = message.signals[i]; - if (bag[sig.name] == null || bag[sig.name] === '') { - bag[sig.name] = getDefaultSignalValue(sig); - } - } - return bag; - } - - function getSignalValueMap(message) { - if (!message) return {}; - return state.signalValues[message.key] || ensureMessageDefaults(message); - } - - function setSignalValue(message, signalName, value) { - if (!message) return; - var bag = state.signalValues[message.key]; - if (!bag) { - bag = {}; - state.signalValues[message.key] = bag; - } - bag[signalName] = value; - } - - function getDefaultSignalValue(signal) { - /* 优先使用 DBC 中 BA_ "GenSigDefaultValue" 声明的物理值,否则回退为 0 */ - if (signal && signal.defaultValue != null && Number.isFinite(Number(signal.defaultValue))) { - return String(signal.defaultValue); - } - return '0'; - } - - function getSelectedMessage() { - if (!state.dbc || !state.selectedMessageKey) return null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i]; - } - return null; - } - - function getMultiplexerSignal(message) { - if (!message) return null; - for (var i = 0; i < message.signals.length; i++) { - if (message.signals[i].multiplexRole === 'mux') return message.signals[i]; - } - return null; - } - - function getActiveSignals(message) { - if (!message) return []; - var muxSignal = getMultiplexerSignal(message); - if (!muxSignal) return message.signals.slice(); - var bag = getSignalValueMap(message); - var currentValue = Number(bag[muxSignal.name]); - return message.signals.filter(function (signal) { - if (!signal.multiplexRole || signal.multiplexRole === 'mux') return true; - return signal.multiplexRole === 'value' && Number.isFinite(currentValue) && signal.multiplexValue === currentValue; - }); - } - - /** - * @description 旧版“顶部独立信号编辑面板”的兼容 shim。 - * 该面板已移除,信号编辑现在通过报文行展开完成; - * 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用, - * 保留空函数以避免修改所有调用方。 - */ - function renderSelectedMessage() {} - - function renderSignalRow(message, signal, index) { - var bag = getSignalValueMap(message); - var enumKeys = Object.keys(signal.enumMap || {}); - var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola'); - var rangeText = ''; - if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) { - rangeText = (Number.isFinite(signal.min) ? signal.min : '-') + ' ~ ' + (Number.isFinite(signal.max) ? signal.max : '-'); - } else { - rangeText = '未声明'; - } - var typeText = (signal.valueType || 'int').toUpperCase() + ' / ' + (signal.isSigned ? 'signed' : 'unsigned'); - var factorText = 'factor ' + signal.factor + ' / offset ' + signal.offset + (signal.unit ? (' / ' + signal.unit) : ''); - var comment = signal.comment || '-'; - if (comment.length > 48) comment = comment.slice(0, 48) + '...'; - var inputHtml = ''; - if (enumKeys.length) { - inputHtml = ''; - } else { - inputHtml = ''; - } - return '' + - '
' + - '' + (index + 1) + '' + - '' + - '' + sanitizeHtml(signal.name) + '' + - '' + sanitizeHtml((signal.receivers || []).join(', ') || '-') + '' + - '' + - '' + inputHtml + '' + - '' + - '' + sanitizeHtml(rangeText) + '' + - (enumKeys.length ? '' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumKeys.length > 3 ? ' ...' : '') + '' : '' + sanitizeHtml(factorText) + '') + - '' + - '' + - '' + sanitizeHtml(bitRange) + '' + - '' + sanitizeHtml(typeText) + '' + - '' + - '' + sanitizeHtml(comment) + '' + - '
'; - } - - function handleSignalInputEvent(evt) { - var input = evt.currentTarget; - var key = input.getAttribute('data-msg'); - var name = input.getAttribute('data-signal'); - if (!key || !name || !state.dbc) return; - var message = null; - for (var i = 0; i < state.dbc.messages.length; i++) { - if (state.dbc.messages[i].key === key) { message = state.dbc.messages[i]; break; } - } - if (!message) return; - setSignalValue(message, name, input.value); - var signal = findSignal(message, name); - if (signal && signal.multiplexRole === 'mux') { - if (key === state.selectedMessageKey) renderSelectedMessage(); - var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host'); - if (inlineGrid) renderInlineSignalGrid(message, inlineGrid); - } - if (key === state.selectedMessageKey) updateEncodedPreview(); - } - - function cssEscape(value) { - if (window.CSS && CSS.escape) return CSS.escape(value); - return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\\\' + ch; }); - } - - function bindSignalInputs(container, message) { - if (!container) return; - var inputs = container.querySelectorAll('.signal-input'); - for (var i = 0; i < inputs.length; i++) { - inputs[i].addEventListener('input', handleSignalInputEvent); - inputs[i].addEventListener('change', handleSignalInputEvent); - } - if (message && message.signals.length && message.key === state.selectedMessageKey) { - updateEncodedPreview(); - } - } - - function updateEncodedPreview() { - var message = getSelectedMessage(); - if (!message) { - state.generatedData = new Uint8Array(0); - if (el.generatedHex) el.generatedHex.textContent = '--'; - if (el.generatedBytes) el.generatedBytes.innerHTML = ''; - return; - } - try { - state.generatedData = encodeMessageData(message, getSignalValueMap(message)); - var hex = bytesToHex(state.generatedData); - el.generatedHex.textContent = hex || '--'; - el.generatedBytes.innerHTML = renderByteStrip(state.generatedData); - var hint = 'DLC ' + message.dlc + ',当前编码 ' + state.generatedData.length + - ' 字节,发送通道 CH' + (el.defaultChannel ? el.defaultChannel.value : '0') + '。'; - if (el.encodeHint) { - el.encodeHint.className = 'helper-note ok'; - el.encodeHint.innerHTML = hint; - } - } catch (err) { - state.generatedData = new Uint8Array(0); - el.generatedHex.textContent = '--'; - el.generatedBytes.innerHTML = ''; - if (el.encodeHint) { - el.encodeHint.className = 'helper-note err'; - el.encodeHint.innerHTML = '编码失败:' + sanitizeHtml(err.message); - } - } - } - - function renderByteStrip(bytes) { - if (!bytes || !bytes.length) return '
无数据
'; - var html = []; - for (var i = 0; i < bytes.length; i++) { - html.push('B' + i + '' + padHex(bytes[i], 2) + ''); - } - return html.join(''); - } - - function encodeMessageData(message, signalValues) { - var bytes = new Uint8Array(message.dlc); - var activeSignals = getActiveSignals(message); - for (var i = 0; i < activeSignals.length; i++) { - var signal = activeSignals[i]; - var parsed = parseSignalInput(signal, signalValues[signal.name]); - if (signal.valueType === 'float' || signal.valueType === 'double') { - writeFloatSignal(bytes, signal, parsed.numeric); - } else { - writeIntegerSignal(bytes, signal, parsed); - } - } - return bytes; - } - - function parseSignalInput(signal, inputValue) { - var value = inputValue; - if (value === '' || value == null) value = getDefaultSignalValue(signal); - var num = Number(value); - if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字'); - if (Number.isFinite(signal.min) && num < signal.min) { - throw new Error(signal.name + ' 低于最小值 ' + signal.min); - } - if (Number.isFinite(signal.max) && num > signal.max) { - throw new Error(signal.name + ' 高于最大值 ' + signal.max); - } - return { - numeric: num, - text: String(value).trim() - }; - } - - function isIntegerLike(value) { - return /^-?\d+$/.test(String(value == null ? '' : value).trim()); - } - - function physicalToRawBigInt(signal, parsedValue) { - if ((signal.factor === 1 || signal.factor === 1.0) && - (signal.offset === 0 || signal.offset === 0.0) && - isIntegerLike(parsedValue.text)) { - return BigInt(parsedValue.text); - } - var rawNumber = signal.factor === 0 - ? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric) - : Math.round((parsedValue.numeric - signal.offset) / signal.factor); - if (!Number.isFinite(rawNumber)) { - throw new Error(signal.name + ' 原始值计算失败'); - } - return BigInt(rawNumber); - } - - function bigPow2(bits) { - return 1n << BigInt(bits); - } - - function maxUnsignedValue(bits) { - return bigPow2(bits) - 1n; - } - - function minSignedValue(bits) { - return -(1n << BigInt(bits - 1)); - } - - function maxSignedValue(bits) { - return (1n << BigInt(bits - 1)) - 1n; - } - - function writeIntegerSignal(bytes, signal, parsedValue) { - var raw = physicalToRawBigInt(signal, parsedValue); - if (signal.length <= 0 || signal.length > 64) { - throw new Error(signal.name + ' 位宽超出当前页面支持范围'); - } - if (signal.isSigned) { - var min = minSignedValue(signal.length); - var max = maxSignedValue(signal.length); - if (raw < min || raw > max) { - throw new Error(signal.name + ' 原始值超出带符号范围'); - } - } else { - var maxUnsigned = maxUnsignedValue(signal.length); - if (raw < 0 || raw > maxUnsigned) { - throw new Error(signal.name + ' 原始值超出无符号范围'); - } - } - var unsignedRaw = raw < 0 ? bigPow2(signal.length) + raw : raw; - - if (signal.byteOrder === 'little') { - for (var i = 0; i < signal.length; i++) { - var bitVal = Number((unsignedRaw >> BigInt(i)) & 1n); - var bitIndex = signal.startBit + i; - setBitByIndex(bytes, bitIndex, bitVal); - } - return; - } - - var positions = buildMotorolaBitPositions(signal.startBit, signal.length); - for (var j = 0; j < positions.length; j++) { - var shift = signal.length - 1 - j; - var motorolaBit = Number((unsignedRaw >> BigInt(shift)) & 1n); - setBitByIndex(bytes, positions[j], motorolaBit); - } - } - - function writeFloatSignal(bytes, signal, physicalValue) { - if (signal.length !== 32 && signal.length !== 64) { - throw new Error(signal.name + ' 浮点信号长度需为 32 或 64 位'); - } - if (signal.byteOrder !== 'little') { - throw new Error(signal.name + ' 浮点信号当前仅支持 Intel 小端'); - } - if (signal.startBit % 8 !== 0) { - throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐'); - } - var buffer = new ArrayBuffer(signal.length / 8); - var view = new DataView(buffer); - if (signal.length === 32) view.setFloat32(0, physicalValue, true); - else view.setFloat64(0, physicalValue, true); - var rawBytes = new Uint8Array(buffer); - for (var i = 0; i < rawBytes.length; i++) { - bytes[(signal.startBit / 8) + i] = rawBytes[i]; - } - } - - function buildMotorolaBitPositions(startBit, length) { - var positions = []; - var bit = startBit; - for (var i = 0; i < length; i++) { - positions.push(bit); - if (bit % 8 === 0) bit += 15; - else bit -= 1; - } - return positions; - } - - function setBitByIndex(bytes, bitIndex, bitValue) { - var byteIndex = Math.floor(bitIndex / 8); - var bitInByte = bitIndex % 8; - if (byteIndex < 0 || byteIndex >= bytes.length) { - throw new Error('信号写入越界,bit=' + bitIndex); - } - if (bitValue) bytes[byteIndex] |= (1 << bitInByte); - else bytes[byteIndex] &= ~(1 << bitInByte); - } - - async function sendCanRequest(payload) { - var resp = await fetch(getBaseUrl() + '/can/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(5000) - }); - var text = await resp.text(); - var json = {}; - try { - json = text ? JSON.parse(text) : {}; - } catch (err) { - throw new Error('响应不是合法 JSON: ' + text); - } - if (!resp.ok) { - throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '请求失败')); - } - return json; - } - - - function updateResponseBox(box, textEl, result, mode) { - if (!box || !textEl) return; - box.style.display = 'block'; - box.className = 'can-response' + (result.status === 'ok' ? ' ok' : ' err'); - textEl.textContent = buildSendResponseText(result); - if (mode === 'manual') { - el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err'); - el.manualHint.innerHTML = result.status === 'ok' - ? '原始帧已发送到设备,若有响应则显示在下方。' - : '设备返回错误码 ' + sanitizeHtml(result.code) + '。'; - } - } - - function addHistoryEntry(entry) { - state.history.unshift(entry); - if (state.history.length > 20) state.history.length = 20; - renderHistory(); - } - - function renderHistory() { - if (!el.historyList) return; - if (!state.history.length) { - el.historyList.innerHTML = '
尚未发送任何 CAN 帧。
'; - return; - } - el.historyList.innerHTML = state.history.map(function (item) { - return '' + - '
' + - '
' + - '' + sanitizeHtml(item.mode) + '' + - '' + sanitizeHtml(item.time) + '' + - '
' + - '
' + - 'CH' + sanitizeHtml(item.channel) + ' · ' + - sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) + - '
' + - '
' + sanitizeHtml(item.dataHex || '--') + '
' + - '
' + sanitizeHtml(item.resultText) + '
' + - '
'; - }).join(''); - } - - async function sendCheckedMessages() { - if (!state.dbc) { showToast('请先导入 DBC', 'warning'); return; } - var keys = Object.keys(state.checkedKeys); - if (!keys.length) { showToast('请先勾选要发送的报文', 'warning'); return; } - if (state.batchInFlight) return; - state.batchInFlight = true; - updateBatchUi(); - var channel = parseInt(el.defaultChannel.value, 10) || 0; - var ok = 0, fail = 0; - /* BATCH 间隔:GD32 SPI 从机用 RBNE 中断逐字节接收,无 RX DMA, - * 连续高频 CS 事务易触发 RXORERR -> 帧字节丢失 -> CRC 失败 -> 0xEE。 - * 30ms 间隔给 GD32 足够时间完成 onCsRelease 清理 + EXTI 重新待命。 */ - var BATCH_INTERVAL_MS = 30; - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var msg = null; - for (var m = 0; m < state.dbc.messages.length; m++) { - if (state.dbc.messages[m].key === key) { msg = state.dbc.messages[m]; break; } - } - if (!msg) continue; - ensureMessageDefaults(msg); - var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); - var dataBytes; - try { - dataBytes = encodeMessageData(msg, getSignalValueMap(msg)); - } catch (err) { - fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: false, - channel: channel, - idHex: idHex, - dlc: msg.dlc, - dataHex: '', - resultText: '编码失败: ' + err.message - }); - continue; - } - var dataHex = bytesToHex(dataBytes); - try { - var result = await sendCanRequest({ - ch: channel, - id: msg.id, - dlc: msg.dlc, - data: dataHex - }); - if (result.status === 'ok') ok++; else fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: result.status === 'ok', - channel: channel, - idHex: idHex, - dlc: msg.dlc, - dataHex: dataHex, - resultText: msg.name + ' · ' + buildSendResponseText(result) - }); - } catch (err) { - fail++; - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'BATCH', - ok: false, - channel: channel, - idHex: idHex, - dlc: msg.dlc, - dataHex: dataHex, - resultText: msg.name + ' · ' + err.message - }); - } - /* 帧间间隔,避免 GD32 SPI RXORERR(最后一帧不需要等) */ - if (i < keys.length - 1) { - await new Promise(function (r) { setTimeout(r, BATCH_INTERVAL_MS); }); - } - } - state.batchInFlight = false; - updateBatchUi(); - var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning'); - showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone); - } - async function sendSelectedMessage() { - var msg = getSelectedMessage(); - if (!msg) { - showToast('请先选择一条报文', 'warning'); - return; - } - updateEncodedPreview(); - if (!state.generatedData.length && msg.dlc > 0) { - showToast('当前报文还没有可发送的数据', 'error'); - return; - } - var channel = parseInt(el.defaultChannel.value, 10) || 0; - var payload = { - ch: channel, - id: msg.id, - dlc: msg.dlc, - data: bytesToHex(state.generatedData) - }; - try { - var result = await sendCanRequest(payload); - updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc'); - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'DBC', - ok: result.status === 'ok', - channel: channel, - idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), - dlc: msg.dlc, - dataHex: bytesToHex(state.generatedData), - resultText: buildSendResponseText(result) - }); - showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); - } catch (err) { - if (el.dbcResp) { - el.dbcResp.style.display = 'block'; - el.dbcResp.className = 'can-response err'; - el.dbcRespText.textContent = err.message; - } - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'DBC', - ok: false, - channel: channel, - idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), - dlc: msg.dlc, - dataHex: bytesToHex(state.generatedData), - resultText: err.message - }); - showToast('发送失败: ' + err.message, 'error'); - } - } - - function validateManualData() { - var dataHex = (el.manualData.value || '').trim().replace(/\s+/g, ''); - var dlc = parseInt(el.manualDlc.value, 10); - if (!Number.isFinite(dlc) || dlc < 0 || dlc > 64) { - throw new Error('DLC 需在 0~64 之间'); - } - if (dataHex && !/^[0-9a-fA-F]+$/.test(dataHex)) { - throw new Error('HEX 数据包含非法字符'); - } - if (dataHex.length % 2 !== 0) { - throw new Error('HEX 数据长度必须为偶数'); - } - var byteLen = dataHex.length / 2; - if (byteLen !== dlc) { - throw new Error('当前 HEX 为 ' + byteLen + ' 字节,但 DLC=' + dlc); - } - return dataHex.toUpperCase(); - } - - function refreshManualHint() { - try { - var dataHex = validateManualData(); - el.manualHint.className = 'helper-note ok'; - el.manualHint.innerHTML = '原始 HEX 合法,当前长度 ' + (dataHex.length / 2) + ' 字节。'; - } catch (err) { - el.manualHint.className = 'helper-note err'; - el.manualHint.innerHTML = sanitizeHtml(err.message); - } - } - - async function sendManualFrame() { - var id = parseNumericId(el.manualId.value); - if (!Number.isFinite(id)) { - showToast('CAN ID 格式错误', 'error'); - return; - } - var channel = parseInt(el.manualChannel.value, 10) || 0; - var dlc = parseInt(el.manualDlc.value, 10) || 0; - var dataHex = ''; - try { - dataHex = validateManualData(); - } catch (err) { - showToast(err.message, 'error'); - refreshManualHint(); - return; - } - - try { - var result = await sendCanRequest({ - ch: channel, - id: id, - dlc: dlc, - data: dataHex - }); - updateResponseBox(el.manualResp, el.manualRespText, result, 'manual'); - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'RAW', - ok: result.status === 'ok', - channel: channel, - idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), - dlc: dlc, - dataHex: dataHex, - resultText: buildSendResponseText(result) - }); - showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); - } catch (err) { - if (el.manualResp) { - el.manualResp.style.display = 'block'; - el.manualResp.className = 'can-response err'; - el.manualRespText.textContent = err.message; - } - addHistoryEntry({ - time: formatTime(new Date()), - mode: 'RAW', - ok: false, - channel: channel, - idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), - dlc: dlc, - dataHex: dataHex, - resultText: err.message - }); - showToast('发送失败: ' + err.message, 'error'); - } - } - - function resetManualFields() { - el.manualChannel.value = el.defaultChannel.value; - el.manualId.value = '0x18DA00F1'; - el.manualDlc.value = '8'; - el.manualData.value = '0102030405060708'; - if (el.manualResp) el.manualResp.style.display = 'none'; - refreshManualHint(); - } - - function loadPreviewToManual() { - var msg = getSelectedMessage(); - if (!msg || !state.generatedData.length) { - showToast('当前没有可载入的预览帧', 'warning'); - return; - } - el.manualChannel.value = el.defaultChannel.value; - el.manualId.value = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); - el.manualDlc.value = String(msg.dlc); - el.manualData.value = bytesToHex(state.generatedData); - refreshManualHint(); - showToast('已载入到原始发送区', 'success'); - } - - async function readDbcText(file) { - var buf = await file.arrayBuffer(); - var bytes = new Uint8Array(buf); - var stripBom = function (s) { - return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s; - }; - try { - var strictUtf8 = new TextDecoder('utf-8', { fatal: true }); - return stripBom(strictUtf8.decode(bytes)); - } catch (e) { - try { - var gb = new TextDecoder('gb18030'); - return stripBom(gb.decode(bytes)); - } catch (e2) { - return stripBom(new TextDecoder('utf-8').decode(bytes)); - } - } - } - - async function handleDbcFile(file) { - if (!file) return; - var text = await readDbcText(file); - var dbc = parseDbc(text); - state.dbcFile = file; - state.dbcText = text; - state.dbc = dbc; - state.selectedMessageKey = ''; - state.signalValues = {}; - state.checkedKeys = {}; - state.expandedKeys = {}; - state.transmitterFilter = Object.create(null); - state.transmitterFilterEmpty = true; - updateDbcFileInfo(file, dbc); - buildSummary(); - filterMessages(); - if (dbc.messages.length) selectMessage(dbc.messages[0].key); - else renderSelectedMessage(); - if (el.dbcHint) { - el.dbcHint.className = 'helper-note ok'; - el.dbcHint.innerHTML = 'DBC 已解析:' + dbc.messages.length + ' 条报文,' + - dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0) + - ' 个信号。'; - } - showToast('DBC 导入成功', 'success'); - } - - function clearDbc() { - state.dbcFile = null; - state.dbcText = ''; - state.dbc = null; - state.filteredMessages = []; - state.selectedMessageKey = ''; - state.signalValues = {}; - state.checkedKeys = {}; - state.expandedKeys = {}; - state.transmitterFilter = Object.create(null); - state.transmitterFilterEmpty = true; - state.generatedData = new Uint8Array(0); - if (el.dbcFileInput) el.dbcFileInput.value = ''; - if (el.dbcSearch) el.dbcSearch.value = ''; - updateDbcFileInfo(null, null); - buildSummary(); - renderMessageList(); - renderSelectedMessage(); - if (el.generatedHex) el.generatedHex.textContent = '--'; - if (el.generatedBytes) el.generatedBytes.innerHTML = ''; - if (el.dbcHint) { - el.dbcHint.className = 'helper-note'; - el.dbcHint.innerHTML = '当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、CM_ 和 SIG_VALTYPE_。'; - } - } - - function bindEvents() { - el.testConnBtn.addEventListener('click', testConnection); - el.defaultChannel.addEventListener('change', function () { - el.manualChannel.value = el.defaultChannel.value; - el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; - renderMessageList(); - renderSelectedMessage(); - updateEncodedPreview(); - }); - el.dbcDropZone.addEventListener('click', function () { el.dbcFileInput.click(); }); - el.dbcDropZone.addEventListener('dragover', function (event) { - event.preventDefault(); - el.dbcDropZone.classList.add('dragover'); - }); - el.dbcDropZone.addEventListener('dragleave', function () { - el.dbcDropZone.classList.remove('dragover'); - }); - el.dbcDropZone.addEventListener('drop', function (event) { - event.preventDefault(); - el.dbcDropZone.classList.remove('dragover'); - var file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null; - if (file) handleDbcFile(file).catch(handleDbcError); - }); - el.dbcFileInput.addEventListener('change', function (event) { - var file = event.target && event.target.files ? event.target.files[0] : null; - if (file) handleDbcFile(file).catch(handleDbcError); - }); - el.clearDbcBtn.addEventListener('click', clearDbc); - el.dbcSearch.addEventListener('input', filterMessages); - el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); - el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); - el.sendManualBtn.addEventListener('click', sendManualFrame); - el.resetManualBtn.addEventListener('click', resetManualFields); - el.manualData.addEventListener('input', refreshManualHint); - el.manualDlc.addEventListener('input', refreshManualHint); - el.manualId.addEventListener('input', refreshManualHint); - el.toolIp.addEventListener('change', testConnection); - if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages); - if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () { - var visible = state.filteredMessages || []; - if (this.checked) { - visible.forEach(function (m) { state.checkedKeys[m.key] = true; }); - } else { - visible.forEach(function (m) { delete state.checkedKeys[m.key]; }); - } - renderMessageList(); - }); - if (el.expandAllBtn) el.expandAllBtn.addEventListener('click', function () { - var visible = state.filteredMessages || []; - visible.forEach(function (m) { state.expandedKeys[m.key] = true; ensureMessageDefaults(m); }); - renderMessageList(); - }); - if (el.collapseAllBtn) el.collapseAllBtn.addEventListener('click', function () { - var visible = state.filteredMessages || []; - visible.forEach(function (m) { delete state.expandedKeys[m.key]; }); - renderMessageList(); - }); - bindNodeFilterEvents(); - } - - function handleDbcError(err) { - if (el.dbcHint) { - el.dbcHint.className = 'helper-note err'; - el.dbcHint.innerHTML = 'DBC 解析失败:' + sanitizeHtml(err.message); - } - showToast('DBC 解析失败: ' + err.message, 'error'); - } - - - - function init() { - cacheElements(); - bindEvents(); - setConnectionState(false, '未连接', '', ''); - if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; - if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value; - buildSummary(); - renderMessageList(); - renderSelectedMessage(); - refreshManualHint(); - renderHistory(); - setTimeout(testConnection, 150); - } - - var CAN_SEND_ERR_DESC = { - '-1': '参数错误(通道/ID/DLC 无效)', - '-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)', - '-3': 'GD32 CAN 总线拒绝', - }; - - var GD32_ERR_DESC = { - 0xE0: 'payload 过短', - 0xE1: '通道号非法', - 0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)', - 0xE3: 'TEC 过高(错误计数 >96,对端 ACK 失败率高)', - 0xE4: '邮箱满(3 个硬件邮箱全 PENDING,对端不 ACK)', - 0xE5: '参数无效(通道未启用/DLC 越界)', - 0xEE: 'SPI 帧解析失败(GD32 收到的帧 CRC/SOF 校验失败,通常是 SPI 字节丢失或 CS 时序冲突)' - }; - - function buildSendResponseText(result) { - if (result.status === 'ok') { - return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; - } - var code = String(result.code); - var desc = CAN_SEND_ERR_DESC[code] || '未知错误'; - /* 用 GD32 细分错误码补充具体原因 */ - if (result.gd32_err != null && result.gd32_err !== 0) { - var gd32Desc = GD32_ERR_DESC[result.gd32_err]; - if (gd32Desc) { - desc = desc + ' · ' + gd32Desc; - } else { - desc = desc + ' · gd32_err=0x' + result.gd32_err.toString(16).toUpperCase(); - } - } - return '发送失败: ' + desc + ' (code=' + result.code + ')'; - } - - init(); -})(); diff --git a/tools/can_monitor.html b/tools/can_monitor.html index 6c9e857..5f9f290 100644 --- a/tools/can_monitor.html +++ b/tools/can_monitor.html @@ -4,7 +4,11 @@ ESP32 CAN 接收监控 - + + + + +
@@ -158,6 +162,6 @@ 操作成功
- + \ No newline at end of file diff --git a/tools/can_monitor.js b/tools/can_monitor.js deleted file mode 100644 index 9395fe4..0000000 --- a/tools/can_monitor.js +++ /dev/null @@ -1,244 +0,0 @@ -(function () { - 'use strict'; - - var state = { fbTimer: null }; - - var el = {}; - - var ids = [ - 'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt', - 'monConnIndicator', 'monConnIndicatorTxt', 'monHelper', - 'fbModePill', 'fbPollStatus', - 'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Tec', 'fb0Rec', 'fb0Bus', 'fb0Seq', 'fb0Delta', - 'fb0LastId', 'fb0Dlc', 'fb0Flags', 'fb0LastData', - 'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Tec', 'fb1Rec', 'fb1Bus', 'fb1Seq', 'fb1Delta', - 'fb1LastId', 'fb1Dlc', 'fb1Flags', 'fb1LastData', - 'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Tec', 'fb2Rec', 'fb2Bus', 'fb2Seq', 'fb2Delta', - 'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData', - 'btnModeAllPass', 'btnModeFilter', 'btnModeBridge', - 'idsnapChannel', 'idsnapStatus', 'idsnapBody', - 'toast', 'toastIco', 'toastMsg' - ]; - - function cacheElements() { - ids.forEach(function (id) { el[id] = document.getElementById(id); }); - } - - function showToast(msg, type) { - type = type || 'success'; - if (!el.toast) return; - el.toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show'; - el.toastIco.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0'; - el.toastMsg.textContent = msg; - clearTimeout(showToast._t); - showToast._t = setTimeout(function () { el.toast.classList.remove('show'); }, 3000); - } - - function sanitize(text) { - return String(text == null ? '' : text) - .replace(/&/g, '&').replace(//g, '>'); - } - - function getBaseUrl() { - var ip = (el.monIp && el.monIp.value ? el.monIp.value.trim() : '') || '192.168.31.127'; - return 'http://' + ip; - } - - function setConnState(ok, text) { - if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline'); - if (el.monConnTxt) el.monConnTxt.textContent = text; - if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err'); - if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text; - } - - // ── 1Hz 轮询 /can/feedback ───────────────────────────────── - async function pollFeedback() { - // 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次 - if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} } - state.fbAbortCtrl = new AbortController(); - var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000); - try { - var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal }); - if (!resp.ok) return; - var data = await resp.json(); - var channels = data.channels || []; - var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value) : 0; - - for (var i = 0; i < 3; i++) { - var ch = channels[i]; - if (!ch || !ch.valid) { - renderChannelEmpty(i); - continue; - } - renderChannel(i, ch); - } - - // 渲染选中通道的 ID 快照表 - var sel = channels[selectedCh]; - if (sel && sel.valid && sel.entries) { - renderIdTable(sel.entries, sel.count || sel.entries.length); - if (el.idsnapStatus) { - el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID'; - el.idsnapStatus.className = 'pill online'; - } - } else { - renderIdTableEmpty(); - } - - if (el.fbPollStatus) { - el.fbPollStatus.textContent = '轮询: 正常'; - el.fbPollStatus.className = 'pill online'; - } - setConnState(true, '已连接'); - clearTimeout(fbPollTimer); - } catch (e) { - clearTimeout(fbPollTimer); - // 自身 AbortController 触发的取消不算失败,静默返回 - if (e && e.name === "AbortError") return; - if (el.fbPollStatus) { - el.fbPollStatus.textContent = '轮询: 离线'; - el.fbPollStatus.className = 'pill offline'; - } - setConnState(false, '连接失败'); - } - } - - function renderChannel(i, ch) { - var p = 'fb' + i; - if (el[p + 'State']) { - var hasIds = ch.count > 0; - el[p + 'State'].textContent = hasIds ? '活跃 (' + ch.count + ' ID)' : '无帧(总线空闲)'; - el[p + 'State'].className = 'fb-val ' + (hasIds ? 'ok' : 'info'); - } - if (el[p + 'Rx']) el[p + 'Rx'].textContent = ch.rx; - if (el[p + 'Tx']) el[p + 'Tx'].textContent = ch.tx; - if (el[p + 'Drop']) { el[p + 'Drop'].textContent = ch.drop; el[p + 'Drop'].className = 'fb-val' + (ch.drop > 0 ? ' warn' : ''); } - if (el[p + 'Err']) { el[p + 'Err'].textContent = ch.err; el[p + 'Err'].className = 'fb-val' + (ch.err > 0 ? ' err' : ''); } - if (el[p + 'Tec']) { el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--'; el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : ''); } - if (el[p + 'Rec']) { el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--'; el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : ''); } - if (el[p + 'Bus']) { - if (ch.bus_state === undefined || ch.bus_state === null) { - el[p + 'Bus'].textContent = '未知'; - el[p + 'Bus'].className = 'fb-val'; - } else { - var bs = ch.bus_state; - var parts = []; - if (bs & 1) parts.push('INIT'); - if (bs & 2) parts.push('SLEEP'); - if (bs & 4) parts.push('ERR_PASSIVE'); - if (bs & 8) parts.push('BUS_OFF'); - if (bs & 16) parts.push('ERR_WARN'); - el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常'); - el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4|16)) ? ' warn' : ''); - } - } - if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq; - if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; } - // 最新帧摘要 - var entries = ch.entries; - if (entries && entries.length) { - var last = entries[entries.length - 1]; - if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; } - if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc; - if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-'); - if (el[p + 'LastData']) { el[p + 'LastData'].textContent = last.data || '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } - } else { - if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } - if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--'; - if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--'; - if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } - } - if (i === 0 && el.fbModePill) { - var modeText = ch.mode === 2 ? '桥接(CAN0→1/2)' : (ch.mode === 1 ? '过滤' : '全收'); - el.fbModePill.textContent = '模式: ' + modeText; - } - } - - function renderChannelEmpty(i) { - var p = 'fb' + i; - if (el[p + 'State']) { el[p + 'State'].textContent = 'SPI 离线'; el[p + 'State'].className = 'fb-val err'; } - ['Rx','Tx','Drop','Err','Tec','Rec','Bus','Seq','Delta','Dlc','Flags'].forEach(function (f) { - if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; } - }); - if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } - if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } - } - function renderIdTable(entries, count) { - var tbody = el.idsnapBody; - if (!tbody) return; - if (!entries || !entries.length) { - tbody.innerHTML = '无数据(CAN 未收到帧)'; - return; - } - var html = ''; - for (var i = 0; i < entries.length; i++) { - var e = entries[i]; - html += ''; - html += '' + (i + 1) + ''; - html += '' + sanitize(e.id) + ''; - html += '' + e.dlc + ''; - html += '' + (e.fd ? 'Y' : '-') + ''; - html += '' + (e.brs ? 'Y' : '-') + ''; - html += '' + (e.esi ? 'Y' : '-') + ''; - html += '' + sanitize(e.data || '--') + ''; - html += ''; - } - tbody.innerHTML = html; - } - - function renderIdTableEmpty() { - var tbody = el.idsnapBody; - if (tbody) tbody.innerHTML = '等待数据...'; - if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; } - } - - // ── 连接测试 ───────────────────────────────────────────── - async function testConnection() { - try { - var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) }); - if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; } - setConnState(true, '已连接'); - showToast('ESP32 已连接', 'success'); - } catch (e) { - setConnState(false, '连接失败'); - showToast('连接失败: ' + e.message, 'error'); - } - } - - // ── 模式切换 ───────────────────────────────────────────── - async function setCanMode(mode) { - try { - var resp = await fetch(getBaseUrl() + '/can/mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: mode }), - signal: AbortSignal.timeout(3000) - }); - var data = await resp.json(); - if (data.ok) { - showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success'); - } else { - showToast('模式切换失败', 'error'); - } - } catch (e) { - showToast('模式切换失败: ' + e.message, 'error'); - } - } - - // ── 初始化 ─────────────────────────────────────────────── - function init() { - cacheElements(); - setConnState(false, '未连接'); - if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection); - if (el.monIp) el.monIp.addEventListener('change', testConnection); - if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); }); - if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); }); - if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); }); - if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback); - setTimeout(testConnection, 150); - pollFeedback(); - state.fbTimer = setInterval(pollFeedback, 1000); - } - - init(); -})(); diff --git a/tools/css/base.css b/tools/css/base.css new file mode 100644 index 0000000..c463b99 --- /dev/null +++ b/tools/css/base.css @@ -0,0 +1,54 @@ +/* === base.css — 基础变量与容器 === */ +/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */ + +:root { + --bg-root: #090e15; + --bg-panel: #0f161f; + --bg-card: #141d28; + --bg-elevated: #1a2432; + --bg-input: #0d131b; + --border: #1d2a3a; + --border-focus: #22d3ee; + --text: #e2e8f0; + --text-dim: #7b8fa8; + --text-label: #5a6d82; + --accent: #22d3ee; + --accent-dim: rgba(34,211,238,.15); + --green: #22c55e; + --amber: #f59e0b; + --red: #ef4444; + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + --font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif; + --font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace; + --gap: 16px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: var(--bg-root); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* subtle noise-grid background */ +body::before { + content: ""; + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background-image: + linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 70%); +} + +.container { + position: relative; z-index: 1; + max-width: 1480px; margin: 0 auto; padding: 24px 32px 40px; +} diff --git a/tools/css/can.css b/tools/css/can.css new file mode 100644 index 0000000..3310063 --- /dev/null +++ b/tools/css/can.css @@ -0,0 +1,1029 @@ +/* === can.css — CAN 面板/工作台/1Hz反馈/ID快照/报文行展开/节点过滤 等 CAN 专用样式 === */ +/* === CAN PANEL === */ +.can-grid { + display: grid; grid-template-columns: 70px 1fr 60px 1fr; gap: 10px; +} +.can-launch-card { + background: + radial-gradient(circle at top right, rgba(34,211,238,.18), transparent 28%), + linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%); + border: 1px solid rgba(34,211,238,.14); + border-radius: 14px; + padding: 20px; + box-shadow: + inset 0 0 0 1px rgba(255,255,255,.02), + 0 18px 40px rgba(0,0,0,.18); + position: relative; + overflow: hidden; +} +.can-launch-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18)); +} +.can-launch-copy { + position: relative; + z-index: 1; +} +.can-launch-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.can-launch-copy h3 { + font-size: 22px; + line-height: 1.2; + margin: 6px 0 0; +} +.can-launch-desc { + color: var(--text-dim); + font-size: 13px; + line-height: 1.7; + max-width: 700px; +} +.can-launch-kicker { + color: #88ecff; + font-size: 10px; + letter-spacing: .18em; + text-transform: uppercase; + opacity: .92; +} +.can-launch-badge { + display: inline-flex; + align-items: center; + padding: 7px 12px; + border-radius: 999px; + background: rgba(34,211,238,.08); + border: 1px solid rgba(34,211,238,.18); + color: #a9f4ff; + font-size: 11px; + font-weight: 700; + letter-spacing: .12em; + text-transform: uppercase; + white-space: nowrap; +} +.can-launch-points { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.can-launch-point { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 11px; + border-radius: 999px; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.06); + color: #c6d7e8; + font-size: 12px; +} +.can-launch-point::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 10px rgba(34,211,238,.35); +} +.can-launch-actions { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(180px, .85fr); + gap: 10px; + margin-top: 18px; + position: relative; + z-index: 1; +} +.can-launch-primary, +.can-launch-secondary { + min-height: 42px; +} +.can-launch-primary { + box-shadow: 0 10px 24px rgba(34,211,238,.18); +} +.can-launch-secondary { + color: #d5e4f4; + border-color: rgba(255,255,255,.08); + background: rgba(255,255,255,.015); +} +.can-launch-secondary:hover:not(:disabled) { + background: rgba(255,255,255,.05); + border-color: rgba(34,211,238,.18); +} +.can-launch-foot { + display: flex; + gap: 10px; + align-items: flex-start; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(255,255,255,.06); + color: #91a6bc; + font-size: 12px; + line-height: 1.7; + position: relative; + z-index: 1; +} +.can-launch-foot-label { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255,255,255,.04); + color: #d8e6f4; + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; +} +.spi-grid { + display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px; +} + +/* === CAN WORKSPACE PAGE === */ +.can-page::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 10% 18%, rgba(34,211,238,.08), transparent 26%), + radial-gradient(circle at 85% 14%, rgba(245,158,11,.05), transparent 24%); +} +.can-container { + max-width: 1700px; +} +.can-header { + position: sticky; + top: 16px; + z-index: 5; + backdrop-filter: blur(14px); + background: rgba(15,22,31,.86); +} +.can-header-actions { + margin-left: auto; +} +.can-link-pill { + text-decoration: none; + color: var(--text); +} +.can-toolbar-card, +.can-table-card, +.can-editor-layout { + margin-top: 18px; +} +.can-toolbar-grid { + display: grid; + grid-template-columns: minmax(260px, 1.1fr) 180px minmax(220px, .9fr) minmax(260px, 1fr); + gap: 14px; + align-items: stretch; +} +.can-toolbar-block { + min-width: 0; +} +.can-toolbar-upload .upload-zone { + height: 100%; + min-height: 88px; + padding: 18px 14px; +} +.can-file-info-inline { + margin-top: 14px; +} +.can-file-name { + font-weight: 700; +} +.can-toolbar-foot { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + margin-top: 14px; + align-items: start; +} +.can-table-card-body { + padding: 0; +} +.can-editor-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 18px; + align-items: start; +} +.can-sidepanel { + display: flex; + flex-direction: column; + gap: 18px; +} +.dbc-dropzone { + position: relative; + overflow: hidden; +} +.dbc-dropzone::after { + content: ""; + position: absolute; + inset: -40% auto auto -10%; + width: 180px; + height: 180px; + background: radial-gradient(circle, rgba(34,211,238,.18), transparent 68%); + pointer-events: none; +} +.message-list { + max-height: 390px; + overflow: auto; +} +.message-table { + /* 列宽随左栏宽度自适应压缩,不再强制水平滚动 */ + min-width: 0; + width: 100%; +} +.message-table-head, +.message-row { + display: grid; + grid-template-columns: + 36px /* 勾选 */ + 32px /* 展开 */ + 40px /* # */ + minmax(0, 1.6fr) /* 报文名称 */ + 96px /* 标识符 */ + 52px /* 通道 */ + 52px /* 类型 */ + 46px /* DLC */ + 50px /* 信号 */ + minmax(0, 1fr) /* 发送节点 */ + minmax(0, 2fr); /* 注释 */ + align-items: center; + min-width: 0; +} +.message-table-head { + position: sticky; + top: 0; + z-index: 2; + background: #182330; + border-bottom: 1px solid var(--border); +} +.message-row { + width: 100%; + border: 0; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,.04); + transition: background .12s ease, border-color .12s ease; +} +.message-row:hover { + background: rgba(255,255,255,.03); +} +.message-row.active { + background: linear-gradient(90deg, rgba(34,211,238,.2), rgba(34,211,238,.07)); + box-shadow: inset 3px 0 0 0 var(--accent); +} +.message-col { + padding: 11px 12px; + font-size: 12px; + line-height: 1.45; + border-right: 1px solid rgba(255,255,255,.03); +} +.message-table-head .message-col { + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + color: #c8d6e7; + text-transform: uppercase; +} +.message-col:last-child { + border-right: 0; +} +.message-col-index, +.message-col-dlc, +.message-col-signal, +.message-col-channel, +.message-col-type { + text-align: center; +} +.message-col-id, +.history-item-hex, +.can-frame-hex, +.can-title, +.can-subtitle, +.byte-pill, +.history-item-main, +.history-item-foot, +.signal-table, +.signal-cell-meta, +.signal-cell-range { + font-family: var(--font-mono); +} +.message-col-id { + color: var(--accent); + font-size: 12px; +} +.can-empty-state { + display: grid; + place-items: center; + min-height: 300px; + text-align: center; + border: 1px dashed rgba(34,211,238,.2); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(26,36,50,.7), rgba(20,29,40,.96)); + padding: 28px; +} +.can-empty-icon { + width: 70px; + height: 70px; + border-radius: 18px; + display: grid; + place-items: center; + margin-bottom: 14px; + background: rgba(34,211,238,.12); + font-size: 32px; + box-shadow: inset 0 0 0 1px rgba(34,211,238,.16); +} +.can-empty-state h3 { + font-size: 22px; + margin-bottom: 8px; +} +.can-empty-state p { + max-width: 560px; + color: var(--text-dim); + line-height: 1.75; +} +.can-title { + font-size: 24px; + font-weight: 700; + letter-spacing: .02em; +} +.can-subtitle { + margin-top: 6px; + color: var(--text-dim); + font-size: 13px; +} +.can-selection-bar, +.history-item-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.can-selection-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.can-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; +} +.can-meta-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; +} +.can-meta-card span { + display: block; + font-size: 10px; + color: var(--text-label); + letter-spacing: .08em; + text-transform: uppercase; + margin-bottom: 4px; +} +.can-meta-card b { + font-size: 14px; +} +.can-comment-note { + margin-top: 16px; +} +.can-frame-panel { + background: linear-gradient(180deg, rgba(26,36,50,.95), rgba(13,19,27,.98)); + border: 1px solid rgba(34,211,238,.12); + border-radius: 16px; + padding: 18px; + box-shadow: inset 0 0 0 1px rgba(255,255,255,.02); +} +.can-frame-panel-compact { + padding: 16px; +} +.can-panel-kicker { + color: var(--text-label); + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; +} +.can-panel-title { + margin-top: 6px; + font-size: 16px; + font-weight: 700; +} +.can-frame-hex { + margin-top: 14px; + padding: 16px 18px; + border-radius: 12px; + background: #081019; + color: #7ef3ff; + font-size: 18px; + line-height: 1.6; + word-break: break-all; + border: 1px solid rgba(34,211,238,.16); +} +.can-byte-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.byte-pill { + min-width: 62px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(34,211,238,.08); + border: 1px solid rgba(34,211,238,.16); + text-align: center; + font-size: 12px; +} +.byte-pill b { + display: block; + color: var(--text-label); + font-size: 10px; + margin-bottom: 3px; +} +.signal-table-host { + display: block; + margin-top: 18px; +} +.signal-table { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.signal-table-head, +.signal-row { + display: grid; + grid-template-columns: 52px minmax(220px, 1.2fr) minmax(220px, 1fr) minmax(220px, 1fr) minmax(190px, .9fr) minmax(220px, 1fr); + align-items: stretch; +} +.signal-table-head { + background: #182330; + border-bottom: 1px solid var(--border); +} +.signal-table-body { + max-height: 520px; + overflow: auto; +} +.signal-row { + border-bottom: 1px solid rgba(255,255,255,.04); + background: rgba(255,255,255,.01); +} +.signal-row:nth-child(even) { + background: rgba(255,255,255,.025); +} +.signal-cell { + padding: 11px 12px; + border-right: 1px solid rgba(255,255,255,.03); + font-size: 12px; + line-height: 1.5; + min-width: 0; +} +.signal-cell:last-child { + border-right: 0; +} +.signal-table-head .signal-cell { + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + color: #c8d6e7; + text-transform: uppercase; +} +.signal-cell-index { + text-align: center; +} +.signal-cell-name b, +.signal-cell-range b, +.signal-cell-meta b { + display: block; + font-size: 12px; + color: var(--text); +} +.signal-cell-name small, +.signal-cell-range small, +.signal-cell-meta small, +.signal-cell-comment { + display: block; + margin-top: 4px; + color: var(--text-dim); + font-size: 11px; + line-height: 1.45; +} +.signal-input-compact { + min-width: 0; + padding: 8px 10px; + font-size: 12px; +} +.can-response { + margin-top: 14px; + padding: 12px 14px; + border-radius: 12px; + background: var(--bg-elevated); + border: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 12px; + word-break: break-all; +} +.can-response.ok { + border-color: rgba(34,197,94,.25); + color: #c7f9d5; +} +.can-response.err { + border-color: rgba(239,68,68,.25); + color: #fecaca; +} +.can-response-label { + color: var(--text-label); + font-size: 11px; + margin-bottom: 6px; + font-family: var(--font); +} +.history-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 620px; + overflow: auto; + padding-right: 4px; +} +.history-list-compact { + max-height: 360px; +} +.history-item { + padding: 12px 13px; + border-radius: 12px; + background: var(--bg-elevated); + border: 1px solid var(--border); +} +.history-item-time { + color: var(--text-label); + font-size: 11px; +} +.history-item-tag { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: .1em; +} +.history-item-tag.ok { + background: rgba(34,197,94,.12); + color: #b8f5cb; +} +.history-item-tag.err { + background: rgba(239,68,68,.12); + color: #fecaca; +} +.history-item-main { + margin-top: 10px; + font-size: 12px; + color: var(--text); +} +.history-item-hex { + margin-top: 8px; + font-size: 12px; + color: #7ef3ff; + word-break: break-all; +} +.history-item-foot { + margin-top: 8px; + font-size: 11px; + color: var(--text-dim); +} + +/* === CAN 1Hz FEEDBACK === */ +.can-feedback-card { margin-top: 18px; } +.can-feedback-card .card-head { + display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; +} +.feedback-controls { display: flex; align-items: center; gap: 8px; } +.feedback-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; +} +.feedback-channel { + background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); + border-radius: 10px; padding: 14px 16px; +} +.feedback-ch-head { + font-weight: 700; font-size: 15px; margin-bottom: 10px; + display: flex; align-items: center; gap: 8px; +} +.ch-dot { + width: 10px; height: 10px; border-radius: 50%; display: inline-block; +} +.ch-dot.ch0 { background: #22d3ee; } +.ch-dot.ch1 { background: #a78bfa; } +.ch-dot.ch2 { background: #f59e0b; } +.feedback-row { + display: flex; justify-content: space-between; padding: 3px 0; + font-size: 13px; border-bottom: 1px solid rgba(255,255,255,.04); +} +.feedback-row:last-child { border-bottom: none; } +.fb-label { color: var(--muted, #8892a4); } +.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; } +.fb-val.mono { font-family: monospace; } +.fb-val.ok { color: #34d399; } +.fb-val.warn { color: #f59e0b; } +.fb-val.err { color: #f87171; } +.fb-val.zero { color: #64748b; } +@media (max-width: 900px) { + .feedback-grid { grid-template-columns: 1fr; } +} + +/* === ID SNAPSHOT TABLE === */ +.idsnap-table-wrap { overflow-x: auto; } +.idsnap-table { + width: 100%; border-collapse: collapse; font-size: 13px; + font-variant-numeric: tabular-nums; +} +.idsnap-table th { + text-align: left; padding: 6px 10px; font-weight: 600; + border-bottom: 2px solid rgba(255,255,255,.12); color: var(--muted, #8892a4); + white-space: nowrap; +} +.idsnap-table td { + padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,.05); + white-space: nowrap; +} +.idsnap-table td.mono { font-family: monospace; } +.idsnap-table tr:hover td { background: rgba(255,255,255,.03); } +.idsnap-table .flag-on { color: #34d399; font-weight: 600; } +.idsnap-table .flag-off { color: #475569; } +.idsnap-data { font-family: monospace; max-width: 320px; overflow: hidden; text-overflow: ellipsis; } + +.fb-data-cell { + max-width: 200px; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; word-break: break-all; line-height: 1.4; +} + +/* === CAN message row expand / batch additions === */ +.message-col-check, +.message-col-toggle { + text-align: center; + padding: 6px 4px; +} +.message-check { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--accent, #22d3ee); +} +.message-toggle { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,.12); + background: rgba(255,255,255,.04); + color: #c8d6e7; + cursor: pointer; + font-size: 11px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background .12s ease, border-color .12s ease, color .12s ease; +} +.message-toggle:hover { + background: rgba(34,211,238,.12); + border-color: rgba(34,211,238,.4); + color: #e6fbff; +} +.message-toggle.expanded { + background: rgba(34,211,238,.18); + border-color: rgba(34,211,238,.55); + color: #e6fbff; +} + +.message-row-expand { + grid-column: 1 / -1; + background: rgba(255,255,255,.02); + border-top: 1px dashed rgba(255,255,255,.06); + border-bottom: 1px solid rgba(255,255,255,.04); + padding: 10px 14px 14px; +} +.message-row-expand[hidden] { + display: none; +} +.message-row-expand .signal-table-host { + margin-top: 0; +} +.message-row-expand .signal-table { + border-radius: 10px; +} +.message-row-expand .signal-table-body { + max-height: 360px; +} + +.can-batch-actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-wrap: wrap; +} +.checkbox-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-dim, #93a4bf); + cursor: pointer; + user-select: none; +} +.checkbox-inline input { + accent-color: var(--accent, #22d3ee); + cursor: pointer; +} +.card-head-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} +.btn.btn-sm { + padding: 4px 10px; + font-size: 12px; +} +.btn[disabled] { + opacity: .55; + cursor: not-allowed; +} + +/* === 2026-06-30c: 报文列表与右侧操作栏并列布局调整 === */ +/* 报文列表移入 can-editor-layout 后,作为左列;侧栏作为右列;不再需要顶部独立信号编辑卡。 */ +.can-editor-layout > .can-table-card { + margin-top: 0; +} +.can-sidepanel .card-body { padding-top: 14px; } +.can-frame-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} +.can-frame-actions .btn { flex: 1 1 auto; min-width: 120px; } +.can-frame-panel-compact .can-frame-hex { word-break: break-all; } + + +/* === 2026-06-30c: 报文列表压缩列宽 + 纵向滚动美化 === */ +.message-list { + /* 提高可视高度,并改用美化的纵向滚动条 */ + max-height: calc(100vh - 300px); + min-height: 420px; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(34,211,238,.35) transparent; +} +.message-list::-webkit-scrollbar { width: 8px; } +.message-list::-webkit-scrollbar-track { + background: transparent; + margin: 4px 0; +} +.message-list::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18)); + border-radius: 6px; + border: 2px solid transparent; + background-clip: padding-box; +} +.message-list::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28)); + background-clip: padding-box; + border: 2px solid transparent; +} + +/* 单元格统一省略号 */ +.message-row .message-col, +.message-table-head .message-col { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* 紧凑列的内边距收一点,腾出空间给名称/注释 */ +.message-col-index, +.message-col-channel, +.message-col-type, +.message-col-dlc, +.message-col-signal { + padding-left: 6px; + padding-right: 6px; +} +.message-col-check, +.message-col-toggle { + padding-left: 4px; + padding-right: 4px; +} +/* 注释列允许浅色降权显示 */ +.message-row .message-col-comment { + color: var(--muted); +} + +/* === 2026-06-30c: 窄屏报文表降级(按位置缩为 0 宽,保持 grid 对齐) === */ +/* 列序:check(1) toggle(2) #(3) name(4) id(5) channel(6) type(7) dlc(8) signal(9) node(10) comment(11) */ +@media (max-width: 1280px) { + .message-table-head, + .message-row { + grid-template-columns: + 36px 32px 40px + minmax(0, 1.6fr) /* name */ + 90px /* id */ + 52px /* channel */ + 0 /* type 隐藏 */ + 46px /* dlc */ + 0 /* signal 隐藏 */ + minmax(0, 0.9fr) /* node */ + minmax(0, 1.8fr); /* comment */ + } + .message-col-type, + .message-col-signal { padding: 0; border: 0; overflow: hidden; } +} +@media (max-width: 1024px) { + .message-table-head, + .message-row { + grid-template-columns: + 36px 32px 36px + minmax(0, 1.6fr) + 86px + 0 + 0 + 46px + 0 + 0 + minmax(0, 1.8fr); + } + .message-col-channel, + .message-col-node { padding: 0; border: 0; overflow: hidden; } +} + + +/* 行内信号编辑展开区允许局部横向滚动(信号字段较多时),不影响外层报文表 */ +.message-row-expand { + overflow-x: auto; +} +.message-row-expand::-webkit-scrollbar { height: 6px; } +.message-row-expand::-webkit-scrollbar-thumb { + background: rgba(34,211,238,.35); + border-radius: 4px; +} + + +/* === 2026-06-30e: 工作台设置 + 最近发送 双列布局 === */ +.can-top-layout { + margin-top: 18px; + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 18px; + align-items: stretch; +} +/* 工作台卡和历史卡都放进 grid 后不需要单独的顶部外边距 */ +.can-top-layout > .can-toolbar-card, +.can-top-layout > .can-history-card { + margin-top: 0; +} +/* 历史卡固定高度,仅容纳约 3 条历史,超出滚动 */ +.can-history-card { align-self: start; } +.can-history-card .card-body { + display: flex; + flex-direction: column; +} +.can-history-card .history-list { + /* 约 3 条历史的高度:(每条 ~96px) * 3 + gap 10px * 2 ≈ 308px */ + max-height: 312px; + overflow-y: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(34,211,238,.35) transparent; +} +.can-history-card .history-list::-webkit-scrollbar { width: 8px; } +.can-history-card .history-list::-webkit-scrollbar-track { background: transparent; margin: 4px 0; } +.can-history-card .history-list::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18)); + border-radius: 6px; + border: 2px solid transparent; + background-clip: padding-box; +} +.can-history-card .history-list::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28)); + background-clip: padding-box; +} + +/* 窄屏堆叠 */ +@media (max-width: 1100px) { + .can-top-layout { grid-template-columns: 1fr; } + .can-history-card .history-list { max-height: 312px; } +} + + +/* === 2026-07-01a: 发送节点过滤下拉 === */ +.can-node-filter { + position: relative; + display: inline-flex; +} +.can-node-filter-btn { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.can-node-filter-btn.active { + border-color: rgba(34,211,238,.65); + color: var(--accent); + background: rgba(34,211,238,.08); +} +.can-node-filter-caret { + font-size: 10px; + opacity: .75; +} +.can-node-filter-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + min-width: 260px; + max-width: 320px; + background: #182330; + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 18px 38px rgba(0,0,0,.45); + padding: 10px; +} +.can-node-filter-panel[hidden] { display: none; } +.can-node-filter-head { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} +.can-node-filter-search { + width: 100%; + padding: 6px 8px; + font-size: 12px; +} +.can-node-filter-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} +.can-node-filter-list { + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + scrollbar-width: thin; + scrollbar-color: rgba(34,211,238,.35) transparent; +} +.can-node-filter-list::-webkit-scrollbar { width: 8px; } +.can-node-filter-list::-webkit-scrollbar-thumb { + background: rgba(34,211,238,.35); + border-radius: 6px; +} +.can-node-filter-item { + display: grid; + grid-template-columns: 18px 1fr auto; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + color: var(--text); + transition: background .12s; +} +.can-node-filter-item:hover { + background: rgba(255,255,255,.04); +} +.can-node-filter-item input[type="checkbox"] { + margin: 0; +} +.can-node-filter-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); +} +.can-node-filter-count { + font-size: 11px; + color: var(--muted); + background: rgba(255,255,255,.05); + border-radius: 999px; + padding: 1px 8px; + min-width: 26px; + text-align: center; +} diff --git a/tools/css/components.css b/tools/css/components.css new file mode 100644 index 0000000..e2775f3 --- /dev/null +++ b/tools/css/components.css @@ -0,0 +1,658 @@ +/* === components.css — 上传/文件信息/进度/指标/堆概况/状态徽章/OTA流程/日志/Toast/Tooltip 等组件 === */ +/* === UPLOAD ZONE === */ +.upload-zone { + border: 2px dashed var(--border); border-radius: var(--radius); + padding: 28px 16px; text-align: center; cursor: pointer; + transition: border-color .2s, background .2s; +} +.upload-zone:hover, +.upload-zone.dragover { border-color: var(--accent); background: var(--accent-dim); } +.upload-zone .upload-ico { font-size: 32px; margin-bottom: 10px; } +.upload-zone h4 { font-size: 14px; margin-bottom: 4px; } +.upload-zone p { font-size: 12px; color: var(--text-dim); } +.upload-zone-sm { padding: 16px 12px; } +.upload-zone-sm .upload-ico { font-size: 24px; margin-bottom: 6px; } +.upload-zone-sm h4 { font-size: 13px; } + +/* === FILE INFO === */ +.file-info { + display: none; margin-top: 12px; + background: var(--bg-elevated); border-radius: var(--radius-sm); padding: 12px 14px; +} +.file-info.show { display: block; } +.file-info-row { display: flex; justify-content: space-between; align-items: center; } +.file-info-row + .file-info-row { margin-top: 10px; } +.file-info-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; +} +.file-meta-item { + background: var(--bg-card); padding: 8px 10px; border-radius: 6px; + font-size: 12px; +} +.file-meta-item span { color: var(--text-label); display: block; font-size: 10px; margin-bottom: 2px; } +.file-meta-item b { font-weight: 600; } + +/* === PROGRESS === */ +.progress-phase-row { + display: flex; align-items: center; gap: 0; margin-bottom: 14px; + flex-wrap: nowrap; + background: var(--bg-input); + border-radius: 20px; + padding: 4px; + border: 1px solid var(--border); +} +.pp-step { + flex: 1; + display: flex; align-items: center; justify-content: center; gap: 5px; + padding: 7px 10px; border-radius: 16px; + font-size: 12px; font-weight: 600; color: var(--text-dim); + background: transparent; + transition: all .25s ease; white-space: nowrap; + cursor: default; +} +.pp-ico { font-size: 13px; opacity: .45; transition: all .25s ease; } +.pp-txt { transition: color .25s ease; } +.pp-sep { display: none; } + +/* Active step — highlighted capsule */ +.pp-step.active { + color: #07121c; + background: var(--accent); + box-shadow: 0 0 10px var(--accent-dim); +} +.pp-step.active .pp-ico { opacity: 1; } +.pp-step.active .pp-txt { color: #07121c; } + +/* Done step — green outlined capsule */ +.pp-step.done { + color: var(--green); + background: rgba(34,197,94,.12); +} +.pp-step.done .pp-ico { opacity: 1; } +.pp-step.done .pp-txt::after { + content: ' ✓'; font-size: 10px; +} + +.progress-wrap { margin-top: 0; display: none; } +.progress-wrap.show { display: block; } +.progress-track { + width: 100%; height: 10px; border-radius: 20px; + background: var(--bg-input); overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0,0,0,.3); +} +.progress-fill { + height: 100%; border-radius: 20px; + background: linear-gradient(90deg, #22d3ee, #06b6d4); + width: 0%; transition: width .3s ease; + position: relative; + box-shadow: 0 0 8px rgba(34,211,238,.3); +} +.progress-fill::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.3) 50%, transparent 100%); + animation: sweep 1.6s linear infinite; +} +.progress-fill.done { + background: linear-gradient(90deg, #22c55e, #16a34a); + box-shadow: 0 0 8px rgba(34,197,94,.3); +} +@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } +.progress-text { + display: flex; justify-content: space-between; margin-top: 7px; + font-size: 12px; color: var(--text-dim); +} + +/* === BUTTON ROW === */ +.btn-row { display: flex; gap: 10px; margin-top: 16px; } +.btn-row .btn { flex: 1; } + +/* === METRIC GRID === */ +.metric-grid { + display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; +} +.metric { + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 14px; +} +.metric .k { + font-size: 10px; color: var(--text-label); text-transform: uppercase; + letter-spacing: .1em; margin-bottom: 4px; +} +.metric .v { + font-size: 20px; font-weight: 700; word-break: break-word; +} +.metric .v.highlight { color: var(--accent); } + +.status-kv-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} +.status-kv { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; +} +.status-kv .k { + display: block; + font-size: 10px; + color: var(--text-label); + text-transform: uppercase; + letter-spacing: .08em; + margin-bottom: 4px; +} +.status-kv b { + display: block; + color: var(--text); + font-size: 13px; + font-weight: 700; + word-break: break-word; + font-family: var(--font-mono); +} +.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; } + +/* ===== 堆概况 ===== */ +.heap-group { + background: rgba(255,255,255,0.02); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 10px; +} +.heap-group:last-child { margin-bottom: 0; } +.cpu-group { margin-bottom: 8px; } +.cpu-task-list { + margin-bottom: 12px; +} +.cpu-task-list .status-task { + background: rgba(255,255,255,0.025); +} +.cpu-fill { + background: linear-gradient(90deg, #38bdf8, #22c55e); +} + +.heap-group-title { + font-size: 11px; + font-weight: 600; + color: var(--text-label); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 10px; +} + +.heap-bar-row { + display: flex; + align-items: center; + gap: 10px; +} + +.heap-bar-text { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + min-width: 165px; + color: var(--text); +} + +.heap-bar-track { + flex: 1; + height: 14px; + background: rgba(255,255,255,0.06); + border-radius: 7px; + overflow: hidden; + position: relative; +} + +.heap-bar-fill { + display: block; + height: 100%; + border-radius: 7px; + background: linear-gradient(90deg, var(--accent), #5eead4); + transition: width .4s ease; +.heap-bar-fill.warn { + background: linear-gradient(90deg, #f59e0b, #d97706); +} +.heap-bar-fill.danger { + background: linear-gradient(90deg, #ef4444, #dc2626); +} +} + +.heap-bar-pct { + font-size: 12px; + font-weight: 600; + min-width: 42px; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-dim); +} + +.heap-detail-row { + display: flex; + gap: 16px; + font-size: 11px; + color: var(--text-dim); + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid rgba(255,255,255,0.04); + flex-wrap: wrap; +} +.heap-detail-row span { + display: inline-flex; + align-items: center; + gap: 4px; +} +.heap-detail-row b { + color: var(--text); + font-weight: 500; + font-family: var(--font-mono); + font-size: 11px; +} + +.status-task-list { + display: grid; + gap: 8px; +} +.status-task { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; +} +.status-task-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.status-task-name { + font-size: 12px; + font-weight: 700; + color: var(--text); + font-family: var(--font-mono); +} +.status-task-ratio { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono); +} +.status-task-bar { + width: 100%; + height: 8px; + border-radius: 999px; + background: var(--bg-input); + overflow: hidden; + margin-bottom: 8px; +} +.status-task-bar-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #22d3ee, #06b6d4); +} +.status-task-bar-fill.warn { + background: linear-gradient(90deg, #f59e0b, #d97706); +} +.status-task-bar-fill.err { + background: linear-gradient(90deg, #ef4444, #dc2626); +} +.status-task-meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.status-task-meta span { + display: block; + font-size: 10px; + color: var(--text-label); + text-transform: uppercase; + letter-spacing: .06em; +} +.status-task-meta b { + display: block; + margin-top: 2px; + color: var(--text); + font-size: 11px; + font-family: var(--font-mono); +} +.status-json { + margin-top: 0; + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-dim); + font-size: 11px; + line-height: 1.6; + max-height: 280px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); +} +.status-empty { + padding: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-dim); + font-size: 12px; +} + +/* === PRIMARY STATUS BADGES (large) === */ +.badge-grid { + display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; +} +.badge-stat { + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px 16px; text-align: center; +} +.badge-stat .v { + font-size: 22px; font-weight: 700; +} +.badge-stat .v.accent { color: var(--accent); } +.badge-stat .v.ok { color: var(--green); } +.badge-stat .v.warn { color: var(--amber); } +.badge-stat .v.err { color: var(--red); } +.badge-stat .k { + font-size: 10px; color: var(--text-label); text-transform: uppercase; + letter-spacing: .1em; margin-top: 2px; +} + +/* === OTA STAGE FLOW === */ +.stage-flow { + display: flex; align-items: flex-start; gap: 0; padding: 8px 0; +} +.stage-node { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: 6px; position: relative; +} +.stage-node:not(:last-child)::after { + content: ""; position: absolute; top: 18px; left: calc(50% + 18px); + width: calc(100% - 36px); height: 2px; + background: var(--border); transition: background .3s; +} +.stage-node.done:not(:last-child)::after, +.stage-node.live:not(:last-child)::after { background: var(--accent); } +.stage-dot { + width: 36px; height: 36px; border-radius: 50%; + background: var(--bg-input); border: 2px solid var(--border); + display: flex; align-items: center; justify-content: center; + font-size: 16px; z-index: 1; transition: all .3s; +} +.stage-node.live .stage-dot { + background: var(--accent); border-color: var(--accent); color: #07121c; + box-shadow: 0 0 12px var(--accent-dim); +} +.stage-node.done .stage-dot { + background: var(--green); border-color: var(--green); color: #fff; +} +.stage-node.err .stage-dot { + background: var(--red); border-color: var(--red); color: #fff; +} +.stage-label { + font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3; +} +.stage-detail { + font-size: 10px; color: var(--accent); text-align: center; line-height: 1.2; + margin-top: 2px; min-height: 14px; + font-family: var(--font-mono); + transition: color .3s; +} +.stage-node.live .stage-label, +.stage-node.done .stage-label, +.stage-node.err .stage-label { color: var(--text); } +.stage-node.live .stage-detail { color: var(--accent); } +.stage-node.done .stage-detail { color: var(--green); } +.stage-node.err .stage-detail { color: var(--red); } + +/* Stage status bar */ +.stage-status-bar { + display: flex; align-items: center; gap: 10px; + margin-top: 14px; padding: 10px 16px; + border-radius: 20px; + background: var(--bg-elevated); border: 1px solid var(--border); + font-size: 13px; + transition: all .3s; +} +.stage-status-bar .ssb-dot { + width: 8px; height: 8px; border-radius: 50%; + flex-shrink: 0; + background: var(--text-dim); + transition: all .3s; +} +.stage-status-bar .ssb-text { + color: var(--text-dim); font-size: 13px; font-weight: 500; + letter-spacing: .01em; +} +/* idle — muted, with subtle pulse */ +.stage-status-bar .ssb-dot { animation: dotPulse 2.5s ease-in-out infinite; } +@keyframes dotPulse { + 0%, 100% { opacity: .45; } + 50% { opacity: .8; } +} +/* active — accent colored */ +.stage-status-bar.active { + border-color: rgba(34,211,238,.25); + background: linear-gradient(135deg, rgba(34,211,238,.06), rgba(34,211,238,.02)); +} +.stage-status-bar.active .ssb-dot { + background: var(--accent); + box-shadow: 0 0 6px var(--accent-dim); + animation: dotPulseLive 1.2s ease-in-out infinite; +} +.stage-status-bar.active .ssb-text { color: var(--accent); font-weight: 600; } +@keyframes dotPulseLive { + 0%, 100% { opacity: .7; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.25); } +} +/* ok — green */ +.stage-status-bar.ok { + border-color: rgba(34,197,94,.25); + background: linear-gradient(135deg, rgba(34,197,94,.06), rgba(34,197,94,.02)); +} +.stage-status-bar.ok .ssb-dot { + background: var(--green); + box-shadow: 0 0 6px rgba(34,197,94,.3); + animation: none; +} +.stage-status-bar.ok .ssb-text { color: var(--green); font-weight: 600; } +/* err — red */ +.stage-status-bar.err { + border-color: rgba(239,68,68,.25); + background: linear-gradient(135deg, rgba(239,68,68,.06), rgba(239,68,68,.02)); +} +.stage-status-bar.err .ssb-dot { + background: var(--red); + box-shadow: 0 0 6px rgba(239,68,68,.3); + animation: none; +} +.stage-status-bar.err .ssb-text { color: var(--red); font-weight: 600; } + +/* Stage verification info panel */ +.stage-verify-info { + margin-top: 12px; padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border); +} +.svi-row { + display: flex; align-items: flex-start; gap: 12px; + padding: 6px 0; font-size: 12px; + border-bottom: 1px solid rgba(255,255,255,.03); +} +.svi-row:last-child { border-bottom: none; } +.svi-key { + color: var(--text-label); font-weight: 600; + text-transform: uppercase; letter-spacing: .06em; font-size: 10px; + min-width: 100px; flex-shrink: 0; + padding-top: 2px; +} +.svi-val { + color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; + word-break: break-all; +} +.svi-val.ok { color: var(--green); font-weight: 600; } +.svi-val.err { color: var(--red); font-weight: 600; } + +/* Stage result panel - 上位机确认 0x07/0x85 收尾 */ +.stage-result { + margin-top: 12px; padding: 14px 16px; + border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border); + display: flex; flex-direction: column; gap: 10px; +} +.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); } +.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); } +.stage-result .sr-head { display: flex; align-items: center; gap: 10px; } +.stage-result .sr-ico { + width: 32px; height: 32px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 18px; font-weight: 700; color: #07121c; +} +.stage-result.ok .sr-ico { background: var(--green); } +.stage-result.err .sr-ico { background: var(--red); } +.stage-result .sr-title { font-size: 16px; font-weight: 700; } +.stage-result.ok .sr-title { color: var(--green); } +.stage-result.err .sr-title { color: var(--red); } +.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; } +.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; } + +/* === LOG PANEL === */ +.log-panel { display: flex; flex-direction: column; min-height: 380px; } +.log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; } +.log-toolbar { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 16px; border-bottom: 1px solid var(--border); + gap: 10px; flex-wrap: wrap; +} +.log-filters { display: flex; gap: 6px; } +.log-filter { + padding: 4px 10px; border-radius: 14px; font-size: 11px; font-weight: 500; + border: 1px solid var(--border); background: transparent; color: var(--text-dim); + cursor: pointer; transition: all .15s; +} +.log-filter.active { background: var(--accent); color: #07121c; border-color: var(--accent); } +.log-filter:hover:not(.active) { border-color: var(--text-dim); } + +.log-scroll { + flex: 1; overflow-y: auto; padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; line-height: 1.7; + max-height: 420px; +} +.log-scroll::-webkit-scrollbar { width: 5px; } +.log-scroll::-webkit-scrollbar-track { background: transparent; } +.log-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +.log-line { + display: flex; gap: 8px; padding: 3px 0; + border-bottom: 1px solid rgba(255,255,255,.02); +} +.log-line .lt { color: var(--text-label); flex-shrink: 0; } +.log-line .lv { + font-weight: 600; min-width: 20px; flex-shrink: 0; +} +.log-line .lv.E { color: var(--red); } +.log-line .lv.W { color: var(--amber); } +.log-line .lv.I { color: var(--accent); } +.log-line .lv.D { color: var(--text-label); } +.log-line .lg { color: #a78bfa; flex-shrink: 0; } +.log-line .lm { color: var(--text); word-break: break-all; } +/* BLE log compat */ +.log-line .t { color: var(--text-label); flex-shrink: 0; } +.log-line .tag { color: #a78bfa; flex-shrink: 0; } + +.log-foot { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 16px; border-top: 1px solid var(--border); + font-size: 11px; color: var(--text-label); +} + +/* === TOAST === */ +.toast { + position: fixed; top: 45%; left: 50%; transform: translateX(-50%) translateY(30px); + z-index: 9999; + display: flex; align-items: center; gap: 10px; + padding: 14px 24px; border-radius: 10px; + font-size: 14px; font-weight: 600; color: #fff; pointer-events: none; + opacity: 0; + transition: all .35s cubic-bezier(.4,0,.2,1); + box-shadow: 0 8px 32px rgba(0,0,0,.35); +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } +.toast.ok { background: #16a34a; } +.toast.err { background: #dc2626; } +.toast.warn { background: #d97706; } +.toast .toast-ico { font-size: 18px; } + +/* === GRID ROWS === */ +.row-2, .row-3, .row-4 { display: grid; gap: 10px; } +.row-2 { grid-template-columns: 1fr 1fr; } +.row-3 { grid-template-columns: repeat(3, 1fr); } +.row-4 { grid-template-columns: repeat(4, 1fr); } + +/* === SUB ACTIONS === */ +.sub-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; } + +/* === FOOTER NOTE === */ +.footer-note { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim); } + +/* === CODE === */ +code { color: #d3f9ff; font-family: var(--font-mono); } + +/* === FILE INFO BORDER === */ +.file-info { border: 1px solid var(--border); } + +/* === PILL LIVE === */ +.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; } + +/* ===== Header sub-row (设备名/设备ID) ===== */ +.header-sub { + display: flex; gap: 12px; + margin-top: 0; + padding: 0; +} +.pill-val { + font-weight: 600; + color: var(--text); +} + +/* ===== Custom Tooltip ===== */ +.pill[data-tooltip] { + position: relative; + cursor: help; +} +.pill[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 10px); + left: 50%; + transform: translateX(-50%); + background: #1a2432; + color: #e2e8f0; + font-size: 11px; + font-weight: 400; + font-family: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #2a3a4e; + box-shadow: 0 4px 16px rgba(0,0,0,.45); + white-space: nowrap; + z-index: 1000; + pointer-events: none; + animation: tooltipFadeIn .15s ease-out; +} +.pill[data-tooltip]:hover::before { + content: ''; + position: absolute; + top: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-bottom-color: #2a3a4e; + z-index: 1001; + pointer-events: none; + animation: tooltipFadeIn .15s ease-out; +} +@keyframes tooltipFadeIn { + from { opacity: 0; transform: translateX(-50%) translateY(3px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} diff --git a/tools/css/layout.css b/tools/css/layout.css new file mode 100644 index 0000000..c1c8ea4 --- /dev/null +++ b/tools/css/layout.css @@ -0,0 +1,156 @@ +/* === layout.css — 头部/主布局/卡片/表单/按钮/连接状态 === */ +/* === HEADER — compact status bar === */ +.header-bar { + display: flex; align-items: center; gap: 12px; + padding: 14px 20px; margin-bottom: 24px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + flex-wrap: wrap; +} +.header-brand { + display: flex; align-items: center; gap: 10px; + padding-right: 20px; border-right: 1px solid var(--border); + white-space: nowrap; +} +.header-brand .dot-mark { + width: 10px; height: 10px; border-radius: 50%; background: var(--accent); + box-shadow: 0 0 8px var(--accent-dim); + flex-shrink: 0; +} +.header-brand strong { font-size: 15px; font-weight: 700; } +.header-status { + display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; +} +.pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; + background: var(--bg-elevated); border: 1px solid var(--border); + white-space: nowrap; +} +.pill .indicator { + width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; +} +.pill.online .indicator { background: var(--green); } +.pill.offline .indicator { background: var(--red); } +.pill.processing .indicator { background: var(--amber); animation: blink 1s infinite; } +.pill.idle .indicator { background: var(--text-dim); } +@keyframes blink { 50% { opacity: .35; } } + +/* === MAIN LAYOUT === */ +.main-layout { + display: grid; + grid-template-columns: 380px 1fr; + gap: var(--gap); + align-items: start; +} + +/* === CARDS === */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.card + .card { margin-top: var(--gap); } +.card-head { + display: flex; align-items: center; gap: 8px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + font-size: 13px; font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; letter-spacing: .06em; +} +.card-head .ico { font-size: 15px; } +.card-head-btn { + margin-left: auto; + padding: 4px 10px; + position: relative; + z-index: 2; +} +.card-body { padding: 16px 18px; } +.card.collapsed .card-body { display: none; } +.card-head { cursor: pointer; } +.card-head .collapse-arrow { + margin-left: auto; font-size: 10px; opacity: .45; + transition: transform .2s; +} +.card.collapsed .collapse-arrow { transform: rotate(-90deg); } + +/* === FORM ELEMENTS === */ +.form-row { margin-bottom: 14px; } +.form-row:last-child { margin-bottom: 0; } +.form-label { + display: block; font-size: 11px; color: var(--text-label); + text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; +} +.input, select, textarea { + width: 100%; padding: 10px 12px; font-size: 13px; font-family: inherit; + background: var(--bg-input); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius-sm); + outline: none; transition: border-color .15s; +} +.input:focus, select:focus, textarea:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 3px var(--accent-dim); +} +select { cursor: pointer; } +select option { background: var(--bg-panel); color: var(--text); } +.input-group { display: flex; gap: 8px; } +.input-group .input { flex: 1; } + +/* === BUTTONS === */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 9px 16px; border: none; border-radius: var(--radius-sm); + font-size: 13px; font-weight: 600; cursor: pointer; + transition: opacity .15s, transform .15s; + font-family: inherit; white-space: nowrap; +} +.btn:hover:not(:disabled) { transform: translateY(-1px); } +.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; } +.btn-primary { background: var(--accent); color: #07121c; } +.btn-primary:hover:not(:disabled) { opacity: .88; } +.btn-success { background: var(--green); color: #051a0c; } +.btn-danger { background: var(--red); color: #fff; } +.btn-outline { + background: transparent; color: var(--text-dim); + border: 1px solid var(--border); +} +.btn-outline:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); } +.btn-ghost { + background: transparent; color: var(--text-dim); +} +.btn-ghost:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); } +.btn-block { width: 100%; justify-content: center; } +.btn-sm { padding: 6px 12px; font-size: 12px; } + +/* === CONNECTION STATUS ROW === */ +.conn-indicator { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border); + font-size: 13px; font-weight: 500; +} +.conn-indicator .light { + width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; +} +.conn-indicator.ok .light { background: var(--green); } +.conn-indicator.err .light { background: var(--red); } +.conn-indicator.warn .light { background: var(--amber); } + +.helper-note { + margin-top: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + font-size: 12px; + line-height: 1.6; + color: var(--text-dim); +} +.helper-note.ok { border-color: rgba(34,197,94,.25); color: #b8f5cb; } +.helper-note.warn { border-color: rgba(245,158,11,.25); color: #fbd38d; } +.helper-note.err { border-color: rgba(239,68,68,.25); color: #fecaca; } +.helper-note code { color: #d7fbff; font-family: var(--font-mono); } diff --git a/tools/css/responsive.css b/tools/css/responsive.css new file mode 100644 index 0000000..33ae6b9 --- /dev/null +++ b/tools/css/responsive.css @@ -0,0 +1,250 @@ +/* === responsive.css — 响应式断点 + 增强动画 === */ +/* === RESPONSIVE === */ +@media (max-width: 1100px) { + .main-layout { grid-template-columns: 1fr; } + .badge-grid { grid-template-columns: repeat(2, 1fr); } + .can-toolbar-grid, + .can-toolbar-foot, + .can-editor-layout { grid-template-columns: 1fr; } + .message-list { + max-height: 420px; + } + .can-meta-grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 640px) { + .container { padding: 16px; } + .header-bar { flex-direction: column; align-items: flex-start; } + .badge-grid, .metric-grid { grid-template-columns: 1fr; } + .status-kv-grid, .status-task-meta { grid-template-columns: 1fr; } + .can-grid { grid-template-columns: 1fr 1fr; } + .btn-row { flex-direction: column; } + .can-meta-grid { grid-template-columns: 1fr; } + .can-launch-top, + .can-launch-actions, + .can-launch-foot { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } + .can-selection-bar, + .can-selection-actions, + .history-item-top { + flex-direction: column; + align-items: flex-start; + } + .can-title { + font-size: 22px; + } + .can-frame-hex { + font-size: 15px; + } + .message-table, + .signal-table { + min-width: 980px; + } +} + + + +/* ===== ENHANCED ANIMATIONS (v20260625b) ===== */ + +/* Global keyframes */ +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes popIn { + 0% { opacity: 0; transform: scale(.85); } + 60% { opacity: 1; transform: scale(1.04); } + 100% { opacity: 1; transform: scale(1); } +} +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +@keyframes ringPulse { + 0% { box-shadow: 0 0 0 0 rgba(34,211,238,.55); } + 70% { box-shadow: 0 0 0 10px rgba(34,211,238,0); } + 100% { box-shadow: 0 0 0 0 rgba(34,211,238,0); } +} +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(3px); } +} +@keyframes spin { + to { transform: rotate(360deg); } +} +@keyframes floatY { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} +@keyframes glowBreath { + 0%, 100% { box-shadow: 0 0 8px var(--accent-dim); } + 50% { box-shadow: 0 0 18px rgba(34,211,238,.45); } +} +@keyframes flowDash { + to { background-position: 200% 0; } +} + +/* Page entrance — header + cards staggered */ +.header-bar { animation: fadeInUp .45s ease both; } +.main-layout > div > .card { animation: fadeInUp .5s ease both; } +.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .05s; } +.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .12s; } +.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .19s; } +.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .26s; } +.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .08s; } +.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .15s; } +.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .22s; } +.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .29s; } + +/* Card hover — lift + glow */ +.card { + transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; +} +.card:hover { + transform: translateY(-2px); + border-color: rgba(34,211,238,.28); + box-shadow: 0 10px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(34,211,238,.08); +} + +/* Brand dot — gentle ring pulse */ +.header-brand .dot-mark { + animation: ringPulse 2.2s ease-out infinite, glowBreath 3.6s ease-in-out infinite; +} + +/* Pills — subtle hover */ +.pill { transition: transform .2s ease, border-color .2s ease, background .2s ease; } +.pill:hover { transform: translateY(-1px); border-color: rgba(34,211,238,.3); } + +/* Button — press + sheen on hover for primary */ +.btn { position: relative; overflow: hidden; } +.btn:active:not(:disabled) { transform: translateY(0) scale(.97); transition: transform .08s ease; } +.btn-primary::before, +.btn-success::before, +.btn-danger::before { + content: ""; + position: absolute; top: 0; left: -120%; + width: 60%; height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent); + transform: skewX(-20deg); + transition: left .55s ease; + pointer-events: none; +} +.btn-primary:hover:not(:disabled)::before, +.btn-success:hover:not(:disabled)::before, +.btn-danger:hover:not(:disabled)::before { + left: 130%; +} + +/* Upload zone — drag pulse + icon float */ +.upload-zone { transition: border-color .2s, background .2s, transform .2s; } +.upload-zone:hover { transform: translateY(-1px); } +.upload-zone .upload-ico { + display: inline-block; + transition: transform .25s ease; +} +.upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; } +.upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; } + +/* File info appearance */ +.file-info.show { animation: fadeInUp .3s ease both; } + +/* Progress bar — striped flow on active */ +.progress-fill { + background-image: + linear-gradient(90deg, #22d3ee, #06b6d4), + repeating-linear-gradient(45deg, + rgba(255,255,255,.18) 0 10px, + rgba(255,255,255,0) 10px 20px); + background-size: 100% 100%, 200% 100%; + background-blend-mode: normal, overlay; + animation: flowDash 1.4s linear infinite; +} +.progress-fill.done { animation: none; } + +/* Phase step capsule — pop when becoming active/done */ +.pp-step.active, +.pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } + +/* Stage flow — connector grow + dot pop */ +.stage-node:not(:last-child)::after { + background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 50%, var(--border) 50%, var(--border) 100%); + background-size: 200% 100%; + background-position: 100% 0; + transition: background-position .5s ease; +} +.stage-node.done:not(:last-child)::after, +.stage-node.live:not(:last-child)::after { + background-position: 0% 0; +} +.stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; } +.stage-node.live .stage-dot { + animation: ringPulse 1.6s ease-out infinite; +} +.stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } +.stage-node.err .stage-dot { animation: shake .4s ease both; } + +/* Stage status bar — slide in when state changes */ +.stage-status-bar.active, +.stage-status-bar.ok, +.stage-status-bar.err { animation: fadeInUp .3s ease both; } + +/* Stage result panel */ +.stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; } + +/* Metric / badge values — number tick highlight */ +.metric .v, +.badge-stat .v { + transition: color .3s ease, transform .3s ease; +} +.metric:hover, +.badge-stat:hover { + border-color: rgba(34,211,238,.25); + transition: border-color .25s ease; +} + +/* Conn indicator — light ring pulse when warn */ +.conn-indicator { transition: background .25s ease, border-color .25s ease; } +.conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; } +.conn-indicator.ok .light { animation: glowBreath 3s ease-in-out infinite; } + +/* Heap / task bars — sheen */ +.heap-bar-track, +.status-task-bar { position: relative; overflow: hidden; } +.heap-bar-track::after, +.status-task-bar::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%); + transform: translateX(-100%); + animation: shimmer 2.6s linear infinite; + background-size: 200% 100%; + pointer-events: none; +} +.heap-bar-fill, +.status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); } + +/* Log lines — fresh entry fade-in */ +.log-line { animation: fadeIn .25s ease both; } + +/* Toast — pop and float */ +.toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } + +/* Reduced motion — respect user preference */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition-duration: .001ms !important; + } +} diff --git a/tools/index.html b/tools/index.html index d8c1a73..dab2dd9 100644 --- a/tools/index.html +++ b/tools/index.html @@ -9,7 +9,11 @@ ESP32 OTA 中继控制台 - + + + + +
@@ -427,261 +431,8 @@ 操作成功
- - - + + + diff --git a/tools/js/app.js b/tools/js/app.js new file mode 100644 index 0000000..bb6edf6 --- /dev/null +++ b/tools/js/app.js @@ -0,0 +1,245 @@ +/** + * app.js - ESP32 OTA 中继控制台入口(WiFi OTA 通道) + * + * 薄入口:import 所有 ota/* + common/* 模块,将 HTML onclick 引用的函数 + * 显式挂载到 window 对象,保留原有内联事件处理器兼容。 + * + * 模块通过 window.* 共享状态,模块间依赖通过 import 解耦。 + * 此入口只负责挂载 + 顶层副作用(拖拽区绑定、SPI 输入监听、启动轮询)。 + */ +import { formatBytes, formatTime, formatUptime, escapeHtml } from './common/utils.js'; +import { showToast } from './common/toast.js'; +import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js'; +import { + updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, + testConnection, refreshSystemStatusNow +} from './common/connection.js'; +import { + scheduleStatusPoll, startPolling, pausePollingForUpload, + resumePollingAfterUpload, pollStatus, pollLogs +} from './common/polling.js'; + +import { + OtaSessionState, otaSessionState, otaSessionTarget, otaFailReason, + otaSessionActive, otaSessionTerminated, otaSessionTransition, + otaSessionReset, clearOtaSessionResiduals, + stopUploadWaitHeartbeat, startUploadWaitHeartbeat +} from './ota/session.js'; +import { + toggleCollapse, switchStageFlow, updateEsp32StagesFromPhase, + updateEsp32StagesError, cleanupAfterOtaFailure, setActivePhase, + markPhaseDone, setProgress, resetProgress, esp32PhaseLabels +} from './ota/progress.js'; +import { + updateStagesFromRelayState, updateSystemStatusUi +} from './ota/stages.js'; +import { + handleFileSelect, pickFileViaFSAPI, setupDropZone, + parseIntelHex, isIntelHexText, convertHexFileToBin, + processFile, clearFile +} from './ota/file-handle.js'; +import { sendSpiFrame, clearSpiFields, updateSpiHint } from './ota/spi.js'; +import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js'; +import { triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch, startRelayProgressWatch } from './ota/upload-gd32.js'; + +// ==================== 工具函数(部分未拆分的辅助函数) ==================== +function formatPercent(value) { + if (!Number.isFinite(value)) return '--'; + return value.toFixed(1).replace(/\.0$/, '') + '%'; +} +function formatHttpErrorDetail(text) { + var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + if (detail.length > 180) detail = detail.slice(0, 177) + '...'; + return detail; +} +function isOtaBusyError(status, detail) { + var text = (detail || '').toLowerCase(); + return status === 403 || + status === 409 || + status === 423 || + text.indexOf('busy') >= 0 || + text.indexOf('occupied') >= 0 || + text.indexOf('another ota') >= 0 || + text.indexOf('invalid state') >= 0 || + text.indexOf('占用') >= 0 || + text.indexOf('互斥') >= 0 || + text.indexOf('系统忙') >= 0; +} +function getEspIp() { + return document.getElementById('espIp').value.trim() || '192.168.31.127'; +} +function getBaseUrl() { + return 'http://' + getEspIp(); +} +function updateUploadButtonLabel() { + var esp32Btn = document.getElementById('uploadEsp32Btn'); + var gd32Btn = document.getElementById('uploadGd32Btn'); + if (esp32Btn) esp32Btn.disabled = !window.esp32File || window.isUploading; + if (gd32Btn) gd32Btn.disabled = !window.gd32File || window.isUploading; +} +function resetStageFlow() { + ['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + var statusBar = document.getElementById('stageStatusBar'); + if (statusBar) statusBar.className = 'stage-status-bar'; + var statusText = document.getElementById('stageStatusText'); + if (statusText) statusText.textContent = '待机'; +} +function applyDisconnectedRuntimeState() { + document.getElementById('pillIP').textContent = '--'; + document.getElementById('pillRSSI').textContent = '--'; + // ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度 + if (otaSessionActive()) return; + // FAILED 态:保留失败进度显示,只清连接胶囊(让用户看到失败原因) + if (otaSessionState === OtaSessionState.FAILED) return; + resetProgress(); + resetStageFlow(); +} +function applyRebootingRuntimeState() { + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + pc.className = 'pill processing'; + var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); + pct.textContent = '重启中 ' + elapsed + 's'; + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + if (cs) cs.className = 'conn-indicator warn'; + if (cst) cst.textContent = 'ESP32 正在重启...'; + var bc = document.getElementById('bConn'); + if (bc) bc.textContent = '重启中'; + if (bc) bc.className = 'v warn'; +} +function applyUploadingRuntimeState() { + // ★ 状态机守卫:会话已失败/完成时,绝不重新应用"上传中"运行时态, + // 防止网络抖动后的轮询错误分支把 UI 拉回"上传中"。 + if (otaSessionState === OtaSessionState.FAILED || + otaSessionState === OtaSessionState.DONE) { + return; + } + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + pc.className = 'pill processing'; + pct.textContent = '上传中'; + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + if (cs) cs.className = 'conn-indicator warn'; + if (cst) cst.textContent = '固件上传中,设备忙...'; + var bc = document.getElementById('bConn'); + if (bc) bc.textContent = '上传中'; + if (bc) bc.className = 'v warn'; +} + +// ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ==================== +window.OtaSessionState = OtaSessionState; +window.otaSessionActive = otaSessionActive; +window.otaSessionTerminated = otaSessionTerminated; +window.otaSessionTransition = otaSessionTransition; +window.otaSessionReset = otaSessionReset; +window.clearOtaSessionResiduals = clearOtaSessionResiduals; +window.stopUploadWaitHeartbeat = stopUploadWaitHeartbeat; +window.startUploadWaitHeartbeat = startUploadWaitHeartbeat; + +window.toggleCollapse = toggleCollapse; +window.switchStageFlow = switchStageFlow; +window.updateEsp32StagesFromPhase = updateEsp32StagesFromPhase; +window.updateEsp32StagesError = updateEsp32StagesError; +window.cleanupAfterOtaFailure = cleanupAfterOtaFailure; +window.setActivePhase = setActivePhase; +window.markPhaseDone = markPhaseDone; +window.setProgress = setProgress; +window.resetProgress = resetProgress; + +window.updateStagesFromRelayState = updateStagesFromRelayState; +window.updateSystemStatusUi = updateSystemStatusUi; + +window.handleFileSelect = handleFileSelect; +window.pickFileViaFSAPI = pickFileViaFSAPI; +window.parseIntelHex = parseIntelHex; +window.isIntelHexText = isIntelHexText; +window.convertHexFileToBin = convertHexFileToBin; +window.processFile = processFile; +window.clearFile = clearFile; + +window.sendSpiFrame = sendSpiFrame; +window.clearSpiFields = clearSpiFields; +window.updateSpiHint = updateSpiHint; + +window.startUpload = startUpload; +window.verifyEsp32Firmware = verifyEsp32Firmware; +window.restartEsp32 = restartEsp32; + +window.triggerGd32Relay = triggerGd32Relay; +window.markGd32OtaUiComplete = markGd32OtaUiComplete; +window.stopRelayProgressWatch = stopRelayProgressWatch; +window.startRelayProgressWatch = startRelayProgressWatch; + +// ==================== 挂载 common/* 函数到 window(供 polling.js 等通过 window.* 调用) ==================== +window.formatBytes = formatBytes; +window.formatTime = formatTime; +window.formatUptime = formatUptime; +window.escapeHtml = escapeHtml; +window.formatPercent = formatPercent; +window.showToast = showToast; +window.addLog = addLog; +window.clearLogs = clearLogs; +window.setLogFilter = setLogFilter; +window.toggleAutoScroll = toggleAutoScroll; +window.updateConnectionUi = updateConnectionUi; +window.updateHealthUi = updateHealthUi; +window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi; +window.testConnection = testConnection; +window.refreshSystemStatusNow = refreshSystemStatusNow; +window.scheduleStatusPoll = scheduleStatusPoll; +window.startPolling = startPolling; +window.pausePollingForUpload = pausePollingForUpload; +window.resumePollingAfterUpload = resumePollingAfterUpload; +window.pollStatus = pollStatus; +window.pollLogs = pollLogs; + +// ==================== 挂载本入口的辅助函数到 window ==================== +window.getEspIp = getEspIp; +window.getBaseUrl = getBaseUrl; +window.updateUploadButtonLabel = updateUploadButtonLabel; +window.resetStageFlow = resetStageFlow; +window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState; +window.applyRebootingRuntimeState = applyRebootingRuntimeState; +window.applyUploadingRuntimeState = applyUploadingRuntimeState; +window.formatHttpErrorDetail = formatHttpErrorDetail; +window.isOtaBusyError = isOtaBusyError; + +// ==================== 顶层副作用(原 app.js 末尾的初始化) ==================== +setupDropZone('uploadZoneEsp32', 'esp32'); +setupDropZone('uploadZoneGd32', 'gd32'); +var spiTypeEl = document.getElementById('spiType'); +var spiDataEl = document.getElementById('spiData'); +if (spiTypeEl) spiTypeEl.addEventListener('input', updateSpiHint); +if (spiDataEl) spiDataEl.addEventListener('input', updateSpiHint); + +// 启动轮询(必须在所有模块加载完成、window.OtaSessionState 已挂载后调用, +// 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError) +startPolling(); + +// IP 输入变化时清零失败计数并立即刷新状态 +var ipInput = document.getElementById('espIp'); +if (ipInput) { + ipInput.addEventListener('change', function () { + window.consecutivePollFailures = 0; + addLog('I', '系统', 'IP 已修改,重新启动轮询...'); + if (!window.statusPollInFlight) { + scheduleStatusPoll(0); + } + }); +} + +// 初始化按钮状态与提示 +setTimeout(function () { + updateUploadButtonLabel(); + addLog('I', '系统', '控制台初始化完成,等待固件文件...'); + updateSpiHint(); +}, 500); \ No newline at end of file diff --git a/tools/js/ble/constants.js b/tools/js/ble/constants.js new file mode 100644 index 0000000..dac2bf8 --- /dev/null +++ b/tools/js/ble/constants.js @@ -0,0 +1,57 @@ +// ==================== BLE OTA 协议常量 ==================== +// 与 WiFi OTA 共享界面元素:进度条、阶段流程、日志面板、连接状态、文件选择。 +// 仅传输层不同 (GATT vs HTTP)。通过 connectionMode 变量判断当前模式。 + +export const UUIDS = { + service: 0xFFFF, + ctrl: 0x0001, + dataIn: 0x0002, + dataOut: 0x0003, + version: 0x0004, +}; + +export const UI_BUILD = '2026-07-12-ble-reorder-v6'; +export const BLE_OTA_NOTIFY_ACK = 0xA1; +export const BLE_OTA_NOTIFY_FINISH = 0xB1; +export const BLE_OTA_NOTIFY_SHA256 = 0xC1; +export const BLE_OTA_ACK_WINDOW = 32; +export const BLE_OTA_FINISH_ESP32_DONE = 0x02; +export const BLE_OTA_FINISH_REBOOT_NOW = 0x07; +export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10; +export const BLE_OTA_FINISH_GD32_RELAY_DONE = 0x11; +export const BLE_OTA_FINISH_ABORTED = 0x20; // 用户主动中止(BLE 不断开) +export const BLE_OTA_CTRL_ABORT_OTA = 0xFB; // 中止当前 OTA(不断开 BLE) +export const BLE_OTA_FINISH_ERR_FINALIZE = 0x81; +export const BLE_OTA_FINISH_ERR_MUTEX = 0x83; +export const BLE_OTA_FINISH_ERR_GD32_SIZE = 0x84; +export const BLE_OTA_FINISH_ERR_SHA_MISMATCH = 0x85; +export const BLE_OTA_FINISH_ERR_GD32_RELAY = 0x86; + +export const TARGETS = { + gd32: { + label: 'GD32', + packetCmd: 0x00, + ctrlHex: 'F7', + recommendedPayload: 480, + finishText: '自动触发中继', + hint: '当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。', + verifyStage: 6, + finalizeStage: 5, + stage5Label: '完成触发', + stage6Label: '校验比对' + }, + esp32: { + label: 'ESP32', + packetCmd: 0x01, + ctrlHex: 'F8', + recommendedPayload: 480, + finishText: '自动完成 OTA', + hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后发 0xF8 + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。', + verifyStage: 5, + finalizeStage: 6, + stage5Label: '校验比对', + stage6Label: '完成触发' + } +}; + +export const MAX_LOG_LINES = 1000; diff --git a/tools/js/ble/file.js b/tools/js/ble/file.js new file mode 100644 index 0000000..e8fe928 --- /dev/null +++ b/tools/js/ble/file.js @@ -0,0 +1,211 @@ +// ==================== BLE OTA 文件准备与分包 ==================== +import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState } from './state.js'; +import { setBleStage, setBleStageText, resetBleStages } from './stages.js'; +import { setProgress } from './transfer.js'; +import { hideBleVerifyInfo } from './verify.js'; +import { formatBytes } from '../common/utils.js'; +import { showToast } from '../common/toast.js'; + +export function updateFileInfo(file) { + // ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget + // 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...) + // 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。 + var isUploading = state.uploading || state.autoFlashing; + if (isUploading && file && state.selectedFile) { + // 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段 + bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D'); + return Promise.resolve(); + } + + els.fileName.textContent = file ? file.name : '-'; + els.fileType.textContent = file ? (file.name.toLowerCase().includes('.hex') ? 'Intel HEX' : 'Binary') : '-'; + els.fileSize.textContent = file ? formatBytes(file.size) : '0 B'; + const payloadSize = currentPayloadSize(); + const packets = file ? Math.ceil(file.size / payloadSize) : 0; + els.packetCount.textContent = String(packets); + els.sentCount.textContent = '0'; + + state.selectedFile = file || null; + state.lastUploadStatus = 'idle'; + + if (els.fileInfo) { + if (file) els.fileInfo.classList.add('show'); + else els.fileInfo.classList.remove('show'); + } + + // ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin + if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') { + return convertHexFileToBin(file).then(function(result) { + if (!result.converted) { + bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W'); + finalizeFileInfo(file); + computeFileSha256(file); + return; + } + state.selectedFile = result.file; + var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); + bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')'); + showToast('HEX 已自动转换为 BIN', 'success'); + // 更新 UI 显示转换后的信息 + els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; + els.fileType.textContent = 'Binary (HEX转换)'; + els.fileSize.textContent = formatBytes(result.file.size); + var newPackets = Math.ceil(result.file.size / payloadSize); + els.packetCount.textContent = String(newPackets); + finalizeFileInfo(result.file); + computeFileSha256(result.file); + }).catch(function(err) { + bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E'); + showToast('HEX 文件解析失败: ' + err.message, 'error'); + state.selectedFile = null; + state.fileSha256 = null; + if (els.fileInfo) els.fileInfo.classList.remove('show'); + setConnectionState(!!state.server); + }); + } + + // 非 HEX 文件直接完成 + finalizeFileInfo(file); + computeFileSha256(file); + return Promise.resolve(); +} + +function finalizeFileInfo(file) { + // ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间 + // 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。 + var isUploading = state.uploading || state.autoFlashing; + if (isUploading) { + bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D'); + return; + } + setConnectionState(!!state.server); + if (file) { + setProgress(0, `已选择 ${file.name}`); + bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`); + if (state.server) { + setBleStage(3, '计算哈希...', 'active'); + setBleStageText('正在计算文件 SHA-256...'); + } + } else { + setProgress(0, '等待选择文件'); + if (state.server) { + resetBleStages(); + setBleStage(3, '', null); + setBleStageText('BLE 连接就绪,请选择固件文件'); + } + } +} + +function computeFileSha256(file) { + var shaElem = document.getElementById('fileSha256'); + if (file && shaElem) { + shaElem.textContent = '计算中...'; + file.arrayBuffer().then(function(buf) { + return crypto.subtle.digest('SHA-256', buf); + }).then(function(hashBuffer) { + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + shaElem.textContent = hashHex.toUpperCase(); + state.fileSha256 = hashHex.toUpperCase(); + bleLog('FILE', 'SHA-256: ' + hashHex.toUpperCase()); + // If connected, advance to stage 3 done + if (state.server) { + setBleStage(3, 'SHA-256 已计算', null); + setBleStage(4, '', null); + setBleStageText('文件就绪,可以开始刷写'); + } + }).catch(function(err) { + shaElem.textContent = '计算失败: ' + err.message; + state.fileSha256 = null; + }); + } else if (shaElem) { + shaElem.textContent = '-'; + state.fileSha256 = null; + } +} + +export async function syncSelectedFileFromInput() { + // ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间 + // 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。 + // ★ 必须同时拦截 WiFi 通道上传 (app.js 全局 isUploading):focus 事件不分通道, + // WiFi 刷写时点击网页/切标签回来会触发本函数 → finalizeFileInfo → setProgress(0), + // 把进度"闪一下"归零,直到下一次 XHR progress 事件才恢复。 + if (state.uploading || state.autoFlashing) return; + if (typeof isUploading !== 'undefined' && isUploading) return; + // FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空 + if (state.selectedFileHandle) return; + const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null; + await updateFileInfo(file); +} + +// 通过 File System Access API 选择文件(支持热重载) +export async function pickFileViaFSAPI() { + if (!window.showOpenFilePicker) { + bleLog('FILE', '当前浏览器不支持 File System Access API', 'W'); + return false; + } + try { + const handles = await window.showOpenFilePicker({ + types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }], + multiple: false + }); + if (handles && handles.length > 0) { + const handle = handles[0]; + const file = await handle.getFile(); + state.selectedFileHandle = handle; + await updateFileInfo(file); + bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name); + return true; + } + } catch (e) { + if (e.name !== 'AbortError') { + bleLog('FILE', 'FSAPI 选择失败: ' + e.message, 'W'); + } + } + return false; +} + +export function clearSelectedFile() { + // ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用, + // 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。 + if (state.uploading || state.autoFlashing) { + bleLog('FILE', '上传进行中,拒绝清除文件', 'W'); + return; + } + state.selectedFile = null; + state.selectedFileHandle = null; + state.lastUploadStatus = 'idle'; + state.fileSha256 = null; + state.deviceSha256 = null; + if (els.firmwareFile) els.firmwareFile.value = ''; + updateFileInfo(null); + hideBleVerifyInfo(); + // Reset stages back to connection-ready state + if (state.server) { + resetBleStages(); + setBleStage(3, '', null); + setBleStageText('BLE 连接就绪,请选择固件文件'); + } + bleLog('FILE', '文件已清除'); +} + +// 别名导出,与 window.bleOta.clearFile 对齐 +export { clearSelectedFile as clearFile }; + +export async function notifyFileChange(file, handle) { + // ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget + // 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。 + if (state.uploading || state.autoFlashing) { + bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D'); + return; + } + // ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度 + if (typeof isUploading !== 'undefined' && isUploading) { + bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D'); + return; + } + if (file) { + if (handle) state.selectedFileHandle = handle; + await updateFileInfo(file); + } +} diff --git a/tools/js/ble/gatt.js b/tools/js/ble/gatt.js new file mode 100644 index 0000000..e04f4eb --- /dev/null +++ b/tools/js/ble/gatt.js @@ -0,0 +1,546 @@ +// ==================== BLE OTA 连接管理 ==================== +import { + UUIDS, + BLE_OTA_NOTIFY_ACK, + BLE_OTA_NOTIFY_FINISH, + BLE_OTA_NOTIFY_SHA256, + BLE_OTA_ACK_WINDOW, + BLE_OTA_FINISH_ESP32_DONE, + BLE_OTA_FINISH_REBOOT_NOW, + BLE_OTA_FINISH_GD32_RELAY_START, + BLE_OTA_FINISH_GD32_RELAY_DONE, + BLE_OTA_FINISH_ABORTED, + BLE_OTA_FINISH_ERR_FINALIZE, + BLE_OTA_FINISH_ERR_MUTEX, + BLE_OTA_FINISH_ERR_GD32_SIZE, + BLE_OTA_FINISH_ERR_SHA_MISMATCH, + BLE_OTA_FINISH_ERR_GD32_RELAY, + TARGETS, +} from './constants.js'; +import { + state, + els, + bleLog, + toHex, + setConnectionState, + resetUiAfterDisconnect, + currentPayloadSize, + flowTarget, + currentTarget, + sleep, +} from './state.js'; +import { setBleStage, setBleStageError, setBleStageText, resetBleStages } from './stages.js'; +import { setProgress, updateProgressFromAck } from './transfer.js'; +import { showBleVerifyInfo } from './verify.js'; +import { + clearOtaResultWaiter, + resolveOtaResult, + rejectOtaResult, + finishBelongsToWaiter, + finishSessionId, + isSessionlessFinish, + hasCurrentWaiter, +} from './session.js'; +import { formatBytes } from '../common/utils.js'; +import { showToast } from '../common/toast.js'; + +/** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */ +export async function connectBle() { + if (!navigator.bluetooth) { + bleLog('BLE', '当前浏览器不支持 Web Bluetooth', 'E'); + alert('当前浏览器不支持 Web Bluetooth,请使用 Chrome / Edge。'); + return; + } + + try { + resetBleStages(); + setBleStage(1, '扫描中...', 'active'); + setBleStageText('正在打开设备选择器...'); + bleLog('BLE', '打开设备选择器...'); + const device = await navigator.bluetooth.requestDevice({ + filters: [{ namePrefix: 'ESP32-OTA' }], + optionalServices: [UUIDS.service], + }); + + state.device = device; + state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true }); + if (typeof connectionMode === 'undefined' || connectionMode === 'ble') { + els.deviceName.textContent = device.name || '未知设备'; + els.deviceId.textContent = device.id || '-'; + } + + setBleStage(1, '已选择', null); + setBleStage(2, '连接GATT...', 'active'); + setBleStageText('正在连接 GATT 服务...'); + bleLog('BLE', `已选择设备: ${device.name || 'unknown'}`); + const server = await device.gatt.connect(); + state.server = server; + + setBleStage(2, '获取服务...', null); + setBleStageText('获取 GATT 服务和特征...'); + state.service = await server.getPrimaryService(UUIDS.service); + state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl); + state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn); + state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut); + state.versionChar = await state.service.getCharacteristic(UUIDS.version); + + setBleStage(2, '已就绪', null); + setConnectionState(true); + bleLog('BLE', 'GATT 服务与特征获取完成'); + await enableNotifications(); + // ★ enableNotifications 内部已包含重试 + 主动断开逻辑; + // 若返回时仍为 false,说明连接半死,UI 已被断开事件重置,不要继续推进到 Stage 3 + if (!state.notificationsEnabled) { + bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E'); + return; + } + // Mark stages 1-2 as done + setBleStage(3, '', null); + setBleStageText('BLE 连接就绪,请选择固件文件'); + } catch (err) { + bleLog('BLE', `连接失败: ${err.message}`, 'E'); + if (err.name !== 'NotFoundError') { + setBleStageError(err.message); + } else { + resetBleStages(); + } + resetUiAfterDisconnect(); + } +} + +export function onDisconnected() { + bleLog('BLE', '设备已断开', 'W'); + var wasExpected = state.expectedReboot; + resetUiAfterDisconnect(); + if (wasExpected && state.device) { + // 是主动重启引起的断开,启动自动重连 + state.reconnecting = true; + state.reconnectAttempts = 0; + tryReconnect(); + } +} + +/** @description 断开 BLE 连接并清理资源。 */ +export async function disconnectBle() { + // 用户主动断开:取消任何进行中的重连 / expectedReboot + state.expectedReboot = false; + state.reconnecting = false; + try { + if (state.dataOutChar && state.notificationsEnabled) { + await state.dataOutChar.stopNotifications(); + } + } catch (_) {} + if (state.device && state.device.gatt && state.device.gatt.connected) { + state.device.gatt.disconnect(); + } + resetUiAfterDisconnect(); +} + +export async function readVersion() { + try { + const value = await state.versionChar.readValue(); + const text = new TextDecoder().decode(value.buffer).replace(/\0/g, '').trim() || '(空)'; + if (typeof connectionMode === 'undefined' || connectionMode === 'ble') { + if (els.versionText) els.versionText.textContent = text; + // 更新头部版本显示:仅显示版本号,编译时间放在 title 中悬停显示 + var pv = document.getElementById('pillVer'); + if (pv) { + var spaceIdx = text.indexOf(' '); + if (spaceIdx > 0) { + pv.textContent = 'v' + text.substring(0, spaceIdx); + pv.dataset.tooltip = '编译: ' + text.substring(spaceIdx + 1); + } else { + pv.textContent = 'v' + text; + delete pv.dataset.tooltip; + } + } + } + bleLog('BLE', `版本: ${text}`); + } catch (err) { + bleLog('BLE', `读取版本失败: ${err.message}`, 'E'); + } +} + +/** @description 处理 BLE Notify 事件(解析 ACK/ERROR/PROGRESS 等响应类型)。 */ +export function handleNotify(event) { + const view = event.target.value; + const bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + const hex = toHex(bytes); + state.lastNotificationHex = hex; + + if (bytes.length >= 8 && bytes[0] === BLE_OTA_NOTIFY_ACK) { + const ackCmd = bytes[1]; + const ackSeq = bytes[2] | (bytes[3] << 8); + const ackBytes = (bytes[4]) | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24); + // ★ 保护:陈旧 ACK 来自跨 run / 旧窗口时直接丢弃。 + // 仅以「本 run 上界」过滤(ackSeq 不能超过文件总包数 + 一个窗口余量), + // 不再用 ackMinSeenSeq 作下界 —— 因为本 run 第一个窗口的 seq 本身就是 0..31, + // 下界过严会把正常 ACK 也误判为越界(导致 ackBytes 永远 0)。 + if (state.uploading && state.activeTotalSize > 0) { + const payloadSize = Math.max(1, currentPayloadSize()); + const maxExpectedSeq = Math.ceil(state.activeTotalSize / payloadSize) - 1; + if (ackSeq > maxExpectedSeq + BLE_OTA_ACK_WINDOW) { + bleLog('ACK', `忽略越界 ACK: seq=${ackSeq} (run 范围 0..${maxExpectedSeq})`, 'W'); + return; + } + } else if (!state.uploading) { + // 非上传阶段:清空状态,避免污染下次 run + state.lastAckCmd = null; + state.lastAckSeq = -1; + state.lastAckBytes = 0; + return; + } + state.lastAckCmd = ackCmd; + state.lastAckSeq = ackSeq; + state.lastAckBytes = ackBytes >>> 0; + + // ★ 以设备端真实 ACK 字节数驱动 UI 进度 + if (state.uploading && state.activeTotalSize > 0 && state.activePacketTotal > 0) { + updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes); + } + + const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`; + bleLog('ACK', ackText); + + // ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK + // 不再要求 ackSeq >= expectedSeq,因为 ESP32 可能用较低的 seq 发送 ACK + // 但字节数已覆盖期望位置(例如 ACK seq=255 bytes=138240 覆盖 seq=287 的数据) + if (state.ackWaiter && state.ackWaiter.cmd === ackCmd + && state.ackWaiter.generation === state.ackGeneration) { + const waiter = state.ackWaiter; + const expectedBytes = waiter.expectedTotalSize !== undefined + ? Math.min((waiter.expectedSeq + 1) * waiter.payloadSize, waiter.expectedTotalSize) + : 0; + + // ★ ACK 字节数不足:立即 resolve,让重试循环立即续传,不等超时 + if (expectedBytes > 0 && state.lastAckBytes < expectedBytes) { + bleLog('ACK', `ACK 字节数不足: ${state.lastAckBytes}/${expectedBytes},立即进入重试`, 'W'); + state.ackWaiter = null; + clearTimeout(waiter.timer); + waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes }); + return; + } + + // 字节数足够,resolve + state.ackWaiter = null; + clearTimeout(waiter.timer); + waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes }); + } + return; + } + + + if (bytes.length >= 6 && bytes[0] === BLE_OTA_NOTIFY_FINISH) { + const stage = bytes[1]; + const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24); + const finishMap = { + [BLE_OTA_FINISH_ESP32_DONE]: '[FINISH] ESP32 OTA 收尾完成', + [BLE_OTA_FINISH_REBOOT_NOW]: '[FINISH] ESP32 即将重启', + [BLE_OTA_FINISH_GD32_RELAY_START]: '[FINISH] GD32 OTA 转发已开始', + [BLE_OTA_FINISH_GD32_RELAY_DONE]: '[FINISH] GD32 OTA 转发完成', + [BLE_OTA_FINISH_ABORTED]: '[FINISH] OTA 已中止(BLE 保持连接)', + [BLE_OTA_FINISH_ERR_FINALIZE]: `[FINISH] ESP32 OTA 收尾失败: err=${value}`, + [BLE_OTA_FINISH_ERR_MUTEX]: '[FINISH] OTA 被其他通道占用,互斥拒绝', + [BLE_OTA_FINISH_ERR_GD32_SIZE]: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`, + [BLE_OTA_FINISH_ERR_SHA_MISMATCH]: '[FINISH] OTA SHA-256 校验失败', + [BLE_OTA_FINISH_ERR_GD32_RELAY]: `[FINISH] GD32 OTA 转发失败: result=${value >> 16}`, + }; + const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`; + bleLog('FINISH', finishText); + const finalizeStage = TARGETS.esp32.finalizeStage; + const resultCode = (value >>> 16); + + if (stage === BLE_OTA_FINISH_GD32_RELAY_START) { + if (!finishBelongsToWaiter('gd32', value)) return; + if (typeof switchStageFlow === 'function') switchStageFlow('gd32'); + if (typeof setActivePhase === 'function') setActivePhase('gd32Ota'); + if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(1); + setProgress(50, '[GD32] OTA 转发已开始,正在认证握手...'); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '等待设备端上报...', { text: '缓存写入完成,正在通过 SPI 转发' }); + return; + } + + if (stage === BLE_OTA_FINISH_GD32_RELAY_DONE) { + if (!finishBelongsToWaiter('gd32', value)) return; + if (typeof switchStageFlow === 'function') switchStageFlow('gd32'); + if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(5); + if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota'); + setProgress(100, '[GD32] OTA 转发完成,APP 已启动'); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true }); + var gd32Bar = document.getElementById('stageStatusBar'); + var gd32Text = document.getElementById('stageStatusText'); + if (gd32Bar) gd32Bar.className = 'stage-status-bar ok'; + if (gd32Text) gd32Text.textContent = 'GD32 刷写完成'; + state.lastUploadStatus = 'success'; + showToast('GD32 OTA 刷写完成', 'success'); + resolveOtaResult('success', { stage: stage, value: value }); + return; + } + + // ── 0x20 ABORTED:ESP32 已中止 OTA(BLE 保持连接)── + // 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发, + // 也可能是设备端因错误自动中止。两种情况都走同一清理路径。 + if (stage === BLE_OTA_FINISH_ABORTED) { + state.abortRequested = true; + state.expectedReboot = false; + state.reconnecting = false; + state.reconnectAttempts = 0; + state.lastUploadStatus = 'aborted'; + setProgress(0, 'OTA 已中止'); + setBleStageError('OTA 已中止'); + showToast('OTA 已中止(BLE 保持连接)', 'warning'); + // 解除任何等待 ACK / OTA 结果的 Promise + if (state.ackWaiter) { + const waiter = state.ackWaiter; + state.ackWaiter = null; + clearTimeout(waiter.timer); + waiter.reject(new Error('OTA 已中止')); + } + rejectOtaResult('OTA 已中止', { stage: stage, value: value }); + return; + } + + if (stage === BLE_OTA_FINISH_ERR_GD32_RELAY) { + if (!finishBelongsToWaiter('gd32', value)) return; + if (typeof switchStageFlow === 'function') switchStageFlow('gd32'); + if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(255); + setProgress(0, '[GD32] OTA 转发失败'); + state.lastUploadStatus = 'failed'; + showToast('GD32 OTA 转发失败', 'error'); + showBleVerifyInfo(state.fileSha256 || '-', 'GD32 未完成转发', { text: 'GD32 OTA 转发失败: ' + resultCode, err: true }); + rejectOtaResult('GD32 OTA 转发失败: ' + resultCode, { stage: stage, value: value }); + return; + } + + // Update BLE stage flow based on FINISH notifications + if (stage === BLE_OTA_FINISH_ESP32_DONE) { + if (!finishBelongsToWaiter('esp32', value)) return; + state.lastEsp32DoneSession = finishSessionId(value); + setBleStage(finalizeStage, '收尾完成', null); + setBleStageText('OTA 收尾完成,等待重启'); + state.lastUploadStatus = 'success'; + setProgress(100, '[ESP32] OTA 收尾完成,等待重启'); + resolveOtaResult('success', { stage: stage, value: value }); + } else if (stage === BLE_OTA_FINISH_REBOOT_NOW) { + if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return; + state.expectedReboot = true; + setBleStage(finalizeStage, '设备重启', null); + // ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝 + var rebootBar = document.getElementById('stageStatusBar'); + var rebootBarTxt = document.getElementById('stageStatusText'); + if (rebootBar) rebootBar.className = 'stage-status-bar ok'; + if (rebootBarTxt) rebootBarTxt.textContent = 'ESP32 刷写完成,等待重启'; + if (typeof markPhaseDone === 'function') { + markPhaseDone('esp32Ota'); + } + } + + // OTA 结果 toast 提示(用户可见,不只是在日志里) + if (stage === BLE_OTA_FINISH_ERR_MUTEX) { + if (!isSessionlessFinish(value)) { + if (hasCurrentWaiter('esp32') && !finishBelongsToWaiter('esp32', value)) return; + if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return; + } + showToast('OTA 被其他通道占用,请稍后重试', 'error'); + state.abortRequested = true; + setProgress(0, 'OTA 被其他通道占用'); + state.lastUploadStatus = 'failed'; + setBleStageError('OTA 被其他通道占用'); + rejectOtaResult('OTA 被其他通道占用', { stage: stage, value: value }); + } else if (stage === BLE_OTA_FINISH_ERR_GD32_SIZE) { + if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return; + showToast(`GD32 固件超出分区大小 (${formatBytes(value)},上限 216KB)`, 'error'); + setProgress(0, 'GD32 固件太大,无法刷写'); + state.lastUploadStatus = 'failed'; + setBleStageError('固件超出分区大小'); + rejectOtaResult('GD32 固件超出分区大小', { stage: stage, value: value }); + } else if (stage === BLE_OTA_FINISH_ERR_FINALIZE) { + if (!finishBelongsToWaiter('esp32', value)) return; + showToast('ESP32 OTA 刷写失败', 'error'); + setProgress(0, '刷写失败'); + state.lastUploadStatus = 'failed'; + setBleStageError('OTA 刷写失败'); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true }); + rejectOtaResult('ESP32 OTA 刷写失败', { stage: stage, value: value }); + } else if (stage === BLE_OTA_FINISH_ERR_SHA_MISMATCH) { + const isGd32Waiter = hasCurrentWaiter('gd32'); + const targetKey = isGd32Waiter ? 'gd32' : 'esp32'; + if (!finishBelongsToWaiter(targetKey, value)) return; + const msg = isGd32Waiter + ? 'GD32 缓存 SHA-256 校验失败,未进入 SPI 转发' + : 'ESP32 OTA 校验失败,未触发重启'; + showToast(msg, 'error'); + setProgress(0, '设备端 SHA-256 校验失败'); + state.lastUploadStatus = 'failed'; + setBleStage(flowTarget().verifyStage, 'SHA-256 ✗', null); + setBleStageError('设备端 SHA-256 校验失败'); + showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: msg, err: true }); + rejectOtaResult(msg, { stage: stage, value: value }); + } else if (stage === BLE_OTA_FINISH_REBOOT_NOW) { + if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return; + showToast('ESP32 OTA 刷写完成,等待重启', 'success'); + // If device SHA-256 hasn't arrived yet, show what we have + var devSha = state.deviceSha256 || '设备未上报'; + if (state.deviceSha256 && state.fileSha256 && state.deviceSha256.toUpperCase() === state.fileSha256.toUpperCase()) { + showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '校验通过 — SHA-256 一致,设备重启中', ok: true }); + } else if (state.deviceSha256) { + showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '校验失败 — SHA-256 不一致', err: true }); + } else { + showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '刷写成功,设备未上报 SHA-256(重启中)' }); + } + } + // 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环 + if (stage >= 0x80) { + state.abortRequested = true; // ★ 阻止 fire-and-forget 继续发包 + state.expectedReboot = false; // ★ 刷写失败,取消任何重连退避 + state.reconnecting = false; + state.reconnectAttempts = 0; + if (state.ackWaiter) { + const waiter = state.ackWaiter; + state.ackWaiter = null; + clearTimeout(waiter.timer); + waiter.reject(new Error(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`)); + } + } + return; + } + + // SHA-256 verification from device (after transfer complete) + if (bytes[0] === BLE_OTA_NOTIFY_SHA256 && bytes.length >= 33) { + if (bytes.length >= 35) { + var shaSessionId = bytes[33] | (bytes[34] << 8); + if (!state.otaResultWaiter || + state.otaResultWaiter.sessionId !== shaSessionId) { + bleLog('VERIFY', '忽略非本轮 SHA-256 通知: session=' + shaSessionId, 'W'); + return; + } + } + var devSha = Array.from(bytes.slice(1, 33)).map(function(b) { + return b.toString(16).padStart(2, '0'); + }).join('').toUpperCase(); + state.deviceSha256 = devSha; + bleLog('VERIFY', '设备端 SHA-256: ' + devSha); + + // 设备端上报 SHA-256 后,前端仅做展示与状态同步。 + var frontSha = state.fileSha256; + var verifyStage = flowTarget().verifyStage; + if (frontSha && frontSha.toUpperCase() === devSha) { + bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I'); + showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true }); + setBleStage(verifyStage, 'SHA-256 ✓', null); + // ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进 + var okBar = document.getElementById('stageStatusBar'); + var okTxt = document.getElementById('stageStatusText'); + if (okBar) okBar.className = 'stage-status-bar ok'; + if (okTxt) okTxt.textContent = '刷写成功 — SHA-256 校验通过'; + if (typeof markPhaseDone === 'function') { + markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota'); + } + } else if (frontSha) { + bleLog('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E'); + showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true }); + setBleStage(verifyStage, 'SHA-256 ✗', null); + setBleStageError('SHA-256 不一致'); + } else { + showBleVerifyInfo(undefined, devSha, { text: '前端 SHA-256 不可用,无法比对' }); + setBleStage(verifyStage, '无法比对', null); + } + return; + } + + + bleLog('NOTIFY', hex); +} + + +// ===== 自动重连 ===== +export async function tryReconnect() { + if (!state.device || !state.expectedReboot) { + state.reconnecting = false; + return; + } + if (!state.device.gatt) { + // 设备引用已丧失,重连不了 + state.reconnecting = false; + state.expectedReboot = false; + showToast('设备引用丧失,请手动重连', 'error'); + return; + } + state.reconnecting = true; + state.reconnectAttempts += 1; + var attempt = state.reconnectAttempts; + // 退避:1s, 2s, 4s, 8s, 上限 10s + var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1)); + if (backoff > 200) await sleep(backoff); + if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { + if (els.connState) els.connState.textContent = '重连中(' + attempt + ')'; + if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing'; + } + setConnectionState(!!state.server); + bleLog('BLE', '自动重连第 ' + attempt + ' 次' + (backoff > 0 ? '(等待 ' + Math.round(backoff) + 'ms)' : '') + '...', 'W'); + try { + var server = await state.device.gatt.connect(); + state.server = server; + state.service = await server.getPrimaryService(UUIDS.service); + state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl); + state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn); + state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut); + state.versionChar = await state.service.getCharacteristic(UUIDS.version); + state.notificationsEnabled = false; + await enableNotifications(); + state.expectedReboot = false; + state.reconnecting = false; + state.reconnectAttempts = 0; + // 重连后重注 onDisconnected({once: true},旧的已被上次断开时消耗) + state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true }); + bleLog('BLE', '✅ 自动重连成功', 'I'); + showToast('BLE 已重连', 'success'); + setConnectionState(true); + // 重连后默认刷新一下版本 + try { await readVersion(); } catch (_) {} + } catch (e) { + bleLog('BLE', '重连第 ' + attempt + ' 次失败: ' + e.message, 'W'); + if (state.expectedReboot && attempt < 12) { + tryReconnect(); + } else { + state.reconnecting = false; + state.expectedReboot = false; + showToast('重连失败,请手动连接', 'error'); + bleLog('BLE', '自动重连放弃,请点击「连接设备」重连', 'E'); + setConnectionState(!!state.server); + } + } +} + +export async function enableNotifications() { + // 修复偶发的 GATT CCC 竞态:startNotifications 与 NimBLE gap 完成握手存在窗口冲突。 + // 现象:Chrome 报 "GATT operation failed for unknown reason",NimBLE 实际返回 EBUSY/ENOTCONN。 + // 处理:内部退避重试 + 最终失败时主动断开,让上层进入干净的重连路径,而不是带着半死连接去刷写。 + const MAX_RETRY = 3; + for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { + try { + await state.dataOutChar.startNotifications(); + state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify); + state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify); + state.notificationsEnabled = true; + setConnectionState(!!state.server); + bleLog('BLE', 'DATA_OUT 通知已自动开启' + (attempt > 1 ? ` (重试 ${attempt}/${MAX_RETRY} 成功)` : '')); + return; + } catch (err) { + bleLog('BLE', `自动开启 DATA_OUT 通知失败(第 ${attempt}/${MAX_RETRY} 次): ${err.message}`, 'E'); + if (attempt < MAX_RETRY) { + // 200ms / 400ms 退避,给 NimBLE 主机任务时间把 connect/MTU 事件消化完 + await new Promise(r => setTimeout(r, 200 * attempt)); + } + } + } + state.notificationsEnabled = false; + setConnectionState(!!state.server); + // ★ 最终失败:连接处于半死状态,主动断开让用户重连,避免带着残废连接走到刷写 + try { + if (state.device && state.device.gatt && state.device.gatt.connected) { + state.device.gatt.disconnect(); + } + } catch (_) {} + bleLog('BLE', 'DATA_OUT 通知开启彻底失败,已主动断开,请重连后重试', 'E'); +} diff --git a/tools/js/ble/session.js b/tools/js/ble/session.js new file mode 100644 index 0000000..b389de3 --- /dev/null +++ b/tools/js/ble/session.js @@ -0,0 +1,123 @@ +// ==================== BLE OTA 结果等待与中止 ==================== +import { BLE_OTA_CTRL_ABORT_OTA } from './constants.js'; +import { state, els, bleLog } from './state.js'; +import { connectBle, disconnectBle } from './gatt.js'; +import { autoFlash } from './transfer.js'; +import { syncSelectedFileFromInput } from './file.js'; + +export function clearOtaResultWaiter() { + if (state.otaResultWaiter) { + clearTimeout(state.otaResultWaiter.timer); + state.otaResultWaiter = null; + } +} + +export function resolveOtaResult(status, detail) { + if (!state.otaResultWaiter) return; + var waiter = state.otaResultWaiter; + state.otaResultWaiter = null; + clearTimeout(waiter.timer); + waiter.resolve({ status: status, detail: detail || {} }); +} + +export function rejectOtaResult(message, detail) { + if (!state.otaResultWaiter) return; + var waiter = state.otaResultWaiter; + state.otaResultWaiter = null; + clearTimeout(waiter.timer); + var err = new Error(message || 'OTA 收尾失败'); + err.detail = detail || {}; + waiter.reject(err); +} + +export function nextOtaSessionId() { + state.otaRunId = (state.otaRunId + 1) & 0xFFFF; + if (state.otaRunId === 0) state.otaRunId = 1; + return state.otaRunId; +} + +export function finishBelongsToWaiter(targetKey, value) { + if (!state.otaResultWaiter || state.otaResultWaiter.targetKey !== targetKey) return false; + return (value & 0xFFFF) === state.otaResultWaiter.sessionId; +} + +export function finishSessionId(value) { + return value & 0xFFFF; +} + +export function isSessionlessFinish(value) { + return finishSessionId(value) === 0; +} + +export function hasCurrentWaiter(targetKey) { + return !!(state.otaResultWaiter && state.otaResultWaiter.targetKey === targetKey); +} + +export function waitForOtaResult(targetKey, sessionId, timeoutMs) { + clearOtaResultWaiter(); + var runId = state.otaRunId; + return new Promise(function(resolve, reject) { + var timer = setTimeout(function() { + if (state.otaResultWaiter && state.otaResultWaiter.runId === runId) { + state.otaResultWaiter = null; + } + reject(new Error(targetKey === 'gd32' ? '等待 GD32 OTA 转发结果超时' : '等待 ESP32 OTA 收尾结果超时')); + }, timeoutMs); + state.otaResultWaiter = { + targetKey: targetKey, + sessionId: sessionId, + runId: runId, + timer: timer, + resolve: resolve, + reject: reject, + }; + }); +} + +/** + * @description 中止当前 BLE OTA 任务并清理状态。 + * 改进:发 0xFB 控制命令让 ESP32 主动清理 OTA 会话,BLE 保持连接。 + * 旧做法是直接断开 BLE 触发 GAP disconnect 清理,导致用户需重连。 + * 若 0xFB 发送失败(如 CTRL 特征不可用),回退到设置标志等待发包循环自然退出。 + */ +export async function abortTask() { + state.abortRequested = true; // 立即停止发送循环 + bleLog('OTA', '已请求中止,发送 0xFB 让设备端清理(BLE 保持连接)'); + + if (state.ctrlChar && state.server && state.device.gatt.connected) { + try { + const session = state.sessionId || 0; + const abortCmd = new Uint8Array([BLE_OTA_CTRL_ABORT_OTA, session & 0xFF, (session >> 8) & 0xFF]); + await state.ctrlChar.writeValue(abortCmd); + bleLog('OTA', '0xFB 已发送,等待设备端回复 ABORTED (stage=0x20)'); + } catch (e) { + bleLog('OTA', '0xFB 发送失败: ' + e.message + ',回退到本地中止', 'E'); + // CTRL 写入失败时回退:本地立即标记失败,不再等设备端 ABORTED 回复 + state.lastUploadStatus = 'failed'; + if (state.ackWaiter) { + const waiter = state.ackWaiter; + state.ackWaiter = null; + clearTimeout(waiter.timer); + waiter.reject(new Error('中止失败: ' + e.message)); + } + rejectOtaResult('中止失败: ' + e.message, { stage: 0xFB, value: 0 }); + } + } else { + // CTRL 特征未就绪(连接已断或未完成握手),仅本地清理 + bleLog('OTA', 'CTRL 特征不可用,仅本地中止', 'W'); + state.lastUploadStatus = 'failed'; + rejectOtaResult('CTRL 特征不可用', { stage: 0xFB, value: 0 }); + } +} + +export function bindEvents() { + els.connectBtn.addEventListener('click', connectBle); + els.disconnectBtn.addEventListener('click', disconnectBle); + els.autoFlashBtn.addEventListener('click', autoFlash); + els.abortBtn.addEventListener('click', abortTask); + // 上传区事件由 app.js 的 setupSharedUploadZone 统一处理 + // 文件输入变化时同步 BLE 文件状态 + els.firmwareFile.addEventListener('change', syncSelectedFileFromInput); + els.firmwareFile.addEventListener('input', syncSelectedFileFromInput); + window.addEventListener('focus', () => setTimeout(syncSelectedFileFromInput, 0)); +} diff --git a/tools/js/ble/stages.js b/tools/js/ble/stages.js new file mode 100644 index 0000000..1ede4c8 --- /dev/null +++ b/tools/js/ble/stages.js @@ -0,0 +1,66 @@ +// ==================== BLE OTA Stage Flow Management ==================== + +export var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6']; +export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6']; +export var bleStageLabelIds = ['bsl5', 'bsl6']; + +export function updateBleStageLabels(cfg) { + var stage5 = document.getElementById(bleStageLabelIds[0]); + var stage6 = document.getElementById(bleStageLabelIds[1]); + if (stage5) stage5.textContent = cfg.stage5Label || '完成触发'; + if (stage6) stage6.textContent = cfg.stage6Label || '校验比对'; +} + +export function resetBleStages() { + bleStageIds.forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + bleStageDetailIds.forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + var bar = document.getElementById('stageStatusBar'); + if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '待机'; } +} + +export function setBleStage(stageNum, detail, barState) { + // stageNum: 1-6, detail: sub-step text + for (var i = 0; i < bleStageIds.length; i++) { + var el = document.getElementById(bleStageIds[i]); + if (!el) continue; + if (i + 1 < stageNum) el.className = 'stage-node done'; + else if (i + 1 === stageNum) el.className = 'stage-node live'; + else el.className = 'stage-node'; + } + // Set detail text + if (stageNum >= 1 && stageNum <= 6) { + var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]); + if (detailEl && detail !== undefined) detailEl.textContent = detail; + } + // Update status bar + var bar = document.getElementById('stageStatusBar'); + if (bar) { + bar.className = 'stage-status-bar' + (barState ? ' ' + barState : (stageNum > 0 ? ' active' : '')); + } +} + +export function setBleStageText(text) { + var txt = document.getElementById('stageStatusText'); + if (txt) txt.textContent = text; +} + +export function setBleStageError(detail) { + // Mark current active stage as error + for (var i = 0; i < bleStageIds.length; i++) { + var el = document.getElementById(bleStageIds[i]); + if (el && el.className === 'stage-node live') { + el.className = 'stage-node err'; + var detailEl = document.getElementById(bleStageDetailIds[i]); + if (detailEl && detail) detailEl.textContent = detail; + } + } + var bar = document.getElementById('stageStatusBar'); + if (bar) bar.className = 'stage-status-bar err'; + setBleStageText('错误: ' + (detail || '')); +} diff --git a/tools/js/ble/state.js b/tools/js/ble/state.js new file mode 100644 index 0000000..246a737 --- /dev/null +++ b/tools/js/ble/state.js @@ -0,0 +1,188 @@ +// ==================== BLE OTA 状态与元素缓存 ==================== +import { TARGETS } from './constants.js'; +import { addLog } from '../common/log.js'; +import { updateBleStageLabels, resetBleStages } from './stages.js'; +import { hideBleVerifyInfo } from './verify.js'; +import { updateFileInfo } from './file.js'; + +export const state = { + device: null, + server: null, + service: null, + ctrlChar: null, + dataInChar: null, + dataOutChar: null, + versionChar: null, + notificationsEnabled: false, + selectedFile: null, + selectedFileHandle: null, // FileSystemFileHandle for hot reload + abortRequested: false, + uploading: false, + autoFlashing: false, + lastUploadStatus: 'idle', + lastNotificationHex: '', + lastAckCmd: null, + lastAckSeq: -1, + lastAckBytes: 0, + ackWaiter: null, + otaResultWaiter: null, + otaRunId: 0, + lastEsp32DoneSession: null, + fileSha256: null, + deviceSha256: null, + expectedReboot: false, // 发送 0xFA 后置 true,重连后清 + reconnecting: false, // 自动重连中 + reconnectAttempts: 0, // 重连尝试次数 + uploadTargetKey: null, + activeTotalSize: 0, + activePacketTotal: 0, +}; + +export const $ = (id) => document.getElementById(id); +export const els = { + connectBtn: $('connectBtn'), + disconnectBtn: $('disconnectBtn'), + autoFlashBtn: $('autoFlashBtn'), + abortBtn: $('abortBtn'), + uploadZone: $('uploadZone'), + firmwareFile: $('firmwareFile'), + connStatus: $('connStatus'), // 共享连接指示器 + connStatusTxt: $('connStatusTxt'), // 共享连接指示器文本 + connState: $('connState'), + connBadge: $('connBadge'), + deviceName: $('deviceName'), + deviceId: $('deviceId'), + gattState: $('gattState'), + versionText: $('versionText'), + fileInfo: $('fileInfo'), + clearFileBtn: $('clearFileBtn'), + progressFill: $('progressFill'), // 共享进度条 + progressTxt: $('progressTxt'), // 共享进度文本 + progressPct: $('progressPct'), // 共享进度百分比 + fileName: $('fileName'), + fileType: $('fileType'), + fileSize: $('fileSize'), + packetCount: $('packetCount'), + sentCount: $('sentCount'), + logScroll: $('logScroll'), // 共享日志面板 +}; + +export function currentTarget() { + return TARGETS[window.flashTarget] || TARGETS.esp32; +} + +export function currentPayloadSize() { + const cfg = currentTarget(); + return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480)); +} + +export function flowTarget() { + return TARGETS[state.uploadTargetKey] || currentTarget(); +} + +export function bleLog(tag, message, level) { + level = level || 'I'; + if (typeof addLog === 'function') { + addLog(level, 'BLE-' + tag, message); + } +} + +export function toHex(uint8) { + return Array.from(uint8).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); +} + +export function hexToBytes(hex) { + const clean = hex.replace(/[^0-9a-fA-F]/g, ''); + if (clean.length === 0 || clean.length % 2 !== 0) throw new Error('HEX 长度必须为偶数'); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < clean.length; i += 2) out[i / 2] = parseInt(clean.slice(i, i + 2), 16); + return out; +} + +export async function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +export function setReadyHint(html) { + const el = $('readyHint'); + if (el) el.innerHTML = html; +} + +export function setConnectionState(connected) { + const idle = connected && state.selectedFile && !state.uploading; + // 仅在 BLE 模式下更新共享连接指示器,避免覆盖 WiFi 轮询的状态 + if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { + if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; + if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接'; + } + // BLE 专用连接徽章 — 仅在 BLE 模式下更新,避免覆盖 WiFi 状态 + if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { + if (els.connState) els.connState.textContent = connected ? '已连接' : '未连接'; + if (els.connState && els.connState.parentElement) { + els.connState.parentElement.className = connected ? 'pill online' : 'pill offline'; + } + els.connBadge.textContent = connected ? 'online' : 'offline'; + els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`; + els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪'; + } + if (connected) { + els.connectBtn.disabled = true; + } else { + els.connectBtn.disabled = false; + } + els.disconnectBtn.disabled = !connected; + els.autoFlashBtn.disabled = !idle; + els.abortBtn.disabled = !state.uploading; + + if (!connected) { + setReadyHint('当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。'); + } else if (!state.notificationsEnabled) { + setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。'); + } else if (!state.selectedFile) { + setReadyHint('设备已连接。下一步请选择目标芯片与固件文件。'); + } else { + setReadyHint('设备与文件都已就绪,可以直接开始刷写或执行一键刷写。'); + } +} + +export function resetUiAfterDisconnect() { + state.server = null; + state.service = null; + state.ctrlChar = null; + state.dataInChar = null; + state.dataOutChar = null; + state.versionChar = null; + state.notificationsEnabled = false; + state.uploading = false; + state.autoFlashing = false; + state.lastUploadStatus = 'idle'; + state.deviceSha256 = null; + if (typeof connectionMode === 'undefined' || connectionMode === 'ble') { + els.deviceName.textContent = '-'; + els.deviceId.textContent = '-'; + if (els.versionText) els.versionText.textContent = '未读取'; + var pv = document.getElementById('pillVer'); + if (pv) pv.textContent = 'v--'; + } + resetBleStages(); + hideBleVerifyInfo(); + setConnectionState(false); +} + +export function refreshTargetUi() { + const cfg = currentTarget(); + updateBleStageLabels(cfg); + // ★ 上传进行中不调用 updateFileInfo:避免目标切换时进度条被重置。 + // 阶段标签更新(updateBleStageLabels)安全,不碰进度条。 + if (state.selectedFile && !state.uploading && !state.autoFlashing) { + updateFileInfo(state.selectedFile); + } + setConnectionState(!!state.server); + // 同步目标胶囊标签 + var espTab = document.getElementById('targetEsp32Tab'); + var gdTab = document.getElementById('targetGd32Tab'); + var isEsp = cfg.label === 'ESP32'; + if (espTab) espTab.classList.toggle('active', isEsp); + if (gdTab) gdTab.classList.toggle('active', !isEsp); + bleLog('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`); +} diff --git a/tools/js/ble/transfer.js b/tools/js/ble/transfer.js new file mode 100644 index 0000000..332e169 --- /dev/null +++ b/tools/js/ble/transfer.js @@ -0,0 +1,489 @@ +// ==================== BLE OTA 数据写入与 ACK ==================== +import { BLE_OTA_ACK_WINDOW, UI_BUILD } from './constants.js'; +import { state, els, bleLog, currentTarget, currentPayloadSize, flowTarget, setConnectionState, sleep, toHex, hexToBytes } from './state.js'; +import { setBleStage, setBleStageText, setBleStageError } from './stages.js'; +import { showBleVerifyInfo, hideBleVerifyInfo } from './verify.js'; +import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js'; +import { enableNotifications } from './gatt.js'; +import { syncSelectedFileFromInput, updateFileInfo } from './file.js'; +import { formatBytes } from '../common/utils.js'; +import { showToast } from '../common/toast.js'; + +export function setProgress(percent, text) { + const p = Math.max(0, Math.min(100, percent)); + els.progressFill.style.width = `${p}%`; + els.progressPct.textContent = `${p.toFixed(0)}%`; + els.progressTxt.textContent = text; + // Toggle done class for green finish effect + if (p >= 100) { + els.progressFill.classList.add('done'); + } else { + els.progressFill.classList.remove('done'); + } +} + +// ★ 以设备端 ACK 字节数为唯一进度数据源 +// 仅当处于上传阶段(state.uploading)且未中止时才刷 UI,避免陈旧 ACK / abort 后覆盖 +export function updateProgressFromAck(totalSize, packetTotal, ackBytes) { + if (!state.uploading) return; + if (state.abortRequested) return; + if (!Number.isFinite(totalSize) || totalSize <= 0) return; + const safeBytes = Math.max(0, Math.min(totalSize, ackBytes >>> 0)); + const percent = (safeBytes / totalSize) * 100; + const payloadSize = Math.max(1, currentPayloadSize()); + const ackedPackets = Math.min(packetTotal, Math.ceil(safeBytes / payloadSize)); + const cfg = currentTarget(); + setProgress(percent, `[${cfg.label}] 已确认 ${ackedPackets}/${packetTotal} 包`); + els.sentCount.textContent = String(ackedPackets); + // 阶段 4 detail 文本:只在写入阶段刷新,避免与 stage 5/6 冲突 + var detailEl = document.getElementById('bsd4'); + if (detailEl) detailEl.textContent = `${ackedPackets}/${packetTotal} 包`; + setBleStageText(`数据写入 ${Math.round(percent)}%`); +} + +/** @description 构建 BLE OTA 数据包(seq + totalSize + payload + cmd + CRC)。 */ +function buildPacket(seq, totalSize, payload, cmd) { + const packet = new Uint8Array(7 + payload.length); + packet[0] = cmd; + packet[1] = seq & 0xFF; + packet[2] = (seq >> 8) & 0xFF; + packet[3] = totalSize & 0xFF; + packet[4] = (totalSize >> 8) & 0xFF; + packet[5] = (totalSize >> 16) & 0xFF; + packet[6] = (totalSize >> 24) & 0xFF; + packet.set(payload, 7); + return packet; +} + +export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) { + // ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null), + // 这里立即 reject,避免在已中止的会话上盲等超时。 + if (state.abortRequested) { + return Promise.reject(new Error('OTA 已中止')); + } + const gen = state.ackGeneration; + // ★ 提前返回:只要 cmd 匹配 + generation 匹配 + 字节数足够,即可 resolve + // 不再要求 lastAckSeq >= expectedSeq(与 handleNotify 保持一致) + if (state.lastAckCmd === cmd && gen === state.ackGeneration) { + const expectedBytes = opts.expectedTotalSize !== undefined + ? Math.min((expectedSeq + 1) * (opts.payloadSize || 480), opts.expectedTotalSize) + : 0; + if (expectedBytes === 0 || state.lastAckBytes >= expectedBytes) { + return Promise.resolve({ seq: state.lastAckSeq, bytes: state.lastAckBytes }); + } + // 字节数不够,不提前 resolve,等待后续 ACK + } + if (state.ackWaiter) { + clearTimeout(state.ackWaiter.timer); + state.ackWaiter.reject(new Error('ACK waiter 被新的窗口覆盖')); + state.ackWaiter = null; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (state.ackWaiter && state.ackWaiter.expectedSeq === expectedSeq && state.ackWaiter.cmd === cmd) { + state.ackWaiter = null; + } + reject(new Error(`等待 ACK 超时: cmd=0x${cmd.toString(16).padStart(2, '0').toUpperCase()} seq=${expectedSeq}`)); + }, timeoutMs); + state.ackWaiter = { + cmd, expectedSeq, resolve, reject, timer, generation: gen, + expectedTotalSize: opts.expectedTotalSize, + payloadSize: opts.payloadSize, + }; + }); +} + +/** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */ +export async function uploadFirmware() { + // 每次刷写前尝试重新从本地读取文件 + if (state.selectedFileHandle) { + try { + const freshFile = await state.selectedFileHandle.getFile(); + await updateFileInfo(freshFile); + var actualFile = state.selectedFile || freshFile; + bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); + } catch (e) { + bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); + } + } else { + await syncSelectedFileFromInput(); + } + if (!state.selectedFile || !state.dataInChar) return false; + + const cfg = currentTarget(); + state.uploadTargetKey = window.flashTarget || 'esp32'; + const payloadSize = Math.max(32, Math.min(498, currentPayloadSize())); + const file = state.selectedFile; + + state.abortRequested = false; + state.uploading = true; + state.lastUploadStatus = 'uploading'; + const otaSessionId = nextOtaSessionId(); + clearOtaResultWaiter(); + if (typeof setActivePhase === 'function') { + setActivePhase(cfg.label === 'GD32' ? 'gd32Ota' : 'esp32Ota'); + } + switchStageFlow('ble'); + state.lastAckCmd = null; + state.lastAckSeq = -1; + state.lastAckBytes = 0; + state.ackGeneration = 0; + state.deviceSha256 = null; + state.lastEsp32DoneSession = null; + state.activeTotalSize = 0; + state.activePacketTotal = 0; + if (state.ackWaiter) { + clearTimeout(state.ackWaiter.timer); + state.ackWaiter = null; + } + setConnectionState(true); + + // Stage 4: Data writing + setBleStage(4, '准备中...', 'active'); + setBleStageText('正在准备数据写入...'); + hideBleVerifyInfo(); + + try { + if (!state.notificationsEnabled) { + await enableNotifications(); + } + if (!state.notificationsEnabled) { + throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式'); + } + + bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`); + setProgress(0, `[${cfg.label}] 正在读取固件文件...`); + setBleStage(4, '读取固件...', null); + const fileBytes = new Uint8Array(await file.arrayBuffer()); + const totalSize = fileBytes.length; + const packetTotal = Math.ceil(totalSize / payloadSize); + bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`); + if (totalSize !== file.size) { + bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W'); + } + + // SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。 + // 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。 + var fileSha256; + if (state.fileSha256) { + fileSha256 = state.fileSha256.toLowerCase(); + } else { + // 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置) + bleLog('OTA', '正在计算文件 SHA-256 哈希...'); + const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + state.fileSha256 = fileSha256.toUpperCase(); + } + // Update SHA display in file info + var shaElem = document.getElementById('fileSha256'); + if (shaElem) shaElem.textContent = fileSha256.toUpperCase(); + + setBleStage(4, '写入中...', null); + setBleStageText(`正在写入 ${packetTotal} 包数据...`); + // ★ 写入阶段已读出文件大小/包数,记录到 state 供 handleNotify 在 ACK 到达时使用 + state.activeTotalSize = totalSize; + state.activePacketTotal = packetTotal; + els.sentCount.textContent = '0'; + setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`); + bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`); + + const MAX_WINDOW_RETRIES = 3; + for (let windowStart = 0; windowStart < packetTotal; windowStart += BLE_OTA_ACK_WINDOW) { + if (state.abortRequested) throw new Error('用户中止'); + + const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1; + + // Windows Web Bluetooth 同一时刻只允许一个 GATT 操作。逐包等待仅保证 + // 提交顺序;实际吞吐由设备请求的高速 BLE 连接参数决定。 + // ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动 + for (let seq = windowStart; seq <= endSeq; seq++) { + // ★ 中止检查放在循环内:abortTask 可能在本窗口发送过程中触发 + // (windowStart 处的检查无法覆盖窗口中途的 abort) + if (state.abortRequested) throw new Error('用户中止'); + const start = seq * payloadSize; + const end = Math.min(start + payloadSize, totalSize); + const payload = fileBytes.slice(start, end); + const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd); + + // ★ seq=0 必须用 writeValueWithResponse(带响应): + // seq=0 触发设备端 esp_ota_begin()(flash 擦除 + OTA 会话创建), + // 若用 writeValueWithoutResponse 在 BLE 链路层丢失, + // 设备永远不会启动 OTA 会话,后续所有包都被丢弃。 + if (seq === 0) { + try { + await state.dataInChar.writeValueWithResponse(packet); + bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达', 'I'); + } catch (e) { + bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E'); + throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message); + } + // seq=0 写入后等待设备完成 esp_ota_begin()(flash 擦除较耗时) + await sleep(200); + } else { + await state.dataInChar.writeValueWithoutResponse(packet); + } + } + + state.ackGeneration++; + try { + await waitForAck(cfg.packetCmd, endSeq, 5000, { expectedTotalSize: totalSize, payloadSize }); + } catch (e) { + bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W'); + } + + let expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize); + let retryCount = 0; + let prevAckBytes = state.lastAckBytes; // ★ 进度跟踪:检测重试是否有效 + // ★ 仅以字节数判断是否完成,不依赖 ackOk(generation 不匹配时 ackOk 不可靠) + while (state.lastAckBytes < expectedBytes && retryCount < MAX_WINDOW_RETRIES) { + // ★ 中止检查:0x20 ABORTED 可能在上一轮 waitForAck 超时期间到达, + // 或在 sleep/续发过程中到达。立即抛出,避免无谓重试已中止的会话。 + if (state.abortRequested) throw new Error('用户中止'); + // 如果 ESP32 已确认所有数据(ACK 延迟到达),直接跳出 + if (state.lastAckBytes >= expectedBytes) break; + + // ★ 进度检测:如果重试后字节数没有增长,说明续传无效 + if (retryCount > 0 && state.lastAckBytes <= prevAckBytes) { + bleLog('OTA', `上次重试暂未推进: ackBytes=${state.lastAckBytes} (前次=${prevAckBytes}),继续按缺口重试`, 'W'); + } + prevAckBytes = state.lastAckBytes; + + // 从 ESP32 实际确认的位置续传,而非重发整个窗口 + const resumeSeq = Math.max(windowStart, Math.ceil(state.lastAckBytes / payloadSize)); + const missing = expectedBytes - state.lastAckBytes; + const missingPackets = Math.ceil(missing / payloadSize); + retryCount++; + bleLog('OTA', `丢包检测: seq=${endSeq} 丢失${missing}字节(${missingPackets}包),从 seq=${resumeSeq} 续发 (非全窗口重发),第${retryCount}次`, 'W'); + setBleStage(4, `续发 #${retryCount} (seq=${resumeSeq})`, null); + setBleStageText(`丢包续发中 (${retryCount}/${MAX_WINDOW_RETRIES})...`); + + // ★ 等待设备端 reorder buffer flush + ACK 回传。 + // 设备端可能正在 flush 缺口包(getReceived 跳跃增长), + // 留足时间让最新的 ACK 到达,避免重发已 flush 的包。 + await sleep(10); + + for (let seq = resumeSeq; seq <= endSeq; seq++) { + // ★ 中止检查:续发过程中可能收到 0x20 ABORTED + if (state.abortRequested) throw new Error('用户中止'); + const start = seq * payloadSize; + const end = Math.min(start + payloadSize, totalSize); + const payload = fileBytes.slice(start, end); + const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd); + // ★ 重试路径中 seq=0 也必须用 writeValueWithResponse + if (seq === 0) { + try { + await state.dataInChar.writeValueWithResponse(packet); + bleLog('OTA', '重试 seq=0 已用 writeValueWithResponse 确认', 'I'); + } catch (e) { + bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E'); + } + await sleep(200); + } else { + await state.dataInChar.writeValueWithoutResponse(packet); + } + } + + state.ackGeneration++; + try { + await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize }); + } catch (e) { + bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W'); + } + expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize); + } + + if (state.lastAckBytes < expectedBytes) { + const missing = expectedBytes - state.lastAckBytes; + throw new Error(`BLE 丢包检测: seq=${endSeq} 期望${expectedBytes}字节 实际${state.lastAckBytes}字节 丢失${missing}字节(${(missing / payloadSize).toFixed(1)}包) 已重试${retryCount}次`); + } + } + + // Stage 4 done + // ★ 不再用本地 seq 推算固定百分比:进度以设备端 ACK 字节数为准 + const confirmedPct = state.activeTotalSize > 0 + ? Math.min(100, (state.lastAckBytes >>> 0) / state.activeTotalSize * 100) + : 100; + // 阶段 4 完成后,base 进度:GD32 留 55% 给中继/校验,ESP32 留 10% 给收尾 + const phaseBase = cfg.label === 'GD32' ? 45 : 90; + // ACK 推进到的进度 = 阶段 4 在整体里占的份额 (phaseBase) × 设备真实确认比例 + const stage4Share = phaseBase * (confirmedPct / 100); + setBleStage(4, '完成', null); + setProgress(Math.min(phaseBase, stage4Share), `[${cfg.label}] 固件数据已全部写入 DATA_IN`); + + // Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对 + if (cfg.label === 'ESP32') { + state.deviceSha256 = null; + const frontSha = fileSha256.toUpperCase(); + const frontShaBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16); + } + const trigger = new Uint8Array(35); + trigger[0] = 0xF8; + trigger.set(frontShaBytes, 1); + trigger[33] = otaSessionId & 0xFF; + trigger[34] = (otaSessionId >> 8) & 0xFF; + const resultPromise = waitForOtaResult('esp32', otaSessionId, 45000); + showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' }); + setBleStage(5, '发送校验...', 'active'); + setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...'); + try { + await state.ctrlChar.writeValue(trigger); + bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验'); + } catch (e) { + clearOtaResultWaiter(); + state.lastUploadStatus = 'failed'; + setProgress(0, `0xF8 发送失败: ${e.message}`); + bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E'); + setBleStageError('0xF8 发送失败'); + return false; + } + setBleStage(5, '等待设备端校验', null); + setBleStage(6, '设备端收尾中', 'active'); + setBleStageText('0xF8 已发,等待设备端完成 OTA 收尾...'); + try { + await resultPromise; + } catch (e) { + state.lastUploadStatus = 'failed'; + setProgress(0, e.message); + setBleStageError(e.message); + clearOtaResultWaiter(); + return false; + } + return true; + } + + // GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result. + const frontSha = fileSha256.toUpperCase(); + const frontShaBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16); + } + state.deviceSha256 = null; + setBleStage(5, '发送触发...', 'active'); + setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...'); + showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' }); + await sleep(30); + const cmdHex = cfg.ctrlHex; + const cmdBase = hexToBytes(cmdHex); + const cmd = new Uint8Array(cmdBase.length + 32 + 2); + cmd.set(cmdBase, 0); + cmd.set(frontShaBytes, cmdBase.length); + cmd[cmdBase.length + 32] = otaSessionId & 0xFF; + cmd[cmdBase.length + 33] = (otaSessionId >> 8) & 0xFF; + const resultPromise = waitForOtaResult('gd32', otaSessionId, 120000); + try { + await state.ctrlChar.writeValue(cmd); + setBleStage(5, '已触发', null); + setBleStage(6, '等待设备端校验', 'active'); + bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`); + } catch (_) { + clearOtaResultWaiter(); + state.lastUploadStatus = 'failed'; + setProgress(0, `触发命令发送失败: ${toHex(cmd)}`); + bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E'); + setBleStageError('触发命令发送失败'); + return false; + } + + setBleStageText('GD32 缓存校验已发起,等待 SPI 转发流程...'); + try { + await resultPromise; + } catch (e) { + state.lastUploadStatus = 'failed'; + setProgress(0, e.message); + setBleStageError(e.message); + clearOtaResultWaiter(); + return false; + } + bleLog('OTA', `✅ GD32 OTA 刷写完成!`); + bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`); + return true; + } catch (err) { + // ★ 中止不算失败:0x20 ABORTED handler 已更新 UI(progress/stage/toast), + // 这里只记录日志并返回 false,避免把"用户中止"误报为写入失败。 + if (state.abortRequested || state.lastUploadStatus === 'aborted') { + bleLog('OTA', `写入已中止: ${err.message}`, 'W'); + return false; + } + state.lastUploadStatus = 'failed'; + state.expectedReboot = false; // ★ 刷写失败,取消重连退避 + state.reconnecting = false; + state.reconnectAttempts = 0; + bleLog('OTA', `写入失败: ${err.message}`, 'E'); + setProgress(0, `写入失败: ${err.message}`); + setBleStageError(err.message); + // ★ BLE OTA 失败后不再强制断开 BLE:错误 FINISH 已通过 0xFB 路径或设备端 + // 自动 abort 清理了 ESP32 OTA 会话。保留连接让用户可以快速重试或查看日志。 + // 仅当连接已半死(gatt.connected=false)时由 onDisconnected 走重连路径。 + return false; + } finally { + if (state.ackWaiter) { + clearTimeout(state.ackWaiter.timer); + state.ackWaiter = null; + } + if (state.lastUploadStatus === 'failed') { + clearOtaResultWaiter(); + } + state.uploading = false; + state.uploadTargetKey = null; + setConnectionState(!!state.server); + } +} + +/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */ +export async function autoFlash() { + // 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择) + if (state.selectedFileHandle) { + try { + const freshFile = await state.selectedFileHandle.getFile(); + // 仅在文件变化时才刷新元数据并重算 SHA-256 + // (name/size/lastModified 任一不同即视为变化) + // 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志 + const cached = state.selectedFile; + // HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex, + // 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换 + const isHex = freshFile.name.toLowerCase().endsWith('.hex'); + const changed = !cached || + (isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) || + cached.lastModified !== freshFile.lastModified; + if (changed) { + await updateFileInfo(freshFile); + var actualFile = state.selectedFile || freshFile; + bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); + } + } catch (e) { + bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); + } + } else { + await syncSelectedFileFromInput(); + } + if (!state.selectedFile || !state.server) return; + + const cfg = currentTarget(); + state.autoFlashing = true; + try { + bleLog('AUTO', `开始一键刷写 -> ${cfg.label}`); + const ok = await uploadFirmware(); + if (!ok || state.abortRequested) { + bleLog('AUTO', '一键刷写中止', 'W'); + // ★ 中止/失败后不再断开 BLE:0xFB 已让设备端清理 OTA 会话,保留连接方便重试。 + // 若连接已半死,onDisconnected 会自动走重连路径。 + return; + } + if (cfg.label === 'ESP32') { + setProgress(100, '[ESP32] OTA 完成,等待设备重启'); + bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启'); + } else { + setProgress(100, '[GD32] OTA 转发完成,APP 已启动'); + bleLog('AUTO', 'GD32 OTA 完成'); + } + } catch (err) { + bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E'); + setProgress(0, `一键刷写异常: ${err && err.message ? err.message : err}`); + } finally { + state.autoFlashing = false; + setConnectionState(!!state.server); + } +} diff --git a/tools/js/ble/verify.js b/tools/js/ble/verify.js new file mode 100644 index 0000000..79eda9b --- /dev/null +++ b/tools/js/ble/verify.js @@ -0,0 +1,26 @@ +// ==================== BLE OTA SHA-256 校验展示 ==================== + +export function showBleVerifyInfo(frontSha, devSha, result) { + var panel = document.getElementById('stageVerifyInfo'); + if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; } + if (frontSha !== undefined) { + var el = document.getElementById('sviFrontSha'); + if (el) el.textContent = frontSha || '-'; + } + if (devSha !== undefined) { + var el = document.getElementById('sviDeviceSha'); + if (el) el.textContent = devSha || '-'; + } + if (result !== undefined) { + var el = document.getElementById('sviResult'); + if (el) { + el.textContent = result.text || '-'; + el.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : ''); + } + } +} + +export function hideBleVerifyInfo() { + var panel = document.getElementById('stageVerifyInfo'); + if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; } +} diff --git a/tools/js/ble_ota.js b/tools/js/ble_ota.js new file mode 100644 index 0000000..5254978 --- /dev/null +++ b/tools/js/ble_ota.js @@ -0,0 +1,58 @@ +/** + * ble_ota.js - BLE OTA 控制台入口 + * + * 薄入口:import 所有 ble/* 模块,组装并导出 window.bleOta API 对象, + * 调用 init() 完成事件绑定与 UI 初始化。 + */ +import { UI_BUILD } from './ble/constants.js'; +import { + state, els, bleLog, refreshTargetUi, setConnectionState, + resetUiAfterDisconnect +} from './ble/state.js'; +import { resetBleStages } from './ble/stages.js'; +import { connectBle, disconnectBle } from './ble/gatt.js'; +import { clearSelectedFile, notifyFileChange } from './ble/file.js'; +import { autoFlash } from './ble/transfer.js'; +import { abortTask, bindEvents } from './ble/session.js'; +import { showToast } from './common/toast.js'; + +window.bleOta = { + init: function() { + bindEvents(); + refreshTargetUi(); + setConnectionState(false); + resetBleStages(); + bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`); + }, + disconnect: disconnectBle, + showToast: showToast, + notifyFileChange: async function(file, handle) { + // ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget + // 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。 + if (state.uploading || state.autoFlashing) { + bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D'); + return; + } + // ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度 + if (typeof window.isUploading !== 'undefined' && window.isUploading) { + bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D'); + return; + } + if (file) { + if (handle) state.selectedFileHandle = handle; + await notifyFileChange(file, handle); + } + }, + clearFile: clearSelectedFile, + refreshButtons: function() { + setConnectionState(!!state.server); + }, + refreshTargetUi: refreshTargetUi, + // ★ 暴露上传状态查询,供 index.html 的 selectFlashTarget 等外部守卫使用 + isUploading: function() { + return !!(state.uploading || state.autoFlashing); + }, +}; + +// 脚本以 type="module" 加载(默认 defer),DOM 元素已就绪,直接初始化 +window.bleOta.init(); \ No newline at end of file diff --git a/tools/js/can.js b/tools/js/can.js new file mode 100644 index 0000000..2e9b513 --- /dev/null +++ b/tools/js/can.js @@ -0,0 +1,265 @@ +/** + * can.js - CAN 工具页面入口(ESM 组装层) + * + * 本文件对应原 tools/can.js 中未拆分到子模块的编排逻辑: + * - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象) + * - setConnectionState:CAN 页面专用连接状态更新(canConnPill 等元素) + * - testConnection:CAN 页面专用连接检测(GET /system/status,操作 el.connectionNote 等) + * - clearDbc / handleDbcFile / handleDbcError:DBC 文件读取与解析流程编排 + * - bindEvents:绑定所有事件监听器(调用子模块的 bindNodeFilterEvents 等) + * - init:页面初始化入口 + * + * state / el 由 dbc.js 导出(ES Module live binding),cacheElements() 填充 el 后, + * 各子模块通过 import 引用同一对象,可见最新缓存结果。 + * + * 注意:原 can.js 的 testConnection 与 common/connection.js 的 testConnection 实现不同 + * (操作不同的 DOM 元素集),此处保留原 can.js 版本以保证行为一致。 + */ + +import { state, el, getBaseUrl, parseDbc, readDbcText, buildSummary } from './can/dbc.js'; +import { + updateDbcFileInfo, + filterMessages, + bindNodeFilterEvents, + renderMessageList, + selectMessage +} from './can/messages.js'; +import { + ensureMessageDefaults, + renderSelectedMessage, + updateEncodedPreview +} from './can/signals.js'; +import { + renderHistory, + sendSelectedMessage, + sendCheckedMessages, + refreshManualHint, + sendManualFrame, + resetManualFields, + loadPreviewToManual +} from './can/send.js'; +import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js'; +import { showToast } from './common/toast.js'; + +// ==================== 本地工具 ==================== +// 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h), +// 为保持原行为,此处复用 common/utils.js 的 formatUptime(已在 import 中引入)。 +// 若后续需要对齐原 can.js 输出,可在此处覆盖本地实现。 + +// ==================== 元素缓存 ==================== +function cacheElements() { + [ + 'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData', + 'testConnBtn', 'connIndicator', 'connIndicatorTxt', 'connectionNote', + 'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount', + 'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcSearch', 'dbcHint', 'clearDbcBtn', + 'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint', + 'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn', + 'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco', + 'toastMsg', 'canConnPill', 'canConnTxt', 'canVersionPill', 'canChannelPill', + 'dbcSummaryPill', 'messageSummaryPill', + 'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn', + 'nodeFilterWrap', 'nodeFilterBtn', 'nodeFilterLabel', 'nodeFilterPanel', 'nodeFilterSearch', + 'nodeFilterAllBtn', 'nodeFilterNoneBtn', 'nodeFilterList' + ].forEach(function (id) { + el[id] = document.getElementById(id); + }); +} + +// ==================== 连接状态 ==================== +function setConnectionState(connected, text, buildVersion, buildTime) { + if (el.canConnPill) { + el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline'); + } + if (el.canConnTxt) el.canConnTxt.textContent = text; + if (el.connIndicator) { + el.connIndicator.className = 'conn-indicator ' + (connected ? 'ok' : 'err'); + } + if (el.connIndicatorTxt) el.connIndicatorTxt.textContent = text; + if (el.canVersionPill) { + el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--'; + el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : ''; + } +} + +async function testConnection() { + var baseUrl = getBaseUrl(); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note'; + el.connectionNote.innerHTML = '正在请求 /system/status 检查设备状态...'; + } + try { + var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) { + setConnectionState(false, 'HTTP ' + resp.status, '', ''); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note err'; + el.connectionNote.innerHTML = '状态接口返回 HTTP ' + resp.status + ',请确认设备地址和 HTTP 服务。'; + } + showToast('设备响应异常: HTTP ' + resp.status, 'error'); + return; + } + var data = await resp.json(); + state.latestStatus = data; + var wifi = data.wifi || {}; + var connected = !!wifi.connected; + var mode = wifi.mode || 'STA'; + var text = connected ? (mode + ' 已连接') : (mode + ' 未连接'); + setConnectionState(connected, text, data.version, data.build_time); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note ' + (connected ? 'ok' : 'warn'); + el.connectionNote.innerHTML = + 'IP: ' + sanitizeHtml(wifi.ip || '--') + ',RSSI: ' + + sanitizeHtml(wifi.rssi !== undefined && wifi.rssi !== -127 ? (wifi.rssi + ' dBm') : '--') + + ',运行: ' + sanitizeHtml(formatUptime(data.uptime_sec)) + ''; + } + showToast(connected ? 'ESP32 当前在线' : '设备已响应,但 WiFi 未连接', connected ? 'success' : 'warning'); + } catch (err) { + setConnectionState(false, '连接失败', '', ''); + if (el.connectionNote) { + el.connectionNote.className = 'helper-note err'; + el.connectionNote.innerHTML = '无法访问 /system/status:' + sanitizeHtml(err.message); + } + showToast('连接失败: ' + err.message, 'error'); + } +} + +// ==================== DBC 文件流程 ==================== +async function handleDbcFile(file) { + if (!file) return; + var text = await readDbcText(file); + var dbc = parseDbc(text); + state.dbcFile = file; + state.dbcText = text; + state.dbc = dbc; + state.selectedMessageKey = ''; + state.signalValues = {}; + state.checkedKeys = {}; + state.expandedKeys = {}; + state.transmitterFilter = Object.create(null); + state.transmitterFilterEmpty = true; + updateDbcFileInfo(file, dbc); + buildSummary(); + filterMessages(); + if (dbc.messages.length) selectMessage(dbc.messages[0].key); + else renderSelectedMessage(); + if (el.dbcHint) { + el.dbcHint.className = 'helper-note ok'; + el.dbcHint.innerHTML = 'DBC 已解析:' + dbc.messages.length + ' 条报文,' + + dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0) + + ' 个信号。'; + } + showToast('DBC 导入成功', 'success'); +} + +function clearDbc() { + state.dbcFile = null; + state.dbcText = ''; + state.dbc = null; + state.filteredMessages = []; + state.selectedMessageKey = ''; + state.signalValues = {}; + state.checkedKeys = {}; + state.expandedKeys = {}; + state.transmitterFilter = Object.create(null); + state.transmitterFilterEmpty = true; + state.generatedData = new Uint8Array(0); + if (el.dbcFileInput) el.dbcFileInput.value = ''; + if (el.dbcSearch) el.dbcSearch.value = ''; + updateDbcFileInfo(null, null); + buildSummary(); + renderMessageList(); + renderSelectedMessage(); + if (el.generatedHex) el.generatedHex.textContent = '--'; + if (el.generatedBytes) el.generatedBytes.innerHTML = ''; + if (el.dbcHint) { + el.dbcHint.className = 'helper-note'; + el.dbcHint.innerHTML = '当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、CM_ 和 SIG_VALTYPE_。'; + } +} + +function handleDbcError(err) { + if (el.dbcHint) { + el.dbcHint.className = 'helper-note err'; + el.dbcHint.innerHTML = 'DBC 解析失败:' + sanitizeHtml(err.message); + } + showToast('DBC 解析失败: ' + err.message, 'error'); +} + +// ==================== 事件绑定 ==================== +function bindEvents() { + el.testConnBtn.addEventListener('click', testConnection); + el.defaultChannel.addEventListener('change', function () { + el.manualChannel.value = el.defaultChannel.value; + el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + renderMessageList(); + renderSelectedMessage(); + updateEncodedPreview(); + }); + el.dbcDropZone.addEventListener('click', function () { el.dbcFileInput.click(); }); + el.dbcDropZone.addEventListener('dragover', function (event) { + event.preventDefault(); + el.dbcDropZone.classList.add('dragover'); + }); + el.dbcDropZone.addEventListener('dragleave', function () { + el.dbcDropZone.classList.remove('dragover'); + }); + el.dbcDropZone.addEventListener('drop', function (event) { + event.preventDefault(); + el.dbcDropZone.classList.remove('dragover'); + var file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null; + if (file) handleDbcFile(file).catch(handleDbcError); + }); + el.dbcFileInput.addEventListener('change', function (event) { + var file = event.target && event.target.files ? event.target.files[0] : null; + if (file) handleDbcFile(file).catch(handleDbcError); + }); + el.clearDbcBtn.addEventListener('click', clearDbc); + el.dbcSearch.addEventListener('input', filterMessages); + el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); + el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); + el.sendManualBtn.addEventListener('click', sendManualFrame); + el.resetManualBtn.addEventListener('click', resetManualFields); + el.manualData.addEventListener('input', refreshManualHint); + el.manualDlc.addEventListener('input', refreshManualHint); + el.manualId.addEventListener('input', refreshManualHint); + el.toolIp.addEventListener('change', testConnection); + if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages); + if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () { + var visible = state.filteredMessages || []; + if (this.checked) { + visible.forEach(function (m) { state.checkedKeys[m.key] = true; }); + } else { + visible.forEach(function (m) { delete state.checkedKeys[m.key]; }); + } + renderMessageList(); + }); + if (el.expandAllBtn) el.expandAllBtn.addEventListener('click', function () { + var visible = state.filteredMessages || []; + visible.forEach(function (m) { state.expandedKeys[m.key] = true; ensureMessageDefaults(m); }); + renderMessageList(); + }); + if (el.collapseAllBtn) el.collapseAllBtn.addEventListener('click', function () { + var visible = state.filteredMessages || []; + visible.forEach(function (m) { delete state.expandedKeys[m.key]; }); + renderMessageList(); + }); + bindNodeFilterEvents(); +} + +// ==================== 初始化 ==================== +function init() { + cacheElements(); + bindEvents(); + setConnectionState(false, '未连接', '', ''); + if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value; + if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value; + buildSummary(); + renderMessageList(); + renderSelectedMessage(); + refreshManualHint(); + renderHistory(); + setTimeout(testConnection, 150); +} + +init(); \ No newline at end of file diff --git a/tools/js/can/dbc.js b/tools/js/can/dbc.js new file mode 100644 index 0000000..52eb7a3 --- /dev/null +++ b/tools/js/can/dbc.js @@ -0,0 +1,256 @@ +/** + * dbc.js - DBC 解析器 + 共享状态/元素缓存/基础工具 + * + * 从 tools/can.js 拆分。对应原 can.js 约 4~21 行(state/el)、78~104 行(基础工具)、 + * 178~335 行(DBC 解析)、830~833 行(cssEscape)、1335~1352 行(readDbcText)。 + * + * 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出, + * 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。 + */ + +// ==================== 共享状态 ==================== +export var state = { + dbcFile: null, + dbcText: '', + dbc: null, + filteredMessages: [], + selectedMessageKey: '', + signalValues: {}, + checkedKeys: {}, + expandedKeys: {}, + transmitterFilter: Object.create(null), + transmitterFilterEmpty: true, + generatedData: new Uint8Array(0), + history: [], + latestStatus: null, + batchInFlight: false +}; + +// ==================== 共享元素缓存 ==================== +// 由入口文件(js/can.js)的 cacheElements() 填充,各子模块通过 import 引用同一对象。 +export var el = {}; + +// ==================== 基础工具 ==================== +/** @description 拼接设备基础 URL(http://),ip 取自 el.toolIp,默认 192.168.31.127。 */ +export function getBaseUrl() { + var ip = (el.toolIp && el.toolIp.value ? el.toolIp.value.trim() : '') || '192.168.31.127'; + return 'http://' + ip; +} + +/** @description 解析手动输入的 CAN ID(支持 0x/hex/十进制)。 */ +export function parseNumericId(input) { + var raw = String(input == null ? '' : input).trim(); + if (!raw) return NaN; + if (/^0x/i.test(raw)) return parseInt(raw, 16); + if (/^[0-9]+$/.test(raw)) return parseInt(raw, 10); + if (/^[0-9a-fA-F]+h$/i.test(raw)) return parseInt(raw.slice(0, -1), 16); + if (/^[0-9a-fA-F]+$/.test(raw)) return parseInt(raw, 16); + return NaN; +} + +/** @description 将数值格式化为指定宽度的十六进制大写字符串。 */ +export function padHex(value, width) { + return value.toString(16).toUpperCase().padStart(width, '0'); +} + +/** @description 将字节数组转为连续的十六进制字符串。 */ +export function bytesToHex(bytes) { + if (!bytes || !bytes.length) return ''; + var parts = []; + for (var i = 0; i < bytes.length; i++) { + parts.push(padHex(bytes[i], 2)); + } + return parts.join(''); +} + +/** @description 转义 CSS 选择器中的特殊字符。 */ +export function cssEscape(value) { + if (window.CSS && CSS.escape) return CSS.escape(value); + return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\\\' + ch; }); +} + +/** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */ +export function normalizeWhitespace(text) { + return String(text || '').replace(/\r/g, '').trim(); +} + +// ==================== DBC 文件读取 ==================== +/** @description 读取 DBC 文件文本,优先 UTF-8,失败回退 GB18030,自动去 BOM。 */ +export async function readDbcText(file) { + var buf = await file.arrayBuffer(); + var bytes = new Uint8Array(buf); + var stripBom = function (s) { + return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s; + }; + try { + var strictUtf8 = new TextDecoder('utf-8', { fatal: true }); + return stripBom(strictUtf8.decode(bytes)); + } catch (e) { + try { + var gb = new TextDecoder('gb18030'); + return stripBom(gb.decode(bytes)); + } catch (e2) { + return stripBom(new TextDecoder('utf-8').decode(bytes)); + } + } +} + +// ==================== DBC 解析 ==================== +/** @description 解析 DBC 文本,返回 { messages, nodeNames, messageMap }。 */ +export function parseDbc(text) { + var lines = String(text || '').replace(/\r/g, '').split('\n'); + var messages = []; + var messageMap = {}; + var comments = { messages: {}, signals: {} }; + var valueTables = {}; + var sigDefaults = {}; + var nodeNames = []; + var current = null; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) continue; + + var nodeMatch = line.match(/^BU_\s*:\s*(.*)$/); + if (nodeMatch) { + nodeNames = nodeMatch[1].trim() ? nodeMatch[1].trim().split(/\s+/) : []; + continue; + } + + var messageMatch = line.match(/^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+(.+)$/); + if (messageMatch) { + current = { + idRaw: parseInt(messageMatch[1], 10), + name: messageMatch[2], + dlc: parseInt(messageMatch[3], 10), + transmitter: messageMatch[4].trim(), + signals: [], + comment: '' + }; + current.id = current.idRaw & 0x1FFFFFFF; + current.isExtended = current.idRaw > 0x7FF; + current.key = String(current.idRaw) + ':' + current.name; + messages.push(current); + messageMap[current.idRaw] = current; + continue; + } + + var signalMatch = line.match(/^SG_\s+([A-Za-z0-9_]+)(?:\s+((?:M|m-?\d+)))?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s+\(([^\),]+),([^\)]+)\)\s+\[([^\|]*)\|([^\]]*)\]\s+"([^"]*)"\s+(.+)$/); + if (signalMatch && current) { + var muxInfo = parseMultiplexToken(signalMatch[2]); + current.signals.push({ + name: signalMatch[1], + startBit: parseInt(signalMatch[3], 10), + length: parseInt(signalMatch[4], 10), + byteOrder: signalMatch[5] === '1' ? 'little' : 'big', + isSigned: signalMatch[6] === '-', + factor: parseFloat(signalMatch[7]), + offset: parseFloat(signalMatch[8]), + min: signalMatch[9].trim() === '' ? null : parseFloat(signalMatch[9]), + max: signalMatch[10].trim() === '' ? null : parseFloat(signalMatch[10]), + unit: signalMatch[11], + receivers: signalMatch[12].split(/\s*,\s*/), + comment: '', + valueType: 'int', + enumMap: {}, + defaultValue: null, + multiplexRole: muxInfo.role, + multiplexValue: muxInfo.value + }); + continue; + } + + var messageCommentMatch = line.match(/^CM_\s+BO_\s+(\d+)\s+"([\s\S]*)";$/); + if (messageCommentMatch) { + comments.messages[messageCommentMatch[1]] = messageCommentMatch[2]; + continue; + } + + var signalCommentMatch = line.match(/^CM_\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+"([\s\S]*)";$/); + if (signalCommentMatch) { + comments.signals[signalCommentMatch[1] + ':' + signalCommentMatch[2]] = signalCommentMatch[3]; + continue; + } + + var valueMatch = line.match(/^VAL_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(.+);$/); + if (valueMatch) { + var table = {}; + var pairRe = /(-?\d+)\s+"([^"]*)"/g; + var m; + while ((m = pairRe.exec(valueMatch[3])) !== null) { + table[m[1]] = m[2]; + } + valueTables[valueMatch[1] + ':' + valueMatch[2]] = table; + continue; + } + + var valTypeMatch = line.match(/^SIG_VALTYPE_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s*;$/); + if (valTypeMatch) { + var msg = messageMap[valTypeMatch[1]]; + if (!msg) continue; + var signal = findSignal(msg, valTypeMatch[2]); + if (signal) { + signal.valueType = valTypeMatch[3] === '1' ? 'float' : valTypeMatch[3] === '2' ? 'double' : 'int'; + } + continue; + } + + /* BA_ "GenSigDefaultValue" SG_ ; + * 注意该值是物理值(已应用 factor/offset),与 GenSigStartValue(raw)不同 */ + var sigDefaultMatch = line.match(/^BA_\s+"GenSigDefaultValue"\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*;$/); + if (sigDefaultMatch) { + sigDefaults[sigDefaultMatch[1] + ':' + sigDefaultMatch[2]] = sigDefaultMatch[3]; + continue; + } + } + + messages.forEach(function (msg) { + msg.comment = comments.messages[String(msg.idRaw)] || ''; + msg.signals.forEach(function (signal) { + signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || ''; + signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {}; + var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name]; + signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? dv : null; + }); + }); + + return { + messages: messages, + nodeNames: nodeNames, + messageMap: messageMap + }; +} + +/** @description 在报文的 signals 数组中按名称查找信号。 */ +export function findSignal(message, signalName) { + for (var i = 0; i < message.signals.length; i++) { + if (message.signals[i].name === signalName) return message.signals[i]; + } + return null; +} + +/** @description 解析 SG_ 行的 multiplex token(M / mN / 空)。 */ +export function parseMultiplexToken(token) { + if (!token) return { role: null, value: null }; + if (token === 'M') return { role: 'mux', value: null }; + var match = token.match(/^m(-?\d+)$/); + if (match) { + return { role: 'value', value: parseInt(match[1], 10) }; + } + return { role: null, value: null }; +} + +/** @description 更新顶部 DBC / 报文数量摘要 pill。 */ +export function buildSummary() { + if (!state.dbc) { + if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC'; + if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文'; + return; + } + if (el.dbcSummaryPill) { + el.dbcSummaryPill.textContent = (state.dbc.nodeNames.length || 0) + ' 节点'; + } + if (el.messageSummaryPill) { + el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文'; + } +} diff --git a/tools/js/can/feedback.js b/tools/js/can/feedback.js new file mode 100644 index 0000000..86d456d --- /dev/null +++ b/tools/js/can/feedback.js @@ -0,0 +1,19 @@ +/** + * feedback.js - 1Hz 反馈轮询(预留模块) + * + * ⚠️ 说明:原 tools/can.js(1526 行)中不存在 1Hz 反馈轮询 / ID 快照表渲染相关代码。 + * 这些功能实际位于 tools/can_monitor.js(CAN 监控页面),操作的是 can_monitor.html + * 的 DOM 元素(fb0State / fb0Rx / idsnapBody 等),与 can.html 页面的元素体系不同, + * 因此无法直接迁移。 + * + * can.js 中与"连接状态反馈"相关的代码为 setConnectionState / testConnection + * (一次性 GET /system/status),属于入口文件(js/can.js)的 init/cacheElements/bindEvents + * 编排范畴,不在本子模块内。 + * + * 本模块按任务要求 import common/connection.js,供入口文件在需要时引入连接管理能力。 + * 若后续 can 页面需要增加 1Hz 轮询 /can/feedback 或 ID 快照功能,可在此模块实现。 + */ +import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js'; + +// 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。 +export { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow }; diff --git a/tools/js/can/messages.js b/tools/js/can/messages.js new file mode 100644 index 0000000..72c3862 --- /dev/null +++ b/tools/js/can/messages.js @@ -0,0 +1,356 @@ +/** + * messages.js - 报文列表管理(渲染、过滤、节点过滤、批量 UI) + * + * 从 tools/can.js 拆分。对应原 can.js 约 337~686 行(DBC 文件信息、报文过滤、 + * 节点过滤面板、报文列表渲染、报文选择/展开、批量 UI)。 + * + * 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、 + * common/utils.js(escapeHtml/formatBytes)、 + * signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。 + */ +import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js'; +import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js'; +import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js'; + +// ==================== DBC 文件信息 ==================== +/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */ +export function updateDbcFileInfo(file, dbc) { + if (!el.dbcFileInfo) return; + if (!file || !dbc) { + el.dbcFileInfo.classList.remove('show'); + el.dbcFileName.textContent = '-'; + el.dbcMessageCount.textContent = '0'; + el.dbcSignalCount.textContent = '0'; + el.dbcNodeCount.textContent = '0'; + el.dbcFileSize.textContent = '0 B'; + return; + } + var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0); + el.dbcFileInfo.classList.add('show'); + el.dbcFileName.textContent = file.name || '-'; + el.dbcMessageCount.textContent = String(dbc.messages.length); + el.dbcSignalCount.textContent = String(signalCount); + el.dbcNodeCount.textContent = String(dbc.nodeNames.length); + el.dbcFileSize.textContent = formatBytes(file.size || 0); +} + +// ==================== 报文过滤 ==================== +/** @description 根据 DBC 搜索关键字 + 发送节点过滤报文,并重新渲染列表。 */ +export function filterMessages() { + var keyword = normalizeWhitespace(el.dbcSearch ? el.dbcSearch.value : '').toLowerCase(); + var messages = state.dbc ? state.dbc.messages.slice() : []; + if (!state.transmitterFilterEmpty) { + messages = messages.filter(function (msg) { + var tx = msg.transmitter || 'Vector__XXX'; + return !!state.transmitterFilter[tx]; + }); + } + if (keyword) { + messages = messages.filter(function (msg) { + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + if (msg.name.toLowerCase().indexOf(keyword) >= 0) return true; + if (idHex.toLowerCase().indexOf(keyword) >= 0) return true; + if (String(msg.idRaw).indexOf(keyword) >= 0) return true; + if ((msg.transmitter || '').toLowerCase().indexOf(keyword) >= 0) return true; + for (var i = 0; i < msg.signals.length; i++) { + if (msg.signals[i].name.toLowerCase().indexOf(keyword) >= 0) return true; + } + return false; + }); + } + state.filteredMessages = messages; + renderMessageList(); + updateNodeFilterLabel(); +} + +// ==================== 发送节点过滤 ==================== +/** @description 收集 DBC 中所有发送节点(去重 + 排序)。 */ +export function collectTransmitters() { + var set = Object.create(null); + var list = []; + if (!state.dbc) return list; + state.dbc.messages.forEach(function (msg) { + var tx = msg.transmitter || 'Vector__XXX'; + if (!set[tx]) { set[tx] = 1; list.push(tx); } + }); + list.sort(function (a, b) { return a.localeCompare(b); }); + return list; +} + +/** @description 渲染发送节点过滤面板的复选框列表。 */ +export function renderNodeFilterList() { + if (!el.nodeFilterList) return; + var txs = collectTransmitters(); + var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase(); + if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; }); + if (!txs.length) { + el.nodeFilterList.innerHTML = '
无匹配节点
'; + return; + } + var counts = Object.create(null); + if (state.dbc) { + state.dbc.messages.forEach(function (msg) { + var tx = msg.transmitter || 'Vector__XXX'; + counts[tx] = (counts[tx] || 0) + 1; + }); + } + el.nodeFilterList.innerHTML = txs.map(function (name) { + var checked = !!state.transmitterFilter[name] ? ' checked' : ''; + return ''; + }).join(''); + var inputs = el.nodeFilterList.querySelectorAll('input[type="checkbox"]'); + for (var i = 0; i < inputs.length; i++) { + inputs[i].addEventListener('change', function () { + var name = this.getAttribute('data-node'); + if (this.checked) state.transmitterFilter[name] = true; + else delete state.transmitterFilter[name]; + recomputeTransmitterFilterEmpty(); + filterMessages(); + }); + } +} + +/** @description 重新计算 transmitterFilterEmpty 标志(无任何勾选时为 true)。 */ +export function recomputeTransmitterFilterEmpty() { + var any = false; + for (var k in state.transmitterFilter) { + if (state.transmitterFilter[k]) { any = true; break; } + } + state.transmitterFilterEmpty = !any; +} + +/** @description 更新发送节点过滤按钮的标签文本。 */ +export function updateNodeFilterLabel() { + if (!el.nodeFilterLabel) return; + if (state.transmitterFilterEmpty) { + el.nodeFilterLabel.textContent = '发送节点:全部'; + if (el.nodeFilterBtn) el.nodeFilterBtn.classList.remove('active'); + } else { + var picked = Object.keys(state.transmitterFilter).filter(function (k) { return state.transmitterFilter[k]; }); + var preview = picked.length === 1 ? picked[0] : ('已选 ' + picked.length + ' 个'); + el.nodeFilterLabel.textContent = '发送节点:' + preview; + if (el.nodeFilterBtn) el.nodeFilterBtn.classList.add('active'); + } +} + +/** @description 打开/关闭发送节点过滤面板。 */ +export function setNodeFilterPanelOpen(open) { + if (!el.nodeFilterPanel || !el.nodeFilterBtn) return; + if (open) { + renderNodeFilterList(); + el.nodeFilterPanel.hidden = false; + el.nodeFilterBtn.setAttribute('aria-expanded', 'true'); + } else { + el.nodeFilterPanel.hidden = true; + el.nodeFilterBtn.setAttribute('aria-expanded', 'false'); + } +} + +/** @description 绑定发送节点过滤面板的事件(按钮/搜索/全选/全不选/外部点击/Esc)。 */ +export function bindNodeFilterEvents() { + if (!el.nodeFilterBtn) return; + el.nodeFilterBtn.addEventListener('click', function (event) { + event.stopPropagation(); + setNodeFilterPanelOpen(el.nodeFilterPanel.hidden); + }); + document.addEventListener('click', function (event) { + if (!el.nodeFilterWrap || el.nodeFilterPanel.hidden) return; + if (!el.nodeFilterWrap.contains(event.target)) setNodeFilterPanelOpen(false); + }); + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && el.nodeFilterPanel && !el.nodeFilterPanel.hidden) { + setNodeFilterPanelOpen(false); + } + }); + if (el.nodeFilterSearch) { + el.nodeFilterSearch.addEventListener('input', renderNodeFilterList); + } + if (el.nodeFilterAllBtn) el.nodeFilterAllBtn.addEventListener('click', function () { + collectTransmitters().forEach(function (n) { state.transmitterFilter[n] = true; }); + recomputeTransmitterFilterEmpty(); + renderNodeFilterList(); + filterMessages(); + }); + if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () { + state.transmitterFilter = Object.create(null); + state.transmitterFilterEmpty = true; + renderNodeFilterList(); + filterMessages(); + }); +} + +// ==================== 报文列表渲染 ==================== +/** @description 渲染报文列表表格(表头 + 行 + 展开区)。 */ +export function renderMessageList() { + if (!el.messageList) return; + if (!state.dbc) { + el.messageList.innerHTML = '
等待导入 DBC...
'; + updateBatchUi(); + return; + } + if (!state.filteredMessages.length) { + el.messageList.innerHTML = '
没有匹配的报文。
'; + updateBatchUi(); + return; + } + var channelTxt = el.defaultChannel ? el.defaultChannel.value : '0'; + var rows = state.filteredMessages.map(function (msg, index) { + var active = msg.key === state.selectedMessageKey ? ' active' : ''; + var checked = state.checkedKeys[msg.key] ? ' checked' : ''; + var expanded = !!state.expandedKeys[msg.key]; + var caret = expanded ? '\u25BE' : '\u25B8'; + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + var comment = msg.comment || ''; + if (comment.length > 36) comment = comment.slice(0, 36) + '...'; + var rowHtml = '' + + '
' + + '' + + '' + + '' + (index + 1) + '' + + '' + sanitizeHtml(msg.name) + '' + + '' + sanitizeHtml(idHex) + '' + + 'CH' + sanitizeHtml(channelTxt) + '' + + '' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '' + + '' + sanitizeHtml(msg.dlc) + '' + + '' + sanitizeHtml(msg.signals.length) + '' + + '' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '' + + '' + sanitizeHtml(comment || '-') + '' + + '
'; + var expandHtml = '
' + + '
' + + '
'; + return rowHtml + expandHtml; + }).join(''); + el.messageList.innerHTML = + '
' + + '
' + + '' + + '' + + '#' + + '报文名称' + + '标识符' + + '通道' + + '类型' + + 'DLC' + + '信号' + + '发送节点' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + + var rowEls = el.messageList.querySelectorAll('.message-row'); + for (var i = 0; i < rowEls.length; i++) { + rowEls[i].addEventListener('click', handleMessageRowClick); + rowEls[i].addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectMessage(this.getAttribute('data-key')); + } + }); + } + var checks = el.messageList.querySelectorAll('.message-check'); + for (var j = 0; j < checks.length; j++) { + checks[j].addEventListener('click', function (event) { event.stopPropagation(); }); + checks[j].addEventListener('change', function () { + var k = this.getAttribute('data-key'); + if (this.checked) state.checkedKeys[k] = true; + else delete state.checkedKeys[k]; + updateBatchUi(); + }); + } + var toggles = el.messageList.querySelectorAll('.message-toggle'); + for (var t = 0; t < toggles.length; t++) { + toggles[t].addEventListener('click', function (event) { + event.stopPropagation(); + toggleMessageExpand(this.getAttribute('data-key')); + }); + } + + state.filteredMessages.forEach(function (msg) { + if (state.expandedKeys[msg.key]) { + var host = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(msg.key) + '"] .signal-table-host'); + if (host) renderInlineSignalGrid(msg, host); + } + }); + updateBatchUi(); +} + +function handleMessageRowClick(event) { + if (event.target.closest('.message-check') || event.target.closest('.message-toggle')) return; + var key = this.getAttribute('data-key'); + selectMessage(key); +} + +function toggleMessageExpand(key) { + if (!state.dbc) return; + var msg = null; + for (var i = 0; i < state.dbc.messages.length; i++) { + if (state.dbc.messages[i].key === key) { msg = state.dbc.messages[i]; break; } + } + if (!msg) return; + var willExpand = !state.expandedKeys[key]; + if (willExpand) { + state.expandedKeys[key] = true; + ensureMessageDefaults(msg); + } else { + delete state.expandedKeys[key]; + } + var panel = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"]'); + var toggle = el.messageList.querySelector('.message-toggle[data-key="' + cssEscape(key) + '"]'); + if (panel) { + if (willExpand) { + panel.hidden = false; + var host = panel.querySelector('.signal-table-host'); + if (host) renderInlineSignalGrid(msg, host); + } else { + panel.hidden = true; + } + } + if (toggle) { + toggle.classList.toggle('expanded', willExpand); + toggle.textContent = willExpand ? '\u25BE' : '\u25B8'; + } +} + +// ==================== 批量 UI ==================== +/** @description 更新批量发送按钮 / 全选框 / 已选计数 pill。 */ +export function updateBatchUi() { + var keys = Object.keys(state.checkedKeys); + var count = keys.length; + if (el.selectedCountPill) el.selectedCountPill.textContent = '已勾选 ' + count; + if (el.sendCheckedBtn) { + el.sendCheckedBtn.disabled = count === 0 || state.batchInFlight; + el.sendCheckedBtn.textContent = state.batchInFlight ? '发送中...' : ('批量发送勾选 (' + count + ')'); + } + if (el.selectAllBox) { + var visible = state.filteredMessages || []; + var allChecked = visible.length > 0 && visible.every(function (m) { return !!state.checkedKeys[m.key]; }); + var someChecked = visible.some(function (m) { return !!state.checkedKeys[m.key]; }); + el.selectAllBox.checked = allChecked; + el.selectAllBox.indeterminate = !allChecked && someChecked; + } +} + +// ==================== 报文选择 ==================== +/** @description 选中指定 key 的报文,并刷新列表/信号编辑/编码预览。 */ +export function selectMessage(key) { + if (!state.dbc) return; + var msg = null; + for (var i = 0; i < state.dbc.messages.length; i++) { + if (state.dbc.messages[i].key === key) { + msg = state.dbc.messages[i]; + break; + } + } + if (!msg) return; + state.selectedMessageKey = key; + ensureMessageDefaults(msg); + renderMessageList(); + renderSelectedMessage(); + updateEncodedPreview(); +} diff --git a/tools/js/can/send.js b/tools/js/can/send.js new file mode 100644 index 0000000..e690b8f --- /dev/null +++ b/tools/js/can/send.js @@ -0,0 +1,383 @@ +/** + * send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史) + * + * 从 tools/can.js 拆分。对应原 can.js 约 1030~1333 行(sendCanRequest、响应框、 + * 历史、批量/选中/手动发送、手动帧校验/重置/载入预览)及 1491~1523 行 + * (CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText)。 + * + * 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、 + * common/toast.js(showToast)、common/utils.js(escapeHtml)、 + * messages.js(updateBatchUi)、signals.js(ensureMessageDefaults/getSignalValueMap/ + * getSelectedMessage/updateEncodedPreview/encodeMessageData)。 + * + * 注意:formatTime 保留 can.js 本地实现(HH:MM:SS,不含毫秒),与 common/utils.js + * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出与原版完全一致。 + */ +import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js'; +import { escapeHtml as sanitizeHtml } from '../common/utils.js'; +import { showToast } from '../common/toast.js'; +import { updateBatchUi } from './messages.js'; +import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js'; + +// ==================== 本地工具(保持与原 can.js 一致) ==================== +function formatTime(date) { + var d = date instanceof Date ? date : new Date(); + var hh = String(d.getHours()).padStart(2, '0'); + var mm = String(d.getMinutes()).padStart(2, '0'); + var ss = String(d.getSeconds()).padStart(2, '0'); + return hh + ':' + mm + ':' + ss; +} + +// ==================== 发送错误码描述 ==================== +export var CAN_SEND_ERR_DESC = { + '-1': '参数错误(通道/ID/DLC 无效)', + '-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)', + '-3': 'GD32 CAN 总线拒绝', +}; + +export var GD32_ERR_DESC = { + 0xE0: 'payload 过短', + 0xE1: '通道号非法', + 0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)', + 0xE3: 'TEC 过高(错误计数 >96,对端 ACK 失败率高)', + 0xE4: '邮箱满(3 个硬件邮箱全 PENDING,对端不 ACK)', + 0xE5: '参数无效(通道未启用/DLC 越界)', + 0xEE: 'SPI 帧解析失败(GD32 收到的帧 CRC/SOF 校验失败,通常是 SPI 字节丢失或 CS 时序冲突)' +}; + +/** @description 构建发送响应的可读文本(成功显示 recv,失败拼接 CAN_SEND_ERR_DESC + GD32_ERR_DESC)。 */ +export function buildSendResponseText(result) { + if (result.status === 'ok') { + return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; + } + var code = String(result.code); + var desc = CAN_SEND_ERR_DESC[code] || '未知错误'; + /* 用 GD32 细分错误码补充具体原因 */ + if (result.gd32_err != null && result.gd32_err !== 0) { + var gd32Desc = GD32_ERR_DESC[result.gd32_err]; + if (gd32Desc) { + desc = desc + ' · ' + gd32Desc; + } else { + desc = desc + ' · gd32_err=0x' + result.gd32_err.toString(16).toUpperCase(); + } + } + return '发送失败: ' + desc + ' (code=' + result.code + ')'; +} + +// ==================== HTTP 请求 ==================== +async function sendCanRequest(payload) { + var resp = await fetch(getBaseUrl() + '/can/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000) + }); + var text = await resp.text(); + var json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch (err) { + throw new Error('响应不是合法 JSON: ' + text); + } + if (!resp.ok) { + throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '请求失败')); + } + return json; +} + +// ==================== 响应框 ==================== +function updateResponseBox(box, textEl, result, mode) { + if (!box || !textEl) return; + box.style.display = 'block'; + box.className = 'can-response' + (result.status === 'ok' ? ' ok' : ' err'); + textEl.textContent = buildSendResponseText(result); + if (mode === 'manual') { + el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err'); + el.manualHint.innerHTML = result.status === 'ok' + ? '原始帧已发送到设备,若有响应则显示在下方。' + : '设备返回错误码 ' + sanitizeHtml(result.code) + '。'; + } +} + +// ==================== 发送历史 ==================== +function addHistoryEntry(entry) { + state.history.unshift(entry); + if (state.history.length > 20) state.history.length = 20; + renderHistory(); +} + +/** @description 渲染发送历史列表。 */ +export function renderHistory() { + if (!el.historyList) return; + if (!state.history.length) { + el.historyList.innerHTML = '
尚未发送任何 CAN 帧。
'; + return; + } + el.historyList.innerHTML = state.history.map(function (item) { + return '' + + '
' + + '
' + + '' + sanitizeHtml(item.mode) + '' + + '' + sanitizeHtml(item.time) + '' + + '
' + + '
' + + 'CH' + sanitizeHtml(item.channel) + ' · ' + + sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) + + '
' + + '
' + sanitizeHtml(item.dataHex || '--') + '
' + + '
' + sanitizeHtml(item.resultText) + '
' + + '
'; + }).join(''); +} + +// ==================== 批量发送 ==================== +/** @description 批量发送所有勾选的报文(30ms 帧间间隔防 GD32 SPI RXORERR)。 */ +export async function sendCheckedMessages() { + if (!state.dbc) { showToast('请先导入 DBC', 'warning'); return; } + var keys = Object.keys(state.checkedKeys); + if (!keys.length) { showToast('请先勾选要发送的报文', 'warning'); return; } + if (state.batchInFlight) return; + state.batchInFlight = true; + updateBatchUi(); + var channel = parseInt(el.defaultChannel.value, 10) || 0; + var ok = 0, fail = 0; + /* BATCH 间隔:GD32 SPI 从机用 RBNE 中断逐字节接收,无 RX DMA, + * 连续高频 CS 事务易触发 RXORERR -> 帧字节丢失 -> CRC 失败 -> 0xEE。 + * 30ms 间隔给 GD32 足够时间完成 onCsRelease 清理 + EXTI 重新待命。 */ + var BATCH_INTERVAL_MS = 30; + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var msg = null; + for (var m = 0; m < state.dbc.messages.length; m++) { + if (state.dbc.messages[m].key === key) { msg = state.dbc.messages[m]; break; } + } + if (!msg) continue; + ensureMessageDefaults(msg); + var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + var dataBytes; + try { + dataBytes = encodeMessageData(msg, getSignalValueMap(msg)); + } catch (err) { + fail++; + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'BATCH', + ok: false, + channel: channel, + idHex: idHex, + dlc: msg.dlc, + dataHex: '', + resultText: '编码失败: ' + err.message + }); + continue; + } + var dataHex = bytesToHex(dataBytes); + try { + var result = await sendCanRequest({ + ch: channel, + id: msg.id, + dlc: msg.dlc, + data: dataHex + }); + if (result.status === 'ok') ok++; else fail++; + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'BATCH', + ok: result.status === 'ok', + channel: channel, + idHex: idHex, + dlc: msg.dlc, + dataHex: dataHex, + resultText: msg.name + ' · ' + buildSendResponseText(result) + }); + } catch (err) { + fail++; + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'BATCH', + ok: false, + channel: channel, + idHex: idHex, + dlc: msg.dlc, + dataHex: dataHex, + resultText: msg.name + ' · ' + err.message + }); + } + /* 帧间间隔,避免 GD32 SPI RXORERR(最后一帧不需要等) */ + if (i < keys.length - 1) { + await new Promise(function (r) { setTimeout(r, BATCH_INTERVAL_MS); }); + } + } + state.batchInFlight = false; + updateBatchUi(); + var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning'); + showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone); +} + +// ==================== 选中报文发送 ==================== +/** @description 发送当前选中报文(DBC 编码后 POST /can/send)。 */ +export async function sendSelectedMessage() { + var msg = getSelectedMessage(); + if (!msg) { + showToast('请先选择一条报文', 'warning'); + return; + } + updateEncodedPreview(); + if (!state.generatedData.length && msg.dlc > 0) { + showToast('当前报文还没有可发送的数据', 'error'); + return; + } + var channel = parseInt(el.defaultChannel.value, 10) || 0; + var payload = { + ch: channel, + id: msg.id, + dlc: msg.dlc, + data: bytesToHex(state.generatedData) + }; + try { + var result = await sendCanRequest(payload); + updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc'); + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'DBC', + ok: result.status === 'ok', + channel: channel, + idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), + dlc: msg.dlc, + dataHex: bytesToHex(state.generatedData), + resultText: buildSendResponseText(result) + }); + showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); + } catch (err) { + if (el.dbcResp) { + el.dbcResp.style.display = 'block'; + el.dbcResp.className = 'can-response err'; + el.dbcRespText.textContent = err.message; + } + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'DBC', + ok: false, + channel: channel, + idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), + dlc: msg.dlc, + dataHex: bytesToHex(state.generatedData), + resultText: err.message + }); + showToast('发送失败: ' + err.message, 'error'); + } +} + +// ==================== 原始帧发送 ==================== +function validateManualData() { + var dataHex = (el.manualData.value || '').trim().replace(/\s+/g, ''); + var dlc = parseInt(el.manualDlc.value, 10); + if (!Number.isFinite(dlc) || dlc < 0 || dlc > 64) { + throw new Error('DLC 需在 0~64 之间'); + } + if (dataHex && !/^[0-9a-fA-F]+$/.test(dataHex)) { + throw new Error('HEX 数据包含非法字符'); + } + if (dataHex.length % 2 !== 0) { + throw new Error('HEX 数据长度必须为偶数'); + } + var byteLen = dataHex.length / 2; + if (byteLen !== dlc) { + throw new Error('当前 HEX 为 ' + byteLen + ' 字节,但 DLC=' + dlc); + } + return dataHex.toUpperCase(); +} + +/** @description 刷新手动帧输入的校验提示。 */ +export function refreshManualHint() { + try { + var dataHex = validateManualData(); + el.manualHint.className = 'helper-note ok'; + el.manualHint.innerHTML = '原始 HEX 合法,当前长度 ' + (dataHex.length / 2) + ' 字节。'; + } catch (err) { + el.manualHint.className = 'helper-note err'; + el.manualHint.innerHTML = sanitizeHtml(err.message); + } +} + +/** @description 发送手动输入的原始 CAN 帧。 */ +export async function sendManualFrame() { + var id = parseNumericId(el.manualId.value); + if (!Number.isFinite(id)) { + showToast('CAN ID 格式错误', 'error'); + return; + } + var channel = parseInt(el.manualChannel.value, 10) || 0; + var dlc = parseInt(el.manualDlc.value, 10) || 0; + var dataHex = ''; + try { + dataHex = validateManualData(); + } catch (err) { + showToast(err.message, 'error'); + refreshManualHint(); + return; + } + + try { + var result = await sendCanRequest({ + ch: channel, + id: id, + dlc: dlc, + data: dataHex + }); + updateResponseBox(el.manualResp, el.manualRespText, result, 'manual'); + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'RAW', + ok: result.status === 'ok', + channel: channel, + idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), + dlc: dlc, + dataHex: dataHex, + resultText: buildSendResponseText(result) + }); + showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); + } catch (err) { + if (el.manualResp) { + el.manualResp.style.display = 'block'; + el.manualResp.className = 'can-response err'; + el.manualRespText.textContent = err.message; + } + addHistoryEntry({ + time: formatTime(new Date()), + mode: 'RAW', + ok: false, + channel: channel, + idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), + dlc: dlc, + dataHex: dataHex, + resultText: err.message + }); + showToast('发送失败: ' + err.message, 'error'); + } +} + +/** @description 重置手动帧输入区为默认值。 */ +export function resetManualFields() { + el.manualChannel.value = el.defaultChannel.value; + el.manualId.value = '0x18DA00F1'; + el.manualDlc.value = '8'; + el.manualData.value = '0102030405060708'; + if (el.manualResp) el.manualResp.style.display = 'none'; + refreshManualHint(); +} + +/** @description 将当前选中报文的编码预览载入到手动帧输入区。 */ +export function loadPreviewToManual() { + var msg = getSelectedMessage(); + if (!msg || !state.generatedData.length) { + showToast('当前没有可载入的预览帧', 'warning'); + return; + } + el.manualChannel.value = el.defaultChannel.value; + el.manualId.value = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3); + el.manualDlc.value = String(msg.dlc); + el.manualData.value = bytesToHex(state.generatedData); + refreshManualHint(); + showToast('已载入到原始发送区', 'success'); +} diff --git a/tools/js/can/signals.js b/tools/js/can/signals.js new file mode 100644 index 0000000..d85980e --- /dev/null +++ b/tools/js/can/signals.js @@ -0,0 +1,384 @@ +/** + * signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical) + * + * 从 tools/can.js 拆分。对应原 can.js 约 629~1028 行(信号行渲染、信号值存取、 + * 编码写入、浮点/整数/Motorola/Intel 位操作)及 761 行的 renderSelectedMessage shim。 + * + * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 + */ +import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js'; +import { escapeHtml as sanitizeHtml } from '../common/utils.js'; + +// ==================== 信号值存取 ==================== +/** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */ +export function ensureMessageDefaults(message) { + if (!message) return null; + var bag = state.signalValues[message.key]; + if (!bag) { + bag = {}; + state.signalValues[message.key] = bag; + } + for (var i = 0; i < message.signals.length; i++) { + var sig = message.signals[i]; + if (bag[sig.name] == null || bag[sig.name] === '') { + bag[sig.name] = getDefaultSignalValue(sig); + } + } + return bag; +} + +export function getSignalValueMap(message) { + if (!message) return {}; + return state.signalValues[message.key] || ensureMessageDefaults(message); +} + +function setSignalValue(message, signalName, value) { + if (!message) return; + var bag = state.signalValues[message.key]; + if (!bag) { + bag = {}; + state.signalValues[message.key] = bag; + } + bag[signalName] = value; +} + +function getDefaultSignalValue(signal) { + /* 优先使用 DBC 中 BA_ "GenSigDefaultValue" 声明的物理值,否则回退为 0 */ + if (signal && signal.defaultValue != null && Number.isFinite(Number(signal.defaultValue))) { + return String(signal.defaultValue); + } + return '0'; +} + +/** @description 获取当前选中的报文对象(无选中返回 null)。 */ +export function getSelectedMessage() { + if (!state.dbc || !state.selectedMessageKey) return null; + for (var i = 0; i < state.dbc.messages.length; i++) { + if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i]; + } + return null; +} + +function getMultiplexerSignal(message) { + if (!message) return null; + for (var i = 0; i < message.signals.length; i++) { + if (message.signals[i].multiplexRole === 'mux') return message.signals[i]; + } + return null; +} + +/** @description 根据当前 multiplexer 值返回激活信号列表。 */ +export function getActiveSignals(message) { + if (!message) return []; + var muxSignal = getMultiplexerSignal(message); + if (!muxSignal) return message.signals.slice(); + var bag = getSignalValueMap(message); + var currentValue = Number(bag[muxSignal.name]); + return message.signals.filter(function (signal) { + if (!signal.multiplexRole || signal.multiplexRole === 'mux') return true; + return signal.multiplexRole === 'value' && Number.isFinite(currentValue) && signal.multiplexValue === currentValue; + }); +} + +// ==================== 旧版 shim ==================== +/** + * @description 旧版"顶部独立信号编辑面板"的兼容 shim。 + * 该面板已移除,信号编辑现在通过报文行展开完成; + * 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用, + * 保留空函数以避免修改所有调用方。 + */ +export function renderSelectedMessage() {} + +// ==================== 信号行渲染 ==================== +/** @description 在报文展开行内渲染信号表格(inline grid)。 */ +export function renderInlineSignalGrid(message, host) { + if (!host) return; + ensureMessageDefaults(message); + var activeSignals = getActiveSignals(message); + if (!activeSignals.length) { + host.innerHTML = '
当前报文没有信号定义。
'; + return; + } + var rows = activeSignals.map(function (signal, index) { + return renderSignalRow(message, signal, index); + }).join(''); + host.innerHTML = + '
' + + '
' + + '#' + + '信号名称' + + '输入值' + + '范围 / 枚举' + + '位定义' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + bindSignalInputs(host, message); +} + +function renderSignalRow(message, signal, index) { + var bag = getSignalValueMap(message); + var enumKeys = Object.keys(signal.enumMap || {}); + var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola'); + var rangeText = ''; + if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) { + rangeText = (Number.isFinite(signal.min) ? signal.min : '-') + ' ~ ' + (Number.isFinite(signal.max) ? signal.max : '-'); + } else { + rangeText = '未声明'; + } + var typeText = (signal.valueType || 'int').toUpperCase() + ' / ' + (signal.isSigned ? 'signed' : 'unsigned'); + var factorText = 'factor ' + signal.factor + ' / offset ' + signal.offset + (signal.unit ? (' / ' + signal.unit) : ''); + var comment = signal.comment || '-'; + if (comment.length > 48) comment = comment.slice(0, 48) + '...'; + var inputHtml = ''; + if (enumKeys.length) { + inputHtml = ''; + } else { + inputHtml = ''; + } + return '' + + '
' + + '' + (index + 1) + '' + + '' + + '' + sanitizeHtml(signal.name) + '' + + '' + sanitizeHtml((signal.receivers || []).join(', ') || '-') + '' + + '' + + '' + inputHtml + '' + + '' + + '' + sanitizeHtml(rangeText) + '' + + (enumKeys.length ? '' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumKeys.length > 3 ? ' ...' : '') + '' : '' + sanitizeHtml(factorText) + '') + + '' + + '' + + '' + sanitizeHtml(bitRange) + '' + + '' + sanitizeHtml(typeText) + '' + + '' + + '' + sanitizeHtml(comment) + '' + + '
'; +} + +function handleSignalInputEvent(evt) { + var input = evt.currentTarget; + var key = input.getAttribute('data-msg'); + var name = input.getAttribute('data-signal'); + if (!key || !name || !state.dbc) return; + var message = null; + for (var i = 0; i < state.dbc.messages.length; i++) { + if (state.dbc.messages[i].key === key) { message = state.dbc.messages[i]; break; } + } + if (!message) return; + setSignalValue(message, name, input.value); + var signal = findSignal(message, name); + if (signal && signal.multiplexRole === 'mux') { + if (key === state.selectedMessageKey) renderSelectedMessage(); + var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host'); + if (inlineGrid) renderInlineSignalGrid(message, inlineGrid); + } + if (key === state.selectedMessageKey) updateEncodedPreview(); +} + +function bindSignalInputs(container, message) { + if (!container) return; + var inputs = container.querySelectorAll('.signal-input'); + for (var i = 0; i < inputs.length; i++) { + inputs[i].addEventListener('input', handleSignalInputEvent); + inputs[i].addEventListener('change', handleSignalInputEvent); + } + if (message && message.signals.length && message.key === state.selectedMessageKey) { + updateEncodedPreview(); + } +} + +// ==================== 编码预览 ==================== +/** @description 根据当前选中报文 + 信号值更新编码预览(hex / byte strip / hint)。 */ +export function updateEncodedPreview() { + var message = getSelectedMessage(); + if (!message) { + state.generatedData = new Uint8Array(0); + if (el.generatedHex) el.generatedHex.textContent = '--'; + if (el.generatedBytes) el.generatedBytes.innerHTML = ''; + return; + } + try { + state.generatedData = encodeMessageData(message, getSignalValueMap(message)); + var hex = bytesToHex(state.generatedData); + el.generatedHex.textContent = hex || '--'; + el.generatedBytes.innerHTML = renderByteStrip(state.generatedData); + var hint = 'DLC ' + message.dlc + ',当前编码 ' + state.generatedData.length + + ' 字节,发送通道 CH' + (el.defaultChannel ? el.defaultChannel.value : '0') + '。'; + if (el.encodeHint) { + el.encodeHint.className = 'helper-note ok'; + el.encodeHint.innerHTML = hint; + } + } catch (err) { + state.generatedData = new Uint8Array(0); + el.generatedHex.textContent = '--'; + el.generatedBytes.innerHTML = ''; + if (el.encodeHint) { + el.encodeHint.className = 'helper-note err'; + el.encodeHint.innerHTML = '编码失败:' + sanitizeHtml(err.message); + } + } +} + +function renderByteStrip(bytes) { + if (!bytes || !bytes.length) return '
无数据
'; + var html = []; + for (var i = 0; i < bytes.length; i++) { + html.push('B' + i + '' + padHex(bytes[i], 2) + ''); + } + return html.join(''); +} + +// ==================== 信号编解码(raw ↔ physical) ==================== +/** @description 将报文 + 信号值编码为 Uint8Array。 */ +export function encodeMessageData(message, signalValues) { + var bytes = new Uint8Array(message.dlc); + var activeSignals = getActiveSignals(message); + for (var i = 0; i < activeSignals.length; i++) { + var signal = activeSignals[i]; + var parsed = parseSignalInput(signal, signalValues[signal.name]); + if (signal.valueType === 'float' || signal.valueType === 'double') { + writeFloatSignal(bytes, signal, parsed.numeric); + } else { + writeIntegerSignal(bytes, signal, parsed); + } + } + return bytes; +} + +function parseSignalInput(signal, inputValue) { + var value = inputValue; + if (value === '' || value == null) value = getDefaultSignalValue(signal); + var num = Number(value); + if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字'); + if (Number.isFinite(signal.min) && num < signal.min) { + throw new Error(signal.name + ' 低于最小值 ' + signal.min); + } + if (Number.isFinite(signal.max) && num > signal.max) { + throw new Error(signal.name + ' 高于最大值 ' + signal.max); + } + return { + numeric: num, + text: String(value).trim() + }; +} + +function isIntegerLike(value) { + return /^-?\d+$/.test(String(value == null ? '' : value).trim()); +} + +function physicalToRawBigInt(signal, parsedValue) { + if ((signal.factor === 1 || signal.factor === 1.0) && + (signal.offset === 0 || signal.offset === 0.0) && + isIntegerLike(parsedValue.text)) { + return BigInt(parsedValue.text); + } + var rawNumber = signal.factor === 0 + ? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric) + : Math.round((parsedValue.numeric - signal.offset) / signal.factor); + if (!Number.isFinite(rawNumber)) { + throw new Error(signal.name + ' 原始值计算失败'); + } + return BigInt(rawNumber); +} + +function bigPow2(bits) { + return 1n << BigInt(bits); +} + +function maxUnsignedValue(bits) { + return bigPow2(bits) - 1n; +} + +function minSignedValue(bits) { + return -(1n << BigInt(bits - 1)); +} + +function maxSignedValue(bits) { + return (1n << BigInt(bits - 1)) - 1n; +} + +function writeIntegerSignal(bytes, signal, parsedValue) { + var raw = physicalToRawBigInt(signal, parsedValue); + if (signal.length <= 0 || signal.length > 64) { + throw new Error(signal.name + ' 位宽超出当前页面支持范围'); + } + if (signal.isSigned) { + var min = minSignedValue(signal.length); + var max = maxSignedValue(signal.length); + if (raw < min || raw > max) { + throw new Error(signal.name + ' 原始值超出带符号范围'); + } + } else { + var maxUnsigned = maxUnsignedValue(signal.length); + if (raw < 0 || raw > maxUnsigned) { + throw new Error(signal.name + ' 原始值超出无符号范围'); + } + } + var unsignedRaw = raw < 0 ? bigPow2(signal.length) + raw : raw; + + if (signal.byteOrder === 'little') { + for (var i = 0; i < signal.length; i++) { + var bitVal = Number((unsignedRaw >> BigInt(i)) & 1n); + var bitIndex = signal.startBit + i; + setBitByIndex(bytes, bitIndex, bitVal); + } + return; + } + + var positions = buildMotorolaBitPositions(signal.startBit, signal.length); + for (var j = 0; j < positions.length; j++) { + var shift = signal.length - 1 - j; + var motorolaBit = Number((unsignedRaw >> BigInt(shift)) & 1n); + setBitByIndex(bytes, positions[j], motorolaBit); + } +} + +function writeFloatSignal(bytes, signal, physicalValue) { + if (signal.length !== 32 && signal.length !== 64) { + throw new Error(signal.name + ' 浮点信号长度需为 32 或 64 位'); + } + if (signal.byteOrder !== 'little') { + throw new Error(signal.name + ' 浮点信号当前仅支持 Intel 小端'); + } + if (signal.startBit % 8 !== 0) { + throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐'); + } + var buffer = new ArrayBuffer(signal.length / 8); + var view = new DataView(buffer); + if (signal.length === 32) view.setFloat32(0, physicalValue, true); + else view.setFloat64(0, physicalValue, true); + var rawBytes = new Uint8Array(buffer); + for (var i = 0; i < rawBytes.length; i++) { + bytes[(signal.startBit / 8) + i] = rawBytes[i]; + } +} + +function buildMotorolaBitPositions(startBit, length) { + var positions = []; + var bit = startBit; + for (var i = 0; i < length; i++) { + positions.push(bit); + if (bit % 8 === 0) bit += 15; + else bit -= 1; + } + return positions; +} + +function setBitByIndex(bytes, bitIndex, bitValue) { + var byteIndex = Math.floor(bitIndex / 8); + var bitInByte = bitIndex % 8; + if (byteIndex < 0 || byteIndex >= bytes.length) { + throw new Error('信号写入越界,bit=' + bitIndex); + } + if (bitValue) bytes[byteIndex] |= (1 << bitInByte); + else bytes[byteIndex] &= ~(1 << bitInByte); +} diff --git a/tools/js/can_monitor.js b/tools/js/can_monitor.js new file mode 100644 index 0000000..511c5ef --- /dev/null +++ b/tools/js/can_monitor.js @@ -0,0 +1,232 @@ +import { showToast } from './common/toast.js'; + +var state = { fbTimer: null }; + +var el = {}; + +var ids = [ + 'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt', + 'monConnIndicator', 'monConnIndicatorTxt', 'monHelper', + 'fbModePill', 'fbPollStatus', + 'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Tec', 'fb0Rec', 'fb0Bus', 'fb0Seq', 'fb0Delta', + 'fb0LastId', 'fb0Dlc', 'fb0Flags', 'fb0LastData', + 'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Tec', 'fb1Rec', 'fb1Bus', 'fb1Seq', 'fb1Delta', + 'fb1LastId', 'fb1Dlc', 'fb1Flags', 'fb1LastData', + 'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Tec', 'fb2Rec', 'fb2Bus', 'fb2Seq', 'fb2Delta', + 'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData', + 'btnModeAllPass', 'btnModeFilter', 'btnModeBridge', + 'idsnapChannel', 'idsnapStatus', 'idsnapBody', + 'toast', 'toastIco', 'toastMsg' +]; + +function cacheElements() { + ids.forEach(function (id) { el[id] = document.getElementById(id); }); +} + +function sanitize(text) { + return String(text == null ? '' : text) + .replace(/&/g, '&').replace(//g, '>'); +} + +function getBaseUrl() { + var ip = (el.monIp && el.monIp.value ? el.monIp.value.trim() : '') || '192.168.31.127'; + return 'http://' + ip; +} + +function setConnState(ok, text) { + if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline'); + if (el.monConnTxt) el.monConnTxt.textContent = text; + if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err'); + if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text; +} + +// ── 1Hz 轮询 /can/feedback ───────────────────────────────── +async function pollFeedback() { + // 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次 + if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} } + state.fbAbortCtrl = new AbortController(); + var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000); + try { + var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal }); + if (!resp.ok) return; + var data = await resp.json(); + var channels = data.channels || []; + var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value) : 0; + + for (var i = 0; i < 3; i++) { + var ch = channels[i]; + if (!ch || !ch.valid) { + renderChannelEmpty(i); + continue; + } + renderChannel(i, ch); + } + + // 渲染选中通道的 ID 快照表 + var sel = channels[selectedCh]; + if (sel && sel.valid && sel.entries) { + renderIdTable(sel.entries, sel.count || sel.entries.length); + if (el.idsnapStatus) { + el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID'; + el.idsnapStatus.className = 'pill online'; + } + } else { + renderIdTableEmpty(); + } + + if (el.fbPollStatus) { + el.fbPollStatus.textContent = '轮询: 正常'; + el.fbPollStatus.className = 'pill online'; + } + setConnState(true, '已连接'); + clearTimeout(fbPollTimer); + } catch (e) { + clearTimeout(fbPollTimer); + // 自身 AbortController 触发的取消不算失败,静默返回 + if (e && e.name === "AbortError") return; + if (el.fbPollStatus) { + el.fbPollStatus.textContent = '轮询: 离线'; + el.fbPollStatus.className = 'pill offline'; + } + setConnState(false, '连接失败'); + } +} + +function renderChannel(i, ch) { + var p = 'fb' + i; + if (el[p + 'State']) { + var hasIds = ch.count > 0; + el[p + 'State'].textContent = hasIds ? '活跃 (' + ch.count + ' ID)' : '无帧(总线空闲)'; + el[p + 'State'].className = 'fb-val ' + (hasIds ? 'ok' : 'info'); + } + if (el[p + 'Rx']) el[p + 'Rx'].textContent = ch.rx; + if (el[p + 'Tx']) el[p + 'Tx'].textContent = ch.tx; + if (el[p + 'Drop']) { el[p + 'Drop'].textContent = ch.drop; el[p + 'Drop'].className = 'fb-val' + (ch.drop > 0 ? ' warn' : ''); } + if (el[p + 'Err']) { el[p + 'Err'].textContent = ch.err; el[p + 'Err'].className = 'fb-val' + (ch.err > 0 ? ' err' : ''); } + if (el[p + 'Tec']) { el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--'; el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : ''); } + if (el[p + 'Rec']) { el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--'; el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : ''); } + if (el[p + 'Bus']) { + if (ch.bus_state === undefined || ch.bus_state === null) { + el[p + 'Bus'].textContent = '未知'; + el[p + 'Bus'].className = 'fb-val'; + } else { + var bs = ch.bus_state; + var parts = []; + if (bs & 1) parts.push('INIT'); + if (bs & 2) parts.push('SLEEP'); + if (bs & 4) parts.push('ERR_PASSIVE'); + if (bs & 8) parts.push('BUS_OFF'); + if (bs & 16) parts.push('ERR_WARN'); + el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常'); + el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4|16)) ? ' warn' : ''); + } + } + if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq; + if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; } + // 最新帧摘要 + var entries = ch.entries; + if (entries && entries.length) { + var last = entries[entries.length - 1]; + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc; + if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-'); + if (el[p + 'LastData']) { el[p + 'LastData'].textContent = last.data || '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } + } else { + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--'; + if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--'; + if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } + } + if (i === 0 && el.fbModePill) { + var modeText = ch.mode === 2 ? '桥接(CAN0→1/2)' : (ch.mode === 1 ? '过滤' : '全收'); + el.fbModePill.textContent = '模式: ' + modeText; + } +} + +function renderChannelEmpty(i) { + var p = 'fb' + i; + if (el[p + 'State']) { el[p + 'State'].textContent = 'SPI 离线'; el[p + 'State'].className = 'fb-val err'; } + ['Rx','Tx','Drop','Err','Tec','Rec','Bus','Seq','Delta','Dlc','Flags'].forEach(function (f) { + if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; } + }); + if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; } + if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; } +} +function renderIdTable(entries, count) { + var tbody = el.idsnapBody; + if (!tbody) return; + if (!entries || !entries.length) { + tbody.innerHTML = '无数据(CAN 未收到帧)'; + return; + } + var html = ''; + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + html += ''; + html += '' + (i + 1) + ''; + html += '' + sanitize(e.id) + ''; + html += '' + e.dlc + ''; + html += '' + (e.fd ? 'Y' : '-') + ''; + html += '' + (e.brs ? 'Y' : '-') + ''; + html += '' + (e.esi ? 'Y' : '-') + ''; + html += '' + sanitize(e.data || '--') + ''; + html += ''; + } + tbody.innerHTML = html; +} + +function renderIdTableEmpty() { + var tbody = el.idsnapBody; + if (tbody) tbody.innerHTML = '等待数据...'; + if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; } +} + +// ── 连接测试 ───────────────────────────────────────────── +async function testConnection() { + try { + var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; } + setConnState(true, '已连接'); + showToast('ESP32 已连接', 'success'); + } catch (e) { + setConnState(false, '连接失败'); + showToast('连接失败: ' + e.message, 'error'); + } +} + +// ── 模式切换 ───────────────────────────────────────────── +async function setCanMode(mode) { + try { + var resp = await fetch(getBaseUrl() + '/can/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: mode }), + signal: AbortSignal.timeout(3000) + }); + var data = await resp.json(); + if (data.ok) { + showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success'); + } else { + showToast('模式切换失败', 'error'); + } + } catch (e) { + showToast('模式切换失败: ' + e.message, 'error'); + } +} + +// ── 初始化 ─────────────────────────────────────────────── +function init() { + cacheElements(); + setConnState(false, '未连接'); + if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection); + if (el.monIp) el.monIp.addEventListener('change', testConnection); + if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); }); + if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); }); + if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); }); + if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback); + setTimeout(testConnection, 150); + pollFeedback(); + state.fbTimer = setInterval(pollFeedback, 1000); +} + +init(); diff --git a/tools/js/common/connection.js b/tools/js/common/connection.js new file mode 100644 index 0000000..3db3740 --- /dev/null +++ b/tools/js/common/connection.js @@ -0,0 +1,270 @@ +/** + * connection.js - 连接管理 + 系统状态显示 + * + * 从 app.js 的「连接管理」段 + testConnection / refreshSystemStatusNow 提取。 + * 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。 + * + * 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载): + * window.latestSystemStatus — 最近一次 /system/status 响应 + * window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取) + * window.lastRelayState — 上次成功的 relay_state(polling.js 读取) + * window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取) + * + * 未拆分的 app.js 函数(通过 window.* 引用): + * window.getBaseUrl / window.updateSystemStatusUi / window.diagnoseNetwork / window.formatPercent + */ +import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js'; +import { addLog } from './log.js'; +import { showToast } from './toast.js'; + +// ==================== 连接管理 ==================== +export function updateConnectionUi(connected, text) { + // header pill + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + pc.className = connected ? 'pill online' : 'pill offline'; + pct.textContent = text || (connected ? '已连接' : '未连接'); + // connection card indicator + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; + if (cst) cst.textContent = connected ? (text || '已连接') : '未连接'; + // primary badge + var bc = document.getElementById('bConn'); + bc.textContent = connected ? '在线' : '离线'; + bc.className = 'v ' + (connected ? 'ok' : 'err'); +} +export function updateHealthUi(healthCode, freeHeap) { + var healthNames = ['正常', '警告', '危险', '关机']; + var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知'; + var connStatusTxt = document.getElementById('connStatusTxt'); + if (connStatusTxt && window.latestSystemStatus && window.latestSystemStatus.wifi) { + connStatusTxt.textContent = (window.latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health; + } + if (freeHeap !== undefined) { + var mHeapEl = document.getElementById('mHeap'); + if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap); + } + var mh = document.getElementById('mHealth'); + if (mh) { + mh.textContent = health; + mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'); + } +} +export function updateHeapAndTaskStatusUi(data) { + if (!data) return; + var ota = data.ota || {}; + var wifi = data.wifi || {}; + var targetHost = window.getBaseUrl().replace(/^http:\/\//, ''); + var setText = function (id, value) { + var el = document.getElementById(id); + if (el) el.textContent = value; + }; + // ---- 堆概况:已用 / 总计 + 占用条 ---- + function setFill(id, pct) { + var el = document.getElementById(id); + if (!el) return; + el.style.width = Math.max(0, Math.min(100, pct)) + '%'; + el.className = 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : ''); + } + function setBar(usageId, fillId, pctId, free, total) { + if (free === undefined || total === undefined) { + setText(usageId, '--'); setFill(fillId, 0); setText(pctId, '--'); + return; + } + var used = total - free; + var pct = total > 0 ? (used * 100 / total) : 0; + setText(usageId, '已用 ' + formatBytes(used) + ' / 总计 ' + formatBytes(total)); + setFill(fillId, pct); + setText(pctId, pct.toFixed(1) + '%'); + } + function setCpuFill(pct) { + var el = document.getElementById('cpuUsageFill'); + if (!el) return; + var safePct = Math.max(0, Math.min(100, Number(pct) || 0)); + el.style.width = safePct + '%'; + el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''); + } + function updateCpuUsageUi(cpu) { + cpu = cpu || {}; + var taskList = document.getElementById('cpuTaskList'); + if (!cpu.available) { + setText('cpuUsageText', '未启用 FreeRTOS 统计'); + setText('cpuUsagePct', '--'); + setText('cpuIdlePct', '--'); + setText('cpuSampleMs', '--'); + setText('cpuTaskCount', '--'); + setCpuFill(0); + if (taskList) taskList.innerHTML = '
固件未启用 CPU runtime stats
'; + return; + } + if (!cpu.valid) { + setText('cpuUsageText', '等待下一次采样...'); + setText('cpuUsagePct', '--'); + setText('cpuIdlePct', '--'); + setText('cpuSampleMs', '--'); + setText('cpuTaskCount', '--'); + setCpuFill(0); + if (taskList) taskList.innerHTML = '
首次刷新只建立基线,下一次刷新后显示 CPU 占用
'; + return; + } + var usagePct = Number(cpu.usage_pct) || 0; + var idlePct = Number(cpu.idle_pct) || 0; + var sampleMs = Number(cpu.sample_ms) || 0; + var tasks = Array.isArray(cpu.tasks) ? cpu.tasks.slice() : []; + tasks.sort(function (a, b) { + return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0); + }); + + setText('cpuUsageText', 'CPU 已用 ' + window.formatPercent(usagePct)); + setText('cpuUsagePct', window.formatPercent(usagePct)); + setText('cpuIdlePct', window.formatPercent(idlePct)); + setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--'); + setText('cpuTaskCount', String(tasks.length)); + setCpuFill(usagePct); + + if (taskList) { + var visibleTasks = tasks.slice(0, 8); + if (!visibleTasks.length) { + taskList.innerHTML = '
当前采样没有任务增量
'; + } else { + taskList.innerHTML = visibleTasks.map(function (task) { + var pct = Number(task.usage_pct) || 0; + var delta = Number(task.runtime_delta) || 0; + var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : ''; + return '' + + '
' + + '
' + + '
' + escapeHtml(task.name || 'task') + '
' + + '
' + escapeHtml(window.formatPercent(pct)) + '
' + + '
' + + '
' + + '
' + + '
任务号' + escapeHtml(task.task_number) + '
' + + '
增量' + escapeHtml(delta) + '
' + + '
占比' + escapeHtml(window.formatPercent(pct)) + '
' + + '
' + + '
'; + }).join(''); + } + } + } + updateCpuUsageUi(data.cpu); + setText('mInternalHeap', data.internal_free_heap !== undefined ? formatBytes(data.internal_free_heap) : '--'); + setText('mInternalMinHeap', data.internal_min_free_heap !== undefined ? formatBytes(data.internal_min_free_heap) : '--'); + setText('mInternalLargestBlock', data.internal_largest_free_block !== undefined ? formatBytes(data.internal_largest_free_block) : '--'); + setBar('memInternalUsage', 'memInternalFill', 'memInternalPct', data.internal_free_heap, data.internal_heap_total); + + setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--'); + + setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : '')); + setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--'); + // 缓存最近一次成功的 sys_state_code / relay_state,供 pollStatus 失败时判断设备是否在忙 + if (Number.isFinite(ota.sys_state_code)) window.lastSysStateCode = ota.sys_state_code; + if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state; + var storedText = '--'; + if (ota.total_size || ota.stored_size) { + storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0); + } + setText('sOtaStored', storedText); + setText('sStatusUpdated', formatTime(new Date())); + + var taskList = document.getElementById('taskStackList'); + if (taskList) { + var taskStack = data.task_stack || {}; + var names = Object.keys(taskStack); + if (!names.length) { + taskList.innerHTML = '
当前状态未包含任务栈信息
'; + } else { + taskList.innerHTML = names.map(function (name) { + var item = taskStack[name] || {}; + var stackSize = Number(item.stack_size_bytes) || 0; + var watermark = Number(item.high_watermark_bytes) || 0; + var peakUsed = Number(item.peak_used_bytes) || 0; + var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0; + var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : ''; + return '' + + '
' + + '
' + + '
' + escapeHtml(name) + '
' + + '
' + escapeHtml(window.formatPercent(usagePct)) + '
' + + '
' + + '
' + + '
' + + '
栈大小' + escapeHtml(formatBytes(stackSize)) + '
' + + '
剩余水位' + escapeHtml(formatBytes(watermark)) + '
' + + '
峰值占用' + escapeHtml(formatBytes(peakUsed)) + '
' + + '
' + + '
'; + }).join(''); + } + } + + var statusJson = document.getElementById('statusJson'); + if (statusJson) { + statusJson.textContent = JSON.stringify(data, null, 2); + } +} +/** @description 测试与 ESP32 的连接(GET /status)。 */ +export async function testConnection() { + // 立即发一次请求检测 + // 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus + var testAbort = new AbortController(); + var testTimer = setTimeout(function() { try { testAbort.abort(); } catch(_e) {} }, 3000); + try { + var resp = await fetch(window.getBaseUrl() + '/system/status', { + signal: testAbort.signal + }); + if (resp.ok) { + var data = await resp.json(); + window.latestSystemStatus = data; + window.updateSystemStatusUi(data); + var wifi = data.wifi || {}; + addLog('I', '连接', wifi.connected + ? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '') + : '未连接:' + (wifi.mode || 'STA')); + showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', + wifi.connected ? 'success' : 'warning'); + } else { + addLog('E', '连接', 'HTTP ' + resp.status); + showToast('设备响应异常: HTTP ' + resp.status, 'error'); + } + } catch (e) { + addLog('E', '连接', '连接失败: ' + e.message); + // 连接失败时自动诊断 + var diag = await window.diagnoseNetwork(); + addLog('W', '诊断', diag); + showToast('无法连接: ' + diag, 'error'); + } finally { + clearTimeout(testTimer); + } +} +export async function refreshSystemStatusNow(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + // 手动刷新使用独立的 AbortController,避免与递归调度的 pollStatus 互相干扰 + var manualAbort = new AbortController(); + var manualTimer = setTimeout(function() { try { manualAbort.abort(); } catch(_e) {} }, 3000); + try { + var resp = await fetch(window.getBaseUrl() + '/system/status', { + signal: manualAbort.signal + }); + if (!resp.ok) { + addLog('E', '状态', '刷新失败: HTTP ' + resp.status); + showToast('状态刷新失败: HTTP ' + resp.status, 'error'); + return; + } + var data = await resp.json(); + window.latestSystemStatus = data; + window.updateSystemStatusUi(data); + window.consecutivePollFailures = 0; + addLog('I', '状态', '已手动刷新 /system/status'); + } catch (e) { + addLog('E', '状态', '刷新失败: ' + e.message); + showToast('状态刷新失败', 'error'); + } finally { + clearTimeout(manualTimer); + } +} diff --git a/tools/js/common/log.js b/tools/js/common/log.js new file mode 100644 index 0000000..078b0d6 --- /dev/null +++ b/tools/js/common/log.js @@ -0,0 +1,70 @@ +/** + * log.js - 日志系统(带去重 + 自动滚动) + * + * 从 app.js 提取的日志环形缓冲、去重、过滤、自动滚动逻辑。 + * 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。 + */ +import { formatTime } from './utils.js'; + +// ==================== 日志系统状态 ==================== +let logFilter = 'all'; +let logEntries = []; +let lastLogHash = null; +let autoScroll = true; + +// ==================== 日志系统 (带去重 + 自动滚动) ==================== +function makeLogHash(level, tag, message) { + return level + '|' + tag + '|' + message; +} +/** @description 添加一条日志条目到环形缓冲区和日志面板。 */ +export function addLog(level, tag, message) { + var scroll = document.getElementById('logScroll'); + var time = formatTime(new Date()); + var hash = makeLogHash(level, tag, message); + if (hash === lastLogHash) return; // 去重 + lastLogHash = hash; + var entry = { time: time, level: level, tag: tag, message: message }; + logEntries.push(entry); + if (logFilter !== 'all' && level !== logFilter) return; + var div = document.createElement('div'); + div.className = 'log-line'; + div.innerHTML = + '[' + time + ']' + + '' + level + '' + + '[' + tag + ']' + + '' + message + ''; + scroll.appendChild(div); + if (autoScroll) scroll.scrollTop = scroll.scrollHeight; + document.getElementById('logCount').textContent = logEntries.length; +} +export function clearLogs() { + document.getElementById('logScroll').innerHTML = ''; + logEntries = []; + lastLogHash = null; + document.getElementById('logCount').textContent = '0'; +} +export function setLogFilter(level, btn) { + document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + logFilter = level; + var scroll = document.getElementById('logScroll'); + scroll.innerHTML = ''; + logEntries.forEach(function (e) { + if (level === 'all' || e.level === level) { + var div = document.createElement('div'); + div.className = 'log-line'; + div.innerHTML = + '[' + e.time + ']' + + '' + e.level + '' + + '[' + e.tag + ']' + + '' + e.message + ''; + scroll.appendChild(div); + } + }); + if (autoScroll) scroll.scrollTop = scroll.scrollHeight; +} +export function toggleAutoScroll() { + autoScroll = !autoScroll; + var btn = document.getElementById('autoScrollBtn'); + btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动'; +} diff --git a/tools/js/common/polling.js b/tools/js/common/polling.js new file mode 100644 index 0000000..f99f7c4 --- /dev/null +++ b/tools/js/common/polling.js @@ -0,0 +1,363 @@ +/** + * polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取 + * + * 从 app.js 的「轮询状态」「Page Visibility 补偿」「ESP32 实时日志拉取 (增量)」段提取。 + * 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。 + * + * 模块级状态(仅本模块使用,不导出): + * statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs + * lastDiagTime / diagResult / uploadPollChip / lastLogTimestamp / pageVisible + * + * 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载): + * window.pollingPausedForUpload — 上传期间暂停轮询标志 + * window.consecutivePollFailures — 连续轮询失败计数(connection.js 也写入) + * window.lastSysStateCode — 上次成功的 sys_state_code(connection.js 写入) + * window.lastRelayState — 上次成功的 relay_state(connection.js 写入) + * window.isEsp32Rebooting — ESP32 重启中标志 + * window.rebootStartTime — 重启开始时间戳 + * window.rebootObservedDown — 已观察到设备下线 + * window.otaSessionState — OTA 会话状态机当前状态 + * window.OtaSessionState — OTA 会话状态枚举 + * window.activeUploadChip — 当前上传目标芯片 + * window.currentOtaTarget — 当前 OTA 目标 + * + * 未拆分的 app.js 函数(通过 window.* 引用): + * window.getEspIp / window.getBaseUrl + * window.updateSystemStatusUi / window.updateEsp32StagesFromPhase + * window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState + * window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset + * window.updateUploadButtonLabel / window.updateSpiHint + */ +import { addLog } from './log.js'; +import { showToast } from './toast.js'; +import { updateConnectionUi } from './connection.js'; + +// ==================== 轮询状态 ==================== +// 共享状态(初始化到 window,供 connection.js 等其他模块读写) +window.consecutivePollFailures = 0; +window.lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙 +window.lastRelayState = 0; // 上次成功的 relay_state + +// 模块级状态(仅本模块使用) +var statusTimerId = null; +var logsTimerId = null; +var lastDiagTime = 0; // 上次诊断时间 +var diagResult = ''; // 最近一次诊断结果 +var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用) +var statusPollIntervalMs = 1000; // 状态轮询目标间隔 +var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠 +var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false +var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 + +// 从设备 IP 推导网关 IP(取前三段 + .1) +function getGatewayIp() { + var parts = window.getEspIp().split('.'); + if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1'; + return '192.168.31.1'; +} +// 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通 +async function diagnoseNetwork() { + lastDiagTime = Date.now(); + var result = []; + // 1) 浏览器是否在线 + if (navigator.onLine === false) { + result.push('浏览器无网络连接'); + diagResult = result.join(';'); + return diagResult; + } + // 2) 尝试触达网关(超时 2s) + var gwIp = getGatewayIp(); + var gwOk = false; + try { + await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) }); + gwOk = true; // no-cors 模式下不抛异常即视为可达 + } catch (_) {} + if (!gwOk) { + // 再试一次 favicon(有些路由器不响应 /) + try { + await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) }); + gwOk = true; + } catch (_) {} + } + if (!gwOk) { + result.push('网关 ' + gwIp + ' 不可达(本地网络不通)'); + } else { + result.push('网关 ' + gwIp + ' 可达'); + // 3) 网关通但设备不通 → 设备自身问题 + result.push('设备 ' + window.getEspIp() + ' 不响应(可能断电、重启中或 WiFi 断开)'); + } + diagResult = result.join(';'); + return diagResult; +} +/** @description 调度下一次 /system/status 轮询(递归 setTimeout)。 + * 上一次请求完成(或 3s 超时)后才调度下一次,避免 setInterval 把慢响应 + * 直接 abort 而导致堆/任务栈/CPU 等卡片刷新率不稳。 + */ +export function scheduleStatusPoll(delayMs) { + if (statusTimerId) clearTimeout(statusTimerId); + // 中继期加快:进度条依赖 status.relay_seq,1s 间隔体感像卡死 + var interval = statusPollIntervalMs; + // 防御性守卫:window.OtaSessionState 可能尚未挂载(模块加载早期),此时不加速 + if (window.OtaSessionState && window.otaSessionState === window.OtaSessionState.RELAYING) { + interval = Math.min(interval, 400); + } + statusTimerId = setTimeout(function() { + statusTimerId = null; + pollStatus(); + }, delayMs !== undefined ? delayMs : interval); +} + +/** @description 启动定时轮询(状态 + 日志)。 */ + +export function startPolling() { + window.consecutivePollFailures = 0; + statusPollIntervalMs = 1000; + if (statusTimerId) clearTimeout(statusTimerId); + if (logsTimerId) clearInterval(logsTimerId); + scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度 + logsTimerId = setInterval(pollLogs, 1000); +} +export function pausePollingForUpload() { + if (window.pollingPausedForUpload) return; + window.pollingPausedForUpload = true; + uploadPollChip = window.activeUploadChip || window.flashTarget || null; + window.consecutivePollFailures = 0; + // ★ 上传大固件期间必须停掉 status/logs 轮询。 + // httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉 + // 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完), + // 但永远等不到 HTTP 响应,卡在「已上传 xxx / xxx」。 + if (statusTimerId) { + clearTimeout(statusTimerId); + statusTimerId = null; + } + if (logsTimerId) { + clearInterval(logsTimerId); + logsTimerId = null; + } + if (statusAbortCtrl) { + try { statusAbortCtrl.abort(); } catch (e) { /* ignore */ } + statusAbortCtrl = null; + statusPollInFlight = false; + } +} +export function resumePollingAfterUpload() { + if (!window.pollingPausedForUpload) return; + window.pollingPausedForUpload = false; + uploadPollChip = null; + window.consecutivePollFailures = 0; + statusPollIntervalMs = 1000; + if (logsTimerId) clearInterval(logsTimerId); + logsTimerId = setInterval(pollLogs, statusPollIntervalMs); + // 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续 + if (statusPollInFlight) { + if (statusTimerId) clearTimeout(statusTimerId); + } else { + scheduleStatusPoll(0); + } + pollLogs(); +} +/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */ +export async function pollStatus() { + // 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求 + if (statusPollInFlight) return; + statusPollInFlight = true; + statusAbortCtrl = new AbortController(); + var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 3000); + try { + var resp = await fetch(window.getBaseUrl() + '/system/status', { + signal: statusAbortCtrl.signal + }); + if (resp.ok) { + var data = await resp.json(); + // 恢复:之前有失败计数,说明设备曾离线,现在恢复在线 + if (window.consecutivePollFailures > 0) { + window.consecutivePollFailures = 0; + addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); + showToast('设备已恢复在线', 'success'); + // 设备离线后恢复在线,清理残留的 OTA 会话状态 + if (window.otaSessionState !== window.OtaSessionState.IDLE) { + window.otaSessionReset(); + addLog('I', '系统', '已清理 OTA 会话状态'); + } + } + if (window.isEsp32Rebooting) { + var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); + if (!window.rebootObservedDown && rbElapsed < 10) { + // pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询 + // 保持"重启中"状态,不清除标志,不推进阶段 + // ★ 真正的"重启完成"由 pollLogs 收到启动 banner(MAIN: ESP32-C3 OTA Relay v1.0 启动)触发 + } else { + // 设备已重启并恢复在线(rebootObservedDown=true) + // 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复 + window.isEsp32Rebooting = false; + window.rebootObservedDown = false; + window.updateEsp32StagesFromPhase(6); // 启动完成 + addLog('I', '系统', 'ESP32 已恢复在线'); + showToast('ESP32 已恢复在线', 'success'); + // ★ 状态机:ESP32 重启完成 → DONE + if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE); + } + } + try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } + // pre-reboot 窗口内 updateSystemStatusUi 会把连接胶囊刷成"已连接",需覆盖回"重启中" + if (window.isEsp32Rebooting) { + window.applyRebootingRuntimeState(); + } + } + clearTimeout(pollTimer); + } catch (e) { + clearTimeout(pollTimer); + // 自身 AbortController 触发的取消不算失败,静默返回 + if (e && e.name === "AbortError") return; + // 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次, + // 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。 + if (pageVisible) window.consecutivePollFailures++; + // 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code): + // 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING, + // 6=ESP32_FINALIZING, 7=ESP32_REBOOTING + // 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾) + var lastDeviceBusy = + window.lastSysStateCode === 1 || window.lastSysStateCode === 3 || + window.lastSysStateCode === 5 || window.lastSysStateCode === 6 || + window.lastSysStateCode === 7 || + (window.lastRelayState >= 1 && window.lastRelayState <= 4); + // OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的) + // 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈 + // ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。 + // FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。 + var sessionActive = window.otaSessionActive(); + var sessionFailed = window.otaSessionState === window.OtaSessionState.FAILED; + if (pageVisible && window.consecutivePollFailures >= 5 && !sessionActive && !window.isEsp32Rebooting && !window.currentOtaTarget && !lastDeviceBusy && !sessionFailed) { + addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); + var diag = await diagnoseNetwork(); + addLog('W', '诊断', diag); + updateConnectionUi(false, '未连接'); + window.applyDisconnectedRuntimeState(); + var cst = document.getElementById('connStatusTxt'); + if (cst) cst.textContent = '诊断中: ' + diag; + } + // ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接" + if (sessionFailed) { + if (lastDeviceBusy) { + // 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段 + var pcF = document.getElementById('pillConn'); + var pctF = document.getElementById('pillConnTxt'); + if (pcF) pcF.className = 'pill processing'; + if (pctF) pctF.textContent = '设备忙'; + var csF = document.getElementById('connStatus'); + var cstF = document.getElementById('connStatusTxt'); + if (csF) csF.className = 'conn-indicator warn'; + if (cstF) cstF.textContent = '设备忙(OTA 后台运行中),等待回 IDLE'; + updateConnectionUi(true, '设备忙'); + } else { + // 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留 + updateConnectionUi(false, '未连接'); + } + } else if (window.otaSessionState === window.OtaSessionState.UPLOADING) { + window.applyUploadingRuntimeState(); + } else if (window.otaSessionState === window.OtaSessionState.REBOOTING || window.isEsp32Rebooting) { + window.rebootObservedDown = true; // 确认设备已下线(正在重启) + var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); + if (elapsed > 60) { + window.otaSessionTransition(window.OtaSessionState.FAILED, 'ESP32 重启超时'); + window.isEsp32Rebooting = false; + window.rebootObservedDown = false; + addLog('E', '系统', 'ESP32 重启超时,设备未恢复'); + showToast('ESP32 重启超时,请检查设备', 'error'); + updateConnectionUi(false, '未连接'); + window.applyDisconnectedRuntimeState(); + } else { + window.applyRebootingRuntimeState(); + } + } else if (sessionActive || lastDeviceBusy) { + // 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙" + // 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导) + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + if (pc) pc.className = 'pill processing'; + if (pct) pct.textContent = '设备忙'; + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + if (cs) cs.className = 'conn-indicator warn'; + if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询'; + updateConnectionUi(true, '设备忙'); + } else { + updateConnectionUi(false, '未连接'); + window.applyDisconnectedRuntimeState(); + } + } finally { + statusPollInFlight = false; + scheduleStatusPoll(); + } +} +// 注意:startPoll() 由入口模块(app.js / ble_ota.js)在所有模块加载完成后调用, +// 此处不在模块顶层自动启动,避免 ESM 求值顺序导致 window.OtaSessionState 尚未初始化。 +// ==================== Page Visibility 补偿 ==================== +// Chrome/Edge 在标签隐藏/窗口最小化时会把 setInterval 节流到最低 1 分钟/次。 +// 切回前台时立即拉一次状态 + 日志,并清零后台期间累积的失败计数, +// 避免在"刚切回前台那一轮"被误判为 5-fail 离线。 +document.addEventListener('visibilitychange', function () { + pageVisible = !document.hidden; + if (!pageVisible) return; + window.consecutivePollFailures = 0; + // 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排 + if (statusPollInFlight) { + if (statusTimerId) clearTimeout(statusTimerId); + } else { + if (statusTimerId) clearTimeout(statusTimerId); + scheduleStatusPoll(0); + } + pollLogs(); +}); +// ==================== ESP32 实时日志拉取 (增量) ==================== +var lastLogTimestamp = 0; +/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */ +export async function pollLogs() { + // ★ ESP32 上传期间也允许日志轮询 + try { + var resp = await fetch(window.getBaseUrl() + '/logs', { + signal: AbortSignal.timeout(2000) + }); + if (!resp.ok) return; + var logs = await resp.json(); + if (!Array.isArray(logs) || logs.length === 0) return; + var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' }; + logs.forEach(function (line) { + var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/); + if (m) { + addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd()); + // ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。 + // esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断 + // "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动, + // 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。 + if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay v1\.0 启动/.test(m[3])) { + window.isEsp32Rebooting = false; + window.rebootObservedDown = false; + window.updateEsp32StagesFromPhase(6); // 启动完成 + addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)'); + showToast('ESP32 已恢复在线', 'success'); + if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE); + } + } else { + addLog('I', 'ESP32', line.trimEnd()); + } + }); + } catch (e) { /* 静默 */ } +} +setTimeout(function () { + window.updateUploadButtonLabel(); + addLog('I', '系统', '控制台初始化完成,等待固件文件...'); + window.updateSpiHint(); + // IP 输入变化时清零失败计数并立即刷新状态 + var ipInput = document.getElementById('espIp'); + if (ipInput) { + ipInput.addEventListener('change', function () { + window.consecutivePollFailures = 0; + addLog('I', '系统', 'IP 已修改,重新启动轮询...'); + if (!statusPollInFlight) { + if (statusTimerId) clearTimeout(statusTimerId); + scheduleStatusPoll(0); + } + }); + } +}, 500); diff --git a/tools/js/common/toast.js b/tools/js/common/toast.js new file mode 100644 index 0000000..59b7ab4 --- /dev/null +++ b/tools/js/common/toast.js @@ -0,0 +1,33 @@ +/** + * toast.js - Toast 提示组件 + * + * 从 app.js 提取的 showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。 + * 支持 info / success / warning / error 类型,内置 5s 去重防抖。 + */ +/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */ +var _toastHideTimer = null; +var _toastLastKey = ''; +var _toastLastAt = 0; +export function showToast(message, type) { + type = type || 'success'; + // 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」 + var key = type + '|' + message; + var now = Date.now(); + if (key === _toastLastKey && (now - _toastLastAt) < 5000) { + return; + } + _toastLastKey = key; + _toastLastAt = now; + + const toast = document.getElementById('toast'); + const ico = document.getElementById('toastIco'); + const msg = document.getElementById('toastMsg'); + toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show'; + ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0'; + msg.textContent = message; + if (_toastHideTimer) clearTimeout(_toastHideTimer); + _toastHideTimer = setTimeout(function () { + toast.classList.remove('show'); + _toastHideTimer = null; + }, 3000); +} diff --git a/tools/js/common/utils.js b/tools/js/common/utils.js new file mode 100644 index 0000000..90a3279 --- /dev/null +++ b/tools/js/common/utils.js @@ -0,0 +1,41 @@ +/** + * utils.js - 通用工具函数 + * + * 从 app.js 提取的格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。 + */ +// ==================== 工具函数 ==================== +/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */ +export function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; +} +/** @description 格式化 Date 对象为 HH:MM:SS.mmm 时间字符串。 */ +export function formatTime(date) { + var h = String(date.getHours()).padStart(2, '0'); + var m = String(date.getMinutes()).padStart(2, '0'); + var s = String(date.getSeconds()).padStart(2, '0'); + var ms = String(date.getMilliseconds()).padStart(3, '0'); + return h + ':' + m + ':' + s + '.' + ms; +} +/** @description 格式化秒数为人类可读运行时间(d/h/m/s)。 */ +export function formatUptime(sec) { + if (!Number.isFinite(sec) || sec < 0) return '--'; + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = Math.floor(sec % 60); + if (d > 0) return d + 'd ' + h + 'h ' + m + 'm'; + if (h > 0) return h + 'h ' + m + 'm ' + s + 's'; + if (m > 0) return m + 'm ' + s + 's'; + return s + 's'; +} +/** @description 转义 HTML 特殊字符,防止 XSS 注入。 */ +export function escapeHtml(text) { + return String(text == null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/tools/js/ota/console-mode.js b/tools/js/ota/console-mode.js new file mode 100644 index 0000000..3550bdf --- /dev/null +++ b/tools/js/ota/console-mode.js @@ -0,0 +1,273 @@ +/** + * console-mode.js - 共享模式/目标切换胶水代码(从 index.html 内联脚本抽取) + * + * 职责: + * - 覆盖 app.js 中的 switchStageFlow,支持 BLE 阶段流程 + * - 提供 setConsoleMode / selectFlashTarget 等供 HTML onclick 调用 + * - 维护共享固件文件状态 (sharedFirmwareFiles) 并在 WiFi/BLE 间同步 + * + * 依赖:app.js (通过 window.* 或 bare 引用) 提供 switchStageFlow、 + * handleFileSelect、clearFile、pickFileViaFSAPI、addLog、showToast、setActivePhase 等。 + */ +window.connectionMode = window.connectionMode || 'wifi'; +window.flashTarget = window.flashTarget || 'esp32'; +window.sharedFirmwareFiles = window.sharedFirmwareFiles || { + esp32: { file: null, handle: null }, + gd32: { file: null, handle: null } +}; +(function () { + var originalSwitchStageFlow = window.switchStageFlow; + window.switchStageFlow = function (target) { + if (target === 'ble') { + window.currentOtaTarget = 'ble'; + var esp32El = document.getElementById('esp32Stages'); + var gd32El = document.getElementById('gd32Stages'); + var bleEl = document.getElementById('bleStages'); + if (esp32El) esp32El.style.display = 'none'; + if (gd32El) gd32El.style.display = 'none'; + if (bleEl) bleEl.style.display = ''; + return; + } + if (typeof originalSwitchStageFlow === 'function') { + originalSwitchStageFlow(target); + } + var bleEl = document.getElementById('bleStages'); + if (bleEl) bleEl.style.display = 'none'; + }; + + window.setConsoleMode = function (mode) { + // ★ 刷写进行中禁止切换连接模式:setConsoleMode → syncBleFileFromSelectedTarget → + // notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖 + // 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。 + var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading(); + if (window.isUploading || bleBusy) { + var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase()); + if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式'); + if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error'); + return; + } + var isBle = mode === 'ble'; + window.connectionMode = isBle ? 'ble' : 'wifi'; + var wifiCards = document.getElementById('wifiModeCards'); + var bleCards = document.getElementById('bleModeCards'); + var wifiBtn = document.getElementById('modeWifiBtn'); + var bleBtn = document.getElementById('modeBleBtn'); + if (wifiCards) wifiCards.style.display = isBle ? 'none' : ''; + if (bleCards) bleCards.style.display = isBle ? '' : 'none'; + var headBtns = document.getElementById('bleHeadActions'); + if (headBtns) headBtns.style.display = isBle ? '' : 'none'; + if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary'; + if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline'; + applySharedModeUi(); + syncBleFileFromSelectedTarget(); + if (isBle) { + window.switchStageFlow('ble'); + if (typeof setActivePhase === 'function') { + setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota'); + } + if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') { + window.bleOta.refreshButtons(); + } + } else { + window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32')); + } + }; + + window.selectFlashTarget = function (target) { + // OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死 + // ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。 + var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading(); + if (window.isUploading || bleBusy) { + var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase()); + if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标'); + if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error'); + return; + } + window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32'; + applySharedModeUi(); + syncBleFileFromSelectedTarget(); + if (window.connectionMode === 'ble') { + window.switchStageFlow('ble'); + if (typeof setActivePhase === 'function') { + setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota'); + } + } else { + window.switchStageFlow(window.flashTarget); + } + if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') { + window.bleOta.refreshTargetUi(); + } + }; + + window.handleSharedFileSelect = function (event, chip) { + var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null; + if (file) { + window.sharedFirmwareFiles[chip] = { file: file, handle: null }; + } + if (typeof window.handleFileSelect === 'function') { + window.handleFileSelect(event, chip); + } + window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32'; + applySharedModeUi(); + setTimeout(syncBleFileFromSelectedTarget, 0); + }; + + window.pickSharedFirmware = function (chip) { + window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32'; + applySharedModeUi(); + if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') { + var nativePicker = window.showOpenFilePicker; + var capturedHandle = null; + window.showOpenFilePicker = function () { + return nativePicker.apply(this, arguments).then(function (handles) { + capturedHandle = handles && handles.length ? handles[0] : null; + return handles; + }); + }; + Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () { + window.showOpenFilePicker = nativePicker; + if (!capturedHandle || typeof capturedHandle.getFile !== 'function') { + syncBleFileFromSelectedTarget(); + return; + } + capturedHandle.getFile().then(function (file) { + window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle }; + syncBleFileFromSelectedTarget(); + }).catch(function () { + syncBleFileFromSelectedTarget(); + }); + }).catch(function (err) { + window.showOpenFilePicker = nativePicker; + if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') { + window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message); + } + }); + } else { + var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32'); + if (input) input.click(); + } + }; + + window.clearSharedFirmware = function (chip) { + window.sharedFirmwareFiles[chip] = { file: null, handle: null }; + if (typeof window.clearFile === 'function') { + window.clearFile(chip); + } + if (window.connectionMode === 'ble' && window.flashTarget === chip && window.bleOta && typeof window.bleOta.clearFile === 'function') { + window.bleOta.clearFile(); + } + }; + + window.setBleFirmwareInputFile = function (file) { + var input = document.getElementById('firmwareFile'); + if (!input || !file || typeof window.DataTransfer !== 'function') { + return; + } + try { + var dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + } catch (err) { + // Some browsers restrict assignment to file inputs; BLE state is still synced via notifyFileChange. + } + }; + + window.syncBleFileFromSelectedTarget = function () { + if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') { + return; + } + var isGd32 = window.flashTarget === 'gd32'; + var chip = isGd32 ? 'gd32' : 'esp32'; + var suffix = isGd32 ? 'Gd32' : 'Esp32'; + var stored = window.sharedFirmwareFiles[chip]; + if (stored && stored.handle && typeof stored.handle.getFile === 'function') { + stored.handle.getFile().then(function (file) { + stored.file = file; + setBleFirmwareInputFile(file); + window.bleOta.notifyFileChange(file, stored.handle); + }).catch(function () { + if (stored.file) { + setBleFirmwareInputFile(stored.file); + window.bleOta.notifyFileChange(stored.file, stored.handle); + } + }); + return; + } + if (stored && stored.file) { + setBleFirmwareInputFile(stored.file); + window.bleOta.notifyFileChange(stored.file, stored.handle || null); + return; + } + var input = document.getElementById('fileInput' + suffix); + var info = document.getElementById('fileInfo' + suffix); + var hasInfo = info && info.classList.contains('show'); + var file = input && input.files && input.files[0] ? input.files[0] : null; + if (file) { + window.sharedFirmwareFiles[chip] = { file: file, handle: null }; + setBleFirmwareInputFile(file); + window.bleOta.notifyFileChange(file, null); + return; + } + if (!hasInfo) { + return; + } + var nameEl = document.getElementById('fileName' + suffix); + var typeEl = document.getElementById('fileType' + suffix); + var sizeEl = document.getElementById('fileSize' + suffix); + var bleInfo = document.getElementById('fileInfo'); + var bleName = document.getElementById('fileName'); + var bleType = document.getElementById('fileType'); + var bleSize = document.getElementById('fileSize'); + if (bleInfo) bleInfo.classList.add('show'); + if (bleName && nameEl) bleName.textContent = nameEl.textContent; + if (bleType && typeEl) bleType.textContent = typeEl.textContent; + if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent; + var packetCount = document.getElementById('packetCount'); + var sentCount = document.getElementById('sentCount'); + if (packetCount) packetCount.textContent = '-'; + if (sentCount) sentCount.textContent = '0'; + }; + + window.applySharedModeUi = function () { + var isBle = window.connectionMode === 'ble'; + var isGd32 = window.flashTarget === 'gd32'; + var targetEsp32 = document.getElementById('targetEsp32Tab'); + var targetGd32 = document.getElementById('targetGd32Tab'); + if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary'; + if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline'; + var ids = { + wifiEsp32UploadPane: !isGd32, + wifiGd32UploadPane: isGd32, + bleUploadPane: false, + wifiEsp32ActionPane: !isBle && !isGd32, + wifiGd32ActionPane: !isBle && isGd32, + bleActionPane: isBle + }; + Object.keys(ids).forEach(function (id) { + var el = document.getElementById(id); + if (el) el.style.display = ids[id] ? '' : 'none'; + }); + var hint = document.getElementById('readyHint'); + if (hint) { + if (isBle) { + hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。'; + } else if (isGd32) { + hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。'; + } else { + hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。'; + } + } + }; + + applySharedModeUi(); +})(); + +// 暴露给 HTML onclick 调用的函数(ES Module 迁移:模块作用域不再自动成为全局变量) +window.setConsoleMode = setConsoleMode; +window.selectFlashTarget = selectFlashTarget; +window.handleSharedFileSelect = handleSharedFileSelect; +window.pickSharedFirmware = pickSharedFirmware; +window.clearSharedFirmware = clearSharedFirmware; +window.setBleFirmwareInputFile = setBleFirmwareInputFile; +window.syncBleFileFromSelectedTarget = syncBleFileFromSelectedTarget; +window.applySharedModeUi = applySharedModeUi; diff --git a/tools/js/ota/file-handle.js b/tools/js/ota/file-handle.js new file mode 100644 index 0000000..9bbafca --- /dev/null +++ b/tools/js/ota/file-handle.js @@ -0,0 +1,263 @@ +/** + * file-handle.js - 文件处理 + Intel HEX → BIN 转换 + * + * 从 app.js 的「文件处理」「Intel HEX → BIN 转换」「文件处理」段提取。 + * 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。 + * + * 共享状态(通过 window.* 引用): + * window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle + * window._esp32FileSha256 / window._gd32FileSha256 + * + * 未拆分的函数(通过 window.* 引用): + * window.updateUploadButtonLabel / window.showToast / window.addLog + */ +import { formatBytes } from '../common/utils.js'; +import { showToast } from '../common/toast.js'; +import { addLog } from '../common/log.js'; + +// ==================== 文件处理 ==================== +// 文件状态(模块级,但被 upload-esp32/upload-gd32 通过 window.* 读写) +window.esp32File = null; +window.gd32File = null; +window.esp32FileHandle = null; // FileSystemFileHandle for hot reload +window.gd32FileHandle = null; // FileSystemFileHandle for hot reload + +export function handleFileSelect(event, chip) { + var file = event.target.files[0]; + if (file) processFile(file, chip); +} + +// 通过 File System Access API 选择文件(支持热重载) +export async function pickFileViaFSAPI(chip) { + if (!window.showOpenFilePicker) { + addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件'); + return false; + } + try { + var acceptMap = chip === 'esp32' + ? { 'application/octet-stream': ['.bin'] } + : { 'application/octet-stream': ['.bin', '.hex'] }; + var handles = await window.showOpenFilePicker({ + types: [{ description: '固件文件', accept: acceptMap }], + multiple: false + }); + if (handles && handles.length > 0) { + var handle = handles[0]; + var file = await handle.getFile(); + if (chip === 'esp32') window.esp32FileHandle = handle; + else window.gd32FileHandle = handle; + processFile(file, chip); + addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name); + return true; + } + } catch (e) { + if (e.name !== 'AbortError') { + addLog('W', '文件', 'FSAPI 选择失败: ' + e.message); + } + } + return false; +} + +export function setupDropZone(zoneId, chip) { + var zone = document.getElementById(zoneId); + if (!zone) return; + zone.addEventListener('dragover', function (e) { + e.preventDefault(); e.currentTarget.classList.add('dragover'); + }); + zone.addEventListener('dragleave', function (e) { + e.currentTarget.classList.remove('dragover'); + }); + zone.addEventListener('drop', function (e) { + e.preventDefault(); e.currentTarget.classList.remove('dragover'); + var file = e.dataTransfer.files[0]; + if (file) processFile(file, chip); + }); +} + +// ==================== Intel HEX → BIN 转换 ==================== +/** + * 将 Intel HEX 字符串解析为原始 BIN 字节数组。 + * + * 支持记录类型: 00(数据), 01(EOF), 02(扩展段地址), 04(扩展线性地址)。 + * 地址间隙填充 0xFF(Flash 擦除态)。 + * + * @param {string} hexText Intel HEX 文件的完整文本内容。 + * @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }} + */ +export function parseIntelHex(hexText) { + var baseAddr = 0; // 当前扩展地址 + var segments = []; // { offset, data } 列表 + var minAddr = 0xFFFFFFFF; + var maxAddr = 0; + var lines = hexText.split(/\r?\n/); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line || line[0] !== ':') continue; + // 解析行: :LLAAAATT[DD...]CC + var byteCount = parseInt(line.substr(1, 2), 16); + var addr16 = parseInt(line.substr(3, 4), 16); + var recType = parseInt(line.substr(7, 2), 16); + // 校验和验证 + var sum = 0; + for (var j = 1; j < line.length; j += 2) { + sum += parseInt(line.substr(j, 2), 16); + } + if ((sum & 0xFF) !== 0) { + throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line); + } + if (recType === 0x01) { + // EOF + break; + } else if (recType === 0x04) { + // 扩展线性地址 (upper 16 bits) + baseAddr = parseInt(line.substr(9, 4), 16) << 16; + } else if (recType === 0x02) { + // 扩展段地址 + baseAddr = (parseInt(line.substr(9, 4), 16) << 4) & 0xFFFFF; + } else if (recType === 0x00) { + // 数据记录 + var absAddr = baseAddr + addr16; + var data = new Uint8Array(byteCount); + for (var k = 0; k < byteCount; k++) { + data[k] = parseInt(line.substr(9 + k * 2, 2), 16); + } + segments.push({ offset: absAddr, data: data }); + if (absAddr < minAddr) minAddr = absAddr; + var endAddr = absAddr + byteCount; + if (endAddr > maxAddr) maxAddr = endAddr; + } + // 其他记录类型 (03, 05 等) 忽略 + } + if (segments.length === 0) { + throw new Error('HEX 文件中没有有效数据记录'); + } + // 合并为连续 bin,间隙填 0xFF + var totalSize = maxAddr - minAddr; + var bin = new Uint8Array(totalSize); + // 先填充 0xFF + for (var m = 0; m < totalSize; m++) bin[m] = 0xFF; + for (var s = 0; s < segments.length; s++) { + var seg = segments[s]; + var off = seg.offset - minAddr; + bin.set(seg.data, off); + } + return { data: bin, baseAddr: minAddr, endAddr: maxAddr }; +} + +/** + * 检测文件内容是否为 Intel HEX 格式。 + * 看前几个非空行是否以 ':' 开头。 + */ +export function isIntelHexText(text) { + var lines = text.split(/\r?\n/); + for (var i = 0; i < Math.min(lines.length, 5); i++) { + var line = lines[i].trim(); + if (!line) continue; + return line[0] === ':'; + } + return false; +} + +/** + * 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。 + * BLE / WiFi 共用此函数,避免转换逻辑重复。 + * @param {File} file 原始文件 + * @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>} + */ +export async function convertHexFileToBin(file) { + if (!file || !file.name.toLowerCase().endsWith('.hex')) { + return { file: file, converted: false, baseAddr: null }; + } + var text = await file.text(); + if (!isIntelHexText(text)) { + return { file: file, converted: false, baseAddr: null }; + } + var result = parseIntelHex(text); + var binName = file.name.replace(/\.hex$/i, '.bin'); + var binBlob = new Blob([result.data], { type: 'application/octet-stream' }); + var binFile = new File([binBlob], binName, { type: 'application/octet-stream', lastModified: file.lastModified }); + return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr }; +} + +// ==================== 文件处理 ==================== +/** @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 */ +export function processFile(file, chip) { + var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; + var isHexByName = file.name.toLowerCase().endsWith('.hex'); + if (chip === 'gd32' && isHexByName) { + // GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 + convertHexFileToBin(file).then(function(result) { + if (!result.converted) { + // 文件名 .hex 但内容不像,直接用原始文件 + addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理'); + window.gd32File = file; + document.getElementById('fileInfo' + suffix).classList.add('show'); + document.getElementById('fileName' + suffix).textContent = file.name; + document.getElementById('fileType' + suffix).textContent = 'Binary(?)'; + document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); + window.updateUploadButtonLabel(); + return; + } + window.gd32File = result.file; + var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); + addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name); + addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size)); + showToast('HEX 已自动转换为 BIN', 'success'); + document.getElementById('fileInfo' + suffix).classList.add('show'); + document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; + document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; + document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size); + window.updateUploadButtonLabel(); + }).catch(function(err) { + addLog('E', '文件', 'HEX 解析失败: ' + err.message); + showToast('HEX 文件解析失败: ' + err.message, 'error'); + window.gd32File = null; + document.getElementById('fileInfo' + suffix).classList.remove('show'); + window.updateUploadButtonLabel(); + }); + return; // 异步,回调里设置 gd32File + } + // .bin 文件或 ESP32 文件: 直接使用 + if (chip === 'esp32') window.esp32File = file; + else window.gd32File = file; + document.getElementById('fileInfo' + suffix).classList.add('show'); + document.getElementById('fileName' + suffix).textContent = file.name; + document.getElementById('fileType' + suffix).textContent = + isHexByName ? 'Intel HEX' : 'Binary'; + document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); + window.updateUploadButtonLabel(); + addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); + showToast(chip.toUpperCase() + ' 文件已选择', 'success'); + // ESP32 文件: 计算并显示 SHA-256 + if (chip === 'esp32') { + var shaElem = document.getElementById('fileSha256' + suffix); + if (shaElem) { + shaElem.textContent = '计算中...'; + file.arrayBuffer().then(function(buf) { + return crypto.subtle.digest('SHA-256', buf); + }).then(function(hashBuffer) { + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + shaElem.textContent = hashHex.toUpperCase(); + // 存储到全局变量供上传时使用 + window._esp32FileSha256 = hashHex.toUpperCase(); + addLog('I', '文件', 'ESP32 固件 SHA-256: ' + hashHex.toUpperCase()); + }).catch(function(err) { + shaElem.textContent = '计算失败: ' + err.message; + }); + } + } +} + +/** @description 清除已选固件文件并重置对应 UI 状态。 */ +export function clearFile(chip) { + var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; + if (chip === 'esp32') { window.esp32File = null; window.esp32FileHandle = null; window._esp32FileSha256 = null; } + else { window.gd32File = null; window.gd32FileHandle = null; } + document.getElementById('fileInput' + suffix).value = ''; + document.getElementById('fileInfo' + suffix).classList.remove('show'); + var shaElem = document.getElementById('fileSha256' + suffix); + if (shaElem) shaElem.textContent = '-'; + window.updateUploadButtonLabel(); + addLog('I', '文件', chip.toUpperCase() + ' 文件已清除'); +} \ No newline at end of file diff --git a/tools/js/ota/progress.js b/tools/js/ota/progress.js new file mode 100644 index 0000000..a5c013b --- /dev/null +++ b/tools/js/ota/progress.js @@ -0,0 +1,210 @@ +/** + * progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI + * + * 从 app.js 的「折叠面板」「刷写进度」段提取。 + * 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct + * + * 共享状态(通过 window.* 引用): + * window.activePhase / window.currentOtaTarget / window.esp32OtaPhase + * + * 未拆分的函数(通过 window.* 引用): + * window.updateStagesFromRelayState / window.cleanupAfterOtaFailure + * window.resumePollingAfterUpload / window.addLog + */ +import { addLog } from '../common/log.js'; + +// ==================== 折叠面板 ==================== +export function toggleCollapse(headEl) { + headEl.closest('.card').classList.toggle('collapsed'); +} + +// ==================== 刷写进度 ==================== +window.activePhase = null; // 'esp32Ota' | 'gd32Ota' | null +window.currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段 + +// 切换 OTA 阶段面板显示(原始版本,会被 console-mode.js 覆盖以支持 BLE) +export function switchStageFlow(target) { + window.currentOtaTarget = target; + var esp32El = document.getElementById('esp32Stages'); + var gd32El = document.getElementById('gd32Stages'); + if (target === 'esp32') { + if (esp32El) esp32El.style.display = ''; + if (gd32El) gd32El.style.display = 'none'; + } else { + if (esp32El) esp32El.style.display = 'none'; + if (gd32El) gd32El.style.display = ''; + } + // 重置 ESP32 阶段节点 + ['es1','es2','es3','es4','es5','es6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['esd1','esd2','esd3','esd4','esd5','esd6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + // 重置 GD32 阶段节点 + ['s1','s2','s3','s4','s5','s6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); +} + +// ESP32 OTA 阶段状态: 0=待机, 1=上传中, 2=OTA写入, 3=SHA-256校验, 4=应用固件, 5=重启中, 6=启动完成 +window.esp32OtaPhase = 0; +export const esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应用中', '重启中', '已完成']; + +export function updateEsp32StagesFromPhase(phase) { + window.esp32OtaPhase = phase; + var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6']; + var detailIds = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6']; + var all = ids.map(function(id) { return document.getElementById(id); }); + var details = detailIds.map(function(id) { return document.getElementById(id); }); + all.forEach(function (s) { if (s) s.className = 'stage-node'; }); + details.forEach(function (d) { if (d) d.textContent = ''; }); + var statusBar = document.getElementById('stageStatusBar'); + var statusText = document.getElementById('stageStatusText'); + if (phase <= 0) { + if (statusBar) statusBar.className = 'stage-status-bar'; + if (statusText) statusText.textContent = '待机'; + return; + } + for (var i = 0; i < phase - 1 && i < 6; i++) { + if (all[i]) all[i].className = 'stage-node done'; + } + if (phase >= 1 && phase <= 6) { + if (all[phase - 1]) all[phase - 1].className = 'stage-node live'; + if (details[phase - 1]) details[phase - 1].textContent = esp32PhaseLabels[phase] || ''; + } + if (statusBar && statusText) { + if (phase >= 6) { + statusBar.className = 'stage-status-bar ok'; + statusText.textContent = 'ESP32 刷写完成'; + } else { + statusBar.className = 'stage-status-bar active'; + statusText.textContent = (esp32PhaseLabels[phase] || '进行中') + ' (' + phase + '/6)'; + } + } +} + +export function updateEsp32StagesError() { + var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6']; + var all = ids.map(function(id) { return document.getElementById(id); }); + all.forEach(function (s) { if (s) s.className = 'stage-node'; }); + var statusBar = document.getElementById('stageStatusBar'); + var statusText = document.getElementById('stageStatusText'); + if (statusBar) statusBar.className = 'stage-status-bar err'; + if (statusText) statusText.textContent = '校验/应用失败'; +} + +/** + * @description OTA 失败后的统一清理函数。 + * 所有上传失败、校验失败、中继触发失败等异常路径都应调用此函数, + * 确保 UI 状态完整重置,不会残留"上传中"或中间态。 + * + * ★ 状态机改造:转入 FAILED 态,置位 otaFailReason。 + * 之后轮询(pollStatus/updateSystemStatusUi)不得覆盖此失败态, + * 直到用户发起新上传(otaSessionReset)或检测到设备已回 IDLE。 + * @param {string} reason 失败原因描述 + */ +export function cleanupAfterOtaFailure(reason) { + var chip = window.activeUploadChip || window.otaSessionTarget || window.currentOtaTarget || window.flashTarget || 'esp32'; + + // 0. 状态机:转入 FAILED(先于标志重置) + window.otaSessionTransition(window.OtaSessionState.FAILED, reason); + + // 1. 清掉本会话全部残留标志 / 定时器 / 校验面板 / 旧 progress 语义 + window.clearOtaSessionResiduals({ + keepErrorStages: true, + statusText: reason || '失败', + errorStyle: true, + clearCacheDisplay: true + }); + + // 2. 阶段指示器标错 + if (chip === 'esp32') { + updateEsp32StagesError(); + } else { + window.updateStagesFromRelayState(255); + } + + // 3. 进度条失败文案(clear 已把 fill 的 done 去掉) + setProgress(0, reason || '刷写失败'); + setActivePhase(null); + window.currentOtaTarget = null; + + // 4. 恢复上传按钮 + var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn'; + var btn = document.getElementById(btnId); + if (btn) { + btn.disabled = false; + if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText; + } + window.activeUploadChip = null; + window.otaSessionTarget = null; + window.uploadBtnOrigText = ''; + + window.updateUploadButtonLabel(); + + // 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位) + window.resumePollingAfterUpload(); + + addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因')); +} + +/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */ +export function setActivePhase(phase) { + if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; + window.activePhase = phase; + var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' }; + Object.keys(stepMap).forEach(function(p) { + var el = document.getElementById(stepMap[p]); + if (!el) return; + el.classList.remove('active', 'done'); + if (p === phase) el.classList.add('active'); + }); + // Reset progress fill color + var fill = document.getElementById('progressFill'); + if (fill) fill.classList.remove('done'); +} + +/** @description 标记指定阶段为完成,更新进度条颜色。 */ +export function markPhaseDone(phase) { + if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; + var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' }; + var el = document.getElementById(stepMap[phase]); + if (el) { el.classList.remove('active'); el.classList.add('done'); } + // All done? turn progress bar green + if (phase === 'gd32Ota' || phase === 'esp32Ota') { + var fill = document.getElementById('progressFill'); + if (fill) fill.classList.add('done'); + } +} + +/** @description 更新进度条百分比和文本显示。 */ +export function setProgress(pct, text) { + var fillEl = document.getElementById('progressFill'); + var txtEl = document.getElementById('progressTxt'); + var pctEl = document.getElementById('progressPct'); + if (fillEl) fillEl.style.width = pct + '%'; + if (txtEl) txtEl.textContent = text; + if (pctEl) pctEl.textContent = pct + '%'; +} + +/** @description 重置进度条和阶段状态到初始值。 */ +export function resetProgress() { + setProgress(0, '等待操作...'); + window.activePhase = null; + window.esp32OtaPhase = 0; + switchStageFlow(null); + ['ppEsp32', 'ppGd32Ota'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.classList.remove('active', 'done'); + }); + var fill = document.getElementById('progressFill'); + if (fill) fill.classList.remove('done'); +} \ No newline at end of file diff --git a/tools/js/ota/session.js b/tools/js/ota/session.js new file mode 100644 index 0000000..c2cd665 --- /dev/null +++ b/tools/js/ota/session.js @@ -0,0 +1,244 @@ +/** + * session.js - 前端 OTA 会话状态机 + * + * 从 app.js 的「全局状态」+「前端 OTA 会话状态机」段提取。 + * 统一管理一次 OTA 会话的完整生命周期,消除散布的布尔标志 + * (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting) + * 在网络错误后被轮询错误覆盖、状态混乱的问题。 + * + * 状态流转: + * IDLE → UPLOADING → (ESP32: VERIFYING → REBOOTING) → DONE + * IDLE → UPLOADING → (GD32: RELAYING) → DONE + * 任意活跃态 ──(failure/abort)──→ FAILED + * FAILED/DONE ──(startUpload)──→ UPLOADING + * FAILED/DONE ──(device idle detected by poll)──→ IDLE + * + * 共享状态(通过 window.* 引用,入口模块统一挂载): + * window.isUploading / window.isEsp32Rebooting / window.rebootStartTime + * window.rebootObservedDown / window.activeUploadChip / window.currentOtaTarget + * window.esp32OtaPhase / window.activePhase + * window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive + * window.lastSysStateCode / window.lastRelayState + * + * 未拆分的函数(通过 window.* 引用): + * window.stopRelayProgressWatch / window.setProgress / window.addLog + */ +import { addLog } from '../common/log.js'; + +// ==================== 全局状态 ==================== +// 文件句柄与上传进度等状态保留为模块级,被 file-handle / upload-esp32 等模块通过 window.* 读写 +window.isUploading = false; +window.uploadProgress = 0; +window.isEsp32Rebooting = false; +window.rebootStartTime = 0; +window.rebootObservedDown = false; +window.pollingPausedForUpload = false; +window.uploadPollChip = null; +window.activeXhr = null; +window.uploadBtnOrigText = ''; +window.activeUploadChip = null; + +// 心跳定时器(模块级,仅本模块使用) +let uploadWaitHeartbeatId = null; +let uploadWaitStartMs = 0; + +/** @description 停止「等待设备 HTTP 响应」心跳,避免残留定时器改进度条。 */ +export function stopUploadWaitHeartbeat() { + if (uploadWaitHeartbeatId) { + clearInterval(uploadWaitHeartbeatId); + uploadWaitHeartbeatId = null; + } + uploadWaitStartMs = 0; +} + +/** + * @description 浏览器 body 已发完但尚未收到 HTTP 响应时启动心跳。 + * 上传期轮询已暂停,若无此反馈会像“卡在 100%”。 + */ +export function startUploadWaitHeartbeat() { + stopUploadWaitHeartbeat(); + uploadWaitStartMs = Date.now(); + uploadWaitHeartbeatId = setInterval(function () { + if (!window.activeXhr) { + stopUploadWaitHeartbeat(); + return; + } + var sec = Math.round((Date.now() - uploadWaitStartMs) / 1000); + window.setProgress(100, '数据已发完,设备处理中… 已等待 ' + sec + 's'); + // 每 10s 打一条日志,便于区分“仍在处理”与“真死锁” + if (sec > 0 && sec % 10 === 0) { + addLog('I', '上传', '仍在等待设备 HTTP 响应(已 ' + sec + + 's;设备可能仍在写 Flash / 算 SHA,或连接异常)'); + } + }, 5000); +} + +// ==================== 前端 OTA 会话状态机 ==================== +export const OtaSessionState = { + IDLE: 'idle', // 无会话 + UPLOADING: 'uploading', // 固件上传中(XHR 上传阶段) + VERIFYING: 'verifying', // ESP32 SHA-256 校验 + 应用中 + RELAYING: 'relaying', // GD32 中继转发中(triggerGd32Relay 发起后) + REBOOTING: 'rebooting', // ESP32 重启等待中 + DONE: 'done', // 会话成功完成 + FAILED: 'failed' // 会话失败/中止 +}; +window.OtaSessionState = OtaSessionState; + +let otaSessionState = OtaSessionState.IDLE; +let otaSessionTarget = null; // 'esp32' | 'gd32' | null — 本会话的目标芯片 +let otaFailReason = ''; // FAILED 时的失败原因(用于 UI 显示) +// 同步到 window 供 polling.js / stages.js / upload-gd32.js 等通过 window.* 读取 +window.otaSessionState = otaSessionState; +window.otaSessionTarget = otaSessionTarget; + +export { otaSessionState, otaSessionTarget, otaFailReason }; + +/** @description 查询当前 OTA 会话是否处于活跃态(非 IDLE/DONE/FAILED)。 */ +export function otaSessionActive() { + return otaSessionState === OtaSessionState.UPLOADING || + otaSessionState === OtaSessionState.VERIFYING || + otaSessionState === OtaSessionState.RELAYING || + otaSessionState === OtaSessionState.REBOOTING; +} + +/** @description 查询当前 OTA 会话是否已终结(DONE 或 FAILED)。 */ +export function otaSessionTerminated() { + return otaSessionState === OtaSessionState.DONE || + otaSessionState === OtaSessionState.FAILED; +} + +/** + * @description 转换 OTA 会话状态。 + * 统一在此处维护状态机一致性,同时同步旧标志位 + * (isUploading / currentOtaTarget),保证渐进式迁移期间两套标志不冲突。 + * @param {string} newState 目标状态。 + * @param {string} [reason] 失败原因(仅转入 FAILED 时使用)。 + */ +export function otaSessionTransition(newState, reason) { + var prev = otaSessionState; + otaSessionState = newState; + window.otaSessionState = newState; + if (newState === OtaSessionState.FAILED) { + otaFailReason = reason || '未知原因'; + } else if (newState === OtaSessionState.UPLOADING) { + otaFailReason = ''; + } + // 同步旧标志(渐进式迁移):UPLOADING 对应 isUploading=true + if (newState === OtaSessionState.UPLOADING) window.isUploading = true; + else if (newState === OtaSessionState.IDLE || + newState === OtaSessionState.DONE || + newState === OtaSessionState.FAILED) window.isUploading = false; + // REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖) + if (newState === OtaSessionState.REBOOTING) window.isEsp32Rebooting = true; + else if (newState !== OtaSessionState.VERIFYING) window.isEsp32Rebooting = false; +} + +/** + * @description 清掉本会话残留 UI/标志(失败、超时、中止、新会话开始前都要调)。 + * 不改变 otaSessionState(由调用方负责 FAILED/IDLE 转换)。 + * @param {object} [opts] + * @param {boolean} [opts.keepErrorStages] true=保留错误阶段样式;false=节点复位到待机 + * @param {string} [opts.statusText] 状态栏文案;null 表示「待机」 + * @param {boolean} [opts.errorStyle] 状态栏是否红色 + */ +export function clearOtaSessionResiduals(opts) { + opts = opts || {}; + window.stopRelayProgressWatch(); + stopUploadWaitHeartbeat(); + + window.gd32CompleteAnnounced = false; + window.relayWatchLastSeq = -1; + window.gd32RelaySessionLive = false; + window.lastSysStateCode = -1; + window.lastRelayState = 0; + window.isEsp32Rebooting = false; + window.rebootObservedDown = false; + window.rebootStartTime = 0; + window.isUploading = false; + + /* 先摘掉引用再 abort,避免 abort 事件回调再次进入 cleanup 递归 */ + var xh = window.activeXhr; + window.activeXhr = null; + if (xh) { + try { xh.abort(); } catch (_) {} + } + + // 校验面板 + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'none'; + var sviFront = document.getElementById('sviFrontSha'); + var sviDevice = document.getElementById('sviDeviceSha'); + var sviResult = document.getElementById('sviResult'); + if (sviFront) sviFront.textContent = '-'; + if (sviDevice) sviDevice.textContent = '-'; + if (sviResult) { + sviResult.textContent = '-'; + sviResult.className = 'svi-val'; + } + + // 阶段节点 + if (!opts.keepErrorStages) { + ['es1','es2','es3','es4','es5','es6','s1','s2','s3','s4','s5','s6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['esd1','esd2','esd3','esd4','esd5','esd6','sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + window.esp32OtaPhase = 0; + } + + // 顶部步骤 pill + ['ppEsp32', 'ppGd32Ota'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.classList.remove('active', 'done'); + }); + var fill = document.getElementById('progressFill'); + if (fill) fill.classList.remove('done'); + window.activePhase = null; + + // 状态栏 + var sb = document.getElementById('stageStatusBar'); + var st = document.getElementById('stageStatusText'); + if (sb) { + sb.className = opts.errorStyle ? 'stage-status-bar err' : 'stage-status-bar'; + } + if (st) { + st.textContent = (opts.statusText !== undefined && opts.statusText !== null) + ? opts.statusText + : '待机'; + } + + // 设备侧缓存展示:失败后不要继续显示“已满缓存像成功” + var mOtaCache = document.getElementById('mOtaCache'); + if (mOtaCache && opts.clearCacheDisplay) { + mOtaCache.textContent = '0 / 216 KB'; + mOtaCache.className = 'v'; + } + var sOtaStored = document.getElementById('sOtaStored'); + if (sOtaStored && opts.clearCacheDisplay) { + sOtaStored.textContent = '--'; + } +} + +/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */ +export function otaSessionReset() { + clearOtaSessionResiduals({ + keepErrorStages: false, + statusText: '待机', + errorStyle: false, + clearCacheDisplay: false + }); + if (otaSessionState !== OtaSessionState.IDLE) { + otaSessionState = OtaSessionState.IDLE; + otaSessionTarget = null; + otaFailReason = ''; + window.otaSessionState = OtaSessionState.IDLE; + window.otaSessionTarget = null; + window.currentOtaTarget = null; + window.isUploading = false; + } + window.activeUploadChip = null; +} \ No newline at end of file diff --git a/tools/js/ota/spi.js b/tools/js/ota/spi.js new file mode 100644 index 0000000..e2f2437 --- /dev/null +++ b/tools/js/ota/spi.js @@ -0,0 +1,105 @@ +/** + * spi.js - SPI 帧构建与发送 + * + * 从 app.js 的「SPI 发送」段提取。 + * 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText + * + * 未拆分的函数(通过 window.* 引用): + * window.getBaseUrl / window.showToast / window.addLog + */ +import { showToast } from '../common/toast.js'; +import { addLog } from '../common/log.js'; + +function updateSpiHint() { + var typeHex = document.getElementById('spiType').value.trim(); + var dataHex = document.getElementById('spiData').value.trim().replace(/\s/g, ''); + var hint = document.getElementById('spiHint'); + if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) { + hint.className = 'helper-note err'; + hint.innerHTML = 'type 需填 1 字节 hex(如 01, B0)。'; + return; + } + if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) { + hint.className = 'helper-note err'; + hint.innerHTML = '数据包含非 HEX 字符。'; + return; + } + if (dataHex && dataHex.length % 2 !== 0) { + hint.className = 'helper-note err'; + hint.innerHTML = 'HEX 长度必须为偶数。'; + return; + } + var bytes = dataHex ? dataHex.length / 2 : 0; + var totalOnWire = 5 + bytes; + hint.className = 'helper-note ok'; + hint.innerHTML = 'type=0x' + typeHex.toUpperCase() + '' + + (bytes > 0 ? ',payload: ' + bytes + 'B' : ',无 payload') + + ',线上 ' + totalOnWire + 'B(SOF+TYPE+LEN+PAYLOAD+CRC)'; +} + +export async function sendSpiFrame() { + var typeHex = document.getElementById('spiType').value.trim(); + var dataStr = document.getElementById('spiData').value.trim(); + var timeout = parseInt(document.getElementById('spiTimeout').value) || 1000; + if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) { + showToast('type 需填 1 字节 hex', 'error'); + return; + } + var dataHex = dataStr.replace(/\s/g, ''); + if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) { + showToast('数据包含非 HEX 字符', 'error'); + return; + } + if (dataHex && dataHex.length % 2 !== 0) { + showToast('HEX 数据长度必须为偶数', 'error'); + return; + } + var txBytes = dataHex ? dataHex.length / 2 : 0; + addLog('I', 'SPI', 'type=0x' + typeHex.toUpperCase() + + (txBytes > 0 ? ', payload=' + dataHex.toUpperCase() : ', 无负载')); + var body = { type: typeHex, data: dataHex.toUpperCase(), timeout_ms: timeout }; + try { + var resp = await fetch(window.getBaseUrl() + '/spi/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout + 2000) + }); + var result = await resp.json(); + if (result.status === 'ok') { + var recvHex = result.recv || ''; + var respTypeName = {'80': 'ACK', '81': 'WAIT', '82': 'ERROR'}; + var respTypeLabel = respTypeName[result.resp_type] || ('0x' + result.resp_type); + addLog('I', 'SPI', '→ ' + respTypeLabel + + (recvHex ? ' 0x' + recvHex : '') + ' (' + (result.resp_len || 0) + 'B)'); + var rd = document.getElementById('spiResp'); + rd.style.display = 'block'; + document.getElementById('spiRespText').textContent = + 'type=0x' + result.type + ' payload=' + result.payload_len + 'B → ' + respTypeLabel + + (recvHex ? ' 0x' + recvHex : '') + + ' (' + (result.resp_len || 0) + 'B)'; + showToast('SPI 发送成功', 'success'); + } else { + addLog('E', 'SPI', '发送失败: code=' + result.code + ' ' + (result.message || '')); + var rd = document.getElementById('spiResp'); + rd.style.display = 'block'; + document.getElementById('spiRespText').textContent = + '错误: code=' + result.code + ' ' + (result.message || ''); + showToast('SPI 发送失败', 'error'); + } + } catch (e) { + addLog('E', 'SPI', '发送失败: ' + e.message); + showToast('SPI 发送失败: ' + e.message, 'error'); + } +} + +export function clearSpiFields() { + document.getElementById('spiType').value = '01'; + document.getElementById('spiData').value = ''; + document.getElementById('spiTimeout').value = '1000'; + document.getElementById('spiResp').style.display = 'none'; + updateSpiHint(); +} + +// 导出 updateSpiHint 供入口挂载 window.updateSpiHint +export { updateSpiHint }; \ No newline at end of file diff --git a/tools/js/ota/stages.js b/tools/js/ota/stages.js new file mode 100644 index 0000000..7fe5c29 --- /dev/null +++ b/tools/js/ota/stages.js @@ -0,0 +1,279 @@ +/** + * stages.js - OTA 阶段驱动 (真实状态) + * + * 从 app.js 的「OTA 阶段驱动 (真实状态)」段提取。 + * 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI, + * 并驱动 updateSystemStatusUi 综合 UI 更新。 + * + * 共享状态(通过 window.* 引用): + * window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase + * window.otaSessionState / window.otaSessionTarget / window.isUploading + * window.gd32CompleteAnnounced / window.gd32RelaySessionLive + * window.lastSysStateCode / window.lastRelayState + * + * 未拆分的函数(通过 window.* 引用): + * window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi + * window.updateStagesFromRelayState / window.setActivePhase / window.setProgress + * window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset + * window.stopRelayProgressWatch / window.pollLogs / window.formatBytes / window.formatUptime + */ +import { formatBytes, formatUptime } from '../common/utils.js'; +import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js'; +import { setActivePhase, setProgress, markPhaseDone } from './progress.js'; +import { + otaSessionActive, otaSessionReset, otaSessionTransition, OtaSessionState +} from './session.js'; + +// Relay state comes from relay_state_t in main/relay/ota_relay.h: +// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING, +// 4=FINISHING, 5=DONE, 255=ERROR. +const relayStateDetailMap = { + 0: '待机', + 1: '认证握手', + 2: '传输协商', + 3: '数据转发', + 4: '收尾验证', + 5: 'OTA完成', + 255: '错误' +}; + +export function updateStagesFromRelayState(rs) { + var ids = ['s1', 's2', 's3', 's4', 's5', 's6']; + var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6']; + var all = ids.map(function(id) { return document.getElementById(id); }); + var details = detailIds.map(function(id) { return document.getElementById(id); }); + // reset all + all.forEach(function (s) { if (s) s.className = 'stage-node'; }); + details.forEach(function (d) { if (d) d.textContent = ''; }); + // status bar + var statusBar = document.getElementById('stageStatusBar'); + var statusText = document.getElementById('stageStatusText'); + if (rs === undefined || rs === null) { + if (statusBar) statusBar.className = 'stage-status-bar'; + if (statusText) statusText.textContent = '待机'; + return; + } + if (rs === 255) { + all.forEach(function (s) { if (s) s.className = 'stage-node err'; }); + if (statusBar) statusBar.className = 'stage-status-bar err'; + if (statusText) statusText.textContent = '错误'; + return; + } + var activeStage = 0; + if (rs === 1) activeStage = 1; + else if (rs === 2) activeStage = 2; + else if (rs === 3) activeStage = 3; + else if (rs === 4) activeStage = 5; + else if (rs === 5) activeStage = 6; + for (var i = 0; i < activeStage - 1; i++) { + if (all[i]) all[i].className = 'stage-node done'; + } + if (rs === 5) { + if (all[5]) all[5].className = 'stage-node done'; + } else if (activeStage >= 1 && activeStage <= 6) { + if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live'; + } + // Show detail text for the active stage + var detailText = relayStateDetailMap[rs] || ''; + if (activeStage >= 1 && activeStage <= 6 && details[activeStage - 1]) { + details[activeStage - 1].textContent = detailText; + } + // Update status bar + if (statusBar && statusText) { + if (rs === 0) { + statusBar.className = 'stage-status-bar'; + statusText.textContent = '待机'; + } else { + statusBar.className = 'stage-status-bar active'; + statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')'; + } + } +} + +export function updateSystemStatusUi(data) { + if (!data) return; + window.latestSystemStatus = data; + updateHeapAndTaskStatusUi(data); + var wifi = data.wifi || {}; + var ota = data.ota || {}; + var healthCode = data.health; + // Use global relayStateDetailMap defined in updateStagesFromRelayState section + var connected = !!wifi.connected; + var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接'; + updateConnectionUi(connected, connText); + updateHealthUi(healthCode, data.free_heap); + if (data.version) { + var pillVerEl = document.getElementById('pillVer'); + if (pillVerEl) { + pillVerEl.textContent = 'v' + data.version; + pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : ''; + } + // GD32 版本 + var mGd32App = document.getElementById('mGd32App'); + var mGd32Boot = document.getElementById('mGd32Boot'); + if (mGd32App) { + if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') { + mGd32App.textContent = ota.gd32_app; + mGd32App.className = 'v ok'; + } else { + mGd32App.textContent = ota.gd32_app_alive ? '未知' : '离线'; + mGd32App.className = 'v'; + } + } + if (mGd32Boot) { + if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') { + mGd32Boot.textContent = ota.gd32_boot; + mGd32Boot.className = 'v ok'; + } else { + mGd32Boot.textContent = ota.gd32_app_alive ? '未知' : '离线'; + mGd32Boot.className = 'v'; + } + } + } + // temperature + var mTempEl = document.getElementById('mTemp'); + if (mTempEl) mTempEl.textContent = + Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--'; + // uptime + var uptimeEl = document.getElementById('pillUptime'); + if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec); + // wifi / signal + var pillIPEl = document.getElementById('pillIP'); + if (pillIPEl) pillIPEl.textContent = wifi.ip || '--'; + var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--'; + var pillRSSIEl = document.getElementById('pillRSSI'); + if (pillRSSIEl) pillRSSIEl.textContent = rssi; + var mOtaCache = document.getElementById('mOtaCache'); + var otaStored = ota.stored_size || 0; + if (mOtaCache) { + if (otaStored > 0) { + mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB'; + mOtaCache.className = 'v ok'; + } else { + mOtaCache.textContent = '0 / 216 KB'; + mOtaCache.className = 'v'; + } + } + // 共享进度条按当前连接模式归属:BLE 模式下由 ble_ota.js 的设备 ACK + // 驱动包数、阶段和完成状态,HTTP 状态轮询不能覆盖这套实时进度。 + if (window.connectionMode === 'ble') { + return; + } + // relay state - skip GD32 relay UI when ESP32 OTA is active + if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) { + return; + } + // ★ 状态机守卫:本会话已 FAILED 时,禁止后端状态覆盖失败 UI。 + // 后端可能仍在跑 GD32 中继(网络断开不会停止设备端流程), + // 若不拦截,会把进度条/阶段从"网络错误"错误地刷回"数据转发"。 + // 只有设备真正回 IDLE 才允许自动复位状态机(见下方 else 分支)。 + var rs = ota.relay_state; + var sysState = ota.sys_state || ''; + var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; + var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1; + var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3; + var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4; + var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255; + // OTA cache progress (GD32 upload to ESP32 flash) + var total = ota.total_size || 0; + var stored = ota.stored_size || 0; + var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0; + // GD32 relay forwarding progress — 以 /system/status 的 relay_seq 为准,不依赖日志 + var relaySeq = ota.relay_seq || 0; + var relayTotal = ota.relay_total_packets || 0; + var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; + var gd32AppAlive = ota.gd32_app_alive; + + if (window.otaSessionState === OtaSessionState.FAILED) { + var _deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) && + (rs === 0 || rs === 5); + if (!_deviceIdle) { + // 会话已失败,但设备可能仍在中继:继续用 relay_seq 刷进度,避免“假卡死” + if ((rs === 3 || isGd32Relaying) && relayTotal > 0) { + setActivePhase('gd32Ota'); + var failRelayOverall = 60 + Math.round(relayProgress * 0.30); + setProgress(failRelayOverall, + '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)'); + updateStagesFromRelayState(3); + } else { + updateStagesFromRelayState(255); + } + return; + } + // 设备已回 IDLE —— 自动复位状态机,恢复正常 UI + otaSessionReset(); + window.stopRelayProgressWatch(); + // 落到下方正常分支刷新 UI + } + + var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs); + if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); } + + if (isGd32Error && (window.currentOtaTarget === 'gd32' || window.otaSessionTarget === 'gd32')) { + var failDetail = 'GD32 OTA 失败'; + if (relayTotal > 0) { + failDetail += '(已转发 ' + relaySeq + '/' + relayTotal + + ',relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')'; + } else { + failDetail += '(relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')'; + } + window.cleanupAfterOtaFailure(failDetail); + window.pollLogs(); + } else if (isGd32Error) { + // 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理 + updateStagesFromRelayState(255); + var sbErr = document.getElementById('stageStatusBar'); + var stErr = document.getElementById('stageStatusText'); + if (sbErr) sbErr.className = 'stage-status-bar err'; + if (stErr) stErr.textContent = 'GD32 错误(待机)'; + } else if (window.otaSessionState === OtaSessionState.UPLOADING) { + /* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */ + } else if (window.otaSessionState === OtaSessionState.RELAYING) { + /* 必须先看到本会话真实中继(HANDSHAKE~FINISHING),才允许 DONE; + * 禁止一进 RELAYING 就吃掉上一次的 rs=5 / app_alive。 */ + if (isGd32Relaying || (rs >= 1 && rs <= 4)) { + window.gd32RelaySessionLive = true; + } + if (window.gd32RelaySessionLive && + (isGd32Complete || rs === 5 || + (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) { + if (!window.gd32CompleteAnnounced) { + window.markGd32OtaUiComplete('GD32 刷写完成'); + } + } else if (rs === 1) { + // HANDSHAKING + setActivePhase('gd32Ota'); + setProgress(50, '握手与认证中...'); + } else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5)) { + // TRANSFERRING + setActivePhase('gd32Ota'); + var relayOverall = 60 + Math.round(relayProgress * 0.30); + var relayLabel = relayTotal > 0 + ? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)') + : '数据包传输中...'; + setProgress(relayOverall, relayLabel); + } else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) { + // SIZE_NEG + setActivePhase('gd32Ota'); + setProgress(55, relayTotal > 0 + ? ('大小协商中(共 ' + relayTotal + ' 包)') + : '固件大小协商中'); + } else if (rs === 4) { + setActivePhase('gd32Ota'); + if (!window.gd32CompleteAnnounced) { + setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...'); + } + } else if (!window.gd32RelaySessionLive) { + setActivePhase('gd32Ota'); + setProgress(48, '已触发中继,等待设备进入握手…'); + } + } else if (isGd32Storing || (total > 0 && stored < total && !window.isUploading && + window.otaSessionState !== OtaSessionState.DONE && + window.otaSessionState !== OtaSessionState.IDLE)) { + setActivePhase('gd32Ota'); + var storeOverall = Math.min(45, Math.round(storageProgress * 0.45)); + if (!window.isUploading) { + setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total) + ''); + } + } +} \ No newline at end of file diff --git a/tools/js/ota/upload-esp32.js b/tools/js/ota/upload-esp32.js new file mode 100644 index 0000000..bc65c3a --- /dev/null +++ b/tools/js/ota/upload-esp32.js @@ -0,0 +1,443 @@ +/** + * upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级 + * + * 从 app.js 的「上传固件」「ESP32 OTA 完成升级」段提取。 + * 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、 + * XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。 + * + * 共享状态(通过 window.* 引用): + * window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle + * window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr + * window.isUploading / window.uploadBtnOrigText / window.activeUploadChip + * + * 未拆分的函数(通过 window.* 引用): + * window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset + * window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget + * window.cleanupAfterOtaFailure / window.pausePollingForUpload + * window.resumePollingAfterUpload / window.updateEsp32StagesFromPhase + * window.switchStageFlow / window.setActivePhase / window.setProgress + * window.markPhaseDone / window.startUploadWaitHeartbeat / window.stopUploadWaitHeartbeat + * window.updateUploadButtonLabel / window.processFile / window.clearFile + * window.triggerGd32Relay / window.rebootObservedDown / window.rebootStartTime + * window.isEsp32Rebooting / window.getBaseUrl / window.formatBytes + * window.showToast / window.addLog + */ +import { formatBytes } from '../common/utils.js'; +import { showToast } from '../common/toast.js'; +import { addLog } from '../common/log.js'; +import { + otaSessionActive, otaSessionTerminated, otaSessionReset, + otaSessionTransition, OtaSessionState, stopUploadWaitHeartbeat, + startUploadWaitHeartbeat +} from './session.js'; +import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js'; + +function isOtaBusyError(status, detail) { + var text = (detail || '').toLowerCase(); + return status === 403 || + status === 409 || + status === 423 || + text.indexOf('busy') >= 0 || + text.indexOf('occupied') >= 0 || + text.indexOf('another ota') >= 0 || + text.indexOf('invalid state') >= 0 || + text.indexOf('占用') >= 0 || + text.indexOf('互斥') >= 0 || + text.indexOf('系统忙') >= 0; +} + +function formatHttpErrorDetail(text) { + var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + if (detail.length > 180) detail = detail.slice(0, 177) + '...'; + return detail; +} + +/** @description 启动 ESP32 本机 OTA 上传流程(互斥获取→分块上传→SHA验证→重启)。 */ +export async function startUpload(chip) { + var file = chip === 'esp32' ? window.esp32File : window.gd32File; + if (!file) return; + // ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。 + // FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。 + if (otaSessionActive()) { + var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase(); + addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传'); + showToast('OTA 通道正在占用,请等待当前刷写结束', 'error'); + return; + } + // 上一次会话已终结(DONE/FAILED):发起新会话前先复位状态机 + if (otaSessionTerminated()) otaSessionReset(); + // 记录本会话目标 + window.otaSessionTarget = chip; + + window.pausePollingForUpload(); + // ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。 + // 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间 + // 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send, + // 设备端互斥被绕过、403 路径走漏。 + // ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true) + otaSessionTransition(OtaSessionState.UPLOADING); + window.activeUploadChip = chip; + /* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */ + window.gd32CompleteAnnounced = false; + window.relayWatchLastSeq = -1; + window.gd32RelaySessionLive = false; + if (chip === 'esp32') { + updateEsp32StagesFromPhase(1); + } + // 跨通道互斥前置检查(含 BLE 取消后的瞬态重试) + var _probeReady = false; + for (var _pi = 0; _pi < 4; _pi++) { + if (_pi > 0) { + var _pd = [1500, 3000, 3000][_pi - 1]; + addLog('I', '上传', '等待设备清理 OTA 状态 (' + _pd + 'ms)...'); + await new Promise(function(r) { setTimeout(r, _pd); }); + } + try { + var probe = await fetch(window.getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(2000) + }); + if (probe.ok) { + var st = await probe.json(); + var ota = st.ota || {}; + var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; + var otaChannel = Number.isFinite(ota.channel) ? ota.channel : -1; + var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI'); + // ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出, + // 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。 + // ERROR(8) 也放行给后端清理底层 OTA 句柄;FINALIZING(6)/REBOOTING(7) 是不可逆忙态。 + if (ssc === 5 && otaChannel === 1 && _pi < 3) continue; + var esp32Busy = (ssc === 6 || ssc === 7 || (ssc === 5 && otaChannel !== 0)); + // GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。 + var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0)); + if ((esp32Busy || gd32Busy)) { + addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传'); + showToast('设备正在 OTA 中,请等待结束后再试', 'error'); + // ★ 状态机:预检失败也需完整清理(转入 FAILED) + cleanupAfterOtaFailure('设备 OTA 占用中'); + return; + } + } + _probeReady = true; + break; + } catch (e) { + // 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。 + addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)'); + _probeReady = true; + break; + } + } + // 每次上传前尝试重新从本地读取文件 + var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle; + if (handle) { + // 有 FileSystemFileHandle,调用 getFile() 获取最新内容 + try { + var freshFile = await handle.getFile(); + if (chip === 'esp32') { + window.esp32File = freshFile; + file = freshFile; + } else { + if (freshFile.name.toLowerCase().endsWith('.hex')) { + window.processFile(freshFile, 'gd32'); + await new Promise(function(resolve) { setTimeout(resolve, 200); }); + file = window.gd32File; + } else { + window.gd32File = freshFile; + file = freshFile; + } + } + addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')'); + } catch (e) { + addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本'); + } + } else { + // 没有 handle,尝试直接读取当前 File 对象 + try { + await file.arrayBuffer(); + } catch (e) { + // File 引用已失效,提示用户重新选择 + addLog('E', '文件', '本地文件已变更或失效,请重新选择文件'); + showToast('文件已变更,请重新选择', 'error'); + window.clearFile(chip); + // ★ 状态机:文件失效属于会话失败 + cleanupAfterOtaFailure('本地文件失效'); + return; + } + } + var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn'); + window.uploadBtnOrigText = uploadBtn.textContent; + uploadBtn.disabled = true; + uploadBtn.textContent = '上传中...'; + switchStageFlow(chip); + setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota'); + if (chip === 'esp32') updateEsp32StagesFromPhase(1); + // Reset verification panel + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'none'; + var sviFront = document.getElementById('sviFrontSha'); + var sviDevice = document.getElementById('sviDeviceSha'); + var sviResult = document.getElementById('sviResult'); + if (sviFront) sviFront.textContent = '-'; + if (sviDevice) sviDevice.textContent = '-'; + if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; } + addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase()); + showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); + try { + var arrayBuffer = await file.arrayBuffer(); + // 刷写时重新计算 SHA-256(ESP32 和 GD32 都算) + if (chip === 'esp32') { + try { + var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + window._esp32FileSha256 = hashHex.toUpperCase(); + var shaElem = document.getElementById('fileSha256Esp32'); + if (shaElem) shaElem.textContent = window._esp32FileSha256; + addLog('I', '文件', '刷写前重新计算 SHA-256: ' + window._esp32FileSha256); + } catch (e) { + addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message); + } + } else if (chip === 'gd32') { + try { + var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + window._gd32FileSha256 = hashHex.toUpperCase(); + addLog('I', '文件', 'GD32 SHA-256: ' + window._gd32FileSha256); + } catch (e) { + addLog('W', '文件', 'GD32 SHA-256 重新计算失败: ' + e.message); + window._gd32FileSha256 = null; + } + } + var fileSize = file.size; + var body, url = window.getBaseUrl() + '/ota/upload'; + if (chip === 'esp32') { + var headerObj = { type: 'esp32', total_size: fileSize }; + var payload = arrayBuffer; + addLog('I', '上传', '直传: ' + formatBytes(fileSize) + + '(设备边收边写 Flash;进度 100% 后仍需等 HTTP 响应)'); + setProgress(1, '连接设备并开始写入…'); + var headerJson = JSON.stringify(headerObj); + var encoder = new TextEncoder(); + var headerBytes = encoder.encode(headerJson); + var combined = new Uint8Array(headerBytes.length + payload.byteLength); + combined.set(headerBytes, 0); + combined.set(new Uint8Array(payload), headerBytes.length); + body = combined.buffer; + } else { + var headerObj = { total_size: fileSize }; + var payload = arrayBuffer; + addLog('I', '上传', '直传: ' + formatBytes(fileSize)); + var gd32Header = JSON.stringify(headerObj); + var encoder = new TextEncoder(); + var headerBytes = encoder.encode(gd32Header); + var combined = new Uint8Array(headerBytes.length + payload.byteLength); + combined.set(headerBytes, 0); + combined.set(new Uint8Array(payload), headerBytes.length); + body = combined.buffer; + } + window.activeXhr = new XMLHttpRequest(); + var xhr = window.activeXhr; + xhr.upload.addEventListener('progress', function (e) { + if (e.lengthComputable) { + var pct = Math.round((e.loaded / e.total) * 100); + window.uploadProgress = pct; + if (chip === 'gd32') { + var overallPct = Math.min(45, Math.round(pct * 0.45)); + setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total)); + } else if (pct >= 100) { + setProgress(100, '数据已发完,等待设备写 Flash 并响应…'); + } else { + setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total)); + } + } + }); + xhr.upload.addEventListener('load', function () { + // 请求体发送完毕 ≠ 服务端处理完毕 + setProgress(100, '数据已发完,等待设备写 Flash 并响应…'); + addLog('I', '上传', '浏览器已发完全部数据,等待 ESP32 HTTP 响应…'); + startUploadWaitHeartbeat(); + }); + // 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈 + xhr.timeout = 300000; + xhr.addEventListener('timeout', function () { + stopUploadWaitHeartbeat(); + if (window.activeXhr) { + cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)'); + showToast('上传超时:设备可能忙或连接被踢,请重试', 'error'); + } + }); + xhr.addEventListener('load', function () { + stopUploadWaitHeartbeat(); + window.isUploading = false; + window.activeXhr = null; + window.resumePollingAfterUpload(); + uploadBtn.disabled = false; + uploadBtn.textContent = window.uploadBtnOrigText; + window.updateUploadButtonLabel(); + if (xhr.status === 200) { + /* 校验 body:避免空响应/网关误 200 被当成上传成功 */ + var bodyTxt = (xhr.responseText || '').trim(); + var bodyOk = false; + try { + var bj = bodyTxt ? JSON.parse(bodyTxt) : null; + bodyOk = !!(bj && (bj.status === 'ok' || bj.target === chip)); + } catch (_pe) { + bodyOk = bodyTxt.indexOf('"status"') >= 0 && bodyTxt.indexOf('ok') >= 0; + } + if (!bodyOk) { + addLog('E', '上传', 'HTTP 200 但响应异常: ' + (bodyTxt.slice(0, 120) || '(空)')); + showToast('上传响应异常,请重试', 'error'); + cleanupAfterOtaFailure('响应异常'); + return; + } + // 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复 + if (chip === 'esp32') { + addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…'); + showToast('固件上传成功', 'success'); + setProgress(100, '上传完成'); + markPhaseDone('esp32Ota'); + updateEsp32StagesFromPhase(2); // OTA 写入阶段 + // ★ 状态机:ESP32 上传完成 → 进入 VERIFYING(verifyEsp32Firmware 内部继续推进) + otaSessionTransition(OtaSessionState.VERIFYING); + verifyEsp32Firmware(); + } else { + addLog('I', 'OTA', '上传完成,校验 SHA 并触发 GD32 中继…'); + showToast('上传完成,GD32 OTA 已自动开始', 'success'); + switchStageFlow('gd32'); + setActivePhase('gd32Ota'); + setProgress(45, '上传完成,校验并触发 GD32 OTA…'); + // ★ 状态机:GD32 上传完成 → 进入 RELAYING(triggerGd32Relay 内部驱动中继) + otaSessionTransition(OtaSessionState.RELAYING); + window.triggerGd32Relay(); + } + } else if (isOtaBusyError(xhr.status, formatHttpErrorDetail(xhr.responseText))) { + var busyDetail = formatHttpErrorDetail(xhr.responseText); + addLog('E', '上传', chip.toUpperCase() + ' OTA 被其他通道占用(BLE/WiFi 互斥)' + (busyDetail ? ': ' + busyDetail : '')); + showToast('OTA 通道被占用,请稍后重试', 'error'); + cleanupAfterOtaFailure('通道被占用'); + } else { + var detail = formatHttpErrorDetail(xhr.responseText); + var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : ''); + addLog('E', '上传', message); + showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error'); + cleanupAfterOtaFailure('HTTP ' + xhr.status); + } + }); + xhr.addEventListener('error', function () { + stopUploadWaitHeartbeat(); + if (window.activeXhr) { + cleanupAfterOtaFailure('网络错误'); + showToast('上传过程发生网络错误', 'error'); + } + }); + xhr.addEventListener('abort', function () { + stopUploadWaitHeartbeat(); + if (window.activeXhr) { + cleanupAfterOtaFailure('已中止'); + showToast('上传已中止', 'error'); + } + }); + xhr.open('POST', url); + xhr.send(body); + } catch (e) { + addLog('E', '上传', '上传失败: ' + e.message); + showToast('上传失败: ' + e.message, 'error'); + cleanupAfterOtaFailure('上传异常'); + } +} + +// ==================== ESP32 OTA 完成升级 ==================== +// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮) +/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */ +export async function verifyEsp32Firmware() { + updateEsp32StagesFromPhase(3); // 校验中 + var sha256 = window._esp32FileSha256; + if (sha256) { + addLog('I', 'OTA', '前端文件 SHA-256: ' + sha256); + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'block'; + var frontSha = document.getElementById('sviFrontSha'); + if (frontSha) frontSha.textContent = sha256; + } + + showToast('ESP32 正在校验固件并应用...', 'success'); + try { + // 带 sha256 时设备端校验、set_boot 并自动重启;无需二次“应用”请求 + var requestBody = {}; + if (sha256) requestBody.sha256 = sha256; + + var resp = await fetch(window.getBaseUrl() + '/ota/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(120000) + }); + + var data = await resp.json(); + + if (data.status === 'ok') { + addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-')); + if (data.sha256) { + var deviceSha = document.getElementById('sviDeviceSha'); + if (deviceSha) deviceSha.textContent = data.sha256; + } + var resultEl = document.getElementById('sviResult'); + if (resultEl) { + if (sha256 && data.sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) { + resultEl.textContent = '验证通过'; + resultEl.className = 'svi-val ok'; + } else { + resultEl.textContent = '已应用(未比对 SHA)'; + resultEl.className = 'svi-val ok'; + } + } + // 服务器已设置启动分区并自动重启,前端直接进入重启等待状态 + addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...'); + showToast('校验通过,设备重启中...', 'success'); + updateEsp32StagesFromPhase(4); // 应用固件 + window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线 + updateEsp32StagesFromPhase(5); // 重启中 + window.rebootStartTime = Date.now(); + // ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true) + otaSessionTransition(OtaSessionState.REBOOTING); + } else { + var detail = data.message || (data.device ? '不匹配' : '未知错误'); + addLog('E', 'OTA', '校验失败: ' + detail); + showToast('校验失败: ' + detail, 'error'); + var resultEl = document.getElementById('sviResult'); + if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; } + cleanupAfterOtaFailure('校验失败'); + } + } catch (e) { + // fetch 异常仅代表请求未完成,不能推断设备已重启: + // 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。 + // 如实报告失败,由 pollStatus 探测设备真实状态。 + addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); + showToast('应用请求失败: ' + e.message, 'error'); + var resultEl = document.getElementById('sviResult'); + if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; } + cleanupAfterOtaFailure('应用请求失败'); + } +} + +export async function restartEsp32() { + if (!confirm('确定要重启 ESP32 吗?')) return; + addLog('I', '系统', '=== 请求重启 ESP32 ==='); + showToast('ESP32 正在重启...', 'success'); + try { + var resp = await fetch(window.getBaseUrl() + '/restart', { + method: 'POST', signal: AbortSignal.timeout(5000) + }); + if (resp.ok) { + var data = await resp.json(); + addLog('I', '系统', '重启已触发: ' + data.message); + showToast('ESP32 重启中,页面将自动恢复...', 'success'); + window.isEsp32Rebooting = true; + window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线 + window.rebootStartTime = Date.now(); + } + } catch (e) { + addLog('E', '系统', '重启请求失败: ' + e.message); + showToast('重启失败', 'error'); + } +} \ No newline at end of file diff --git a/tools/js/ota/upload-gd32.js b/tools/js/ota/upload-gd32.js new file mode 100644 index 0000000..92311fb --- /dev/null +++ b/tools/js/ota/upload-gd32.js @@ -0,0 +1,212 @@ +/** + * upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪 + * + * 从 app.js 的「GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)」段提取。 + * 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。 + * + * 共享状态(通过 window.* 引用): + * window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive + * window.pollingPausedForUpload / window.statusPollIntervalMs + * window.otaSessionState / window.lastSysStateCode / window.lastRelayState + * + * 未拆分的函数(通过 window.* 引用): + * window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive + * window.markPhaseDone / window.setProgress / window.updateSystemStatusUi + * window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs + * window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog + */ +import { showToast } from '../common/toast.js'; +import { addLog } from '../common/log.js'; +import { + otaSessionTransition, otaSessionActive, OtaSessionState +} from './session.js'; +import { markPhaseDone, setProgress } from './progress.js'; + +/** + * GD32 中继进度观察:以 /system/status 为唯一真相源。 + * 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间—— + * 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。 + */ +let relayWatchTimerId = null; +let relayWatchInFlight = false; +window.gd32CompleteAnnounced = false; +window.relayWatchLastSeq = -1; +/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */ +window.gd32RelaySessionLive = false; + +export function markGd32OtaUiComplete(msg) { + // 已公告过:禁止再改 className / setProgress / toast,否则每 400ms 重触发动效会「疯狂跳 ✓」 + if (window.gd32CompleteAnnounced) { + return; + } + /* 仅本会话 RELAYING/FAILED 可宣布完成;IDLE/UPLOADING 时 status 里的旧 relay 残留一律忽略 */ + if (window.otaSessionState !== OtaSessionState.RELAYING && + window.otaSessionState !== OtaSessionState.FAILED) { + return; + } + window.gd32CompleteAnnounced = true; + stopRelayProgressWatch(); + + markPhaseDone('gd32Ota'); + window.currentOtaTarget = null; + setProgress(100, msg || 'GD32 刷写完成'); + var sbDone = document.getElementById('stageStatusBar'); + var stDone = document.getElementById('stageStatusText'); + if (sbDone) sbDone.className = 'stage-status-bar ok'; + if (stDone) stDone.textContent = msg || 'GD32 刷写完成'; + if (window.otaSessionState === OtaSessionState.RELAYING || + window.otaSessionState === OtaSessionState.FAILED) { + otaSessionTransition(OtaSessionState.DONE); + } + addLog('I', 'OTA', msg || 'GD32 刷写完成'); + showToast(msg || 'GD32 刷写完成', 'success'); +} + +export function stopRelayProgressWatch() { + if (relayWatchTimerId) { + clearInterval(relayWatchTimerId); + relayWatchTimerId = null; + } + relayWatchInFlight = false; + if (window.statusPollIntervalMs < 1000) { + window.statusPollIntervalMs = 1000; + } +} + +export function startRelayProgressWatch() { + stopRelayProgressWatch(); + if (window.pollingPausedForUpload) { + window.resumePollingAfterUpload(); + } + window.statusPollIntervalMs = 400; + window.scheduleStatusPoll(0); + window.pollLogs(); + addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志'); + relayWatchTimerId = setInterval(function () { + var active = window.otaSessionState === OtaSessionState.RELAYING || + (window.otaSessionState === OtaSessionState.FAILED && + (window.lastSysStateCode === 3 || window.lastRelayState >= 1 && window.lastRelayState <= 4)); + if (!active) { + if (window.otaSessionState === OtaSessionState.DONE) { + stopRelayProgressWatch(); + } + return; + } + if (relayWatchInFlight) return; + relayWatchInFlight = true; + fetch(window.getBaseUrl() + '/system/status', { signal: AbortSignal.timeout(3000) }) + .then(function (resp) { return resp.ok ? resp.json() : null; }) + .then(function (data) { + if (!data) return; + try { window.updateSystemStatusUi(data); } catch (e) { /* ignore */ } + var o = data.ota || {}; + var code = Number(o.sys_state_code); + var rstate = Number(o.relay_state); + var alive = !!o.gd32_app_alive; + var rseq = Number(o.relay_seq) || 0; + var rtot = Number(o.relay_total_packets) || 0; + if (rstate >= 1 && rstate <= 4) { + window.gd32RelaySessionLive = true; + } + if (window.gd32RelaySessionLive && + (code === 4 || rstate === 5 || + (alive && rtot > 0 && rseq >= rtot && rstate >= 3))) { + markGd32OtaUiComplete('GD32 刷写完成'); + window.pollLogs(); + return; + } + if (code === 8 || rstate === 255) { + stopRelayProgressWatch(); + window.pollLogs(); + return; + } + // 前端进度同步:包数变化时更新进度条(updateSystemStatusUi 已做)并写一条前端日志 + // 后端不再打「转发进度」文字,只靠这里 + 进度条 + if (rtot > 0 && rseq > 0 && rseq !== window.relayWatchLastSeq) { + window.relayWatchLastSeq = rseq; + var pct = Math.round(rseq * 100 / rtot); + setProgress(60 + Math.round(pct * 0.30), + '已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)'); + addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)'); + } + }) + .catch(function () { /* 下轮继续 */ }) + .then(function () { relayWatchInFlight = false; }); + }, 400); +} + +/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */ +export async function triggerGd32Relay() { + var sha256 = window._gd32FileSha256; + if (!sha256) { + addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件'); + showToast('SHA-256 缺失', 'error'); + window.cleanupAfterOtaFailure('SHA-256 缺失'); + return; + } + addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...'); + showToast('正在触发 GD32 OTA', 'success'); + setProgress(48, '校验 SHA 并触发 GD32 中继…'); + try { + // 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒 + var resp = await fetch(window.getBaseUrl() + '/ota/relay', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sha256: sha256 }), + signal: AbortSignal.timeout(120000) + }); + var text = await resp.text(); + if (resp.ok) { + try { + var data = JSON.parse(text); + if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256); + } catch (_) {} + addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数(勿只看日志时间戳)'); + showToast('GD32 OTA 已触发', 'success'); + setProgress(50, '中继已启动,同步进度…'); + window.gd32CompleteAnnounced = false; + window.relayWatchLastSeq = -1; + startRelayProgressWatch(); + } else { + // 尝试解析错误响应中的 device SHA + try { + var err = JSON.parse(text); + if (err.device) { + addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256); + showToast('SHA-256 不匹配', 'error'); + window.cleanupAfterOtaFailure('SHA-256 不匹配'); + return; + } + } catch (_) {} + addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text); + showToast('GD32 触发失败', 'error'); + window.cleanupAfterOtaFailure('触发失败 HTTP ' + resp.status); + } + } catch (e) { + // 请求超时/断连 ≠ 设备未启动中继:先探测 status 再决定是否标失败 + try { + var probe = await fetch(window.getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(3000) + }); + if (probe.ok) { + var st = await probe.json(); + var o = st.ota || {}; + var code = Number(o.sys_state_code); + var rstate = Number(o.relay_state); + if (code === 3 || (rstate >= 1 && rstate <= 4)) { + addLog('W', 'OTA', '触发请求中断(' + e.message + '),但设备已在中继,继续跟踪进度'); + showToast('设备已在刷写,同步进度中', 'success'); + if (window.otaSessionState !== OtaSessionState.RELAYING) { + otaSessionTransition(OtaSessionState.RELAYING); + } + try { window.updateSystemStatusUi(st); } catch (_) {} + startRelayProgressWatch(); + return; + } + } + } catch (_) { /* probe 失败再走失败清理 */ } + addLog('E', 'OTA', 'GD32 触发失败: ' + e.message); + showToast('GD32 触发失败', 'error'); + window.cleanupAfterOtaFailure('触发异常'); + } +} \ No newline at end of file diff --git a/tools/style.css b/tools/style.css deleted file mode 100644 index a6e95ac..0000000 --- a/tools/style.css +++ /dev/null @@ -1,2146 +0,0 @@ -/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */ - -:root { - --bg-root: #090e15; - --bg-panel: #0f161f; - --bg-card: #141d28; - --bg-elevated: #1a2432; - --bg-input: #0d131b; - --border: #1d2a3a; - --border-focus: #22d3ee; - --text: #e2e8f0; - --text-dim: #7b8fa8; - --text-label: #5a6d82; - --accent: #22d3ee; - --accent-dim: rgba(34,211,238,.15); - --green: #22c55e; - --amber: #f59e0b; - --red: #ef4444; - --radius-sm: 6px; - --radius: 10px; - --radius-lg: 14px; - --font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif; - --font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace; - --gap: 16px; -} - -* { box-sizing: border-box; margin: 0; padding: 0; } - -html, body { - height: 100%; - background: var(--bg-root); - color: var(--text); - font-family: var(--font); - font-size: 14px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} - -/* subtle noise-grid background */ -body::before { - content: ""; - position: fixed; inset: 0; z-index: 0; pointer-events: none; - background-image: - linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px); - background-size: 40px 40px; - mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 70%); -} - -.container { - position: relative; z-index: 1; - max-width: 1480px; margin: 0 auto; padding: 24px 32px 40px; -} - -/* === HEADER — compact status bar === */ -.header-bar { - display: flex; align-items: center; gap: 12px; - padding: 14px 20px; margin-bottom: 24px; - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - flex-wrap: wrap; -} -.header-brand { - display: flex; align-items: center; gap: 10px; - padding-right: 20px; border-right: 1px solid var(--border); - white-space: nowrap; -} -.header-brand .dot-mark { - width: 10px; height: 10px; border-radius: 50%; background: var(--accent); - box-shadow: 0 0 8px var(--accent-dim); - flex-shrink: 0; -} -.header-brand strong { font-size: 15px; font-weight: 700; } -.header-status { - display: flex; align-items: center; gap: 8px; - flex-wrap: wrap; -} -.pill { - display: inline-flex; align-items: center; gap: 6px; - padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; - background: var(--bg-elevated); border: 1px solid var(--border); - white-space: nowrap; -} -.pill .indicator { - width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; -} -.pill.online .indicator { background: var(--green); } -.pill.offline .indicator { background: var(--red); } -.pill.processing .indicator { background: var(--amber); animation: blink 1s infinite; } -.pill.idle .indicator { background: var(--text-dim); } -@keyframes blink { 50% { opacity: .35; } } - -/* === MAIN LAYOUT === */ -.main-layout { - display: grid; - grid-template-columns: 380px 1fr; - gap: var(--gap); - align-items: start; -} - -/* === CARDS === */ -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; -} -.card + .card { margin-top: var(--gap); } -.card-head { - display: flex; align-items: center; gap: 8px; - padding: 14px 18px; - border-bottom: 1px solid var(--border); - font-size: 13px; font-weight: 600; - color: var(--text-dim); - text-transform: uppercase; letter-spacing: .06em; -} -.card-head .ico { font-size: 15px; } -.card-head-btn { - margin-left: auto; - padding: 4px 10px; - position: relative; - z-index: 2; -} -.card-body { padding: 16px 18px; } -.card.collapsed .card-body { display: none; } -.card-head { cursor: pointer; } -.card-head .collapse-arrow { - margin-left: auto; font-size: 10px; opacity: .45; - transition: transform .2s; -} -.card.collapsed .collapse-arrow { transform: rotate(-90deg); } - -/* === FORM ELEMENTS === */ -.form-row { margin-bottom: 14px; } -.form-row:last-child { margin-bottom: 0; } -.form-label { - display: block; font-size: 11px; color: var(--text-label); - text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; -} -.input, select, textarea { - width: 100%; padding: 10px 12px; font-size: 13px; font-family: inherit; - background: var(--bg-input); color: var(--text); - border: 1px solid var(--border); border-radius: var(--radius-sm); - outline: none; transition: border-color .15s; -} -.input:focus, select:focus, textarea:focus { - border-color: var(--border-focus); - box-shadow: 0 0 0 3px var(--accent-dim); -} -select { cursor: pointer; } -select option { background: var(--bg-panel); color: var(--text); } -.input-group { display: flex; gap: 8px; } -.input-group .input { flex: 1; } - -/* === BUTTONS === */ -.btn { - display: inline-flex; align-items: center; justify-content: center; gap: 6px; - padding: 9px 16px; border: none; border-radius: var(--radius-sm); - font-size: 13px; font-weight: 600; cursor: pointer; - transition: opacity .15s, transform .15s; - font-family: inherit; white-space: nowrap; -} -.btn:hover:not(:disabled) { transform: translateY(-1px); } -.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; } -.btn-primary { background: var(--accent); color: #07121c; } -.btn-primary:hover:not(:disabled) { opacity: .88; } -.btn-success { background: var(--green); color: #051a0c; } -.btn-danger { background: var(--red); color: #fff; } -.btn-outline { - background: transparent; color: var(--text-dim); - border: 1px solid var(--border); -} -.btn-outline:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); } -.btn-ghost { - background: transparent; color: var(--text-dim); -} -.btn-ghost:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); } -.btn-block { width: 100%; justify-content: center; } -.btn-sm { padding: 6px 12px; font-size: 12px; } - -/* === CONNECTION STATUS ROW === */ -.conn-indicator { - display: flex; align-items: center; gap: 10px; - padding: 12px 16px; border-radius: var(--radius-sm); - background: var(--bg-elevated); border: 1px solid var(--border); - font-size: 13px; font-weight: 500; -} -.conn-indicator .light { - width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; -} -.conn-indicator.ok .light { background: var(--green); } -.conn-indicator.err .light { background: var(--red); } -.conn-indicator.warn .light { background: var(--amber); } - -.helper-note { - margin-top: 10px; - padding: 10px 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-elevated); - font-size: 12px; - line-height: 1.6; - color: var(--text-dim); -} -.helper-note.ok { border-color: rgba(34,197,94,.25); color: #b8f5cb; } -.helper-note.warn { border-color: rgba(245,158,11,.25); color: #fbd38d; } -.helper-note.err { border-color: rgba(239,68,68,.25); color: #fecaca; } -.helper-note code { color: #d7fbff; font-family: var(--font-mono); } - -/* === UPLOAD ZONE === */ -.upload-zone { - border: 2px dashed var(--border); border-radius: var(--radius); - padding: 28px 16px; text-align: center; cursor: pointer; - transition: border-color .2s, background .2s; -} -.upload-zone:hover, -.upload-zone.dragover { border-color: var(--accent); background: var(--accent-dim); } -.upload-zone .upload-ico { font-size: 32px; margin-bottom: 10px; } -.upload-zone h4 { font-size: 14px; margin-bottom: 4px; } -.upload-zone p { font-size: 12px; color: var(--text-dim); } -.upload-zone-sm { padding: 16px 12px; } -.upload-zone-sm .upload-ico { font-size: 24px; margin-bottom: 6px; } -.upload-zone-sm h4 { font-size: 13px; } - -/* === FILE INFO === */ -.file-info { - display: none; margin-top: 12px; - background: var(--bg-elevated); border-radius: var(--radius-sm); padding: 12px 14px; -} -.file-info.show { display: block; } -.file-info-row { display: flex; justify-content: space-between; align-items: center; } -.file-info-row + .file-info-row { margin-top: 10px; } -.file-info-grid { - display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; -} -.file-meta-item { - background: var(--bg-card); padding: 8px 10px; border-radius: 6px; - font-size: 12px; -} -.file-meta-item span { color: var(--text-label); display: block; font-size: 10px; margin-bottom: 2px; } -.file-meta-item b { font-weight: 600; } - -/* === PROGRESS === */ -.progress-phase-row { - display: flex; align-items: center; gap: 0; margin-bottom: 14px; - flex-wrap: nowrap; - background: var(--bg-input); - border-radius: 20px; - padding: 4px; - border: 1px solid var(--border); -} -.pp-step { - flex: 1; - display: flex; align-items: center; justify-content: center; gap: 5px; - padding: 7px 10px; border-radius: 16px; - font-size: 12px; font-weight: 600; color: var(--text-dim); - background: transparent; - transition: all .25s ease; white-space: nowrap; - cursor: default; -} -.pp-ico { font-size: 13px; opacity: .45; transition: all .25s ease; } -.pp-txt { transition: color .25s ease; } -.pp-sep { display: none; } - -/* Active step — highlighted capsule */ -.pp-step.active { - color: #07121c; - background: var(--accent); - box-shadow: 0 0 10px var(--accent-dim); -} -.pp-step.active .pp-ico { opacity: 1; } -.pp-step.active .pp-txt { color: #07121c; } - -/* Done step — green outlined capsule */ -.pp-step.done { - color: var(--green); - background: rgba(34,197,94,.12); -} -.pp-step.done .pp-ico { opacity: 1; } -.pp-step.done .pp-txt::after { - content: ' ✓'; font-size: 10px; -} - -.progress-wrap { margin-top: 0; display: none; } -.progress-wrap.show { display: block; } -.progress-track { - width: 100%; height: 10px; border-radius: 20px; - background: var(--bg-input); overflow: hidden; - box-shadow: inset 0 1px 2px rgba(0,0,0,.3); -} -.progress-fill { - height: 100%; border-radius: 20px; - background: linear-gradient(90deg, #22d3ee, #06b6d4); - width: 0%; transition: width .3s ease; - position: relative; - box-shadow: 0 0 8px rgba(34,211,238,.3); -} -.progress-fill::after { - content: ""; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.3) 50%, transparent 100%); - animation: sweep 1.6s linear infinite; -} -.progress-fill.done { - background: linear-gradient(90deg, #22c55e, #16a34a); - box-shadow: 0 0 8px rgba(34,197,94,.3); -} -@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } -.progress-text { - display: flex; justify-content: space-between; margin-top: 7px; - font-size: 12px; color: var(--text-dim); -} - -/* === BUTTON ROW === */ -.btn-row { display: flex; gap: 10px; margin-top: 16px; } -.btn-row .btn { flex: 1; } - -/* === METRIC GRID === */ -.metric-grid { - display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; -} -.metric { - background: var(--bg-elevated); border: 1px solid var(--border); - border-radius: var(--radius-sm); padding: 12px 14px; -} -.metric .k { - font-size: 10px; color: var(--text-label); text-transform: uppercase; - letter-spacing: .1em; margin-bottom: 4px; -} -.metric .v { - font-size: 20px; font-weight: 700; word-break: break-word; -} -.metric .v.highlight { color: var(--accent); } - -.status-kv-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; -} -.status-kv { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; -} -.status-kv .k { - display: block; - font-size: 10px; - color: var(--text-label); - text-transform: uppercase; - letter-spacing: .08em; - margin-bottom: 4px; -} -.status-kv b { - display: block; - color: var(--text); - font-size: 13px; - font-weight: 700; - word-break: break-word; - font-family: var(--font-mono); -} -.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; } - -/* ===== 堆概况 ===== */ -.heap-group { - background: rgba(255,255,255,0.02); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px 14px; - margin-bottom: 10px; -} -.heap-group:last-child { margin-bottom: 0; } -.cpu-group { margin-bottom: 8px; } -.cpu-task-list { - margin-bottom: 12px; -} -.cpu-task-list .status-task { - background: rgba(255,255,255,0.025); -} -.cpu-fill { - background: linear-gradient(90deg, #38bdf8, #22c55e); -} - -.heap-group-title { - font-size: 11px; - font-weight: 600; - color: var(--text-label); - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 10px; -} - -.heap-bar-row { - display: flex; - align-items: center; - gap: 10px; -} - -.heap-bar-text { - font-size: 13px; - font-weight: 600; - white-space: nowrap; - min-width: 165px; - color: var(--text); -} - -.heap-bar-track { - flex: 1; - height: 14px; - background: rgba(255,255,255,0.06); - border-radius: 7px; - overflow: hidden; - position: relative; -} - -.heap-bar-fill { - display: block; - height: 100%; - border-radius: 7px; - background: linear-gradient(90deg, var(--accent), #5eead4); - transition: width .4s ease; -.heap-bar-fill.warn { - background: linear-gradient(90deg, #f59e0b, #d97706); -} -.heap-bar-fill.danger { - background: linear-gradient(90deg, #ef4444, #dc2626); -} -} - -.heap-bar-pct { - font-size: 12px; - font-weight: 600; - min-width: 42px; - text-align: right; - font-variant-numeric: tabular-nums; - color: var(--text-dim); -} - -.heap-detail-row { - display: flex; - gap: 16px; - font-size: 11px; - color: var(--text-dim); - margin-top: 6px; - padding-top: 6px; - border-top: 1px solid rgba(255,255,255,0.04); - flex-wrap: wrap; -} -.heap-detail-row span { - display: inline-flex; - align-items: center; - gap: 4px; -} -.heap-detail-row b { - color: var(--text); - font-weight: 500; - font-family: var(--font-mono); - font-size: 11px; -} - -.status-task-list { - display: grid; - gap: 8px; -} -.status-task { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; -} -.status-task-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 8px; -} -.status-task-name { - font-size: 12px; - font-weight: 700; - color: var(--text); - font-family: var(--font-mono); -} -.status-task-ratio { - font-size: 11px; - color: var(--text-dim); - font-family: var(--font-mono); -} -.status-task-bar { - width: 100%; - height: 8px; - border-radius: 999px; - background: var(--bg-input); - overflow: hidden; - margin-bottom: 8px; -} -.status-task-bar-fill { - height: 100%; - border-radius: 999px; - background: linear-gradient(90deg, #22d3ee, #06b6d4); -} -.status-task-bar-fill.warn { - background: linear-gradient(90deg, #f59e0b, #d97706); -} -.status-task-bar-fill.err { - background: linear-gradient(90deg, #ef4444, #dc2626); -} -.status-task-meta { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -} -.status-task-meta span { - display: block; - font-size: 10px; - color: var(--text-label); - text-transform: uppercase; - letter-spacing: .06em; -} -.status-task-meta b { - display: block; - margin-top: 2px; - color: var(--text); - font-size: 11px; - font-family: var(--font-mono); -} -.status-json { - margin-top: 0; - padding: 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-elevated); - color: var(--text-dim); - font-size: 11px; - line-height: 1.6; - max-height: 280px; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - font-family: var(--font-mono); -} -.status-empty { - padding: 12px; - border: 1px dashed var(--border); - border-radius: var(--radius-sm); - color: var(--text-dim); - font-size: 12px; -} - -/* === PRIMARY STATUS BADGES (large) === */ -.badge-grid { - display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; -} -.badge-stat { - background: var(--bg-elevated); border: 1px solid var(--border); - border-radius: var(--radius); padding: 14px 16px; text-align: center; -} -.badge-stat .v { - font-size: 22px; font-weight: 700; -} -.badge-stat .v.accent { color: var(--accent); } -.badge-stat .v.ok { color: var(--green); } -.badge-stat .v.warn { color: var(--amber); } -.badge-stat .v.err { color: var(--red); } -.badge-stat .k { - font-size: 10px; color: var(--text-label); text-transform: uppercase; - letter-spacing: .1em; margin-top: 2px; -} - -/* === OTA STAGE FLOW === */ -.stage-flow { - display: flex; align-items: flex-start; gap: 0; padding: 8px 0; -} -.stage-node { - flex: 1; display: flex; flex-direction: column; align-items: center; - gap: 6px; position: relative; -} -.stage-node:not(:last-child)::after { - content: ""; position: absolute; top: 18px; left: calc(50% + 18px); - width: calc(100% - 36px); height: 2px; - background: var(--border); transition: background .3s; -} -.stage-node.done:not(:last-child)::after, -.stage-node.live:not(:last-child)::after { background: var(--accent); } -.stage-dot { - width: 36px; height: 36px; border-radius: 50%; - background: var(--bg-input); border: 2px solid var(--border); - display: flex; align-items: center; justify-content: center; - font-size: 16px; z-index: 1; transition: all .3s; -} -.stage-node.live .stage-dot { - background: var(--accent); border-color: var(--accent); color: #07121c; - box-shadow: 0 0 12px var(--accent-dim); -} -.stage-node.done .stage-dot { - background: var(--green); border-color: var(--green); color: #fff; -} -.stage-node.err .stage-dot { - background: var(--red); border-color: var(--red); color: #fff; -} -.stage-label { - font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3; -} -.stage-detail { - font-size: 10px; color: var(--accent); text-align: center; line-height: 1.2; - margin-top: 2px; min-height: 14px; - font-family: var(--font-mono); - transition: color .3s; -} -.stage-node.live .stage-label, -.stage-node.done .stage-label, -.stage-node.err .stage-label { color: var(--text); } -.stage-node.live .stage-detail { color: var(--accent); } -.stage-node.done .stage-detail { color: var(--green); } -.stage-node.err .stage-detail { color: var(--red); } - -/* Stage status bar */ -.stage-status-bar { - display: flex; align-items: center; gap: 10px; - margin-top: 14px; padding: 10px 16px; - border-radius: 20px; - background: var(--bg-elevated); border: 1px solid var(--border); - font-size: 13px; - transition: all .3s; -} -.stage-status-bar .ssb-dot { - width: 8px; height: 8px; border-radius: 50%; - flex-shrink: 0; - background: var(--text-dim); - transition: all .3s; -} -.stage-status-bar .ssb-text { - color: var(--text-dim); font-size: 13px; font-weight: 500; - letter-spacing: .01em; -} -/* idle — muted, with subtle pulse */ -.stage-status-bar .ssb-dot { animation: dotPulse 2.5s ease-in-out infinite; } -@keyframes dotPulse { - 0%, 100% { opacity: .45; } - 50% { opacity: .8; } -} -/* active — accent colored */ -.stage-status-bar.active { - border-color: rgba(34,211,238,.25); - background: linear-gradient(135deg, rgba(34,211,238,.06), rgba(34,211,238,.02)); -} -.stage-status-bar.active .ssb-dot { - background: var(--accent); - box-shadow: 0 0 6px var(--accent-dim); - animation: dotPulseLive 1.2s ease-in-out infinite; -} -.stage-status-bar.active .ssb-text { color: var(--accent); font-weight: 600; } -@keyframes dotPulseLive { - 0%, 100% { opacity: .7; transform: scale(1); } - 50% { opacity: 1; transform: scale(1.25); } -} -/* ok — green */ -.stage-status-bar.ok { - border-color: rgba(34,197,94,.25); - background: linear-gradient(135deg, rgba(34,197,94,.06), rgba(34,197,94,.02)); -} -.stage-status-bar.ok .ssb-dot { - background: var(--green); - box-shadow: 0 0 6px rgba(34,197,94,.3); - animation: none; -} -.stage-status-bar.ok .ssb-text { color: var(--green); font-weight: 600; } -/* err — red */ -.stage-status-bar.err { - border-color: rgba(239,68,68,.25); - background: linear-gradient(135deg, rgba(239,68,68,.06), rgba(239,68,68,.02)); -} -.stage-status-bar.err .ssb-dot { - background: var(--red); - box-shadow: 0 0 6px rgba(239,68,68,.3); - animation: none; -} -.stage-status-bar.err .ssb-text { color: var(--red); font-weight: 600; } - -/* Stage verification info panel */ -.stage-verify-info { - margin-top: 12px; padding: 12px 14px; - border-radius: var(--radius-sm); - background: var(--bg-elevated); border: 1px solid var(--border); -} -.svi-row { - display: flex; align-items: flex-start; gap: 12px; - padding: 6px 0; font-size: 12px; - border-bottom: 1px solid rgba(255,255,255,.03); -} -.svi-row:last-child { border-bottom: none; } -.svi-key { - color: var(--text-label); font-weight: 600; - text-transform: uppercase; letter-spacing: .06em; font-size: 10px; - min-width: 100px; flex-shrink: 0; - padding-top: 2px; -} -.svi-val { - color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; - word-break: break-all; -} -.svi-val.ok { color: var(--green); font-weight: 600; } -.svi-val.err { color: var(--red); font-weight: 600; } - -/* Stage result panel - 上位机确认 0x07/0x85 收尾 */ -.stage-result { - margin-top: 12px; padding: 14px 16px; - border-radius: var(--radius-sm); - background: var(--bg-elevated); border: 1px solid var(--border); - display: flex; flex-direction: column; gap: 10px; -} -.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); } -.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); } -.stage-result .sr-head { display: flex; align-items: center; gap: 10px; } -.stage-result .sr-ico { - width: 32px; height: 32px; border-radius: 50%; - display: inline-flex; align-items: center; justify-content: center; - font-size: 18px; font-weight: 700; color: #07121c; -} -.stage-result.ok .sr-ico { background: var(--green); } -.stage-result.err .sr-ico { background: var(--red); } -.stage-result .sr-title { font-size: 16px; font-weight: 700; } -.stage-result.ok .sr-title { color: var(--green); } -.stage-result.err .sr-title { color: var(--red); } -.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; } -.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; } - -/* === LOG PANEL === */ -.log-panel { display: flex; flex-direction: column; min-height: 380px; } -.log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; } -.log-toolbar { - display: flex; align-items: center; justify-content: space-between; - padding: 10px 16px; border-bottom: 1px solid var(--border); - gap: 10px; flex-wrap: wrap; -} -.log-filters { display: flex; gap: 6px; } -.log-filter { - padding: 4px 10px; border-radius: 14px; font-size: 11px; font-weight: 500; - border: 1px solid var(--border); background: transparent; color: var(--text-dim); - cursor: pointer; transition: all .15s; -} -.log-filter.active { background: var(--accent); color: #07121c; border-color: var(--accent); } -.log-filter:hover:not(.active) { border-color: var(--text-dim); } - -.log-scroll { - flex: 1; overflow-y: auto; padding: 12px 16px; - font-family: var(--font-mono); font-size: 12px; line-height: 1.7; - max-height: 420px; -} -.log-scroll::-webkit-scrollbar { width: 5px; } -.log-scroll::-webkit-scrollbar-track { background: transparent; } -.log-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } - -.log-line { - display: flex; gap: 8px; padding: 3px 0; - border-bottom: 1px solid rgba(255,255,255,.02); -} -.log-line .lt { color: var(--text-label); flex-shrink: 0; } -.log-line .lv { - font-weight: 600; min-width: 20px; flex-shrink: 0; -} -.log-line .lv.E { color: var(--red); } -.log-line .lv.W { color: var(--amber); } -.log-line .lv.I { color: var(--accent); } -.log-line .lv.D { color: var(--text-label); } -.log-line .lg { color: #a78bfa; flex-shrink: 0; } -.log-line .lm { color: var(--text); word-break: break-all; } -/* BLE log compat */ -.log-line .t { color: var(--text-label); flex-shrink: 0; } -.log-line .tag { color: #a78bfa; flex-shrink: 0; } - -.log-foot { - display: flex; justify-content: space-between; align-items: center; - padding: 8px 16px; border-top: 1px solid var(--border); - font-size: 11px; color: var(--text-label); -} - -/* === CAN PANEL === */ -.can-grid { - display: grid; grid-template-columns: 70px 1fr 60px 1fr; gap: 10px; -} -.can-launch-card { - background: - radial-gradient(circle at top right, rgba(34,211,238,.18), transparent 28%), - linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%); - border: 1px solid rgba(34,211,238,.14); - border-radius: 14px; - padding: 20px; - box-shadow: - inset 0 0 0 1px rgba(255,255,255,.02), - 0 18px 40px rgba(0,0,0,.18); - position: relative; - overflow: hidden; -} -.can-launch-card::before { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 4px; - background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18)); -} -.can-launch-copy { - position: relative; - z-index: 1; -} -.can-launch-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; -} -.can-launch-copy h3 { - font-size: 22px; - line-height: 1.2; - margin: 6px 0 0; -} -.can-launch-desc { - color: var(--text-dim); - font-size: 13px; - line-height: 1.7; - max-width: 700px; -} -.can-launch-kicker { - color: #88ecff; - font-size: 10px; - letter-spacing: .18em; - text-transform: uppercase; - opacity: .92; -} -.can-launch-badge { - display: inline-flex; - align-items: center; - padding: 7px 12px; - border-radius: 999px; - background: rgba(34,211,238,.08); - border: 1px solid rgba(34,211,238,.18); - color: #a9f4ff; - font-size: 11px; - font-weight: 700; - letter-spacing: .12em; - text-transform: uppercase; - white-space: nowrap; -} -.can-launch-points { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 14px; -} -.can-launch-point { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 11px; - border-radius: 999px; - background: rgba(255,255,255,.03); - border: 1px solid rgba(255,255,255,.06); - color: #c6d7e8; - font-size: 12px; -} -.can-launch-point::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 10px rgba(34,211,238,.35); -} -.can-launch-actions { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(180px, .85fr); - gap: 10px; - margin-top: 18px; - position: relative; - z-index: 1; -} -.can-launch-primary, -.can-launch-secondary { - min-height: 42px; -} -.can-launch-primary { - box-shadow: 0 10px 24px rgba(34,211,238,.18); -} -.can-launch-secondary { - color: #d5e4f4; - border-color: rgba(255,255,255,.08); - background: rgba(255,255,255,.015); -} -.can-launch-secondary:hover:not(:disabled) { - background: rgba(255,255,255,.05); - border-color: rgba(34,211,238,.18); -} -.can-launch-foot { - display: flex; - gap: 10px; - align-items: flex-start; - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid rgba(255,255,255,.06); - color: #91a6bc; - font-size: 12px; - line-height: 1.7; - position: relative; - z-index: 1; -} -.can-launch-foot-label { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 40px; - padding: 4px 8px; - border-radius: 999px; - background: rgba(255,255,255,.04); - color: #d8e6f4; - font-size: 10px; - letter-spacing: .12em; - text-transform: uppercase; -} -.spi-grid { - display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px; -} - -/* === CAN WORKSPACE PAGE === */ -.can-page::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - background: - radial-gradient(circle at 10% 18%, rgba(34,211,238,.08), transparent 26%), - radial-gradient(circle at 85% 14%, rgba(245,158,11,.05), transparent 24%); -} -.can-container { - max-width: 1700px; -} -.can-header { - position: sticky; - top: 16px; - z-index: 5; - backdrop-filter: blur(14px); - background: rgba(15,22,31,.86); -} -.can-header-actions { - margin-left: auto; -} -.can-link-pill { - text-decoration: none; - color: var(--text); -} -.can-toolbar-card, -.can-table-card, -.can-editor-layout { - margin-top: 18px; -} -.can-toolbar-grid { - display: grid; - grid-template-columns: minmax(260px, 1.1fr) 180px minmax(220px, .9fr) minmax(260px, 1fr); - gap: 14px; - align-items: stretch; -} -.can-toolbar-block { - min-width: 0; -} -.can-toolbar-upload .upload-zone { - height: 100%; - min-height: 88px; - padding: 18px 14px; -} -.can-file-info-inline { - margin-top: 14px; -} -.can-file-name { - font-weight: 700; -} -.can-toolbar-foot { - display: grid; - grid-template-columns: 280px minmax(0, 1fr) minmax(0, 1fr); - gap: 12px; - margin-top: 14px; - align-items: start; -} -.can-table-card-body { - padding: 0; -} -.can-editor-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 18px; - align-items: start; -} -.can-sidepanel { - display: flex; - flex-direction: column; - gap: 18px; -} -.dbc-dropzone { - position: relative; - overflow: hidden; -} -.dbc-dropzone::after { - content: ""; - position: absolute; - inset: -40% auto auto -10%; - width: 180px; - height: 180px; - background: radial-gradient(circle, rgba(34,211,238,.18), transparent 68%); - pointer-events: none; -} -.message-list { - max-height: 390px; - overflow: auto; -} -.message-table { - /* 列宽随左栏宽度自适应压缩,不再强制水平滚动 */ - min-width: 0; - width: 100%; -} -.message-table-head, -.message-row { - display: grid; - grid-template-columns: - 36px /* 勾选 */ - 32px /* 展开 */ - 40px /* # */ - minmax(0, 1.6fr) /* 报文名称 */ - 96px /* 标识符 */ - 52px /* 通道 */ - 52px /* 类型 */ - 46px /* DLC */ - 50px /* 信号 */ - minmax(0, 1fr) /* 发送节点 */ - minmax(0, 2fr); /* 注释 */ - align-items: center; - min-width: 0; -} -.message-table-head { - position: sticky; - top: 0; - z-index: 2; - background: #182330; - border-bottom: 1px solid var(--border); -} -.message-row { - width: 100%; - border: 0; - background: transparent; - color: var(--text); - text-align: left; - cursor: pointer; - border-bottom: 1px solid rgba(255,255,255,.04); - transition: background .12s ease, border-color .12s ease; -} -.message-row:hover { - background: rgba(255,255,255,.03); -} -.message-row.active { - background: linear-gradient(90deg, rgba(34,211,238,.2), rgba(34,211,238,.07)); - box-shadow: inset 3px 0 0 0 var(--accent); -} -.message-col { - padding: 11px 12px; - font-size: 12px; - line-height: 1.45; - border-right: 1px solid rgba(255,255,255,.03); -} -.message-table-head .message-col { - font-size: 11px; - font-weight: 700; - letter-spacing: .08em; - color: #c8d6e7; - text-transform: uppercase; -} -.message-col:last-child { - border-right: 0; -} -.message-col-index, -.message-col-dlc, -.message-col-signal, -.message-col-channel, -.message-col-type { - text-align: center; -} -.message-col-id, -.history-item-hex, -.can-frame-hex, -.can-title, -.can-subtitle, -.byte-pill, -.history-item-main, -.history-item-foot, -.signal-table, -.signal-cell-meta, -.signal-cell-range { - font-family: var(--font-mono); -} -.message-col-id { - color: var(--accent); - font-size: 12px; -} -.can-empty-state { - display: grid; - place-items: center; - min-height: 300px; - text-align: center; - border: 1px dashed rgba(34,211,238,.2); - border-radius: var(--radius); - background: linear-gradient(180deg, rgba(26,36,50,.7), rgba(20,29,40,.96)); - padding: 28px; -} -.can-empty-icon { - width: 70px; - height: 70px; - border-radius: 18px; - display: grid; - place-items: center; - margin-bottom: 14px; - background: rgba(34,211,238,.12); - font-size: 32px; - box-shadow: inset 0 0 0 1px rgba(34,211,238,.16); -} -.can-empty-state h3 { - font-size: 22px; - margin-bottom: 8px; -} -.can-empty-state p { - max-width: 560px; - color: var(--text-dim); - line-height: 1.75; -} -.can-title { - font-size: 24px; - font-weight: 700; - letter-spacing: .02em; -} -.can-subtitle { - margin-top: 6px; - color: var(--text-dim); - font-size: 13px; -} -.can-selection-bar, -.history-item-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} -.can-selection-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; -} -.can-meta-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin-top: 16px; -} -.can-meta-card { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: 10px; - padding: 12px; -} -.can-meta-card span { - display: block; - font-size: 10px; - color: var(--text-label); - letter-spacing: .08em; - text-transform: uppercase; - margin-bottom: 4px; -} -.can-meta-card b { - font-size: 14px; -} -.can-comment-note { - margin-top: 16px; -} -.can-frame-panel { - background: linear-gradient(180deg, rgba(26,36,50,.95), rgba(13,19,27,.98)); - border: 1px solid rgba(34,211,238,.12); - border-radius: 16px; - padding: 18px; - box-shadow: inset 0 0 0 1px rgba(255,255,255,.02); -} -.can-frame-panel-compact { - padding: 16px; -} -.can-panel-kicker { - color: var(--text-label); - font-size: 10px; - letter-spacing: .12em; - text-transform: uppercase; -} -.can-panel-title { - margin-top: 6px; - font-size: 16px; - font-weight: 700; -} -.can-frame-hex { - margin-top: 14px; - padding: 16px 18px; - border-radius: 12px; - background: #081019; - color: #7ef3ff; - font-size: 18px; - line-height: 1.6; - word-break: break-all; - border: 1px solid rgba(34,211,238,.16); -} -.can-byte-strip { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; -} -.byte-pill { - min-width: 62px; - padding: 8px 10px; - border-radius: 10px; - background: rgba(34,211,238,.08); - border: 1px solid rgba(34,211,238,.16); - text-align: center; - font-size: 12px; -} -.byte-pill b { - display: block; - color: var(--text-label); - font-size: 10px; - margin-bottom: 3px; -} -.signal-table-host { - display: block; - margin-top: 18px; -} -.signal-table { - width: 100%; - border: 1px solid var(--border); - border-radius: 12px; - overflow: hidden; -} -.signal-table-head, -.signal-row { - display: grid; - grid-template-columns: 52px minmax(220px, 1.2fr) minmax(220px, 1fr) minmax(220px, 1fr) minmax(190px, .9fr) minmax(220px, 1fr); - align-items: stretch; -} -.signal-table-head { - background: #182330; - border-bottom: 1px solid var(--border); -} -.signal-table-body { - max-height: 520px; - overflow: auto; -} -.signal-row { - border-bottom: 1px solid rgba(255,255,255,.04); - background: rgba(255,255,255,.01); -} -.signal-row:nth-child(even) { - background: rgba(255,255,255,.025); -} -.signal-cell { - padding: 11px 12px; - border-right: 1px solid rgba(255,255,255,.03); - font-size: 12px; - line-height: 1.5; - min-width: 0; -} -.signal-cell:last-child { - border-right: 0; -} -.signal-table-head .signal-cell { - font-size: 11px; - font-weight: 700; - letter-spacing: .08em; - color: #c8d6e7; - text-transform: uppercase; -} -.signal-cell-index { - text-align: center; -} -.signal-cell-name b, -.signal-cell-range b, -.signal-cell-meta b { - display: block; - font-size: 12px; - color: var(--text); -} -.signal-cell-name small, -.signal-cell-range small, -.signal-cell-meta small, -.signal-cell-comment { - display: block; - margin-top: 4px; - color: var(--text-dim); - font-size: 11px; - line-height: 1.45; -} -.signal-input-compact { - min-width: 0; - padding: 8px 10px; - font-size: 12px; -} -.can-response { - margin-top: 14px; - padding: 12px 14px; - border-radius: 12px; - background: var(--bg-elevated); - border: 1px solid var(--border); - font-family: var(--font-mono); - font-size: 12px; - word-break: break-all; -} -.can-response.ok { - border-color: rgba(34,197,94,.25); - color: #c7f9d5; -} -.can-response.err { - border-color: rgba(239,68,68,.25); - color: #fecaca; -} -.can-response-label { - color: var(--text-label); - font-size: 11px; - margin-bottom: 6px; - font-family: var(--font); -} -.history-list { - display: flex; - flex-direction: column; - gap: 10px; - max-height: 620px; - overflow: auto; - padding-right: 4px; -} -.history-list-compact { - max-height: 360px; -} -.history-item { - padding: 12px 13px; - border-radius: 12px; - background: var(--bg-elevated); - border: 1px solid var(--border); -} -.history-item-time { - color: var(--text-label); - font-size: 11px; -} -.history-item-tag { - display: inline-flex; - align-items: center; - padding: 4px 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 700; - letter-spacing: .1em; -} -.history-item-tag.ok { - background: rgba(34,197,94,.12); - color: #b8f5cb; -} -.history-item-tag.err { - background: rgba(239,68,68,.12); - color: #fecaca; -} -.history-item-main { - margin-top: 10px; - font-size: 12px; - color: var(--text); -} -.history-item-hex { - margin-top: 8px; - font-size: 12px; - color: #7ef3ff; - word-break: break-all; -} -.history-item-foot { - margin-top: 8px; - font-size: 11px; - color: var(--text-dim); -} - -/* === TOAST === */ -.toast { - position: fixed; top: 45%; left: 50%; transform: translateX(-50%) translateY(30px); - z-index: 9999; - display: flex; align-items: center; gap: 10px; - padding: 14px 24px; border-radius: 10px; - font-size: 14px; font-weight: 600; color: #fff; pointer-events: none; - opacity: 0; - transition: all .35s cubic-bezier(.4,0,.2,1); - box-shadow: 0 8px 32px rgba(0,0,0,.35); -} -.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } -.toast.ok { background: #16a34a; } -.toast.err { background: #dc2626; } -.toast.warn { background: #d97706; } -.toast .toast-ico { font-size: 18px; } - -/* === GRID ROWS === */ -.row-2, .row-3, .row-4 { display: grid; gap: 10px; } -.row-2 { grid-template-columns: 1fr 1fr; } -.row-3 { grid-template-columns: repeat(3, 1fr); } -.row-4 { grid-template-columns: repeat(4, 1fr); } - -/* === SUB ACTIONS === */ -.sub-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; } - -/* === FOOTER NOTE === */ -.footer-note { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim); } - -/* === CODE === */ -code { color: #d3f9ff; font-family: var(--font-mono); } - -/* === FILE INFO BORDER === */ -.file-info { border: 1px solid var(--border); } - -/* === PILL LIVE === */ -.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; } - -/* ===== Header sub-row (设备名/设备ID) ===== */ -.header-sub { - display: flex; gap: 12px; - margin-top: 0; - padding: 0; -} -.pill-val { - font-weight: 600; - color: var(--text); -} - -/* ===== Custom Tooltip ===== */ -.pill[data-tooltip] { - position: relative; - cursor: help; -} -.pill[data-tooltip]:hover::after { - content: attr(data-tooltip); - position: absolute; - top: calc(100% + 10px); - left: 50%; - transform: translateX(-50%); - background: #1a2432; - color: #e2e8f0; - font-size: 11px; - font-weight: 400; - font-family: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace; - padding: 6px 12px; - border-radius: 6px; - border: 1px solid #2a3a4e; - box-shadow: 0 4px 16px rgba(0,0,0,.45); - white-space: nowrap; - z-index: 1000; - pointer-events: none; - animation: tooltipFadeIn .15s ease-out; -} -.pill[data-tooltip]:hover::before { - content: ''; - position: absolute; - top: calc(100% + 4px); - left: 50%; - transform: translateX(-50%); - border: 6px solid transparent; - border-bottom-color: #2a3a4e; - z-index: 1001; - pointer-events: none; - animation: tooltipFadeIn .15s ease-out; -} -@keyframes tooltipFadeIn { - from { opacity: 0; transform: translateX(-50%) translateY(3px); } - to { opacity: 1; transform: translateX(-50%) translateY(0); } -} - -/* === RESPONSIVE === */ -@media (max-width: 1100px) { - .main-layout { grid-template-columns: 1fr; } - .badge-grid { grid-template-columns: repeat(2, 1fr); } - .can-toolbar-grid, - .can-toolbar-foot, - .can-editor-layout { grid-template-columns: 1fr; } - .message-list { - max-height: 420px; - } - .can-meta-grid { grid-template-columns: 1fr 1fr; } -} -@media (max-width: 640px) { - .container { padding: 16px; } - .header-bar { flex-direction: column; align-items: flex-start; } - .badge-grid, .metric-grid { grid-template-columns: 1fr; } - .status-kv-grid, .status-task-meta { grid-template-columns: 1fr; } - .can-grid { grid-template-columns: 1fr 1fr; } - .btn-row { flex-direction: column; } - .can-meta-grid { grid-template-columns: 1fr; } - .can-launch-top, - .can-launch-actions, - .can-launch-foot { - grid-template-columns: 1fr; - flex-direction: column; - align-items: flex-start; - } - .can-selection-bar, - .can-selection-actions, - .history-item-top { - flex-direction: column; - align-items: flex-start; - } - .can-title { - font-size: 22px; - } - .can-frame-hex { - font-size: 15px; - } - .message-table, - .signal-table { - min-width: 980px; - } -} - - - -/* ===== ENHANCED ANIMATIONS (v20260625b) ===== */ - -/* Global keyframes */ -@keyframes fadeInUp { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} -@keyframes popIn { - 0% { opacity: 0; transform: scale(.85); } - 60% { opacity: 1; transform: scale(1.04); } - 100% { opacity: 1; transform: scale(1); } -} -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} -@keyframes ringPulse { - 0% { box-shadow: 0 0 0 0 rgba(34,211,238,.55); } - 70% { box-shadow: 0 0 0 10px rgba(34,211,238,0); } - 100% { box-shadow: 0 0 0 0 rgba(34,211,238,0); } -} -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 20% { transform: translateX(-4px); } - 40% { transform: translateX(4px); } - 60% { transform: translateX(-3px); } - 80% { transform: translateX(3px); } -} -@keyframes spin { - to { transform: rotate(360deg); } -} -@keyframes floatY { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-4px); } -} -@keyframes glowBreath { - 0%, 100% { box-shadow: 0 0 8px var(--accent-dim); } - 50% { box-shadow: 0 0 18px rgba(34,211,238,.45); } -} -@keyframes flowDash { - to { background-position: 200% 0; } -} - -/* Page entrance — header + cards staggered */ -.header-bar { animation: fadeInUp .45s ease both; } -.main-layout > div > .card { animation: fadeInUp .5s ease both; } -.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .05s; } -.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .12s; } -.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .19s; } -.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .26s; } -.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .08s; } -.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .15s; } -.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .22s; } -.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .29s; } - -/* Card hover — lift + glow */ -.card { - transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; -} -.card:hover { - transform: translateY(-2px); - border-color: rgba(34,211,238,.28); - box-shadow: 0 10px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(34,211,238,.08); -} - -/* Brand dot — gentle ring pulse */ -.header-brand .dot-mark { - animation: ringPulse 2.2s ease-out infinite, glowBreath 3.6s ease-in-out infinite; -} - -/* Pills — subtle hover */ -.pill { transition: transform .2s ease, border-color .2s ease, background .2s ease; } -.pill:hover { transform: translateY(-1px); border-color: rgba(34,211,238,.3); } - -/* Button — press + sheen on hover for primary */ -.btn { position: relative; overflow: hidden; } -.btn:active:not(:disabled) { transform: translateY(0) scale(.97); transition: transform .08s ease; } -.btn-primary::before, -.btn-success::before, -.btn-danger::before { - content: ""; - position: absolute; top: 0; left: -120%; - width: 60%; height: 100%; - background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent); - transform: skewX(-20deg); - transition: left .55s ease; - pointer-events: none; -} -.btn-primary:hover:not(:disabled)::before, -.btn-success:hover:not(:disabled)::before, -.btn-danger:hover:not(:disabled)::before { - left: 130%; -} - -/* Upload zone — drag pulse + icon float */ -.upload-zone { transition: border-color .2s, background .2s, transform .2s; } -.upload-zone:hover { transform: translateY(-1px); } -.upload-zone .upload-ico { - display: inline-block; - transition: transform .25s ease; -} -.upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; } -.upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; } - -/* File info appearance */ -.file-info.show { animation: fadeInUp .3s ease both; } - -/* Progress bar — striped flow on active */ -.progress-fill { - background-image: - linear-gradient(90deg, #22d3ee, #06b6d4), - repeating-linear-gradient(45deg, - rgba(255,255,255,.18) 0 10px, - rgba(255,255,255,0) 10px 20px); - background-size: 100% 100%, 200% 100%; - background-blend-mode: normal, overlay; - animation: flowDash 1.4s linear infinite; -} -.progress-fill.done { animation: none; } - -/* Phase step capsule — pop when becoming active/done */ -.pp-step.active, -.pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } - -/* Stage flow — connector grow + dot pop */ -.stage-node:not(:last-child)::after { - background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 50%, var(--border) 50%, var(--border) 100%); - background-size: 200% 100%; - background-position: 100% 0; - transition: background-position .5s ease; -} -.stage-node.done:not(:last-child)::after, -.stage-node.live:not(:last-child)::after { - background-position: 0% 0; -} -.stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; } -.stage-node.live .stage-dot { - animation: ringPulse 1.6s ease-out infinite; -} -.stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } -.stage-node.err .stage-dot { animation: shake .4s ease both; } - -/* Stage status bar — slide in when state changes */ -.stage-status-bar.active, -.stage-status-bar.ok, -.stage-status-bar.err { animation: fadeInUp .3s ease both; } - -/* Stage result panel */ -.stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; } - -/* Metric / badge values — number tick highlight */ -.metric .v, -.badge-stat .v { - transition: color .3s ease, transform .3s ease; -} -.metric:hover, -.badge-stat:hover { - border-color: rgba(34,211,238,.25); - transition: border-color .25s ease; -} - -/* Conn indicator — light ring pulse when warn */ -.conn-indicator { transition: background .25s ease, border-color .25s ease; } -.conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; } -.conn-indicator.ok .light { animation: glowBreath 3s ease-in-out infinite; } - -/* Heap / task bars — sheen */ -.heap-bar-track, -.status-task-bar { position: relative; overflow: hidden; } -.heap-bar-track::after, -.status-task-bar::after { - content: ""; - position: absolute; inset: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%); - transform: translateX(-100%); - animation: shimmer 2.6s linear infinite; - background-size: 200% 100%; - pointer-events: none; -} -.heap-bar-fill, -.status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); } - -/* Log lines — fresh entry fade-in */ -.log-line { animation: fadeIn .25s ease both; } - -/* Toast — pop and float */ -.toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; } - -/* Reduced motion — respect user preference */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: .001ms !important; - animation-iteration-count: 1 !important; - transition-duration: .001ms !important; - } -} - -/* === CAN 1Hz FEEDBACK === */ -.can-feedback-card { margin-top: 18px; } -.can-feedback-card .card-head { - display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; -} -.feedback-controls { display: flex; align-items: center; gap: 8px; } -.feedback-grid { - display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; -} -.feedback-channel { - background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); - border-radius: 10px; padding: 14px 16px; -} -.feedback-ch-head { - font-weight: 700; font-size: 15px; margin-bottom: 10px; - display: flex; align-items: center; gap: 8px; -} -.ch-dot { - width: 10px; height: 10px; border-radius: 50%; display: inline-block; -} -.ch-dot.ch0 { background: #22d3ee; } -.ch-dot.ch1 { background: #a78bfa; } -.ch-dot.ch2 { background: #f59e0b; } -.feedback-row { - display: flex; justify-content: space-between; padding: 3px 0; - font-size: 13px; border-bottom: 1px solid rgba(255,255,255,.04); -} -.feedback-row:last-child { border-bottom: none; } -.fb-label { color: var(--muted, #8892a4); } -.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; } -.fb-val.mono { font-family: monospace; } -.fb-val.ok { color: #34d399; } -.fb-val.warn { color: #f59e0b; } -.fb-val.err { color: #f87171; } -.fb-val.zero { color: #64748b; } -@media (max-width: 900px) { - .feedback-grid { grid-template-columns: 1fr; } -} - -/* === ID SNAPSHOT TABLE === */ -.idsnap-table-wrap { overflow-x: auto; } -.idsnap-table { - width: 100%; border-collapse: collapse; font-size: 13px; - font-variant-numeric: tabular-nums; -} -.idsnap-table th { - text-align: left; padding: 6px 10px; font-weight: 600; - border-bottom: 2px solid rgba(255,255,255,.12); color: var(--muted, #8892a4); - white-space: nowrap; -} -.idsnap-table td { - padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,.05); - white-space: nowrap; -} -.idsnap-table td.mono { font-family: monospace; } -.idsnap-table tr:hover td { background: rgba(255,255,255,.03); } -.idsnap-table .flag-on { color: #34d399; font-weight: 600; } -.idsnap-table .flag-off { color: #475569; } -.idsnap-data { font-family: monospace; max-width: 320px; overflow: hidden; text-overflow: ellipsis; } - -.fb-data-cell { - max-width: 200px; overflow: hidden; text-overflow: ellipsis; - font-size: 11px; word-break: break-all; line-height: 1.4; -} - -/* === CAN message row expand / batch additions === */ -.message-col-check, -.message-col-toggle { - text-align: center; - padding: 6px 4px; -} -.message-check { - width: 16px; - height: 16px; - cursor: pointer; - accent-color: var(--accent, #22d3ee); -} -.message-toggle { - width: 22px; - height: 22px; - border-radius: 6px; - border: 1px solid rgba(255,255,255,.12); - background: rgba(255,255,255,.04); - color: #c8d6e7; - cursor: pointer; - font-size: 11px; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - transition: background .12s ease, border-color .12s ease, color .12s ease; -} -.message-toggle:hover { - background: rgba(34,211,238,.12); - border-color: rgba(34,211,238,.4); - color: #e6fbff; -} -.message-toggle.expanded { - background: rgba(34,211,238,.18); - border-color: rgba(34,211,238,.55); - color: #e6fbff; -} - -.message-row-expand { - grid-column: 1 / -1; - background: rgba(255,255,255,.02); - border-top: 1px dashed rgba(255,255,255,.06); - border-bottom: 1px solid rgba(255,255,255,.04); - padding: 10px 14px 14px; -} -.message-row-expand[hidden] { - display: none; -} -.message-row-expand .signal-table-host { - margin-top: 0; -} -.message-row-expand .signal-table { - border-radius: 10px; -} -.message-row-expand .signal-table-body { - max-height: 360px; -} - -.can-batch-actions { - display: inline-flex; - align-items: center; - gap: 8px; - margin-left: auto; - flex-wrap: wrap; -} -.checkbox-inline { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-dim, #93a4bf); - cursor: pointer; - user-select: none; -} -.checkbox-inline input { - accent-color: var(--accent, #22d3ee); - cursor: pointer; -} -.card-head-actions { - display: inline-flex; - align-items: center; - gap: 8px; -} -.btn.btn-sm { - padding: 4px 10px; - font-size: 12px; -} -.btn[disabled] { - opacity: .55; - cursor: not-allowed; -} - -/* === 2026-06-30c: 报文列表与右侧操作栏并列布局调整 === */ -/* 报文列表移入 can-editor-layout 后,作为左列;侧栏作为右列;不再需要顶部独立信号编辑卡。 */ -.can-editor-layout > .can-table-card { - margin-top: 0; -} -.can-sidepanel .card-body { padding-top: 14px; } -.can-frame-actions { - display: flex; - gap: 8px; - margin-top: 10px; - flex-wrap: wrap; -} -.can-frame-actions .btn { flex: 1 1 auto; min-width: 120px; } -.can-frame-panel-compact .can-frame-hex { word-break: break-all; } - - -/* === 2026-06-30c: 报文列表压缩列宽 + 纵向滚动美化 === */ -.message-list { - /* 提高可视高度,并改用美化的纵向滚动条 */ - max-height: calc(100vh - 300px); - min-height: 420px; - overflow-x: hidden; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(34,211,238,.35) transparent; -} -.message-list::-webkit-scrollbar { width: 8px; } -.message-list::-webkit-scrollbar-track { - background: transparent; - margin: 4px 0; -} -.message-list::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18)); - border-radius: 6px; - border: 2px solid transparent; - background-clip: padding-box; -} -.message-list::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28)); - background-clip: padding-box; - border: 2px solid transparent; -} - -/* 单元格统一省略号 */ -.message-row .message-col, -.message-table-head .message-col { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -/* 紧凑列的内边距收一点,腾出空间给名称/注释 */ -.message-col-index, -.message-col-channel, -.message-col-type, -.message-col-dlc, -.message-col-signal { - padding-left: 6px; - padding-right: 6px; -} -.message-col-check, -.message-col-toggle { - padding-left: 4px; - padding-right: 4px; -} -/* 注释列允许浅色降权显示 */ -.message-row .message-col-comment { - color: var(--muted); -} - -/* === 2026-06-30c: 窄屏报文表降级(按位置缩为 0 宽,保持 grid 对齐) === */ -/* 列序:check(1) toggle(2) #(3) name(4) id(5) channel(6) type(7) dlc(8) signal(9) node(10) comment(11) */ -@media (max-width: 1280px) { - .message-table-head, - .message-row { - grid-template-columns: - 36px 32px 40px - minmax(0, 1.6fr) /* name */ - 90px /* id */ - 52px /* channel */ - 0 /* type 隐藏 */ - 46px /* dlc */ - 0 /* signal 隐藏 */ - minmax(0, 0.9fr) /* node */ - minmax(0, 1.8fr); /* comment */ - } - .message-col-type, - .message-col-signal { padding: 0; border: 0; overflow: hidden; } -} -@media (max-width: 1024px) { - .message-table-head, - .message-row { - grid-template-columns: - 36px 32px 36px - minmax(0, 1.6fr) - 86px - 0 - 0 - 46px - 0 - 0 - minmax(0, 1.8fr); - } - .message-col-channel, - .message-col-node { padding: 0; border: 0; overflow: hidden; } -} - - -/* 行内信号编辑展开区允许局部横向滚动(信号字段较多时),不影响外层报文表 */ -.message-row-expand { - overflow-x: auto; -} -.message-row-expand::-webkit-scrollbar { height: 6px; } -.message-row-expand::-webkit-scrollbar-thumb { - background: rgba(34,211,238,.35); - border-radius: 4px; -} - - -/* === 2026-06-30e: 工作台设置 + 最近发送 双列布局 === */ -.can-top-layout { - margin-top: 18px; - display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 18px; - align-items: stretch; -} -/* 工作台卡和历史卡都放进 grid 后不需要单独的顶部外边距 */ -.can-top-layout > .can-toolbar-card, -.can-top-layout > .can-history-card { - margin-top: 0; -} -/* 历史卡固定高度,仅容纳约 3 条历史,超出滚动 */ -.can-history-card { align-self: start; } -.can-history-card .card-body { - display: flex; - flex-direction: column; -} -.can-history-card .history-list { - /* 约 3 条历史的高度:(每条 ~96px) * 3 + gap 10px * 2 ≈ 308px */ - max-height: 312px; - overflow-y: auto; - padding-right: 4px; - scrollbar-width: thin; - scrollbar-color: rgba(34,211,238,.35) transparent; -} -.can-history-card .history-list::-webkit-scrollbar { width: 8px; } -.can-history-card .history-list::-webkit-scrollbar-track { background: transparent; margin: 4px 0; } -.can-history-card .history-list::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18)); - border-radius: 6px; - border: 2px solid transparent; - background-clip: padding-box; -} -.can-history-card .history-list::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28)); - background-clip: padding-box; -} - -/* 窄屏堆叠 */ -@media (max-width: 1100px) { - .can-top-layout { grid-template-columns: 1fr; } - .can-history-card .history-list { max-height: 312px; } -} - - -/* === 2026-07-01a: 发送节点过滤下拉 === */ -.can-node-filter { - position: relative; - display: inline-flex; -} -.can-node-filter-btn { - display: inline-flex; - align-items: center; - gap: 6px; - white-space: nowrap; -} -.can-node-filter-btn.active { - border-color: rgba(34,211,238,.65); - color: var(--accent); - background: rgba(34,211,238,.08); -} -.can-node-filter-caret { - font-size: 10px; - opacity: .75; -} -.can-node-filter-panel { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 30; - min-width: 260px; - max-width: 320px; - background: #182330; - border: 1px solid var(--border); - border-radius: 12px; - box-shadow: 0 18px 38px rgba(0,0,0,.45); - padding: 10px; -} -.can-node-filter-panel[hidden] { display: none; } -.can-node-filter-head { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 8px; -} -.can-node-filter-search { - width: 100%; - padding: 6px 8px; - font-size: 12px; -} -.can-node-filter-actions { - display: flex; - gap: 6px; - justify-content: flex-end; -} -.can-node-filter-list { - max-height: 260px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 2px; - scrollbar-width: thin; - scrollbar-color: rgba(34,211,238,.35) transparent; -} -.can-node-filter-list::-webkit-scrollbar { width: 8px; } -.can-node-filter-list::-webkit-scrollbar-thumb { - background: rgba(34,211,238,.35); - border-radius: 6px; -} -.can-node-filter-item { - display: grid; - grid-template-columns: 18px 1fr auto; - align-items: center; - gap: 8px; - padding: 6px 8px; - border-radius: 8px; - cursor: pointer; - font-size: 12px; - color: var(--text); - transition: background .12s; -} -.can-node-filter-item:hover { - background: rgba(255,255,255,.04); -} -.can-node-filter-item input[type="checkbox"] { - margin: 0; -} -.can-node-filter-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); -} -.can-node-filter-count { - font-size: 11px; - color: var(--muted); - background: rgba(255,255,255,.05); - border-radius: 999px; - padding: 1px 8px; - min-width: 26px; - text-align: center; -}