// ==================== 全局状态 ====================
let currentFile = null;
let uploadProgress = 0;
let isUploading = false;
let logFilter = 'all';
let logEntries = [];
let lastLogHash = null;
let autoScroll = true;
let logSocket = null;
let latestSystemStatus = null;
// ==================== 工具函数 ====================
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';
}
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;
}
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';
}
function showToast(message, type) {
type = type || 'success';
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;
setTimeout(function () { toast.classList.remove('show'); }, 3000);
}
function getEspIp() {
return document.getElementById('espIp').value.trim() || '192.168.31.127';
}
function getBaseUrl() {
return 'http://' + getEspIp();
}
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
return level + '|' + tag + '|' + message;
}
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;
}
function clearLogs() {
document.getElementById('logScroll').innerHTML = '';
logEntries = [];
lastLogHash = null;
document.getElementById('logCount').textContent = '0';
}
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;
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
var btn = document.getElementById('autoScrollBtn');
btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
}
function updateUploadButtonLabel() {
var chip = document.getElementById('targetChip').value;
var uploadBtn = document.getElementById('uploadBtn');
if (!uploadBtn) return;
uploadBtn.textContent = chip === 'esp32' ? '上传固件到 ESP32' : '上传固件到中继缓存';
}
function setHelper(id, text, type) {
var el = document.getElementById(id);
if (!el) return;
el.className = 'helper-note ' + (type || '');
el.innerHTML = text;
}
function updateFileHint() {
if (!currentFile) {
setHelper('fileHint', '未选择固件。请选择 .bin 或 .hex 文件后再执行上传。', 'warn');
return;
}
var chip = document.getElementById('targetChip').value;
var targetText = chip === 'esp32' ? 'ESP32 自升级' : 'GD32 中继升级';
setHelper('fileHint', '文件已就绪:' + currentFile.name + ',当前目标:' + targetText + '。可以开始上传。', 'ok');
}
function updateActionHint() {
var chip = document.getElementById('targetChip').value;
if (chip === 'esp32') {
setHelper('actionHint', 'ESP32 模式:上传后设备会执行自身 OTA。上传成功后会自动调用 /ota/update 并重启。', 'warn');
} else {
setHelper('actionHint', 'GD32 模式:先把固件上传到 ESP32 中继缓存,再点击“触发 GD32 OTA 中继”。阶段流会根据真实 relay_state 更新。', '');
}
}
function updateCanHint() {
var dlc = parseInt(document.getElementById('canDlc').value) || 0;
var dataHex = document.getElementById('canData').value.trim().replace(/\s/g, '');
if (!dataHex) {
setHelper('canHint', '输入十六进制数据后会自动检查字节数是否与 DLC 一致。', '');
return;
}
if (!/^[0-9a-fA-F]*$/.test(dataHex)) {
setHelper('canHint', '数据包含非 HEX 字符,请只输入 0-9 A-F。', 'err');
return;
}
if (dataHex.length % 2 !== 0) {
setHelper('canHint', 'HEX 长度必须为偶数,当前还差半个字节。', 'err');
return;
}
var bytes = dataHex.length / 2;
if (dlc && bytes !== dlc) {
setHelper('canHint', '当前数据为 ' + bytes + ' 字节,但 DLC = ' + dlc + '。发送前建议对齐。', 'warn');
return;
}
setHelper('canHint', 'HEX 数据合法,当前长度 ' + bytes + ' 字节,与 DLC 一致。', 'ok');
}
function resetStageFlow() {
['s1', 's2', 's3', 's4'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
}
function applyDisconnectedRuntimeState() {
document.getElementById('pillIP').textContent = '--';
document.getElementById('pillRSSI').textContent = '--';
document.getElementById('pillRelayTxt').textContent = '离线';
document.getElementById('pillRelay').className = 'pill offline';
document.getElementById('mWifi').textContent = '未连接';
document.getElementById('mRSSI').textContent = '--';
document.getElementById('bOTAProgress').textContent = '--';
document.getElementById('bOTAProgress').className = 'v err';
resetStageFlow();
}
// ==================== 连接管理 ====================
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');
cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
cst.textContent = connected ? (text || '已连接') : '未连接';
// primary badge
var bc = document.getElementById('bConn');
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
}
function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
var connStatusTxt = document.getElementById('connStatusTxt');
if (connStatusTxt && latestSystemStatus && latestSystemStatus.wifi) {
connStatusTxt.textContent = (latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
}
if (freeHeap !== undefined) {
document.getElementById('mHeap').textContent = formatBytes(freeHeap);
}
}
// ==================== OTA 阶段驱动 (真实状态) ====================
function updateStagesFromRelayState(rs) {
var s1 = document.getElementById('s1');
var s2 = document.getElementById('s2');
var s3 = document.getElementById('s3');
var s4 = document.getElementById('s4');
var all = [s1, s2, s3, s4];
// reset all
all.forEach(function (s) { s.className = 'stage-node'; });
if (rs === undefined || rs === null) return;
if (rs === 255) {
s1.className = 'stage-node err';
s2.className = 'stage-node err';
s3.className = 'stage-node err';
s4.className = 'stage-node err';
return;
}
var activeStage = 0;
if (rs >= 1 && rs <= 6) activeStage = 1;
else if (rs >= 7 && rs <= 8) activeStage = 2;
else if (rs >= 9 && rs <= 15) activeStage = 3;
else if (rs >= 16 && rs <= 17) activeStage = 4;
for (var i = 0; i < activeStage - 1; i++) {
all[i].className = 'stage-node done';
}
if (activeStage >= 1 && activeStage <= 4) {
all[activeStage - 1].className = 'stage-node live';
}
}
function updateSystemStatusUi(data) {
if (!data) return;
latestSystemStatus = data;
var wifi = data.wifi || {};
var ota = data.ota || {};
var healthCode = data.health;
var relayStates = {
0: '待机', 1: '等待握手', 2: '握手已发', 3: '挑战已发',
4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成',
7: '大小已发', 8: '大小确认', 9: '接收中', 10: '转发中',
11: '数据包已发', 12: 'ACK正常', 13: '等待重发',
14: '接收完成', 15: '传输完成', 16: '结束已发', 17: 'OTA完成',
255: '错误'
};
var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(healthCode, data.free_heap);
// version
if (data.version) {
document.getElementById('pillVer').textContent = 'v' + data.version;
document.getElementById('mVersion').textContent = 'v' + data.version;
}
// temperature
document.getElementById('mTemp').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
document.getElementById('mWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
document.getElementById('pillIP').textContent = wifi.ip || '--';
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
document.getElementById('mRSSI').textContent = rssi;
document.getElementById('pillRSSI').textContent = rssi;
// heap
document.getElementById('mHeap').textContent =
data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
document.getElementById('mMinHeap').textContent =
data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
// relay state
var rs = ota.relay_state;
var relayText = relayStates[rs] || '待机';
document.getElementById('pillRelayTxt').textContent = relayText;
var pr = document.getElementById('pillRelay');
if (rs === 255) pr.className = 'pill offline';
else if (rs > 0 && rs < 17) pr.className = 'pill processing';
else if (rs === 17) pr.className = 'pill online';
else pr.className = 'pill idle';
// OTA progress
var total = ota.total_size || 0;
var stored = ota.stored_size || 0;
var progress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
var bp = document.getElementById('bOTAProgress');
bp.textContent = progress + '%';
bp.className = 'v ' + (progress === 100 ? 'ok' : progress > 0 ? 'warn' : '');
// DUT mode pill
var currentMode = document.getElementById('targetChip').value === 'esp32' ? 'ESP32' : 'GD32';
document.getElementById('pillModeTxt').textContent = currentMode;
document.getElementById('bMode').textContent = currentMode;
// stage flow from real state
updateStagesFromRelayState(rs);
}
async function testConnection() {
if (!latestSystemStatus) {
addLog('W', '连接', '暂无同步数据,请等待 1 秒自动同步');
showToast('暂无同步数据,请稍等 1 秒', 'warning');
return;
}
var wifi = latestSystemStatus.wifi || {};
var connected = !!wifi.connected;
updateSystemStatusUi(latestSystemStatus);
addLog('I', '连接', connected
? '当前已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
: '当前未连接:' + (wifi.mode || 'STA'));
showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
connected ? 'success' : 'warning');
}
// ==================== CAN 帧发送 ====================
async function sendCanFrame() {
var ch = parseInt(document.getElementById('canChannel').value);
var idStr = document.getElementById('canId').value.trim();
var dlc = parseInt(document.getElementById('canDlc').value) || 8;
var dataStr = document.getElementById('canData').value.trim();
var extId = 0;
if (idStr.toLowerCase().indexOf('0x') === 0) extId = parseInt(idStr, 16);
else { extId = parseInt(idStr, 10); if (isNaN(extId)) extId = parseInt(idStr, 16); }
if (isNaN(extId)) { showToast('ID 格式错误', 'error'); return; }
var dataHex = dataStr.replace(/\s/g, '');
if (dataHex.length % 2 !== 0) { showToast('数据长度必须为偶数', 'error'); return; }
addLog('I', 'CAN', '发送 CH' + ch + ' ID=0x' + extId.toString(16).toUpperCase() + ' DLC=' + dlc);
try {
var resp = await fetch(getBaseUrl() + '/can/send', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: ch, id: extId, dlc: dlc, data: dataHex }),
signal: AbortSignal.timeout(5000)
});
var result = await resp.json();
if (result.status === 'ok') {
addLog('I', 'CAN', '响应: ' + (result.recv || '(无数据)') + ' (' + (result.recv_len || 0) + 'B)');
var rd = document.getElementById('canResp');
rd.style.display = 'block';
document.getElementById('canRespText').textContent =
result.recv ? '0x' + result.recv + ' (' + result.recv_len + ' 字节)' : '无响应数据';
showToast('CH' + ch + ' 发送成功', 'success');
} else {
addLog('E', 'CAN', '发送失败: code=' + result.code);
showToast('发送失败: code=' + result.code, 'error');
}
} catch (e) {
addLog('E', 'CAN', '发送失败: ' + e.message);
showToast('发送失败', 'error');
}
}
function onTargetChipChange() {
var chip = document.getElementById('targetChip').value;
var relayBtn = document.getElementById('relayBtn');
var esp32Actions = document.getElementById('esp32Actions');
if (chip === 'esp32') {
relayBtn.style.display = 'none';
esp32Actions.style.display = 'block';
} else {
relayBtn.style.display = '';
esp32Actions.style.display = 'none';
}
updateUploadButtonLabel();
updateActionHint();
document.getElementById('pillModeTxt').textContent = chip === 'esp32' ? 'ESP32' : 'GD32';
document.getElementById('bMode').textContent = chip === 'esp32' ? 'ESP32' : 'GD32';
var th = document.getElementById('targetHint');
th.innerHTML = chip === 'esp32'
? '直接将固件写入 ESP32 自身 Flash,写入完成后 OTA 升级并自动重启。'
: '将固件写入 ESP32 Flash,由 ESP32 通过 SPI 中继到 GD32 完成刷写。';
addLog('I', '目标', '切换到: ' + (chip === 'esp32' ? 'ESP32 自身升级' : 'GD32 Flash 中继'));
}
function clearCanFields() {
document.getElementById('canId').value = '0x18DA00F1';
document.getElementById('canDlc').value = '8';
document.getElementById('canData').value = '0102030405060708';
document.getElementById('canResp').style.display = 'none';
updateCanHint();
}
// ==================== SPI 发送 ====================
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)';
}
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(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');
}
}
function clearSpiFields() {
document.getElementById('spiType').value = '01';
document.getElementById('spiData').value = '';
document.getElementById('spiTimeout').value = '1000';
document.getElementById('spiResp').style.display = 'none';
updateSpiHint();
}
// ==================== 文件处理 ====================
function handleFileSelect(event) {
var file = event.target.files[0];
if (file) processFile(file);
}
document.getElementById('uploadZone').addEventListener('dragover', function (e) {
e.preventDefault(); e.currentTarget.classList.add('dragover');
});
document.getElementById('uploadZone').addEventListener('dragleave', function (e) {
e.currentTarget.classList.remove('dragover');
});
document.getElementById('uploadZone').addEventListener('drop', function (e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var file = e.dataTransfer.files[0];
if (file) processFile(file);
});
document.getElementById('canData').addEventListener('input', updateCanHint);
document.getElementById('canDlc').addEventListener('input', updateCanHint);
document.getElementById('spiType').addEventListener('input', updateSpiHint);
document.getElementById('spiData').addEventListener('input', updateSpiHint);
function processFile(file) {
currentFile = file;
document.getElementById('fileInfo').classList.add('show');
document.getElementById('uploadBtn').disabled = false;
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileType').textContent =
file.name.toLowerCase().indexOf('.hex') >= 0 ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize').textContent = formatBytes(file.size);
var chunks = Math.ceil(file.size / 8192);
document.getElementById('fileChunks').textContent = chunks + ' 包';
updateFileHint();
addLog('I', '文件', '已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast('文件已选择', 'success');
}
function clearFile() {
currentFile = null;
document.getElementById('fileInput').value = '';
document.getElementById('fileInfo').classList.remove('show');
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progressWrap').classList.remove('show');
updateFileHint();
addLog('I', '文件', '文件已清除');
}
// ==================== 上传固件 ====================
async function startUpload() {
if (!currentFile || isUploading) return;
var chip = document.getElementById('targetChip').value;
isUploading = true;
var uploadBtn = document.getElementById('uploadBtn');
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
document.getElementById('progressWrap').classList.add('show');
addLog('I', '上传', '开始上传: ' + currentFile.name + ' \u2192 ' + chip.toUpperCase());
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try {
var arrayBuffer = await currentFile.arrayBuffer();
var fileSize = currentFile.size;
var body, url = getBaseUrl() + '/ota/upload';
if (chip === 'esp32') {
var headerJson = JSON.stringify({ type: 'esp32', total_size: fileSize });
var encoder = new TextEncoder();
var headerBytes = encoder.encode(headerJson);
var fileBytes = new Uint8Array(arrayBuffer);
var combined = new Uint8Array(headerBytes.length + fileSize);
combined.set(headerBytes, 0);
combined.set(fileBytes, headerBytes.length);
body = combined.buffer;
} else {
body = arrayBuffer;
}
var xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
uploadProgress = pct;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressTxt').textContent =
'已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total);
document.getElementById('progressPct').textContent = pct + '%';
document.getElementById('bOTAProgress').textContent = pct + '%';
}
});
xhr.addEventListener('load', function () {
isUploading = false;
uploadBtn.disabled = false;
updateUploadButtonLabel();
if (xhr.status === 200) {
addLog('I', '上传', '固件上传完成!');
showToast('固件上传成功', 'success');
if (chip === 'esp32') {
document.getElementById('esp32Actions').style.display = 'block';
var countdown = 3;
addLog('I', 'OTA', countdown + ' 秒后自动调用 POST /ota/update 完成升级并重启...');
var timer = setInterval(function () {
countdown--;
if (countdown > 0) addLog('I', 'OTA', countdown + ' 秒后开始刷写...');
else {
clearInterval(timer);
addLog('I', 'OTA', '开始刷写!');
triggerEsp32Update();
}
}, 1000);
}
} else {
addLog('E', '上传', '上传失败: HTTP ' + xhr.status);
showToast('上传失败', 'error');
}
});
xhr.addEventListener('error', function () {
isUploading = false; uploadBtn.disabled = false;
updateUploadButtonLabel();
addLog('E', '上传', '上传过程发生错误');
showToast('上传出错', 'error');
});
xhr.open('POST', url);
xhr.send(body);
} catch (e) {
addLog('E', '上传', '上传失败: ' + e.message);
showToast('上传失败', 'error');
isUploading = false; uploadBtn.disabled = false;
updateUploadButtonLabel();
}
}
// ==================== ESP32 OTA 完成升级 ====================
async function triggerEsp32Update() {
addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ===');
showToast('ESP32 正在重启...', 'success');
try {
var resp = await fetch(getBaseUrl() + '/ota/update', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
var data = await resp.json();
addLog('I', 'OTA', '升级完成: ' + data.message);
showToast('ESP32 重启中...', 'success');
}
} catch (e) {
addLog('E', 'OTA', '升级失败: ' + e.message);
showToast('升级失败', 'error');
}
}
async function restartEsp32() {
if (!confirm('确定要重启 ESP32 吗?')) return;
addLog('I', '系统', '=== 请求重启 ESP32 ===');
showToast('ESP32 正在重启...', 'success');
try {
var resp = await fetch(getBaseUrl() + '/restart', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
var data = await resp.json();
addLog('I', '系统', '重启已触发: ' + data.message);
showToast('ESP32 重启中,页面将自动恢复...', 'success');
}
} catch (e) {
addLog('E', '系统', '重启请求失败: ' + e.message);
showToast('重启失败', 'error');
}
}
// ==================== 触发 OTA 中继 (真实状态驱动) ====================
async function triggerRelay() {
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
showToast('正在触发 OTA 中继', 'success');
try {
var resp = await fetch(getBaseUrl() + '/ota/relay', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
addLog('I', 'OTA', 'OTA 中继已触发,等待 GD32 响应...');
// 不再使用假的 setTimeout 推进阶段。
// pollStatus 的 updateSystemStatusUi 会从 ota.relay_state
// 自动更新 stage flow 和 header 状态。
showToast('中继已触发,等待设备响应', 'success');
} else {
addLog('E', 'OTA', '触发失败: HTTP ' + resp.status);
showToast('触发失败', 'error');
}
} catch (e) {
addLog('E', 'OTA', '触发失败: ' + e.message);
showToast('触发失败', 'error');
}
}
// ==================== 轮询状态 ====================
async function pollStatus() {
try {
var resp = await fetch(getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(3000)
});
if (resp.ok) {
var data = await resp.json();
updateSystemStatusUi(data);
}
} catch (e) {
updateConnectionUi(false, '未连接');
applyDisconnectedRuntimeState();
}
}
setInterval(pollStatus, 1000);
// ==================== ESP32 实时日志拉取 (增量) ====================
var lastLogTimestamp = 0;
async function pollLogs() {
try {
var resp = await fetch(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());
} else {
addLog('I', 'ESP32', line.trimEnd());
}
});
} catch (e) { /* 静默 */ }
}
setInterval(pollLogs, 1000);
setTimeout(function () {
onTargetChipChange();
updateUploadButtonLabel();
updateFileHint();
updateActionHint();
updateCanHint();
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
updateSpiHint();
}, 500);