- 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN, 和ble_ota_host.cpp处理GATT/GAP/广播/主机功能 - 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10% (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区 - 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余 refactor(can): 改进CAN反馈监听机制 - 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式, 使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求 - 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送" - 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG
425 lines
17 KiB
JavaScript
425 lines
17 KiB
JavaScript
/**
|
||
* connection.js - 连接管理 + 系统状态显示
|
||
*
|
||
* 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。
|
||
*
|
||
* 共享状态(通过 window.* 引用):
|
||
* window.latestSystemStatus — 最近一次 /system/status 响应
|
||
* window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取)
|
||
* window.lastRelayState — 上次成功的 relay_state(polling.js 读取)
|
||
* window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取)
|
||
*
|
||
* 复用的函数(通过 window.* 引用):
|
||
* window.getBaseUrl / window.updateSystemStatusUi / 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) {
|
||
var label = text || (connected ? '已连接' : '未连接');
|
||
// header pill
|
||
var pc = document.getElementById('pillConn');
|
||
var pct = document.getElementById('pillConnTxt');
|
||
if (pc) {
|
||
pc.className = connected ? 'pill online' : 'pill offline';
|
||
if (pct) pct.textContent = label;
|
||
}
|
||
// 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 ? label : '未连接';
|
||
// primary badge
|
||
var bc = document.getElementById('bConn');
|
||
if (bc) {
|
||
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');
|
||
}
|
||
ensureHealthBootToggleBound();
|
||
}
|
||
|
||
/** 健康状态 badge 点击展开/收起启动自检面板(只绑一次)。 */
|
||
function ensureHealthBootToggleBound() {
|
||
var badge = document.getElementById('healthBadge');
|
||
var panel = document.getElementById('healthBootPanel');
|
||
if (!badge || !panel || badge.dataset.bootToggleBound === '1') return;
|
||
badge.dataset.bootToggleBound = '1';
|
||
|
||
function toggle() {
|
||
var open = panel.classList.contains('collapsed') || panel.hasAttribute('hidden');
|
||
if (open) {
|
||
panel.hidden = false;
|
||
panel.classList.remove('collapsed');
|
||
badge.classList.add('is-open');
|
||
badge.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
panel.hidden = true;
|
||
panel.classList.add('collapsed');
|
||
badge.classList.remove('is-open');
|
||
badge.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
badge.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
toggle();
|
||
});
|
||
badge.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
toggle();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
|
||
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');
|
||
if (!boot) {
|
||
if (resultEl) {
|
||
resultEl.textContent = '--';
|
||
resultEl.className = 'health-boot-summary';
|
||
}
|
||
if (failedEl) {
|
||
failedEl.textContent = '--';
|
||
failedEl.className = '';
|
||
}
|
||
if (listEl) listEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
var criticalOk = !!boot.critical_ok;
|
||
var allOk = !!boot.all_ok;
|
||
var summary = boot.summary || (allOk ? 'OK (all modules)' : (criticalOk ? 'OK (critical)' : 'FAIL'));
|
||
var failed = (boot.failed && String(boot.failed).trim()) ? String(boot.failed) : '';
|
||
var tone = allOk ? 'ok' : (criticalOk ? 'warn' : 'err');
|
||
|
||
if (resultEl) {
|
||
resultEl.textContent = summary;
|
||
resultEl.className = 'health-boot-summary ' + tone;
|
||
resultEl.title = 'mask=0x' + (Number(boot.mask) || 0).toString(16);
|
||
}
|
||
if (failedEl) {
|
||
failedEl.textContent = failed || (allOk ? '无' : '--');
|
||
failedEl.className = failed ? tone : 'ok';
|
||
}
|
||
|
||
if (listEl) {
|
||
var modules = Array.isArray(boot.modules) ? boot.modules : [];
|
||
if (!modules.length) {
|
||
listEl.innerHTML = '';
|
||
} else {
|
||
listEl.innerHTML = modules.map(function (m) {
|
||
var ok = !!m.ok;
|
||
var critical = !!m.critical;
|
||
var cls = 'boot-mod ' + (ok ? 'ok' : (critical ? 'fail' : 'fail optional'));
|
||
var tip = (m.name || '') + (ok ? ' 成功' : ' 失败') + (critical ? ' · 关键' : ' · 非关键');
|
||
return '<span class="' + cls + '" title="' + escapeHtml(tip) + '">' +
|
||
'<span class="boot-mod-dot"></span>' +
|
||
escapeHtml(m.name || '?') +
|
||
'</span>';
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
if (!window._bootInitLogged) {
|
||
window._bootInitLogged = true;
|
||
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
|
||
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
|
||
}
|
||
ensureHealthBootToggleBound();
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
updateBootInitUi(data);
|
||
|
||
// ---- 堆概况:已用 / 总计 + 占用条 ----
|
||
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(cpuData) {
|
||
cpuData = cpuData || {};
|
||
var taskList = document.getElementById('cpuTaskList');
|
||
if (!cpuData.available) {
|
||
setText('cpuUsageText', '未启用 FreeRTOS 统计');
|
||
setText('cpuUsagePct', '--');
|
||
setText('cpuIdlePct', '--');
|
||
setText('cpuSampleMs', '--');
|
||
setText('cpuTaskCount', '--');
|
||
setCpuFill(0);
|
||
if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
|
||
return;
|
||
}
|
||
if (!cpuData.valid) {
|
||
setText('cpuUsageText', '等待下一次采样...');
|
||
setText('cpuUsagePct', '--');
|
||
setText('cpuIdlePct', '--');
|
||
setText('cpuSampleMs', '--');
|
||
setText('cpuTaskCount', '--');
|
||
setCpuFill(0);
|
||
if (taskList) taskList.innerHTML = '<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() : [];
|
||
tasks.sort(function (a, b) {
|
||
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
|
||
});
|
||
|
||
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
|
||
setText('cpuUsagePct', formatPercent(usagePct));
|
||
setText('cpuIdlePct', formatPercent(idlePct));
|
||
setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--');
|
||
setText('cpuTaskCount', String(tasks.length));
|
||
setCpuFill(usagePct);
|
||
|
||
if (taskList) {
|
||
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动 */
|
||
if (!tasks.length) {
|
||
taskList.innerHTML = '<div class="status-empty">当前采样没有任务增量</div>';
|
||
} else {
|
||
taskList.innerHTML = 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('');
|
||
}
|
||
}
|
||
}
|
||
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) : '--');
|
||
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 = '<div class="status-empty">当前状态未包含任务栈信息</div>';
|
||
} else {
|
||
/* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */
|
||
names.sort(function (a, b) {
|
||
var ia = taskStack[a] || {};
|
||
var ib = taskStack[b] || {};
|
||
var sa = Number(ia.stack_size_bytes) || 0;
|
||
var sb = Number(ib.stack_size_bytes) || 0;
|
||
var pa = sa > 0 ? ((Number(ia.peak_used_bytes) || 0) * 100 / sa) : 0;
|
||
var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0;
|
||
return pb - pa;
|
||
});
|
||
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 '' +
|
||
'<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('');
|
||
}
|
||
}
|
||
|
||
var statusJson = document.getElementById('statusJson');
|
||
if (statusJson) {
|
||
statusJson.textContent = JSON.stringify(data, null, 2);
|
||
}
|
||
}
|
||
/** 从设备 IP 推导网关 IP(取前三段 + .1) */
|
||
function getGatewayIp() {
|
||
var parts = (window.getEspIp ? window.getEspIp() : '192.168.31.127').split('.');
|
||
if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1';
|
||
return '192.168.31.1';
|
||
}
|
||
|
||
/**
|
||
* 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通。
|
||
* 放在 connection.js,避免与 polling.js 形成循环 import。
|
||
*/
|
||
export async function diagnoseNetwork() {
|
||
var result = [];
|
||
if (navigator.onLine === false) {
|
||
result.push('浏览器无网络连接');
|
||
return result.join(';');
|
||
}
|
||
var gwIp = getGatewayIp();
|
||
var gwOk = false;
|
||
try {
|
||
await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) });
|
||
gwOk = true;
|
||
} catch (_) {}
|
||
if (!gwOk) {
|
||
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 + ' 可达');
|
||
var espIp = window.getEspIp ? window.getEspIp() : '设备';
|
||
result.push('设备 ' + espIp + ' 不响应(可能断电、重启中或 WiFi 断开)');
|
||
}
|
||
return result.join(';');
|
||
}
|
||
|
||
/**
|
||
* 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
|
||
* 避免与递归调度的 pollStatus 互相干扰。
|
||
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
|
||
*/
|
||
async function fetchSystemStatus(timeoutMs) {
|
||
var ac = new AbortController();
|
||
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
|
||
try {
|
||
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal });
|
||
if (!resp.ok) {
|
||
var err = new Error('HTTP ' + resp.status);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
return await resp.json();
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
/** @description 测试与 ESP32 的连接(GET /status)。 */
|
||
export async function testConnection() {
|
||
try {
|
||
var data = await fetchSystemStatus(3000);
|
||
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');
|
||
} catch (e) {
|
||
if (e && e.status) {
|
||
addLog('E', '连接', 'HTTP ' + e.status);
|
||
showToast('设备响应异常: HTTP ' + e.status, 'error');
|
||
return;
|
||
}
|
||
addLog('E', '连接', '连接失败: ' + e.message);
|
||
// 连接失败时自动诊断
|
||
var diag = await diagnoseNetwork();
|
||
addLog('W', '诊断', diag);
|
||
showToast('无法连接: ' + diag, 'error');
|
||
}
|
||
}
|
||
export async function refreshSystemStatusNow(event) {
|
||
if (event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
try {
|
||
var data = await fetchSystemStatus(3000);
|
||
window.latestSystemStatus = data;
|
||
window.updateSystemStatusUi(data);
|
||
window.consecutivePollFailures = 0;
|
||
addLog('I', '状态', '已手动刷新 /system/status');
|
||
} catch (e) {
|
||
if (e && e.status) {
|
||
addLog('E', '状态', '刷新失败: HTTP ' + e.status);
|
||
showToast('状态刷新失败: HTTP ' + e.status, 'error');
|
||
return;
|
||
}
|
||
addLog('E', '状态', '刷新失败: ' + e.message);
|
||
showToast('状态刷新失败', 'error');
|
||
}
|
||
}
|