feat(esp32): 添加多电机OTA中继功能并优化调试日志系统

- 新增多电机状态、版本和OTA中继监控模块(monitor/motor_state.cpp,
  monitor/motor_version.cpp, monitor/motor_ota_relay.cpp)
- 在CAN反馈任务中添加0xC5/0xC6/0xD2类型消息处理,支持多电机状态推送
- 重构调试日志系统,将一次性JSON分配改为512B栈缓冲流式发送,降低内存峰值
- 调试UART波特率从115200提升至30000
- 修改版本管理方式,从自动生version.h改为手动维护
- 更新VSCode设置以支持ESP-IDF开发环境配置
- 在OTA协调器中添加电机OTA互斥机制,防止并发冲突
- 完善中继状态机异常处理,添加远程取消通知机制
This commit is contained in:
小龙虾
2026-07-26 20:40:16 +08:00
parent 770ae14156
commit 2138a572ee
47 changed files with 6469 additions and 799 deletions
+61 -7
View File
@@ -31,6 +31,43 @@ function formatTime(date) {
/** 简单延时 helper,用于批次间间隔 */
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// ==================== Click-to-Copy ====================
/** 将文本写入剪贴板并在元素上闪烁 copied 提示 */
function copyCell(text, target) {
if (!text) return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
/** 在 historyList 上绑定一次事件代理 */
var _historyCopyBound = false;
function ensureHistoryCopyHandler() {
if (_historyCopyBound || !el.historyList) return;
_historyCopyBound = true;
el.historyList.addEventListener('click', function (e) {
var target = e.target.closest('[data-copy]');
if (!target) return;
copyCell(target.getAttribute('data-copy'), target);
});
}
// ==================== 发送错误码描述 ====================
var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)',
@@ -67,6 +104,17 @@ function buildSendResponseText(result) {
return '发送失败: ' + desc + ' (code=' + result.code + ')';
}
/**
* @description 历史列表用的短文案:红色标签已表明失败,去掉「发送失败:」前缀、
* gd32_err 细分与 (code=) 尾巴,只保留核心原因,避免挤占空间(完整原因见 hover/复制)。
*/
function buildSendResponseShort(result) {
if (result.status === 'ok') {
return result.recv ? ('0x' + result.recv) : 'OK';
}
return CAN_SEND_ERR_DESC[String(result.code)] || ('code=' + result.code);
}
// ==================== HTTP 请求(唯一入口 /can/send_batch)====================
/**
* @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。
@@ -141,7 +189,9 @@ function makeHistoryEntry(opts) {
idHex: opts.idHex,
dlc: opts.dlc,
dataHex: opts.dataHex || '',
resultText: opts.resultText
resultText: opts.resultText,
/* 列表显示用短文案;缺省回退到完整文案,兼容旧持久化数据 */
resultShort: opts.resultShort || opts.resultText
};
}
@@ -161,6 +211,7 @@ function addHistoryEntry(entry, opts) {
/** @description 渲染发送历史列表(单行紧凑:状态|时间|CH·ID·DLC|HEX|结果)。 */
export function renderHistory() {
if (!el.historyList) return;
ensureHistoryCopyHandler();
if (!state.history.length) {
el.historyList.innerHTML = '<div class="status-empty">尚未发送任何 CAN 帧。</div>';
return;
@@ -171,9 +222,9 @@ export function renderHistory() {
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + escapeHtml(item.mode) + '</span>' +
'<span class="history-item-time">' + escapeHtml(item.time) + '</span>' +
'<span class="history-item-main" title="' + escapeHtml(meta) + '">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex" title="' + escapeHtml(item.dataHex || '') + '">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot" title="' + escapeHtml(item.resultText || '') + '">' + escapeHtml(item.resultText) + '</span>' +
'<span class="history-item-main copyable" data-copy="' + escapeHtml(meta) + '" title="点击复制">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex copyable" data-copy="' + escapeHtml(item.dataHex || '') + '" title="点击复制">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot copyable" data-copy="' + escapeHtml(item.resultText || '') + '" title="' + escapeHtml(item.resultText || '点击复制') + '">' + escapeHtml(item.resultShort || item.resultText) + '</span>' +
'</div>';
}).join('');
}
@@ -254,7 +305,8 @@ export async function sendCheckedMessages() {
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: result.status === 'ok', channel: channel,
idHex: item.idHex, dlc: item.dlc, dataHex: item.dataHex,
resultText: item.msg.name + ' · ' + buildSendResponseText(result)
resultText: item.msg.name + ' · ' + buildSendResponseText(result),
resultShort: item.msg.name + ' · ' + buildSendResponseShort(result)
}), silent);
}
} catch (err) {
@@ -311,7 +363,8 @@ export async function sendSelectedMessage() {
mode: 'DBC', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dataHex: dataHex,
resultText: buildSendResponseText(result)
resultText: buildSendResponseText(result),
resultShort: buildSendResponseShort(result)
}));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
@@ -391,7 +444,8 @@ export async function sendManualFrame() {
mode: 'RAW', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc, dataHex: dataHex,
resultText: buildSendResponseText(result)
resultText: buildSendResponseText(result),
resultShort: buildSendResponseShort(result)
}));
showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
+33
View File
@@ -8,6 +8,30 @@ var state = { fbTimer: null, fbInFlight: false };
var el = {};
// ── Click-to-Copy (feedback 值单元格) ──────────────────────
function copyFbVal(text, target) {
if (!text || text === '--') return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
var ids = [
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
@@ -293,6 +317,15 @@ export function initCanMonitor() {
if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); });
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
/* feedback 值 click-to-copy:在 grid 容器上事件代理 */
document.querySelectorAll('.feedback-grid').forEach(function (grid) {
grid.addEventListener('click', function (e) {
var target = e.target.closest('.fb-val');
if (!target) return;
copyFbVal(target.textContent.trim(), target);
});
});
/* 通道卡:默认折叠详情,点标题展开/收起 */
document.querySelectorAll('.feedback-channel .feedback-ch-head').forEach(function (head) {
var toggle = function (e) {
+216 -73
View File
@@ -19,21 +19,55 @@ import {
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
} from './heap-low-history.js';
// ==================== 渲染缓存(性能) ====================
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
// CPU/任务栈列表 + 无条件写几十个节点,引发周期性样式重算/重排/重绘掉帧。
// 这里统一三条策略:静态元素只查一次、文本/宽度/类名写前比较、列表增量更新。
var elCache = {}; // id -> element(页面静态节点,仅首次 getElementById)
function getEl(id) {
var el = elCache[id];
if (el === undefined || el === null) {
el = document.getElementById(id);
if (el) elCache[id] = el; // 未找到不缓存,等节点出现后再查
}
return el || null;
}
/** 写前比较的 textContent 更新:值未变则不触碰 DOM,避免无谓样式失效。 */
function setTextById(id, value) {
var el = getEl(id);
if (!el) return;
var v = String(value);
if (el.textContent !== v) el.textContent = v;
}
function setClassIfChanged(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setTextIfChanged(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
/** 写前比较的宽度更新:用元素上的 _lastW 缓存避免读回 style 字符串。 */
function setWidthIfChanged(el, widthStr) {
if (el && el._lastW !== widthStr) {
el._lastW = widthStr;
el.style.width = widthStr;
}
}
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
var pc = getEl('pillConn');
var pct = getEl('pillConnTxt');
if (pc) {
pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = label;
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
if (pct) setTextIfChanged(pct, label);
}
// 系统状态卡徽章
var bc = document.getElementById('bConn');
var bc = getEl('bConn');
if (bc) {
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
setTextIfChanged(bc, connected ? '在线' : '离线');
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
}
}
export function updateHealthUi(healthCode, freeHeap) {
@@ -41,13 +75,12 @@ export function updateHealthUi(healthCode, freeHeap) {
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
/* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
if (freeHeap !== undefined) {
var mHeapEl = document.getElementById('mHeap');
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
setTextById('mHeap', formatBytes(freeHeap));
}
var mh = document.getElementById('mHealth');
var mh = getEl('mHealth');
if (mh) {
mh.textContent = health;
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
setTextIfChanged(mh, health);
setClassIfChanged(mh, 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'));
}
ensureHealthBootToggleBound();
}
@@ -86,11 +119,22 @@ function ensureHealthBootToggleBound() {
}
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
/** 启动自检渲染签名:boot 数据启动后不变,签名相同就跳过整段 DOM 重建。 */
var bootUiSig = null;
function updateBootInitUi(data) {
var boot = data && data.boot ? data.boot : null;
var resultEl = document.getElementById('mBootResult');
var failedEl = document.getElementById('mBootFailed');
var listEl = document.getElementById('bootModuleList');
/* 签名 = 影响渲染的全部字段;每秒轮询里 boot 几乎永远不变,直接短路 */
var sig = boot
? [boot.mask, boot.critical_ok, boot.all_ok, boot.summary, boot.failed,
Array.isArray(boot.modules) ? boot.modules.length : 0].join('|')
: 'none';
if (sig === bootUiSig) return;
bootUiSig = sig;
var resultEl = getEl('mBootResult');
var failedEl = getEl('mBootFailed');
var listEl = getEl('bootModuleList');
if (!boot) {
if (resultEl) {
resultEl.textContent = '--';
@@ -146,23 +190,128 @@ function updateBootInitUi(data) {
ensureHealthBootToggleBound();
}
/* ==================== 状态任务列表:增量渲染 ====================
* innerHTML 全量重建会重解析 HTML、重建每条 .status-task-bar(其 ::after 微光
* 动画随之重启、合成层反复创建销毁),是主页每秒掉帧的主要来源之一。
* 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅移动节点。
* 行结构统一:head(名称/占比) + bar + 3 项 meta。
* items: [{ key, rowCls, pctText, width, fillCls, metaLabels[3], metas[3] }]
*/
function buildStatusTaskRowHtml(it) {
return '' +
'<div class="' + it.rowCls + '">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(it.key) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(it.pctText) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="' + it.fillCls + '" style="width:' + it.width + '"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>' + it.metaLabels[0] + '</span><b>' + escapeHtml(it.metas[0]) + '</b></div>' +
'<div><span>' + it.metaLabels[1] + '</span><b>' + escapeHtml(it.metas[1]) + '</b></div>' +
'<div><span>' + it.metaLabels[2] + '</span><b>' + escapeHtml(it.metas[2]) + '</b></div>' +
'</div>' +
'</div>';
}
function updateStatusTaskRow(row, it) {
var refs = row._refs;
if (!refs) {
refs = row._refs = {
ratio: row.querySelector('.status-task-ratio'),
fill: row.querySelector('.status-task-bar > div'),
metas: row.querySelectorAll('.status-task-meta b')
};
}
setTextIfChanged(refs.ratio, it.pctText);
setClassIfChanged(refs.fill, it.fillCls);
setWidthIfChanged(refs.fill, it.width);
setTextIfChanged(refs.metas[0], it.metas[0]);
setTextIfChanged(refs.metas[1], it.metas[1]);
setTextIfChanged(refs.metas[2], it.metas[2]);
}
/** 空态提示:内容未变不重写,并清掉行缓存让下次有数据时重建。 */
function setListEmpty(listEl, html) {
var cache = listEl._rowCache;
if (cache) { cache.rows = {}; cache.keys = null; }
if (listEl._emptyHtml !== html) {
listEl._emptyHtml = html;
listEl.innerHTML = html;
}
}
function renderStatusTaskList(listEl, items) {
listEl._emptyHtml = null;
var cache = listEl._rowCache || (listEl._rowCache = { keys: null, rows: {} });
var keys = [];
var seen = {};
var hasDup = false;
for (var i = 0; i < items.length; i++) {
var k = items[i].key;
if (seen[k]) hasDup = true;
seen[k] = true;
keys.push(k);
}
var keyStr = keys.join('\u0001');
var sameSet = !hasDup && keys.length === Object.keys(cache.rows).length &&
keys.every(function (k2) { return !!cache.rows[k2]; });
if (!sameSet) {
/* 集合变化(或同名任务无法索引):一次性重建并缓存行引用 */
listEl.innerHTML = items.map(buildStatusTaskRowHtml).join('');
cache.rows = {};
cache.keys = null;
if (!hasDup) {
for (var c = 0; c < listEl.children.length; c++) cache.rows[keys[c]] = listEl.children[c];
cache.keys = keyStr;
}
return;
}
if (keyStr !== cache.keys) {
/* 集合相同仅排序变化:按新顺序移动既有节点,不重建、不解析 HTML */
for (var m = 0; m < keys.length; m++) listEl.appendChild(cache.rows[keys[m]]);
cache.keys = keyStr;
}
for (var u = 0; u < items.length; u++) updateStatusTaskRow(cache.rows[keys[u]], items[u]);
}
/* 原始 JSON 折叠面板:收起时跳过 JSON.stringify + 大段文本重排;
* 展开瞬间用 window.latestSystemStatus 补渲染,行为与之前一致。 */
var statusJsonBound = false;
function renderStatusJsonIfVisible(data) {
var statusJson = getEl('statusJson');
if (!statusJson) return;
var fold = getEl('statusJsonFold');
if (!statusJsonBound && fold) {
statusJsonBound = true;
var head = fold.querySelector('.status-subfold-head');
var renderLatest = function () {
/* 内联 onclick/onkeydown 已先切换 collapsed,这里只补展开后的内容 */
if (!fold.classList.contains('collapsed') && window.latestSystemStatus) {
statusJson.textContent = JSON.stringify(window.latestSystemStatus, null, 2);
}
};
if (head) {
head.addEventListener('click', renderLatest);
head.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') renderLatest();
});
}
}
if (fold && fold.classList.contains('collapsed')) return;
statusJson.textContent = JSON.stringify(data, null, 2);
}
export function updateHeapAndTaskStatusUi(data) {
if (!data) return;
var formatPercent = window.formatPercent;
var ota = data.ota || {};
var setText = function (id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value;
};
var setText = function (id, value) { setTextById(id, value); };
updateBootInitUi(data);
// ---- 堆概况:已用 / 总计 + 占用条 ----
function setFill(id, pct) {
var el = document.getElementById(id);
var el = getEl(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' : '');
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
setClassIfChanged(el, 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : ''));
}
function setBar(usageId, fillId, pctId, free, total) {
if (free === undefined || total === undefined) {
@@ -176,15 +325,15 @@ export function updateHeapAndTaskStatusUi(data) {
setText(pctId, pct.toFixed(1) + '%');
}
function setCpuFill(pct) {
var el = document.getElementById('cpuUsageFill');
var el = getEl('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' : '');
setWidthIfChanged(el, safePct + '%');
setClassIfChanged(el, 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''));
}
function updateCpuUsageUi(cpuData) {
cpuData = cpuData || {};
var taskList = document.getElementById('cpuTaskList');
var taskList = getEl('cpuTaskList');
if (!cpuData.available) {
setText('cpuUsageText', '未启用 FreeRTOS 统计');
setText('cpuUsagePct', '--');
@@ -192,7 +341,7 @@ export function updateHeapAndTaskStatusUi(data) {
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
if (taskList) setListEmpty(taskList, '<div class="status-empty">固件未启用 CPU runtime stats</div>');
return;
}
if (!cpuData.valid) {
@@ -202,15 +351,19 @@ export function updateHeapAndTaskStatusUi(data) {
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
if (taskList) setListEmpty(taskList, '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>');
return;
}
var usagePct = Number(cpuData.usage_pct) || 0;
var idlePct = Number(cpuData.idle_pct) || 0;
var sampleMs = Number(cpuData.sample_ms) || 0;
var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : [];
/* 按占用率降序;占比相同再按任务号,保证排序稳定。
* renderStatusTaskList 在集合不变时只 appendChild 调序,不重建 DOM。 */
tasks.sort(function (a, b) {
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
var d = (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
if (d !== 0) return d;
return (Number(a.task_number) || 0) - (Number(b.task_number) || 0);
});
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
@@ -221,28 +374,24 @@ export function updateHeapAndTaskStatusUi(data) {
setCpuFill(usagePct);
if (taskList) {
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动 */
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动。
* 增量更新:集合不变时只改数字/宽度,避免每秒 innerHTML 全量重建 */
if (!tasks.length) {
taskList.innerHTML = '<div class="status-empty">当前采样没有任务增量</div>';
setListEmpty(taskList, '<div class="status-empty">当前采样没有任务增量</div>');
} else {
taskList.innerHTML = tasks.map(function (task) {
renderStatusTaskList(taskList, tasks.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 '' +
'<div class="status-task cpu-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(formatPercent(pct)) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
'<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
return {
key: String(task.name || 'task'),
rowCls: 'status-task cpu-task',
pctText: formatPercent(pct),
width: Math.max(0, Math.min(100, pct)) + '%',
fillCls: 'status-task-bar-fill' + (pct >= 85 ? ' err' : pct >= 70 ? ' warn' : ''),
metaLabels: ['任务号', '增量', '占比'],
metas: [String(task.task_number), String(delta), formatPercent(pct)]
};
}));
}
}
}
@@ -321,8 +470,8 @@ export function updateHeapAndTaskStatusUi(data) {
setText('mRtcFree', '--');
setText('mAllocBlocks', '--');
['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.width = '0%';
var el = getEl(id);
if (el) setWidthIfChanged(el, '0%');
});
return;
}
@@ -365,10 +514,10 @@ export function updateHeapAndTaskStatusUi(data) {
? formatBytes(Number(mem.task_stack_total)) : '--');
function setSeg(id, bytes) {
var el = document.getElementById(id);
var el = getEl(id);
if (!el) return;
var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
}
setSeg('memSegIram', iram);
setSeg('memSegData', dataSz);
@@ -410,12 +559,12 @@ export function updateHeapAndTaskStatusUi(data) {
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state;
setText('sStatusUpdated', formatTime(new Date()));
var taskList = document.getElementById('taskStackList');
var taskList = getEl('taskStackList');
if (taskList) {
var taskStack = data.task_stack || {};
var names = Object.keys(taskStack);
if (!names.length) {
taskList.innerHTML = '<div class="status-empty">当前状态未包含任务栈信息</div>';
setListEmpty(taskList, '<div class="status-empty">当前状态未包含任务栈信息</div>');
} else {
/* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */
names.sort(function (a, b) {
@@ -427,34 +576,28 @@ export function updateHeapAndTaskStatusUi(data) {
var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0;
return pb - pa;
});
taskList.innerHTML = names.map(function (name) {
/* 增量更新:任务集合稳定时仅改变化的数值,不重建 DOM */
renderStatusTaskList(taskList, 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 '' +
'<div class="status-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
return {
key: name,
rowCls: 'status-task',
pctText: formatPercent(usagePct),
width: Math.max(0, Math.min(100, usagePct)) + '%',
fillCls: 'status-task-bar-fill' + (usagePct >= 85 ? ' err' : usagePct >= 70 ? ' warn' : ''),
metaLabels: ['栈大小', '剩余水位', '峰值占用'],
metas: [formatBytes(stackSize), formatBytes(watermark), formatBytes(peakUsed)]
};
}));
}
}
var statusJson = document.getElementById('statusJson');
if (statusJson) {
statusJson.textContent = JSON.stringify(data, null, 2);
}
/* 原始 JSON:折叠时跳过序列化与大段文本写入(展开瞬间自动补渲染) */
renderStatusJsonIfVisible(data);
}
/** 从设备 IP 推导网关 IP(取前三段 + .1) */
function getGatewayIp() {
+14 -2
View File
@@ -16,6 +16,18 @@ var autoScroll = true;
// 缓存 DOM 引用,避免高频日志下重复 getElementById
var scrollEl = document.getElementById('logScroll');
var countEl = document.getElementById('logCount');
var scrollPending = false;
/** rAF 批量自动滚动:/logs 一轮可能追加几十行,逐行读 scrollHeight 会每行
* 触发一次强制布局;合并到下一帧只滚一次,行为不变。 */
function scheduleAutoScroll() {
if (!autoScroll || !scrollEl || scrollPending) return;
scrollPending = true;
requestAnimationFrame(function () {
scrollPending = false;
if (autoScroll && scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
});
}
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
@@ -65,7 +77,7 @@ export function addLog(level, tag, message) {
entry.div = div;
if (scrollEl) {
scrollEl.appendChild(div);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
scheduleAutoScroll();
}
syncCount();
}
@@ -92,7 +104,7 @@ export function setLogFilter(level, btn) {
});
scrollEl.innerHTML = '';
scrollEl.appendChild(frag);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
scheduleAutoScroll();
}
export function toggleAutoScroll() {
autoScroll = !autoScroll;
+8 -1
View File
@@ -397,7 +397,14 @@ export function setupDropZone(zoneId, chip) {
zone.addEventListener('drop', function (e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var file = e.dataTransfer.files[0];
if (file) processFile(file, chip);
if (!file) return;
/* 拖拽拿不到 FSAPI 句柄:必须清掉旧句柄,否则热重载会把上一个文件读回来
覆盖本次拖入的文件(表现为"刷错固件"),并同步删除 IDB 持久化句柄 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
processFile(file, chip);
});
}
+6 -3
View File
@@ -171,12 +171,15 @@ export function cleanupAfterOtaFailure(reason) {
setActivePhase(null);
window.currentOtaTarget = null;
// 4. 恢复上传按钮(两枚都解禁并恢复原文案,避免 chip 判错或一侧残留"上传中")
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
// 4. 恢复上传按钮(两枚都解禁并恢复各自原文案,避免 chip 判错或一侧残留"上传中")
// ★ 按钮文案是固定的,绝不能用共享的 uploadBtnOrigText 回写两枚,否则最后
// 刷 GD32 后 ESP32 按钮会被错写成 "GD32 OTA"(反之亦然)。
var uploadBtnLabels = { uploadEsp32Btn: '上传并升级 ESP32', uploadGd32Btn: 'GD32 OTA' };
Object.keys(uploadBtnLabels).forEach(function (btnId) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = false;
if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
btn.textContent = uploadBtnLabels[btnId];
});
window.activeUploadChip = null;
window.otaSessionTarget = null;
+37 -24
View File
@@ -106,6 +106,18 @@ function setDisplayedRelayState(rs) {
updateStagesFromRelayState(rs);
}
/* 写前比较小工具:每秒轮询里大部分仪表盘字段不变,
* 无条件写 textContent/className/title 会白白触发样式失效。 */
function setNodeText(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
function setNodeClass(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setNodeTitle(el, title) {
if (el && el.title !== title) el.title = title;
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
* @param {object} data /system/status JSON
@@ -122,28 +134,29 @@ function applyDashboardUi(data, ota, wifi) {
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) {
pillVerEl.textContent = 'v' + data.version;
pillVerEl.dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
setNodeText(pillVerEl, 'v' + data.version);
var verTip = data.build_time ? ('编译: ' + data.build_time) : '';
if (pillVerEl.dataset.tooltip !== verTip) pillVerEl.dataset.tooltip = verTip;
}
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';
setNodeText(mGd32App, ota.gd32_app);
setNodeClass(mGd32App, 'v ok');
} else {
mGd32App.textContent = ota.gd32_app_alive ? '未知' : '离线';
mGd32App.className = 'v';
setNodeText(mGd32App, ota.gd32_app_alive ? '未知' : '离线');
setNodeClass(mGd32App, 'v');
}
}
if (mGd32Boot) {
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
mGd32Boot.textContent = ota.gd32_boot;
mGd32Boot.className = 'v ok';
setNodeText(mGd32Boot, ota.gd32_boot);
setNodeClass(mGd32Boot, 'v ok');
} else {
/* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
mGd32Boot.textContent = '未知';
mGd32Boot.className = 'v';
setNodeText(mGd32Boot, '未知');
setNodeClass(mGd32Boot, 'v');
}
}
}
@@ -153,19 +166,19 @@ function applyDashboardUi(data, ota, wifi) {
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
mTempEl.textContent = tC.toFixed(1) + '\u00B0C';
setNodeText(mTempEl, tC.toFixed(1) + '\u00B0C');
if (tC >= 100) {
mTempEl.className = 'v err';
setNodeClass(mTempEl, 'v err');
} else if (tC >= 80) {
mTempEl.className = 'v warn';
setNodeClass(mTempEl, 'v warn');
} else {
mTempEl.className = 'v ok';
setNodeClass(mTempEl, 'v ok');
}
mTempEl.title = 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)';
setNodeTitle(mTempEl, 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)');
} else {
mTempEl.textContent = '--';
mTempEl.className = 'v';
mTempEl.title = '温度无效或未采样';
setNodeText(mTempEl, '--');
setNodeClass(mTempEl, 'v');
setNodeTitle(mTempEl, '温度无效或未采样');
}
}
/* GD32 ADC:enabled 来自 HELLO_ACK 功能位(GD32 编译开关),ESP32 无本地开关 */
@@ -177,9 +190,9 @@ function applyDashboardUi(data, ota, wifi) {
function setMetricUi(el, text, cls, title) {
if (!el) return;
el.textContent = text;
el.className = cls || 'v';
if (title) el.title = title;
setNodeText(el, text);
setNodeClass(el, cls || 'v');
if (title) setNodeTitle(el, title);
}
if (mGd32TempEl || mVccEl || mVddaEl) {
@@ -233,12 +246,12 @@ function applyDashboardUi(data, ota, wifi) {
}
}
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
if (uptimeEl) setNodeText(uptimeEl, '\u23F1 ' + formatUptime(data.uptime_sec));
var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
if (pillIPEl) setNodeText(pillIPEl, wifi.ip || '--');
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
if (pillRSSIEl) setNodeText(pillRSSIEl, rssi);
}
/**
+12
View File
@@ -164,11 +164,23 @@ export async function startUpload(chip) {
if (chip === 'esp32') window.esp32File = freshFile;
else window.gd32File = freshFile;
file = freshFile;
/* .bin 直读分支此前无任何日志,热重载是否生效不可观察,补一条 */
addLog('I', '文件', '热重载: 已重新读取 ' + freshFile.name +
' (' + formatBytes(freshFile.size) + ')');
}
} catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
}
} else {
/* 无 FSAPI 句柄(拖拽/传统方式选择):无法读盘,只能用缓存。
GD32 缓存是内存中转换出的 Blob,arrayBuffer 校验永远通过,
若不显式告知,本地重新编译后点上传会静默刷入旧固件 */
var cachedAt = file.lastModified
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
: '未知时间';
addLog('W', '文件', '热重载不可用(无 FSAPI 句柄):本次上传缓存文件 ' + file.name +
'(磁盘修改于 ' + cachedAt + ')。如需自动读取最新编译产物,请点击上传区重新选择');
showToast('热重载不可用,上传的是缓存文件', 'warning');
try {
await file.arrayBuffer();
} catch (e) {