feat: 添加局域网设备发现功能和链路胶囊优化

- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
cbb
2026-10-13 00:48:34 +08:00
parent 514070b36d
commit eac3717f9a
41 changed files with 4229 additions and 934 deletions
+127 -24
View File
@@ -12,7 +12,8 @@ import { showToast } from './common/toast.js';
import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js';
import {
updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi,
testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl
testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl,
updateLinkPills, setWifiPillOverride
} from './common/connection.js';
import {
scheduleStatusPoll, startPolling, pausePollingForUpload,
@@ -35,6 +36,7 @@ import {
} from './ota/stages.js?v=20260916d';
import { startEcatLoadPoller } from './ota/ecat_load.js';
import { deviceLink } from './common/device-link.js';
import { isValidIpv4 } from './common/base-url.js';
import {
handleFileSelect, pickFileViaFSAPI, setupDropZone,
parseIntelHex, isIntelHexText, convertHexFileToBin,
@@ -47,7 +49,7 @@ import {
} from './ota/upload-gd32.js';
import {
bootEcatProbe, bootEcatFlash, bootEcatAbort, bootEcatPick, bootEcatReboot, refreshBootEcatUi
} from './ota/boot_ecat.js';
} from './ota/boot_ecat.js?v=20261013b';
// ==================== 工具函数 ====================
function formatPercent(value) {
@@ -55,10 +57,13 @@ function formatPercent(value) {
return value.toFixed(1).replace(/\.0$/, '') + '%';
}
/** 设备 IP:优先本页 #espIp 输入框,否则 ConnectionHub。
* 只在值变化时 setIp——Owner 会把 set-ip 当成立即重探,读路径每次写会 1Hz 风暴。 */
* 只在值变化时 setIp——Owner 会把 set-ip 当成立即重探,读路径每次写会 1Hz 风暴。
* 输入框不是合法 IPv4 时**不参与拼 URL**:否则本页打到脏地址、Owner 与其余
* 标签还打在旧端点上,会出现"灯是绿的、上传是失败的"这种分裂状态。 */
function getEspIp() {
var el = document.getElementById('espIp');
var ip = el && el.value ? el.value.trim() : '';
if (ip && !isValidIpv4(ip)) return deviceLink.getIp();
if (!ip) return deviceLink.getIp();
if (ip !== deviceLink.getIp()) {
try { deviceLink.setIp(ip); } catch (_) {}
@@ -95,8 +100,10 @@ function resetStageFlow() {
if (statusText) statusText.textContent = '待机';
}
function applyDisconnectedRuntimeState() {
document.getElementById('pillIP').textContent = '--';
document.getElementById('pillRSSI').textContent = '--';
var elIp = document.getElementById('pillIP');
if (elIp) elIp.textContent = '--';
var elRssi = document.getElementById('pillRSSI');
if (elRssi) elRssi.textContent = '--';
// 断线约 1s(本轮轮询失败)即清实时指标,防止残留旧温度/电压误导
clearLiveMetricsOnDisconnect();
// ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度
@@ -107,13 +114,8 @@ function applyDisconnectedRuntimeState() {
resetStageFlow();
}
function applyRebootingRuntimeState() {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) {
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
}
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
setWifiPillOverride('重启中 ' + elapsed + 's');
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '重启中';
if (bc) bc.className = 'v warn';
@@ -125,20 +127,14 @@ function applyUploadingRuntimeState() {
otaSessionState === OtaSessionState.DONE) {
return;
}
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '上传中';
setWifiPillOverride('上传中');
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '上传中';
if (bc) bc.className = 'v warn';
}
/** 设备忙:只更新顶栏连接胶囊 + 系统状态徽章,不写下方重复文案。 */
/** 设备忙:覆盖 WiFi 胶囊文案 + 系统状态徽章。 */
function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = pillText || '设备忙';
setWifiPillOverride(pillText || '设备忙');
var bc = document.getElementById('bConn');
if (bc) bc.textContent = badgeText || '设备忙';
if (bc) bc.className = 'v warn';
@@ -207,6 +203,8 @@ window.clearLogs = clearLogs;
window.setLogFilter = setLogFilter;
window.toggleAutoScroll = toggleAutoScroll;
window.updateConnectionUi = updateConnectionUi;
window.updateLinkPills = updateLinkPills;
window.setWifiPillOverride = setWifiPillOverride;
window.updateHealthUi = updateHealthUi;
window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi;
window.testConnection = testConnection;
@@ -257,6 +255,46 @@ document.addEventListener('click', function (ev) {
}
});
// ==================== ESP32 WiFi 使能(PC9 → ESP32 EN) ====================
// 走 EtherCAT SDO 0x2503(/ecat/api/esp32/en),与 WiFi 解耦。
// action:0=拉高(开) 1=拉低保持(关)
function sendEsp32En(action) {
return fetch('/ecat/api/esp32/en', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action }),
signal: AbortSignal.timeout(8000)
}).then(function (r) {
return r.json().then(function (j) {
if (!r.ok || j.ok === false) throw new Error((j.log && j.log[0]) || j.err || ('HTTP ' + r.status));
return typeof j.status === 'number' ? j.status : -1;
});
});
}
function setWifiEnChecked(on) {
var sw = document.getElementById('esp32WifiSw');
if (sw) sw.checked = !!on;
}
function applyEsp32WifiSwitch(on) {
sendEsp32En(on ? 0 : 1).then(function () {
setWifiEnChecked(on);
showToast(on ? 'ESP32 WiFi 已开启' : 'ESP32 WiFi 已关断', on ? 'success' : 'warning');
}).catch(function (e) {
setWifiEnChecked(!on);
showToast('EtherCAT 下发失败: ' + e.message, 'error');
});
}
(function bindWifiEnSwitch() {
var sw = document.getElementById('esp32WifiSw');
if (!sw) return;
sw.addEventListener('change', function () {
applyEsp32WifiSwitch(sw.checked);
});
})();
// 共享固件文件选择(原内联 onchange="handleSharedFileSelect(event, chip)")
['esp32', 'gd32'].forEach(function (chip) {
var input = document.getElementById(chip === 'esp32' ? 'fileInputEsp32' : 'fileInputGd32');
@@ -298,8 +336,11 @@ startPolling();
* 外壳 Owner 是更权威的「设备在线」来源,离线布尔一翻就清。 */
deviceLink.subscribe(function (s) {
try {
if (!s || !s.ts || !s.links || !s.links.esp32) return;
if (!s || !s.ts || !s.links) return;
if ((Date.now() - s.ts) >= 5000) return;
/* 顶栏三链路:快照一到就刷 WiFi/ECAT 灯(BLE 另走 __bleConnState) */
if (window.updateLinkPills) window.updateLinkPills(null, null);
if (!s.links.esp32) return;
if (s.links.esp32.online) return;
clearLiveMetricsOnDisconnect();
} catch (_) {}
@@ -308,6 +349,50 @@ deviceLink.subscribe(function (s) {
// GD32 CPU 负载率(EtherCAT 通道,独立于 /system/status 轮询;页面上无该指标则自动空转)
startEcatLoadPoller();
/* ── ECAT 胶囊:0x1008 设备名只在「断开→重连」时读一次,平时不轮询 ──
* 路径:/ecat/api/ota/ecat probe 的 name(对象字典 0x1008,如 GD32E508 DCU)
* 注意:status.slave.name 是 SOEM SII 名(DCU Project),不是这里要的。 */
function refreshEcatPillName() {
/* 只负责取从站名字(0x1008)并公布到 window.__ecatProbeName;
灯色与文案由 connection.js 的 updateLinkPills 单点出。这里以前连 className 一起写,
和快照那条路节奏不同,实测出现过"绿点 + 文字离线"的自相矛盾。 */
fetch('/ecat/api/ota/ecat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: 'probe' }),
cache: 'no-store',
signal: AbortSignal.timeout(5000),
}).then(function (r) { return r.json(); }).then(function (st) {
/* probe 对 APP 返回 ok:false,name 仍是 0x1008 读数 */
window.__ecatProbeName = (st && st.name) ? String(st.name).trim() : '';
updateLinkPills();
}).catch(function () {
window.__ecatProbeName = '';
updateLinkPills();
});
}
(function bindEcatNameOnReconnect() {
var lastOnline = null;
deviceLink.subscribe(function (s) {
var on = !!(s && s.links && s.links.ecat && s.links.ecat.busOpen);
if (!on) {
/* 总线没开:名字作废,灯色/文案交给 updateLinkPills 按快照出,不在这里另写一套 */
if (lastOnline !== false) {
lastOnline = false;
window.__ecatProbeName = '';
updateLinkPills();
}
return;
}
/* 首次在线 或 断开后重连 → 读一次 0x1008;保持在线则不再读 */
if (lastOnline !== true) {
lastOnline = true;
refreshEcatPillName();
}
});
})();
// IP 输入变化时清零失败计数并立即刷新状态
var ipInput = document.getElementById('espIp');
if (ipInput) {
@@ -316,13 +401,31 @@ if (ipInput) {
if (savedIp && savedIp.trim()) ipInput.value = savedIp.trim();
} catch (_) {}
ipInput.addEventListener('change', function () {
try { deviceLink.setIp(ipInput.value.trim() || '192.168.31.127'); } catch (_) {}
var r = null;
try { r = deviceLink.setIp(ipInput.value.trim()); } catch (_) { r = { ok: false, reason: '写入异常' }; }
if (!r || !r.ok) {
/* 拒绝要说得出原因,也不把输入框停在脏值上:静默收下脏 IP 会让六个标签
打到旧端点、输入框显示新值,"看起来连着"就是假绿灯。 */
var why = (r && r.reason) || '未接受';
addLog('E', '系统', '端点未改:' + why);
showToast('端点未改:' + why, 'error', { force: true });
var eff = deviceLink.getIp();
if (eff && ipInput.value.trim() !== eff) ipInput.value = eff;
return;
}
window.consecutivePollFailures = 0;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
addLog('I', '系统', 'IP 已修改 → ' + r.ip + ',重新启动轮询...');
if (!window.statusPollInFlight) {
scheduleStatusPoll(0);
}
});
/* 端点被别处改了(发现面板选中、另一页手输)要回流到本输入框,
否则六个标签共用新端点、这一页的框还显示旧值。用户正在敲时不覆盖。 */
deviceLink.subscribe(function (s) {
if (!s || !s.ip) return;
if (document.activeElement === ipInput) return;
if (ipInput.value.trim() !== s.ip) ipInput.value = s.ip;
});
}
// 初始化按钮状态与提示
+8 -2
View File
@@ -84,8 +84,14 @@ export async function connectBle() {
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 || '-';
window.__bleDeviceName = device.name || '未知设备';
window.__bleDeviceId = device.id || '-';
/* 顶栏 BLE 胶囊:连接后直接显示「设备名 · 设备ID」(替代未连接) */
try {
var pct = document.getElementById('pillBleTxt');
if (pct) pct.textContent = window.__bleDeviceName + ' · ' + window.__bleDeviceId;
if (window.updateLinkPills) window.updateLinkPills(null, true);
} catch (_) {}
}
setBleStage(1, '已选择', null);
+21 -7
View File
@@ -110,12 +110,24 @@ export function setReadyHint(html) {
export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
// BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案
window.__bleConnState = !!connected;
// 顶栏 BLE 胶囊:连接后显示「设备名 · 设备ID」,未连接显示「未连接」
var pc = document.getElementById('pillBle');
var pct = document.getElementById('pillBleTxt');
if (pc) pc.className = 'pill link-pill ' + (connected ? 'online' : 'offline');
if (pct) {
if (connected) {
var nm = window.__bleDeviceName || '已连接';
var id = window.__bleDeviceId;
pct.textContent = id ? (nm + ' · ' + id) : nm;
} else {
pct.textContent = '未连接';
}
}
try {
if (window.updateLinkPills) window.updateLinkPills(null, !!connected);
} catch (_) {}
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
@@ -141,6 +153,8 @@ export function setConnectionState(connected) {
els.abortBtn.disabled = !state.uploading;
if (!connected) {
window.__bleDeviceName = '';
window.__bleDeviceId = '';
setReadyHint('当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。');
} else if (!state.notificationsEnabled) {
setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。');
@@ -164,8 +178,8 @@ export function resetUiAfterDisconnect() {
state.lastUploadStatus = 'idle';
state.deviceSha256 = null;
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
window.__bleDeviceName = '';
window.__bleDeviceId = '';
if (els.versionText) els.versionText.textContent = '未读取';
var pv = document.getElementById('pillVer');
if (pv) pv.textContent = 'v--';
+24 -16
View File
@@ -3,7 +3,7 @@
*
* CAN 页面的编排逻辑:
* - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象)
* - setConnectionState:顶栏 pills(连接/IP/RSSI/运行/版本)
* - setConnectionState:顶栏 pills(网络状态合并 IP/RSSI、运行/版本)
* - testConnection:GET /system/status,静默刷新顶栏(5s)
* - clearDbc / handleDbcFile / handleDbcError:DBC 文件读取与解析流程编排
* - bindEvents:绑定所有事件监听器(调用子模块的 bindNodeFilterEvents 等)
@@ -19,7 +19,7 @@
/* JS 间 import 一律不带 ?v=,版本戳只出现在 HTML 入口标签上,
* 避免同一模块因 query 不同被浏览器实例化两次(state/toast 单例分裂) */
import {
state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary,
state, el, getBaseUrl, getDeviceIp, parseDbc, readDbcText, buildSummary,
rebuildMergedDbc, makeDbcSourceId
} from './can/dbc.js';
import {
@@ -44,7 +44,7 @@ import {
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js?v=20260801f';
} from './can/send.js?v=20261010a';
import {
scheduleCanPersist,
loadCanSession,
@@ -71,7 +71,7 @@ function cacheElements() {
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
'toastMsg', 'canConnPill', 'canConnTxt', 'canIpPill', 'canRssiPill', 'canUptimePill',
'toastMsg', 'canNetPill', 'canNetTxt', 'canUptimePill',
'canVersionPill', 'canChannelPill',
'dbcSummaryPill', 'messageSummaryPill',
'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn',
@@ -84,19 +84,19 @@ function cacheElements() {
// ==================== 连接状态(仅顶栏 pills,与工作台去重) ====================
function setConnectionState(connected, text, buildVersion, buildTime, wifi, uptimeSec) {
if (el.canConnPill) {
el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline');
}
if (el.canConnTxt) el.canConnTxt.textContent = text;
/* STA/IP/RSSI 合并成一个网络胶囊,减少顶栏碎片(与 OTA 页 link-pill 文案一致) */
wifi = wifi || {};
if (el.canIpPill) {
var label = text || (connected ? '已连接' : '未连接');
if (connected) {
var ip = wifi.ip || getDeviceIp();
el.canIpPill.textContent = 'IP ' + (ip || '--');
}
if (el.canRssiPill) {
if (ip) label += ' · ' + ip;
var rssiOk = wifi.rssi !== undefined && wifi.rssi !== null && wifi.rssi !== -127;
el.canRssiPill.textContent = rssiOk ? ('RSSI ' + wifi.rssi + ' dBm') : 'RSSI --';
if (rssiOk) label += ' · ' + wifi.rssi + ' dBm';
}
if (el.canNetPill) {
el.canNetPill.className = 'pill link-pill ' + (connected ? 'online' : 'offline');
}
if (el.canNetTxt) el.canNetTxt.textContent = label;
if (el.canUptimePill) {
el.canUptimePill.textContent = '运行 ' + (uptimeSec !== undefined && uptimeSec !== null
? formatUptime(uptimeSec) : '--');
@@ -122,7 +122,15 @@ async function testConnection() {
var data = await resp.json();
state.latestStatus = data;
var wifi = data.wifi || {};
if (wifi.ip) setDeviceIp(wifi.ip);
/* 不再用设备自报 IP 回写共享端点:AP 回退或 STA 掉租约时固件上报的是
* 初值 "0.0.0.0"(ESP32_COMM/main/server/http_handlers.cpp:1776),
* 回写会让 http://0.0.0.0(=回环)成为六个标签一起用的端点。
* 这里只提示不符,换不换由人在发现面板/输入框点。 */
var curIp = deviceLink.getIp();
if (wifi.ip && curIp && String(wifi.ip) !== String(curIp)) {
showToast('设备自报 IP=' + wifi.ip + ',与当前端点 ' + curIp + ' 不符(AP 回退或已换址)',
'warning', { force: true });
}
var connected = !!wifi.connected;
var mode = wifi.mode || 'STA';
var text = connected ? (mode + ' 已连接') : (mode + ' 未连接');
@@ -412,13 +420,13 @@ function init() {
}
setTimeout(testConnection, 150);
/* 周期性刷新顶栏 STA/IP/RSSI/运行时间(与监控 1Hz 解耦,避免重复 pill) */
/* 周期性刷新顶栏网络状态/运行时间(与监控 1Hz 解耦,避免重复 pill) */
setInterval(testConnection, 5000);
/* ConnectionHub 快照只覆盖在线布尔,不改 testConnection 写的语义文案(STA/HTTP…) */
deviceLink.subscribe(function (s) {
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
if (el.canConnPill) el.canConnPill.className = 'pill ' + (on ? 'online' : 'offline');
if (el.canNetPill) el.canNetPill.className = 'pill link-pill ' + (on ? 'online' : 'offline');
});
/* 右侧抽屉:CAN 接收监控 */
initCanMonitor();
+1 -11
View File
@@ -7,9 +7,8 @@
* 供 messages/signals/send 共享(ES Module 的 live binding 保证引用一致)。
*/
import {
DEFAULT_ESP_IP, getSavedDeviceIp, saveDeviceIp
DEFAULT_ESP_IP, getSavedDeviceIp
} from '../common/base-url.js';
import { deviceLink } from '../common/device-link.js';
// ==================== 共享状态 ====================
export var state = {
@@ -47,15 +46,6 @@ export function getDeviceIp() {
return DEFAULT_ESP_IP;
}
/** @description 记住设备 IP(status 回报或其它页写入),并广播给 ConnectionHub。 */
export function setDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
var v = String(ip).trim();
saveDeviceIp(v);
try { deviceLink.setIp(v); } catch (_) {}
if (el.toolIp) el.toolIp.value = v;
}
/** @description 拼接设备基础 URL(http://<ip>)。 */
export function getBaseUrl() {
return 'http://' + getDeviceIp();
+2
View File
@@ -82,6 +82,8 @@ var GD32_ERR_DESC = {
0xE3: 'TEC 过高(错误计数 >96,对端 ACK 失败率高)',
0xE4: '邮箱满(3 个硬件邮箱全 PENDING,对端不 ACK)',
0xE5: '参数无效(通道未启用/DLC 越界)',
0xE6: '只听不发(该通道收发器已 Silent,S 脚置高;接收正常,可在 CAN 监控页切回发送)',
0xE7: '该通道正被电机 OTA 独占(刷写结束自动恢复;重试无用,不是总线故障)',
0xEE: 'SPI 帧解析失败(GD32 收到的帧 CRC/SOF 校验失败,通常是 SPI 字节丢失或 CS 时序冲突)'
};
+151 -17
View File
@@ -2,11 +2,11 @@
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.js';
import { escapeHtml, flashBusyNow } from './common/utils.js';
import { escapeHtml, flashBusyNow, formatUptime } from './common/utils.js';
import { getBaseUrl as hubBaseUrl } from './common/base-url.js';
import { deviceLink } from './common/device-link.js';
var state = { fbTimer: null, fbInFlight: false };
var state = { fbTimer: null, statusTimer: null, fbInFlight: false };
var el = {};
@@ -35,8 +35,9 @@ function copyFbVal(text, target) {
}
var ids = [
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
'monIp', 'monTestBtn',
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
'canNetPill', 'canNetTxt', 'canUptimePill', 'canVersionPill',
'fbModePill', 'fbPollStatus',
'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Tec', 'fb0Rec', 'fb0Bus', 'fb0Seq', 'fb0Delta',
'fb0LastId', 'fb0Dlc', 'fb0Flags', 'fb0LastData',
@@ -45,6 +46,7 @@ var ids = [
'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Tec', 'fb2Rec', 'fb2Bus', 'fb2Seq', 'fb2Delta',
'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData',
'btnModeAllPass', 'btnModeFilter', 'btnModeBridge',
'btnSilent0', 'btnSilent1', 'btnSilent2',
'idsnapChannel', 'idsnapStatus', 'idsnapBody',
'toast', 'toastIco', 'toastMsg',
'toolIp' /* can.html 共用 IP */
@@ -65,22 +67,80 @@ function getBaseUrl() {
return hubBaseUrl();
}
/** 独立页 can_monitor.html 时维护顶栏;嵌入 can.html 抽屉时跳过(由 can.js 写)。 */
function isStandalone() {
return !!(el.monIp);
}
function setNetPill(connected, label) {
if (!isStandalone()) return;
if (el.canNetPill) {
el.canNetPill.className = 'pill link-pill ' + (connected ? 'online' : 'offline');
}
if (el.canNetTxt) el.canNetTxt.textContent = label;
}
function setConnState(ok, text) {
/* 无设备静默态:离线时掐掉常驻动画,规则见 css/responsive.css 的
html[data-dcu-calm="1"] 段(160Hz 屏上每帧重算+重绘 ≈ 白烧一核)。 */
document.documentElement.dataset.dcuCalm = ok ? '0' : '1';
/* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */
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;
/* 轮询状态即监控连通性(与顶栏 canConn 去重时仍更新 fbPollStatus) */
if (!el.monConnPill && el.fbPollStatus && !ok) {
/* 轮询状态即监控连通性(顶栏网络胶囊由 /system/status 统一写) */
if (el.fbPollStatus && !ok) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
}
/**
* 顶栏系统状态(与 can.js setConnectionState 对齐):
* STA/IP/RSSI 合并胶囊 + 运行时间 + 版本。
*/
function setSystemStatus(connected, text, buildVersion, buildTime, wifi, uptimeSec) {
if (!isStandalone()) return;
wifi = wifi || {};
var label = text || (connected ? '已连接' : '未连接');
if (connected) {
var ip = wifi.ip || (el.monIp && el.monIp.value && el.monIp.value.trim()) || '';
if (ip) label += ' · ' + ip;
var rssiOk = wifi.rssi !== undefined && wifi.rssi !== null && wifi.rssi !== -127;
if (rssiOk) label += ' · ' + wifi.rssi + ' dBm';
}
setNetPill(connected, label);
if (el.canUptimePill) {
el.canUptimePill.textContent = '运行 ' + (uptimeSec !== undefined && uptimeSec !== null
? formatUptime(uptimeSec) : '--');
}
if (el.canVersionPill) {
el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--';
el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : '';
}
}
/** 周期性刷新顶栏网络状态(与 can.js 一致,5s)。 */
async function refreshSystemStatus() {
if (!isStandalone()) return;
try {
var resp = await fetch(getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(3000),
cache: 'no-store'
});
if (!resp.ok) {
setSystemStatus(false, 'HTTP ' + resp.status, '', '', {}, null);
return;
}
var data = await resp.json();
var wifi = data.wifi || {};
var connected = !!wifi.connected;
var mode = wifi.mode || 'STA';
var text = connected ? (mode + ' 已连接') : (mode + ' 未连接');
setSystemStatus(connected, text, data.version, data.build_time, wifi, data.uptime_sec);
} catch (err) {
setSystemStatus(false, '连接失败', '', '', {}, null);
}
}
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() {
// 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
@@ -148,6 +208,53 @@ async function pollFeedback() {
}
}
/** 各通道最近一次 bus_state(唯一写入点=renderChannel;静默按钮样式与取反目标都读它)。 */
var s_lastBusState = [null, null, null];
/** 每通道"只听不发"按钮样式:只由 bus_state bit5 决定,不做乐观状态。 */
function updateSilentButton(i) {
var btn = el['btnSilent' + i];
if (!btn) return;
var bs = s_lastBusState[i];
var on = (bs !== null && (bs & 32) !== 0);
btn.className = 'btn ' + (on ? 'btn-primary' : 'btn-outline') + ' btn-sm';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.disabled = (bs === null);
btn.title = (bs === null)
? '通道状态未知(SPI 离线或尚未轮询到),无法判断'
: (on ? '当前只听不发:本端驱动器已关断,接收照常;点击恢复发送'
: '当前可正常发送;点击后该通道只听不发(只关本端发送,接收与上报不受影响)');
}
/** POST /can/silent 翻转指定通道静默;新样式等下一次 feedback 的 bit5 回来再刷。 */
async function toggleCanTxSilent(ch) {
var bs = s_lastBusState[ch];
if (bs === null || bs === undefined) {
showToast('通道状态未知,请先连上设备', 'error');
return;
}
var on = (bs & 32) === 0;
try {
var resp = await fetch(getBaseUrl() + '/can/silent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: ch, on: on ? 1 : 0 }),
signal: AbortSignal.timeout(3000)
});
var data = await resp.json();
if (data.ok) {
showToast('CH' + ch + (data.actual ? ' 已切为只听不发' : ' 已恢复发送'), 'success');
} else if (data.err === 'paused') {
showToast('OTA / 批量发送进行中,暂不可切换静默', 'error');
} else {
showToast('静默切换失败(err=' + data.err + ')', 'error');
}
} catch (e) {
showToast('静默切换失败: ' + e.message, 'error');
}
pollFeedback();
}
function renderChannel(i, ch) {
var p = 'fb' + i;
if (el[p + 'State']) {
@@ -171,17 +278,22 @@ function renderChannel(i, ch) {
if (ch.bus_state === undefined || ch.bus_state === null) {
el[p + 'Bus'].textContent = '未知';
el[p + 'Bus'].className = 'fb-val';
s_lastBusState[i] = null;
} else {
var bs = ch.bus_state;
var parts = [];
if (bs & 1) parts.push('INIT');
if (bs & 2) parts.push('SLEEP');
if (bs & 2) parts.push('控制器睡眠');
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 (bs & 32) parts.push('只听不发');
/* 未识别的位显示 hex,不回落成"正常"(否则新位会被读成一切正常) */
el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : ('0x' + bs.toString(16)));
el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4 | 16 | 32)) ? ' warn' : '');
s_lastBusState[i] = bs;
}
updateSilentButton(i);
}
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'; }
@@ -212,6 +324,8 @@ function renderChannelEmpty(i) {
});
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'; }
s_lastBusState[i] = null;
updateSilentButton(i);
}
/** GD32 编译关闭该通道(APP PING feat can_chN=false) */
@@ -226,6 +340,8 @@ function renderChannelDisabled(i) {
});
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'; }
s_lastBusState[i] = null;
updateSilentButton(i);
}
function renderIdTable(entries) {
@@ -303,21 +419,31 @@ export function initCanMonitor() {
clearInterval(state.fbTimer);
state.fbTimer = null;
}
if (state.statusTimer) {
clearInterval(state.statusTimer);
state.statusTimer = null;
}
setConnState(false, '未连接');
if (!state.bound) {
state.bound = true;
/* ConnectionHub:只覆盖独立页 monConn 灯的布尔;
* 嵌入 can.html 时无 monConnPill,不得碰 fbPollStatus(那是 /can/feedback 自己的轮询态)。 */
/* ConnectionHub:只覆盖独立页网络胶囊灯色;
* 嵌入 can.html 时不得碰顶栏与 fbPollStatus(那是 can.js / /can/feedback 自己的态)。 */
deviceLink.subscribe(function (s) {
if (!el.monConnPill) return;
if (!isStandalone()) return;
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
el.monConnPill.className = 'pill ' + (on ? 'online' : 'offline');
if (el.canNetPill) el.canNetPill.className = 'pill link-pill ' + (on ? 'online' : 'offline');
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (on ? 'ok' : 'err');
});
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
if (el.monIp) el.monIp.addEventListener('change', testConnection);
if (el.monTestBtn) el.monTestBtn.addEventListener('click', function () {
testConnection();
refreshSystemStatus();
});
if (el.monIp) el.monIp.addEventListener('change', function () {
testConnection();
refreshSystemStatus();
});
/* 嵌入 can.html 时 IP 与发送台共用 */
if (el.toolIp && !el.monIp) {
el.toolIp.addEventListener('change', function () {
@@ -327,6 +453,9 @@ export function initCanMonitor() {
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); });
[['btnSilent0', 0], ['btnSilent1', 1], ['btnSilent2', 2]].forEach(function (pair) {
if (el[pair[0]]) el[pair[0]].addEventListener('click', function () { toggleCanTxSilent(pair[1]); });
});
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
/* feedback 值 click-to-copy:在 grid 容器上事件代理 */
@@ -352,6 +481,11 @@ export function initCanMonitor() {
});
}
setTimeout(testConnection, 150);
/* 顶栏系统状态 5s 与 /can/feedback 1Hz 解耦;仅独立页维护 */
if (isStandalone()) {
setTimeout(refreshSystemStatus, 150);
state.statusTimer = setInterval(refreshSystemStatus, 5000);
}
pollFeedback();
state.fbTimer = setInterval(pollFeedback, 1000);
}
+87 -3
View File
@@ -4,9 +4,41 @@
* 各页面共用 localStorage key dcu_esp_ip(OTA / CAN / 电机页一致)。
* 本模块只提供纯 localStorage + 默认值的通用实现;页面特有的输入框回退
* (如 CAN 页 el.toolIp、OTA 页 #espIp)由各自入口在此基础上包装。
*
* 写入一律经 saveDeviceIp 的 IPv4 校验:这是"哪个端点算当前设备"的唯一落盘点,
* 脏值在这里被拒掉,比在每个调用方各判一次可靠(固件 AP/掉租约时会上报
* ip:"0.0.0.0",见 ESP32_COMM/main/server/http_handlers.cpp:1776)。
*/
export var LS_ESP_IP = 'dcu_esp_ip';
export var DEFAULT_ESP_IP = '192.168.31.127';
/** @description 最近一次"选中"的设备快照(ip + 版本指纹),供端点一致性核对。 */
export var LS_ESP_DEVICE = 'dcu_esp_device';
/** @description 局域网发现结果缓存(带 observedAt),只给 UI 当候选种子用。 */
export var LS_LAN_DEVICES = 'dcu_lan_devices';
/**
* @description 严格点分十进制 IPv4,并拒掉不能当 API 目标的保留段。
* 0/8(含固件脏值 0.0.0.0)与 255.255.255.255 直接拒;169.254/16 是链路本地,
* 板子不会在那儿服务 HTTP,也拒——宁可让人重填,也不要网页打到一个
* 永远不通的地址上还显示"已连接"。
* @param {string} s 待校验字符串
* @returns {boolean}
*/
export function isValidIpv4(s) {
var parts = String(s == null ? '' : s).trim().split('.');
if (parts.length !== 4) return false;
var nums = [];
for (var i = 0; i < 4; i++) {
var p = parts[i];
if (!/^\d{1,3}$/.test(p)) return false;
var n = parseInt(p, 10);
if (n > 255) return false;
nums.push(n);
}
if (nums[0] === 0 || nums[0] === 255) return false;
if (nums[0] === 169 && nums[1] === 254) return false;
return true;
}
/** @description 读取 localStorage 中保存的设备 IP,无有效值返回空串。 */
export function getSavedDeviceIp() {
@@ -17,10 +49,17 @@ export function getSavedDeviceIp() {
return '';
}
/** @description 保存设备 IP 到 localStorage(空值忽略)。 */
/**
* @description 保存设备 IP。非法值**拒绝且不改写现值**,返回 false——
* 不能用 DEFAULT_ESP_IP 去"兜住"脏输入,那会把配置自证成连接成功。
* @param {string} ip
* @returns {boolean} 是否真的写进去了
*/
export function saveDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
var v = String(ip == null ? '' : ip).trim();
if (!isValidIpv4(v)) return false;
try { localStorage.setItem(LS_ESP_IP, v); } catch (_) { return false; }
return true;
}
/** @description 设备 IP:localStorage 优先,否则默认值。 */
@@ -32,3 +71,48 @@ export function getDeviceIp() {
export function getBaseUrl() {
return 'http://' + getDeviceIp();
}
/**
* @description 读最近一次选中的设备指纹;结构不对一律当没有。
* @returns {{ip:string, version:string, git_hash:string, observedAt:number, source:string}|null}
*/
export function getDeviceMeta() {
try {
var raw = localStorage.getItem(LS_ESP_DEVICE);
if (!raw) return null;
var o = JSON.parse(raw);
if (!o || typeof o !== 'object' || !isValidIpv4(o.ip)) return null;
return o;
} catch (_) {}
return null;
}
/** @description 写设备指纹(与 IP 同时机,由 deviceLink.selectDevice 统一调用)。 */
export function saveDeviceMeta(meta) {
try {
localStorage.setItem(LS_ESP_DEVICE, JSON.stringify(meta || {}));
} catch (_) {}
}
/**
* @description 读发现结果缓存;返回数组(可能为空),坏数据直接丢弃不猜。
* @returns {Array<object>}
*/
export function getLanDevices() {
try {
var raw = localStorage.getItem(LS_LAN_DEVICES);
if (!raw) return [];
var arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr.filter(function (d) { return d && isValidIpv4(d.ip); });
} catch (_) {}
return [];
}
/** @description 存发现结果缓存(上限 16 条,超了截断——它只是候选种子,不是台账)。 */
export function saveLanDevices(list) {
var arr = Array.isArray(list) ? list.slice(0, 16) : [];
try {
localStorage.setItem(LS_LAN_DEVICES, JSON.stringify(arr));
} catch (_) {}
}
+132 -14
View File
@@ -1,7 +1,7 @@
/**
* connection.js - 连接管理 + 系统状态显示
*
* 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
* 操作 DOM 元素 #pillWifi/#pillEcat/#pillBle / #bConn 等(三链路胶囊在顶栏)。
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应
@@ -14,7 +14,7 @@
*/
import {
formatBytes, formatTime, formatUptime, escapeHtml,
setTextIfChanged, setClassIfChanged, setQuietMode,
setTextIfChanged, setClassIfChanged, setQuietMode, setCellSource,
} from './utils.js';
import { addLog } from './log.js';
import { showToast } from './toast.js';
@@ -72,37 +72,155 @@ function transferOngoing() {
} catch (_) { return false; }
}
/**
* 顶栏三链路胶囊:WiFi / ECAT / BLE 独立亮灭。
* **唯一写入点**:WiFi 文案只在这里生成(mode + 连接态),别处禁止再拼字符串。
* ECAT 文案由 app.js 写入(0x1008 slave.name)。
* @param {boolean|null} wifiOn WiFi 在线;null=按 DeviceLink 快照
* @param {boolean|null} [bleOn] BLE 在线;null=读 window.__bleConnState
*/
/** @description EtherCAT AL 状态位 → 人话(1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP)。 */
function ecatAlText(state) {
if (state === null || state === undefined || !Number.isFinite(Number(state))) return '';
switch (Number(state) & 0x0F) {
case 1: return 'INIT';
case 2: return 'PREOP';
case 3: return 'BOOT';
case 4: return 'SAFEOP';
case 8: return 'OP';
default: return '';
}
}
export function updateLinkPills(wifiOn, bleOn) {
try {
var snap = deviceLink.getSnapshot();
var fresh = snap && snap.ts && (Date.now() - snap.ts) < 5000;
var w = wifiOn;
if (fresh && snap.links && snap.links.esp32) w = !!snap.links.esp32.online;
if (typeof wifiOn === 'boolean' && !fresh) w = wifiOn;
/* WiFi 胶囊:文案 = mode + 连接态 + IP + 信号(STA=Station 客户端模式) */
var pw = getEl('pillWifi'), pwT = getEl('pillWifiTxt');
if (pw) {
var mode = 'STA';
var ip = '';
var rssi = '';
try {
if (fresh && snap && snap.esp32Status) {
var stWifi = snap.esp32Status.wifi || {};
if (stWifi.mode) mode = stWifi.mode;
if (stWifi.ip) ip = stWifi.ip;
if (stWifi.rssi != null && stWifi.rssi !== '') rssi = String(stWifi.rssi) + ' dBm';
}
if (!ip) ip = deviceLink.getIp() || '';
} catch (_) {}
var wifiLabel = mode + (w ? ' 已连接' : ' 未连接');
if (w && ip) wifiLabel += ' · ' + ip;
if (w && rssi) wifiLabel += ' · ' + rssi;
/* 运行时覆盖(重启中/上传中/设备忙):优先显示,避免被 1Hz 轮询冲掉 */
if (window.__wifiPillOverride) wifiLabel = window.__wifiPillOverride;
setClassIfChanged(pw, 'pill link-pill ' + (window.__wifiPillOverride ? 'processing' : (w ? 'online' : 'offline')));
if (pwT) setTextIfChanged(pwT, wifiLabel);
}
/* ECAT 胶囊:灯色与文案**都由这里出**。此前 app.js 的 0x1008 probe 也写同一颗
pill 的 className,两边节奏/判据都不同,实测出现过"绿点 + 文字离线"的矛盾。
分级:后端/网卡没开=离线;开了且 PDO 循环在跑=在线;开了但循环没跑=琥珀并标 AL 档。 */
var pe = getEl('pillEcat'), peT = getEl('pillEcatTxt');
if (pe) {
var E = (fresh && snap && snap.links && snap.links.ecat) ? snap.links.ecat : null;
var eName = (E && E.deviceName) || window.__ecatProbeName || '';
var eCls, eTxt;
if (!E) { eCls = 'idle'; eTxt = '未探测'; }
else if (!E.busOpen) { eCls = 'offline'; eTxt = '离线(后端/网卡未开)'; }
else if (E.loop) { eCls = 'online'; eTxt = eName || (E.op ? 'OP 循环中' : '循环中'); }
else {
var al = ecatAlText(E.slaveState);
eCls = 'processing';
eTxt = (eName ? eName + ' · ' : '') + (al ? al + ' 无循环' : '未进 OP');
}
setClassIfChanged(pe, 'pill link-pill ' + eCls);
if (peT) setTextIfChanged(peT, eTxt);
}
/* BLE 胶囊 */
var pb = getEl('pillBle'), pbT = getEl('pillBleTxt');
if (pb) {
var b = bleOn;
if (b === null || b === undefined) {
try {
var st = window.__bleConnState;
if (st === true || st === false) b = st;
} catch (_) {}
}
if (b === null || b === undefined) {
setClassIfChanged(pb, 'pill link-pill idle');
if (pbT) setTextIfChanged(pbT, '未连接');
} else if (b) {
setClassIfChanged(pb, 'pill link-pill online');
var nm = window.__bleDeviceName || '已连接';
var did = window.__bleDeviceId;
var bl = did ? (nm + ' · ' + did) : nm;
if (pbT) setTextIfChanged(pbT, bl);
} else {
setClassIfChanged(pb, 'pill link-pill offline');
if (pbT) setTextIfChanged(pbT, '未连接');
}
}
} catch (_) {}
}
/** WiFi 胶囊临时覆盖文案(重启中/上传中/设备忙);传 null 清除。 */
export function setWifiPillOverride(text) {
window.__wifiPillOverride = text || null;
updateLinkPills(null, null);
}
export function updateConnectionUi(connected, text) {
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。
双通路:WiFi 或 ECAT 任一在线即「在线」;角标标出实际在线链路。 */
var src = 'wifi';
try {
var snap = deviceLink.getSnapshot();
if (snap && snap.ts && (Date.now() - snap.ts) < 5000) {
var w = !!(snap.links && snap.links.esp32 && snap.links.esp32.online);
var e = !!(snap.links && snap.links.ecat && snap.links.ecat.online);
/* Hub 可把在线压成离线;**不得**把本页刚判的离线改回在线。
* Owner 要连失败 3 次才翻离线,期间若无脑覆盖,
* OTA 页超时判离线后灯又会被点绿 → 拔线后永远「已连接」。 */
if (!snap.links.esp32.online) connected = false;
* OTA 页超时判离线后灯又会被点绿 → 拔线后永远「已连接」。
* 双通路修正:WiFi 掉线但 ECAT 仍在线时,整体仍算在线(ECAT 撑着)。 */
if (!w && !e) connected = false;
else if (!connected && (w || e)) {
/* 仅当两路里至少一路 Hub 判在线,且本页因 WiFi 超时误判离线时,
* 才允许 Hub 把「在线」加回来(ECAT 撑场)。 */
if (e && !w) connected = true;
}
if (w && e) src = 'dual';
else if (e && !w) src = 'ecat';
else src = 'wifi';
if (!text) text = connected ? '已连接' : '离线';
if (connected && e && !w) text = text && text.indexOf('ECAT') < 0
? (text + ' · ECAT') : text;
} else {
/* 无快照:参数 connected 来自 WiFi 轮询 */
src = connected ? 'wifi' : 'off';
}
} catch (_) {}
} catch (_) {
src = connected ? 'wifi' : 'off';
}
var label = text || (connected ? '已连接' : '未连接');
/* 判离线立刻清实时指标:只改灯不等于清数据,否则 APP/缓存下会
* 出现「离线 + 温度/电压仍显示」的假活状态。 */
if (!connected && typeof window.clearLiveMetricsOnDisconnect === 'function') {
try { window.clearLiveMetricsOnDisconnect(); } catch (_) {}
}
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = getEl('pillConn');
var pct = getEl('pillConnTxt');
if (pc) {
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
if (pct) setTextIfChanged(pct, label);
}
// 系统状态卡徽章
// 顶栏三链路:只刷灯/来源角标;WiFi 文案由 updateLinkPills 统一生成
updateLinkPills(connected, null);
// 系统状态卡徽章(整体:任一数据通路在线即在线)
var bc = getEl('bConn');
if (bc) {
setTextIfChanged(bc, connected ? '在线' : '离线');
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
setCellSource('bConn', connected ? src : 'off');
}
setQuietMode(!connected && !transferOngoing());
}
+162 -9
View File
@@ -16,8 +16,10 @@
"use strict";
import {
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp,
isValidIpv4, getDeviceMeta, saveDeviceMeta
} from "./base-url.js";
import { flashBusyNow } from "./utils.js";
import {
runEcatBootstrap, repairEcatDc, getEcatAdapterOrDefault,
ECAT_DEFAULT_ADAPTER,
@@ -28,6 +30,7 @@ var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
var ESP32_POLL_MS = 1000;
var ECAT_POLL_MS = 2000;
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
var AUTO_SCAN_COOLDOWN_MS = 60000; /* 判离线后最多每 60s 提一次候选设备 */
var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
var LS_ECAT_SPIN = "dcu_ecat_spin";
@@ -42,7 +45,8 @@ function readIp() {
}
function writeIp(ip) {
saveDeviceIp(ip);
/* 校验在 base-url.saveDeviceIp 单点做:非法值不改写现值,返回 false。 */
return saveDeviceIp(ip);
}
function readAdapter() {
@@ -95,10 +99,14 @@ function emptySnapshot() {
links: {
esp32: { online: false, ageMs: -1, failStreak: 0 },
ecat: { online: false, busOpen: false, op: false, loop: false,
loadValid: false, loadPerm: 0, ageMs: -1 },
loadValid: false, loadPerm: 0, ageMs: -1, deviceName: "",
slaveState: null, alStatus: null },
ble: { online: false, deviceName: "", owner: null }
},
esp32Status: null
esp32Status: null,
/* 端点一致性:'match' | 'mismatch' | 'unverified'(见 checkEndpoint) */
endpointCheck: 'unverified',
endpointCheckNote: ''
};
}
@@ -118,6 +126,8 @@ function createOwner() {
var ecatBusReady = false;
var ecatBusBusy = false;
var ecatBusNextTryAt = 0;
var uptimeBase = null; /* {ip, up} 只用于 uptime 倒退检测(换板/复位) */
var autoScanAt = 0; /* 上次自动提候选的时刻,见 maybeAutoScan */
function publish() {
snap.ts = nowMs();
@@ -134,16 +144,79 @@ function createOwner() {
snap.links.esp32.online = true;
snap.links.esp32.ageMs = 0;
snap.esp32Status = status || null;
snap.endpointCheck = checkEndpoint(status);
} else {
esp32Fails++;
snap.links.esp32.failStreak = esp32Fails;
snap.links.esp32.ageMs = lastEsp32OkAt ? (nowMs() - lastEsp32OkAt) : -1;
if (esp32Fails >= ESP32_FAIL_ONLINE) snap.links.esp32.online = false;
if (esp32Fails >= ESP32_FAIL_ONLINE) {
snap.links.esp32.online = false;
snap.endpointCheck = checkEndpoint(null);
maybeAutoScan();
}
}
snap.links.esp32.failStreak = esp32Fails;
publish();
}
/**
* 「现在连的还是我选的那台吗」——判据必须有灵敏度,不能是自证:
* 版本变 / 设备自报 IP 与所选不符 / uptime 倒退,三条都能被
* "同地址换了一块板"或"板子刚复位"真实触发。
* 拉不到 status 时**不改判**(保持上一次结论),否则断网会把
* mismatch 说成事实。
*/
function checkEndpoint(status) {
var meta = getDeviceMeta();
var ip = readIp();
if (!meta || String(meta.ip) !== String(ip)) return 'unverified';
if (!status) {
/* 拉不到 status 就**没有**证据可判:不能沿用上一条结论,更不能把
* "上一个端点的 status" 拿来判当前端点(实测会吐出一条自造的
* "设备自报 x.x.x.x 与所选 y.y.y.y 不符"——那是陈旧数据的回声)。 */
snap.endpointCheckNote = '';
return 'unverified';
}
var w = status.wifi || {};
if (status.version && meta.version && String(status.version) !== String(meta.version)) {
snap.endpointCheckNote = '版本与所选不符:' + meta.version + ' → ' + status.version;
return 'mismatch';
}
if (w.connected === true && w.ip && String(w.ip) !== String(ip)) {
snap.endpointCheckNote = '设备自报 ' + w.ip + ',与所选 ' + ip + ' 不符';
return 'mismatch';
}
var up = Number(status.uptime_sec);
if (Number.isFinite(up)) {
if (uptimeBase && uptimeBase.ip === ip && Number.isFinite(uptimeBase.up)
&& up + 2 < uptimeBase.up) {
snap.endpointCheckNote = 'uptime ' + Math.round(uptimeBase.up) + 's → '
+ Math.round(up) + 's:这地址上换板了或刚复位';
uptimeBase = { ip: ip, up: up };
return 'mismatch';
}
uptimeBase = { ip: ip, up: up };
}
snap.endpointCheckNote = '';
return status.version ? 'match' : 'unverified';
}
/**
* 离线了才提一次候选,**绝不自动改端点**。三个门都是必需的:
* 扫描本身会占板子 httpd 的 socket(max_open_sockets=6 + lru_purge,
* wifi_ota.cpp:419-436),而 ECAT 环跑 / 刷写期间 httpd 常常本就不响应
* (spi_master.cpp:768-771)——那时 failStreak 是假阳性,不加门就成了
* "扫→踢连接→更离线→再扫"的正反馈。
*/
function maybeAutoScan() {
var now = nowMs();
if (autoScanAt && now - autoScanAt < AUTO_SCAN_COOLDOWN_MS) return;
if (flashBusyNow()) return;
if (snap.links.ecat && snap.links.ecat.loop) return;
autoScanAt = now;
if (ch) { try { ch.postMessage({ type: 'auto-scan', ip: readIp() }); } catch (_) {} }
}
function pollEsp32() {
if (stopped) return;
var ac = null;
@@ -172,9 +245,17 @@ function createOwner() {
/**
* 开总线:R6 —— 策略只在 js/common/ecat-bus.js(health→open→motor/start,
* DC 250µs,已健康则复用)。外壳 Owner 启动时强制执行,用户不必先去电机页。
* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
*/
function bootstrapEcatBus() {
if (stopped || ecatBusBusy) return;
/* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
* motorOtaEcatBusy = 电机 OTA 正经邮箱灌 GD32(native worker 独占总线十几秒),
* 这条请求只会在锁上排队,排到时把刚恢复的循环再停一遍。 */
if (window.bootEcatBusy || window.isUploading || window.motorOtaEcatBusy) return;
/* 对面是 BOOT(邮箱 PREOP、不跑 OP/PDO)时不要 motor/start:
* 跳转/刷写结束后 bootEcatIsBoot 会钉住,直到 jump_app 确认 APP。 */
if (window.bootEcatIsBoot === true) return;
var now = nowMs();
if (ecatBusReady) return;
if (now < ecatBusNextTryAt) return;
@@ -215,6 +296,14 @@ function createOwner() {
var e = snap.links.ecat;
e.online = !!(st && st.ok && st.slave);
e.busOpen = !!(st && st.ok);
e.deviceName = (st && st.slave && st.slave.name) ? String(st.slave.name) : "";
/* 从站 AL 状态原样带上,让灯色与文案同源:1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP。
* 只有 ok+slave 就判"在线"太松——实测从站在 INIT、0x1008 空、循环没跑时
* 也会被点成绿灯,而文字还写着"离线"。 */
e.slaveState = (st && st.slave && st.slave.state != null) ? Number(st.slave.state) : null;
e.alStatus = (st && st.slave && st.slave.al_status != null) ? Number(st.slave.al_status) : null;
/* 同步到 window:Client 侧 updateLinkPills 在旧快照缺字段时兜底读取 */
window.__ecatDeviceName = e.deviceName;
/* native status 不直接给 op/loop;用 load valid + slave state 粗判 */
e.op = !!(st && st.slave && st.slave.state != null && (st.slave.state & 0x0F) === 8);
e.loadValid = !!(ld && ld.ok && ld.valid === true
@@ -222,8 +311,12 @@ function createOwner() {
e.loop = e.loadValid || e.op;
e.loadPerm = (ld && Number.isFinite(ld.load_perm)) ? ld.load_perm : 0;
e.ageMs = (ld && Number.isFinite(ld.age_ms)) ? ld.age_ms : -1;
/* 与刚上电同判据:bus_open + loop 才算就绪。
* jump_app / commit 后 loop 被 loop_stop 掉、bus 仍开 —— 旧逻辑只在
* !busOpen 时清 ready,于是 Owner 永远不再 motor/start,连接要等外部
* 自愈才回来。loop 一掉就允许 bootstrap 重拉(刷写中由上面 busy 门拦住)。 */
if (e.busOpen && e.loop) ecatBusReady = true;
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
else ecatBusReady = false;
publish();
/* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc */
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
@@ -246,6 +339,14 @@ function createOwner() {
if (m.type === "hello") { publish(); return; }
if (m.type === "set-ip") {
writeIp(m.ip);
/* 换了端点=换了问题:旧的核对结论必须作废,否则新端点还没答过话,
面板就拿着上一个设备的 status 报"不符"。 */
snap.endpointCheck = 'unverified';
snap.endpointCheckNote = '';
uptimeBase = null; /* uptime 倒退检测只在"同一个端点连续观察"内成立 */
/* 无论采纳还是拒绝都把"实际生效的 IP"广播回去:否则各页输入框会停在
用户刚打的脏值上,显示连着、实际打的还是旧端点——那正是自证判据。 */
publish();
try {
fetch(baseUrlFromIp() + "/system/status", {
cache: "no-store",
@@ -287,8 +388,15 @@ function createClient() {
var ch = null;
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
var listeners = [];
var autoScanFns = [];
var last = null;
function emitAutoScan(m) {
for (var i = 0; i < autoScanFns.length; i++) {
try { autoScanFns[i](m || {}); } catch (_) {}
}
}
function emit() {
for (var i = 0; i < listeners.length; i++) {
try { listeners[i](last); } catch (_) {}
@@ -298,7 +406,9 @@ function createClient() {
if (ch) {
ch.addEventListener("message", function (ev) {
var m = ev && ev.data;
if (!m || m.type !== "snapshot" || !m.snap) return;
if (!m || !m.type) return;
if (m.type === "auto-scan") { emitAutoScan(m); return; }
if (m.type !== "snapshot" || !m.snap) return;
last = m.snap;
emit();
});
@@ -334,10 +444,49 @@ function createClient() {
return !!(L && L.online);
},
/* ── WiFi 端点 ── */
/* ── WiFi 端点(唯一写手;各页输入框/发现面板都走这里) ── */
/**
* @description 换设备。**唯一**能改当前端点的入口,门也只在这里判一次:
* - IPv4 合法性(base-url.saveDeviceIp 单点校验,非法不改写现值)
* - 刷写/OTA 进行中不换(motor/start 会 loop_stop、灌写会话会被掐死)
* @returns {{ok:boolean, ip?:string, reason?:string}}
*/
setIp: function (ip) {
writeIp(ip);
var v = String(ip == null ? '' : ip).trim();
if (!isValidIpv4(v)) return { ok: false, reason: '不是可用 IPv4:' + v };
if (flashBusyNow()) return { ok: false, reason: '刷写/OTA 进行中,暂不换端点' };
if (!writeIp(v)) return { ok: false, reason: '写入 localStorage 失败' };
if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} }
return { ok: true, ip: readIp() };
},
/**
* @description 选中一台发现到的设备:比 setIp 多一步指纹登记(版本/来源/时刻),
* Owner 之后每次拉 status 都据此核"连的还是不是这台"(snap.endpointCheck)。
* @param {{ip:string, version?:string, git_hash?:string, source?:string}} rec
*/
selectDevice: function (rec) {
var ip = rec && rec.ip != null ? String(rec.ip).trim() : '';
var r = this.setIp(ip);
if (!r.ok) return r;
saveDeviceMeta({
ip: r.ip,
version: rec.version ? String(rec.version) : '',
git_hash: rec.git_hash ? String(rec.git_hash) : '',
observedAt: nowMs(),
source: rec.source || 'lan'
});
return r;
},
/** @description 当前登记的端点指纹(无则 null)。 */
getDeviceMeta: function () { return getDeviceMeta(); },
/** @description 订阅"Owner 判离线、提候选"的信号(不自动改端点)。 */
onAutoScan: function (fn) {
if (typeof fn !== 'function') return function () {};
autoScanFns.push(fn);
return function () {
var i = autoScanFns.indexOf(fn);
if (i >= 0) autoScanFns.splice(i, 1);
};
},
getIp: function () { return readIp(); },
wifiBaseUrl: function () { return baseUrlFromIp(); },
@@ -371,6 +520,10 @@ function createClient() {
var deviceLink;
try {
if (window.__dcuDeviceLinkOwner) {
/* 把标记消费掉:同一文档里本模块若因入口 ?v= 与 JS 间 import 的 URL 不同被
* 实例化两次(tabs.html 就是这种:入口标签带 stamp,lan-picker.js 不带),
* 只允许有一个 Owner —— 两个 Owner 就是两份 1Hz status 轮询 + 两份快照互相打架。 */
window.__dcuDeviceLinkOwner = false;
window.__dcuDeviceLinkInstance = createOwner();
}
} catch (_) {}
+57 -28
View File
@@ -6,7 +6,9 @@
* health →(未就绪)status →(未开)open 网卡 → motor/start 进 OP + 起 PDO 循环
*
* 红线:
* - DC 钉死 250µs(period_ms 默认/钳制 0.25,dc_cycle_us = max(250, round(ms*1000)))
* - PDO 周期与 DC SYNC0 **始终同值**:dc_cycle_us = round(period_ms*1000),
* 周期可在 UI 档位 0.125–4ms 内随时热改,不再钉死 0.25。
* 默认仍是 0.25ms(历史稳妥档);≥1ms 时 GD32 发送出口可能变慢,属预期代价。
* - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start
* (start 会先 loop_stop,热路径假超时 / SSC 楔死风险)
* - **自动化只能基于证明,人才能基于意图**:任何非用户直接触发的 motor/start
@@ -22,12 +24,30 @@
/** 网卡匹配子串默认值(代码/页面/测试的唯一来源;文档漂移以此为准)。 */
export const ECAT_DEFAULT_ADAPTER = "I210";
/** 默认 PDO 周期(ms):1ms+DC 会让发送出口回到 ~1s,必须 250µs 档。 */
/** 默认 PDO 周期(ms)。 */
export const ECAT_DEFAULT_PERIOD_MS = 0.25;
/** DC SYNC0 目标周期(µs)。 */
/** 允许的 PDO 周期上下限(ms)——与 spi_test 档位一致,可随时热改。 */
export const ECAT_PERIOD_MIN_MS = 0.125;
export const ECAT_PERIOD_MAX_MS = 4;
/** 默认 DC SYNC0 周期(µs):与默认 period 0.25ms 对齐;热改后 DC 跟随 period。 */
export const ECAT_DC_CYCLE_US = 250;
/** 把任意 period 钳进合法区间;非法/缺省回落默认 0.25ms。 */
export function clampEcatPeriodMs(v) {
var per = Number(v);
if (!(per > 0)) return ECAT_DEFAULT_PERIOD_MS;
if (per < ECAT_PERIOD_MIN_MS) return ECAT_PERIOD_MIN_MS;
if (per > ECAT_PERIOD_MAX_MS) return ECAT_PERIOD_MAX_MS;
return per;
}
/** period_ms → DC SYNC0 µs(与 period 锁死同值,不再是恒 250)。 */
export function periodToDcUs(perMs) {
return Math.round(clampEcatPeriodMs(perMs) * 1000);
}
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
const LS_ECAT_SPIN = "dcu_ecat_spin";
const LS_ECAT_ADAPTER = "dcu_ecat_adapter";
@@ -42,20 +62,15 @@ function readAdapterRaw() {
}
/**
* 构造 motor/start body:period 钳到 ≤0.5ms 且默认 0.25,DC 钉 250µs。
* 构造 motor/start body:period 钳进 0.125–4ms,DC 与 period 同值。
* @param {{period_ms?:number, spin?:boolean|null, fromLocalStorage?:boolean}} [opts]
*/
export function resolveEcatStartBody(opts) {
opts = opts || {};
var body = {};
var per = Number(opts.period_ms);
if (!opts.fromLocalStorage && opts.period_ms == null) {
per = ECAT_DEFAULT_PERIOD_MS;
} else if (!(per > 0) || per > 0.5) {
per = ECAT_DEFAULT_PERIOD_MS;
}
var per = clampEcatPeriodMs(opts.period_ms);
body.period_ms = per;
body.dc_cycle_us = Math.max(ECAT_DC_CYCLE_US, Math.round(per * 1000));
body.dc_cycle_us = periodToDcUs(per);
body.dc_shift_us = 0;
var spin = opts.spin;
if (spin == null && opts.fromLocalStorage) {
@@ -79,8 +94,8 @@ export function readStoredEcatPeriod() {
localStorage.setItem(LS_PERIOD_MIG, "1");
}
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
/* 用户显式设过才用;否则 0.25ms */
if (v > 0 && v !== 1) return v;
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
if (v > 0 && v >= ECAT_PERIOD_MIN_MS && v <= ECAT_PERIOD_MAX_MS) return v;
return ECAT_DEFAULT_PERIOD_MS;
} catch (_) {
return ECAT_DEFAULT_PERIOD_MS;
@@ -113,16 +128,26 @@ export function proveEcatLoop(h, opts) {
/**
* 「DC/周期是否被*证明*配错」。缺字段 = 证明不了 = 不动总线(repairEcatDc 旧规则收编于此)。
* 判据:motor 路径必须 DC on;period / dc_cycle_us 各自在合法区间;
* 两者都有值时必须同值(period_ms*1000 ≈ dc_cycle_us)。
* 周期本身可以是 0.125–4ms 内任意档 —— 与 period 锁死的 DC 不再判错。
* @returns {"ok"|"misconfigured"|"unknown"}
*/
export function proveEcatDc(h) {
if (!h || h.ok !== true) return "unknown";
if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";
if (h.dc === 1 && Number(h.dc_cycle_us) === ECAT_DC_CYCLE_US &&
(h.period_ms == null || Math.abs(Number(h.period_ms) - ECAT_DEFAULT_PERIOD_MS) < 0.001)) {
return "ok";
if (h.dc !== 1) return "misconfigured";
var per = h.period_ms == null ? null : Number(h.period_ms);
var dc = h.dc_cycle_us == null ? null : Number(h.dc_cycle_us);
var perOk = per == null || (per >= ECAT_PERIOD_MIN_MS && per <= ECAT_PERIOD_MAX_MS);
var dcMin = Math.round(ECAT_PERIOD_MIN_MS * 1000);
var dcMax = Math.round(ECAT_PERIOD_MAX_MS * 1000);
var dcOk = dc == null || (dc >= dcMin && dc <= dcMax);
if (!perOk || !dcOk) return "misconfigured";
if (per != null && dc != null) {
if (Math.abs(Math.round(per * 1000) - dc) > 1) return "misconfigured";
}
return "misconfigured";
return "ok";
}
/**
@@ -283,10 +308,12 @@ export async function runEcatBootstrap(opts) {
}
/**
* DC/周期自愈:被**证明**配错(free-run 或 ≥1ms)时拉回 250µs。
* 判据在 proveEcatDc 里(唯一实现):证明不了 = 不动总线。这条规则的存在理由就是
* healthCheck 那份内联副本 —— 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包
* 就打出一记 loop_stop 的 motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
* DC/周期自愈:被**证明**配错(free-run / period 越界 / period 与 DC 不同值)时
* 按当前合法 period 把 DC 对齐回去。判据在 proveEcatDc 里(唯一实现):
* 证明不了 = 不动总线。这条规则的存在理由就是 healthCheck 那份内联副本 ——
* 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包就打出一记 loop_stop 的
* motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
* 修复目标是「period 与 DC 同值」,不是「一律拉回 0.25」。
* @param {(path:string,body?:object,timeoutMs?:number)=>Promise<any>} ecatPost
* @param {object} [knownHealth] 调用方已经读到的**原始** native/health 回包(省一次往返,
* 判据仍只走 proveEcatDc 这一处)。传错形状会被判成 unknown —— 也就是不动总线。
@@ -304,13 +331,15 @@ export async function repairEcatDc(ecatPost, knownHealth) {
if (proveEcatDc(h) !== "misconfigured") return null;
/* native 正在全量重连(close→open→OP,秒级)时再插一记 motor/start 是自相残杀:
* 两条恢复路各自 loop_stop,最后谁都不知道总线是谁停的。 */
if (h.recovering) return null;
return ecatPost("motor/start", {
period_ms: ECAT_DEFAULT_PERIOD_MS,
dc_cycle_us: ECAT_DC_CYCLE_US,
dc_shift_us: 0,
reason: "auto_dc_repair",
}, 30000);
if (h && h.recovering) return null;
var per = readStoredEcatPeriod();
if (h && h.period_ms != null) {
var hp = Number(h.period_ms);
if (hp >= ECAT_PERIOD_MIN_MS && hp <= ECAT_PERIOD_MAX_MS) per = hp;
}
var body = resolveEcatStartBody({ period_ms: per });
body.reason = "auto_dc_repair";
return ecatPost("motor/start", body, 30000);
}
/** @description 读当前网卡子串(localStorage → 默认 I210)。 */
+703
View File
@@ -0,0 +1,703 @@
/**
* lan-picker.js — 局域网设备发现面板(一份实现,两个入口)
*
* 入口:tabs.html 顶栏的 🔍(外壳文档 = 扫描的宿主)与 pages/index.html IP 行
* 的「发现」。iframe 里的入口只请求外壳开面板;外壳是同源父文档,所以不需要
* 选举——同一时刻只有一份扫描状态。
*
* 分工(重要):
* - 判据只在服务侧一处:lan_probe.classify() 说是不是板子,页面不复算,
* 免得出现第二个"什么算设备"的口径。
* - 编排只在这里:候选表、分批、进度、取消、缓存、渲染。
* - 换端点只走 deviceLink.selectDevice()(唯一的写手 + 唯一的门)。
*
* 当前已选端点**不重复开连接**去探:Owner 那条 1Hz /system/status 轮询已经是
* 它的活体判决(板子 httpd 只有 6 个 socket 且开了 lru_purge,多开一条会踢掉
* 网页自己的 keep-alive)。所以候选表把当前端点剔掉,行内容由快照渲染。
*/
"use strict";
import { deviceLink } from './device-link.js';
import {
isValidIpv4, getLanDevices, saveLanDevices, getDeviceIp
} from './base-url.js';
import { escapeHtml, flashBusyNow } from './utils.js';
var PROBE_PATH = '/system/status?lite=1';
var BATCH_IPS = 64; // 一次 /api/lan/probe 的 IP 数(与服务侧 BATCH_MAX_IPS 一致)
var CLIENT_PARALLEL = 2; // 同时在途的批数(整段 /24 实测 ~3.5–6s)
var BATCH_TIMEOUT_MS = 6000; // 一批的 fetch 上限(服务侧一批最坏 ~1.8s)
var PER_IP_TIMEOUT_MS = 750; // 交给服务侧的单目标硬截止
var STALE_MS = 20000; // 结果"新鲜"窗口,过期行变灰、按钮改「尝试连接」
var SEED_IPS = ['192.168.31.127', '192.168.31.1'];
var MAX_ROWS = 40; // 未知/拒绝列表各截断到这里,但计数照全报
var state = {
open: false,
scanning: false,
cancel: false,
tier: '',
cidr: '',
local: null,
rows: [], // verdict=board
unknowns: [], // 连不上/读超时/形状合但落不进判据
rejected: [], // 有反证的
scan: null,
skipped: [],
detail: Object.create(null), // ip -> 完整 status(人点了「详情」才要)
direct: Object.create(null), // ip -> 本页直测结论(B 路证据)
note: null,
progress: { done: 0, total: 0 },
autoScanned: 0
};
/* ───────────────────────── 纯函数(不碰 DOM,便于推理与离线核对) ───────────────────────── */
/** @description 192.168.31.0/24 → 1..254 主机地址。只支持 /24(掩码更宽请直接填 CIDR)。 */
export function cidrToIps(cidr) {
var s = String(cidr || '').trim();
var bits = s.split('/');
var q = bits[0].split('.');
if (q.length !== 4 || !isValidIpv4(q.join('.'))) return { err: 'CIDR 不合法:' + s };
var mask = bits.length > 1 ? parseInt(bits[1], 10) : 24;
if (mask !== 24) return { err: '只支持 /24(当前 /' + mask + ');需要更小的段请逐个手填 IP' };
if (bits.length > 1 && q[3] !== '0') return { err: 'CIDR 网络地址段末应为 .0:' + s };
var base = q.slice(0, 3).join('.');
var ips = [];
for (var i = 1; i <= 254; i++) ips.push(base + '.' + i);
return { ips: ips, cidr: base + '.0/24' };
}
/** @description 私有网段判定(10/8、172.16/12、192.168/16)。 */
export function isPrivateIp(ip) {
var q = String(ip).split('.');
var a = parseInt(q[0], 10), b = parseInt(q[1], 10);
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
return false;
}
/**
* @description 候选表 = 去重 + 剔掉"探了也没意义"的地址。
* 被剔掉的每一条都带原因并显示出来——静默丢弃会让本机 :80 服务冒充板子。
* @param {{ips:string[], localIps?:string[], endpointIp?:string, allowPublic?:boolean}} o
*/
export function buildCandidates(o) {
var local = {};
(o.localIps || []).forEach(function (ip) { local[ip] = 1; });
var out = [], skipped = [], seen = {};
(o.ips || []).forEach(function (ip) {
var v = String(ip || '').trim();
if (!isValidIpv4(v)) { if (v) skipped.push([v, '不是 IPv4']); return; }
if (seen[v]) return;
seen[v] = 1;
if (/^127\./.test(v)) { skipped.push([v, '回环']); return; }
if (/^169\.254\./.test(v)) { skipped.push([v, '链路本地']); return; }
if (local[v]) { skipped.push([v, '本机地址']); return; }
if (o.endpointIp && o.endpointIp === v) {
skipped.push([v, '当前端点:由 1Hz 轮询判活,不重复开连接']); return;
}
if (!isPrivateIp(v) && !o.allowPublic) {
skipped.push([v, '非私有网段(要探请勾"允许公网段")']); return;
}
out.push(v);
});
return { ips: out, skipped: skipped };
}
/** @description 把服务侧逐 IP 结果分成三堆。判决本身来自服务侧,这里不重算。 */
export function groupResults(results) {
var g = { boards: [], unknowns: [], rejected: [] };
(results || []).forEach(function (r) {
if (!r || !r.ip) return;
/* 服务侧不返回时间戳,这里补上"本轮观测时刻"——否则行会被当成过期数据变灰。 */
if (!r.observedAt) r.observedAt = Date.now();
if (r.verdict === 'board') g.boards.push(r);
else if (r.verdict === 'rejected') g.rejected.push(r);
else g.unknowns.push(r);
});
return g;
}
/** @description 快扫种子 = 当前端点 + 历史默认 + 上次发现到的(都过一遍候选过滤)。 */
export function seedIps(endpointIp, lastSeen) {
var list = SEED_IPS.slice();
if (endpointIp) list.unshift(endpointIp);
(lastSeen || []).forEach(function (ip) { if (list.indexOf(ip) < 0) list.push(ip); });
return list.slice(0, 8);
}
/* ───────────────────────── 服务侧调用 ───────────────────────── */
async function apiProbe(body, timeoutMs) {
var resp = await fetch('/api/lan/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs || BATCH_TIMEOUT_MS)
});
var text = await resp.text();
var j = null;
try { j = text ? JSON.parse(text) : null; } catch (_) {
throw new Error('探测响应非合法 JSON(HTTP ' + resp.status + ')');
}
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.json = j;
throw err;
}
return j || {};
}
/** @description 「本页直测」:让页面自己打一次 lite,证明*这个文档*能不能到它。
* 服务侧扫到(A)与页面能连(B)是两条独立证据,浏览器对 127.0.0.1→私有子网
* 的限制(Local Network Access)只会影响 B,所以这条要能单独出结论。 */
async function directProbe(ip, path, timeoutMs) {
var t0 = (window.performance || Date).now();
try {
var r = await fetch('http://' + ip + (path || PROBE_PATH), {
mode: 'cors', cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs || 2500)
});
var j = null;
try { j = await r.json(); } catch (_) { j = null; }
return { ok: r.ok && j !== null, http: r.status, json: j, ms: Math.round((window.performance || Date).now() - t0) };
} catch (e) {
return {
ok: false,
name: (e && e.name) || 'Error',
msg: (e && e.message) || String(e),
ms: Math.round((window.performance || Date).now() - t0)
};
}
}
/* ───────────────────────── 扫描编排 ───────────────────────── */
function canStartScan() {
if (state.scanning) return '上一批还在跑(先取消或等它结束)';
/* 门放在发起处一次判完:刷写中扫段会抢设备 httpd 的 6 个 socket 并触发
* lru_purge 踢连接,等于把正在灌写的会话掐死。 */
if (flashBusyNow()) return '刷写/OTA 进行中,不发起扫描(会占设备 httpd 的 socket 并可能踢掉正在灌写的连接)';
return '';
}
async function runScan(tier) {
var refuse = canStartScan();
if (refuse) { say('warn', refuse); return; }
state.scanning = true;
state.cancel = false;
state.tier = tier;
state.rows = []; state.unknowns = []; state.rejected = [];
state.skipped = []; state.scan = null; state.note = null;
lastListHtml = null;
state.progress = { done: 0, total: 0 };
render();
var groups = { boards: [], unknowns: [], rejected: [] };
/* durationMs 是各批服务端耗时之和(并发下会大于真实等待),只用于对账;
* 给人看的"多久"用页面墙钟,别把累加值当耗时报。 */
var stats = { candidates: 0, tcpOpen: 0, httpProbed: 0, board: 0, rejected: 0, unknown: 0, durationMs: 0, wallMs: 0 };
var t0 = Date.now();
var err = '';
try {
var endpoint = deviceLink.getIp();
var lastSeen = getLanDevices().map(function (d) { return d.ip; });
// 一次空探测顺便取本机网段(不浪费往返;候选为 0 时服务侧立即返回)
var hello = await apiProbe({ ips: [], ports: [80], local: true }, 4000);
state.local = hello.local || null;
var localIps = (hello.local && hello.local.local_ips) || [];
function fullIps() {
var cidr = state.cidr || (hello.local && hello.local.cidr) || '';
if (!cidr) throw new Error('取不到本机默认路由,无法派生 /24。请在 CIDR 框手填(如 192.168.31.0/24)后重试。');
var c = cidrToIps(cidr);
if (c.err) throw new Error(c.err);
state.cidr = c.cidr;
return c.ips;
}
async function sweep(raw) {
var built = buildCandidates({
ips: raw, localIps: localIps, endpointIp: endpoint,
allowPublic: !!state.allowPublic
});
state.skipped = state.skipped.concat(built.skipped);
var ips = built.ips;
/* 当前端点不进候选表(由 Owner 的 1Hz 快照判活),但作为一行结果单独摆着。 */
stats.candidates += ips.length;
state.progress.total += ips.length;
render();
var queue = [];
for (var i = 0; i < ips.length; i += BATCH_IPS) queue.push(ips.slice(i, i + BATCH_IPS));
/* 批间并发 CLIENT_PARALLEL;服务侧一批内并发 48(跨不同地址,
* 一块板子全程只被占 1 个 socket)。JS 单线程,await 恢复后的累加是原子的。 */
async function worker() {
while (queue.length && !state.cancel) {
var batch = queue.shift();
if (!batch) break;
var out = await apiProbe({
ips: batch, ports: [80], path: PROBE_PATH,
timeout_ms: PER_IP_TIMEOUT_MS
});
var g = groupResults(out.results);
groups.boards = groups.boards.concat(g.boards);
groups.unknowns = groups.unknowns.concat(g.unknowns);
groups.rejected = groups.rejected.concat(g.rejected);
var s = out.scan || {};
['tcpOpen', 'httpProbed', 'board', 'rejected', 'unknown'].forEach(function (k) {
stats[k] = (stats[k] || 0) + (s[k] || 0);
});
stats.durationMs += (s.durationMs || 0);
if (s.truncated && s.pending && s.pending.length) {
state.skipped.push(['—', '本批预算用尽,未探 ' + s.pending.length + ' 个']);
}
(out.errors || []).forEach(function (e2) { state.skipped.push(['—', '服务侧: ' + e2]); });
state.progress.done = Math.min(state.progress.done + batch.length, state.progress.total);
state.rows = groups.boards; state.unknowns = groups.unknowns;
state.rejected = groups.rejected;
render();
}
}
var workers = [];
for (var w = 0; w < Math.max(1, CLIENT_PARALLEL); w++) workers.push(worker());
await Promise.all(workers);
}
if (tier === 'fast') {
await sweep(seedIps(endpoint, lastSeen));
/* 种子没命中就自动扩到整段:设备换 DHCP 地址正是最常见的失效原因,
* 而整段实测 ~4s(并发 48),没理由让用户再点一次按钮。 */
if (!groups.boards.length && !state.cancel) {
var seg = (state.local && state.local.cidr) || '/24';
state.skipped.push(['—', '种子未命中 → 自动扩到整段 ' + seg]);
say('warn', '种子里没有设备(多半是地址变了)——自动扩到整段 ' + seg);
await sweep(fullIps());
}
} else {
await sweep(fullIps());
}
if (state.cancel) say('warn', '已取消:已探测 ' + state.progress.done + '/'
+ state.progress.total + ' 个地址,结果保留在下面(取消只表示不再发下一批,在途那批仍会跑完)');
} catch (e) {
err = (e && e.message) || String(e);
}
state.scanning = false;
stats.wallMs = Date.now() - t0;
state.scan = stats;
if (err) state.note = { kind: 'error', text: err, at: Date.now() };
// 只把"确认是板子"的写回候选种子缓存;未知/拒绝的不配当种子。
persistSeen(groups.boards);
render();
if (!err) {
say('info', '扫描完成:候选 ' + stats.candidates + ' → 端口开 ' + stats.tcpOpen
+ ' → 板子 ' + groups.boards.length + ' 台,不可判定 ' + groups.unknowns.length
+ ',反证排除 ' + groups.rejected.length + '(用时 ' + stats.wallMs + 'ms)');
}
}
function persistSeen(boards) {
var prev = getLanDevices();
var now = Date.now();
var byIp = {};
(boards || []).forEach(function (b) { byIp[b.ip] = { ip: b.ip, observedAt: now, mode: b.mode, kind: b.kind }; });
// 本轮没探到的旧条目保留但不再刷新 observedAt —— 它会自然变灰,不会假装"还在"
prev.forEach(function (p) { if (!byIp[p.ip]) byIp[p.ip] = p; });
saveLanDevices(Object.keys(byIp).map(function (k) { return byIp[k]; }));
}
/* ───────────────────────── 反馈 ───────────────────────── */
function say(kind, text) {
state.note = { kind: kind, text: text, at: Date.now() };
renderNote();
var lvl = kind === 'error' ? 'E' : (kind === 'warn' ? 'W' : 'I');
/* 外壳文档(tabs.html)没有日志面板也没有 toast 容器,所以面板自己的这行
* 是**兜底的、必定可见的**出口;页面的 log/toast 只是额外一份。 */
if (typeof window.addLog === 'function') {
try { window.addLog(lvl, '设备', text); } catch (_) {}
}
if (kind !== 'info' && typeof window.showToast === 'function') {
try { window.showToast(text, kind === 'error' ? 'error' : 'warning', { force: true }); } catch (_) {}
}
}
/* ───────────────────────── 渲染 ───────────────────────── */
var pillEl = null, pillTxtEl = null, panelEl = null;
/* 面板每 2s 会被定时器刷一次;列表 HTML 不变就不重排 DOM(否则输入焦点/滚动
会被重建打掉)。扫描开始、选中设备时显式作废。 */
var lastListHtml = null;
function ageText(ts) {
if (!ts) return '—';
var s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return s + 's 前';
if (s < 3600) return Math.round(s / 60) + 'min 前';
return Math.round(s / 3600) + 'h 前';
}
function boardLabel(b) {
var d = state.detail[b.ip];
var bits = [b.kind === 'ap' ? 'AP 回退' : 'STA'];
if (d) {
bits.push('v' + (d.version || '?'));
if (d.git_hash) bits.push(d.git_hash.slice(0, 7));
if (d.wifi && Number.isFinite(d.wifi.rssi)) bits.push(d.wifi.rssi + 'dBm');
if (Number.isFinite(d.uptime_sec)) bits.push('up ' + Math.round(d.uptime_sec) + 's');
} else if (b.schema === 'core-only') {
bits.push('旧固件(无 gd32_features)');
}
if (b.schema === 'full' && !d) bits.push('schema 全');
return bits.join(' · ');
}
function rowHtml(b) {
var isSel = b.ip === deviceLink.getIp();
var stale = !b.observedAt || (Date.now() - b.observedAt) > STALE_MS;
var d = state.direct[b.ip];
/* 行用 layout.css 的 .jump-history-item(.jh-tag / .jh-msg / .jh-time 与
.ok/.info 状态色都是既有类),这里只加"可点选"与过期两种修饰。 */
var h = '<li class="jump-history-item ok' + (isSel ? ' lan-cur' : '')
+ (stale ? ' lan-stale' : '') + '" data-ip="' + escapeHtml(b.ip) + '" title="'
+ (stale ? '尝试连接(结果已过期)' : '设为当前设备') + '">';
h += '<span class="jh-tag">' + escapeHtml(b.kind === 'ap' ? 'AP' : 'STA') + '</span>';
h += '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(b.ip) + '</span> '
+ '<span class="lan-meta">' + escapeHtml(boardLabel(b)) + '</span></span>';
h += '<span class="lan-acts">'
+ '<button class="btn btn-ghost btn-sm" data-act="detail" data-ip="' + escapeHtml(b.ip) + '">详情</button>'
+ '<button class="btn btn-ghost btn-sm" data-act="direct" data-ip="' + escapeHtml(b.ip) + '">本页直测</button>'
+ '</span>';
h += '<span class="jh-time">' + escapeHtml(ageText(b.observedAt)) + '</span>';
if (d) {
h += '<span class="lan-sub">' + (d.ok
? '本页可达 ✓ HTTP ' + d.http + ' · ' + d.ms + 'ms'
: '本页取不到:' + escapeHtml(d.name + ' ' + d.msg)
+ '(服务侧扫到了,多半是浏览器对本机访问私有网段的限制)')
+ '</span>';
}
h += '</li>';
return h;
}
function renderPill() {
if (!pillEl) return;
var snap = deviceLink.getSnapshot();
var ip = (snap && snap.ip) || deviceLink.getIp();
var st = snap && snap.esp32Status;
var on = !!(snap && snap.links && snap.links.esp32 && snap.links.esp32.online);
var txt = ip;
if (st && st.version) txt += ' · v' + st.version;
if (st && st.wifi && Number.isFinite(st.wifi.rssi)) txt += ' · ' + st.wifi.rssi + 'dBm';
/* 顶栏这颗就是各页在用的 .pill 组件(online/offline/processing 是既有状态类),
不另造配色。mismatch 用 processing(琥珀)而不是 offline:设备是活的,
只是"不像我选的那台"。 */
var cls = 'pill ' + (snap && snap.endpointCheck === 'mismatch' ? 'processing'
: (on ? 'online' : 'offline'));
if (pillTxtEl && pillTxtEl.textContent !== txt) pillTxtEl.textContent = txt;
if (pillEl.className !== cls) pillEl.className = cls;
pillEl.title = (snap && snap.endpointCheckNote)
? ('端点核对:' + snap.endpointCheck + ' — ' + snap.endpointCheckNote)
: ('端点核对:' + ((snap && snap.endpointCheck) || 'unverified'));
}
function renderNote() {
var n = panelEl && panelEl.querySelector('.lan-note');
if (!n) return;
var t = state.note ? state.note.text : '';
var cls = 'lan-note' + (state.note && state.note.kind !== 'info' ? ' lan-note-' + state.note.kind : '');
if (n.textContent !== t) n.textContent = t;
if (n.className !== cls) n.className = cls;
}
function render() {
if (!panelEl) { renderPill(); return; }
var mask = panelEl.querySelector('.lan-mask');
if (mask) mask.classList.toggle('show', !!state.open);
renderPill();
var head = panelEl.querySelector('.lan-ep');
if (head) {
var snap = deviceLink.getSnapshot();
var ec = snap ? snap.endpointCheck : 'unverified';
var t = '当前端点 ' + getDeviceIp() + ' · 核对 ' + ec
+ ((snap && snap.endpointCheckNote) ? ':' + snap.endpointCheckNote : '');
if (head.textContent !== t) head.textContent = t;
head.className = 'lan-ep' + (ec === 'mismatch' ? ' lan-note-warn' : '');
}
var cidrIn = panelEl.querySelector('.lan-cidr');
if (cidrIn && cidrIn !== document.activeElement && cidrIn.value !== state.cidr) {
cidrIn.value = state.cidr || '';
}
var pub = panelEl.querySelector('.lan-pub');
if (pub) pub.checked = !!state.allowPublic;
var prog = panelEl.querySelector('.lan-progress');
if (prog) {
var ptxt = state.scanning
? ('扫描中… ' + state.progress.done + '/' + state.progress.total
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length)
: (state.scan
? ('上一轮:候选 ' + state.scan.candidates + ' · 端口开 ' + state.scan.tcpOpen
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length
+ ' · 反证排除 ' + state.rejected.length + ' · 用时 ' + state.scan.wallMs + 'ms')
: '还没扫。快扫只打几个已知地址(≈1s);全段扫整条 /24(≈10s)。');
if (prog.textContent !== ptxt) prog.textContent = ptxt;
prog.classList.toggle('lan-busy', !!state.scanning);
}
var list = panelEl.querySelector('.lan-list');
if (list) {
var rows = state.rows.slice(0, MAX_ROWS);
var snapNow = deviceLink.getSnapshot();
var epIp = deviceLink.getIp();
var html = '';
if (snapNow) {
var on = snapNow.links && snapNow.links.esp32 && snapNow.links.esp32.online;
var st2 = snapNow.esp32Status || {};
html += '<li class="jump-history-item info lan-cur">'
+ '<span class="jh-tag">当前</span>'
+ '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(epIp) + '</span> '
+ '<span class="lan-meta">' + (on ? '在线' : '离线(连续 3 次拉不到 status)')
+ (st2.version ? ' · v' + escapeHtml(st2.version) : '')
+ (st2.wifi && Number.isFinite(st2.wifi.rssi) ? ' · ' + st2.wifi.rssi + 'dBm' : '')
+ (Number.isFinite(st2.uptime_sec) ? ' · up ' + Math.round(st2.uptime_sec) + 's' : '')
+ '</span></span></li>';
}
html += rows.map(function (b) { return rowHtml(b); }).join('');
if (state.rows.length > MAX_ROWS) {
html += '<li class="lan-cut">列表截断到 ' + MAX_ROWS + ' 行,实际 ' + state.rows.length + ' 台</li>';
}
if (!rows.length && !state.scanning) {
html += '<li class="lan-empty">本轮没扫到板子。别急着当成"板子没了",先看下面的计数与反证列表:</li>';
}
if (lastListHtml !== html) { list.innerHTML = html; lastListHtml = html; }
}
var misc = panelEl.querySelector('.lan-misc');
if (misc) {
var m = '';
if (state.unknowns.length) {
m += '<div class="lan-block"><b>不可判定 ' + state.unknowns.length + ' 个</b>'
+ '(连不上 / 读超时 / 形状合但落不进判据)——超时不等于"不在",'
+ 'ECAT 环跑与刷写期间设备 httpd 本来就可能不响应。<ul>'
+ state.unknowns.slice(0, MAX_ROWS).map(function (u) {
return '<li>' + escapeHtml(u.ip) + ' — ' + escapeHtml(u.reason || '') + '</li>';
}).join('') + '</ul></div>';
}
if (state.rejected.length) {
m += '<div class="lan-block"><b>反证排除 ' + state.rejected.length + ' 个</b>(有证据不是板子,不是"没扫到")<ul>'
+ state.rejected.slice(0, MAX_ROWS).map(function (r) {
return '<li>' + escapeHtml(r.ip) + ':' + (r.port || 80) + ' — '
+ escapeHtml(r.reason || '') + (r.head ? ' 「' + escapeHtml(String(r.head).slice(0, 60)) + '」' : '') + '</li>';
}).join('') + '</ul></div>';
}
if (state.skipped.length) {
m += '<div class="lan-block"><b>剔掉 ' + state.skipped.length + ' 个</b><ul>'
+ state.skipped.map(function (s) {
return '<li>' + escapeHtml(String(s[0])) + ' — ' + escapeHtml(String(s[1])) + '</li>';
}).join('') + '</ul></div>';
}
if (state.local) {
m += '<div class="lan-block">本机:' + escapeHtml(state.local.default_src_ip || '?')
+ ' → 段 ' + escapeHtml(state.local.cidr || '(取不到)')
+ ';本机地址 ' + escapeHtml((state.local.local_ips || []).join(', ') || '—') + '</div>';
}
m += '<div class="lan-block lan-hint">两块同款板除了 IP 无法区分(固件不上报 mac/chip_id)。'
+ '认领办法:选中一行看「详情」的 uptime,或按板上复位键后立刻快扫——新上电那台 uptime &lt; 30s。'
+ 'AP 回退的板子自称 192.168.31.1:PC 还连着路由器 WLAN 时它在三层上不可达,'
+ '要真人连热点 ESP32_OTA_RELAY 才探得到。</div>';
if (misc.innerHTML !== m) misc.innerHTML = m;
}
renderNote();
var busy = panelEl.querySelector('[data-act="fast"]');
if (busy) busy.disabled = state.scanning;
var full = panelEl.querySelector('[data-act="full"]');
if (full) full.disabled = state.scanning;
var cancelBtn = panelEl.querySelector('[data-act="cancel"]');
if (cancelBtn) cancelBtn.style.display = state.scanning ? '' : 'none';
}
/* ───────────────────────── 挂载 ───────────────────────── */
var PANEL_HTML =
'<div class="lan-mask" role="dialog" aria-modal="true" aria-label="局域网设备发现">'
+ '<div class="lan-box" tabindex="-1">'
+ '<div class="lan-head"><span>设备发现</span>'
+ '<span class="lan-ep"></span>'
+ '<button class="btn btn-ghost btn-sm" data-act="close">关闭 (Esc)</button>'
+ '</div>'
+ '<div class="lan-body">'
+ '<div class="lan-tools">'
+ '<button class="btn btn-primary btn-sm" data-act="fast">快扫</button>'
+ '<button class="btn btn-outline btn-sm" data-act="full">全段扫描</button>'
+ '<button class="btn btn-ghost btn-sm" data-act="cancel">取消</button>'
+ '<span class="lan-field"><label class="form-label" for="lanCidr">CIDR</label>'
+ '<input class="input lan-cidr" id="lanCidr" type="text" placeholder="留空 = 本机 /24"></span>'
+ '<label class="lan-check"><input class="lan-pub" type="checkbox"> 允许公网段</label>'
+ '</div>'
+ '<div class="lan-progress"></div>'
+ '<ul class="lan-list" role="listbox" aria-label="发现的设备"></ul>'
+ '<div class="lan-note"></div>'
+ '<div class="lan-misc"></div>'
+ '</div>'
+ '</div>'
+ '</div>';
function bindPanel() {
panelEl.addEventListener('click', async function (ev) {
/* 遮罩空白处(就是 .lan-mask 自己)= 收起;行内按钮先被 data-act 接走。 */
if (ev.target && ev.target.classList && ev.target.classList.contains('lan-mask')) {
state.open = false;
render();
return;
}
var t = ev.target.closest ? ev.target.closest('[data-act],[data-ip]') : null;
if (!t) return;
var act = t.getAttribute('data-act');
var ip = t.getAttribute('data-ip') || '';
if (act === 'close') { state.open = false; render(); return; }
if (act === 'fast') { runScan('fast'); return; }
if (act === 'full') {
var c = panelEl.querySelector('.lan-cidr');
state.cidr = c && c.value ? c.value.trim() : '';
runScan('full');
return;
}
if (act === 'cancel') {
state.cancel = true;
say('warn', '取消中:不再发下一批;在途那批(≤' + BATCH_IPS + ' 个地址)仍会跑完');
render();
return;
}
if (act === 'detail') {
say('info', '取 ' + ip + ' 的完整 status…');
var r = await directProbe(ip, '/system/status', 3000);
if (r.ok && r.json) {
state.detail[ip] = r.json;
state.note = null;
} else {
state.note = { kind: 'warn', text: ip + ' 完整 status 取不到:'
+ (r.name || ('HTTP ' + r.http)) + ' ' + (r.msg || ''), at: Date.now() };
}
render();
return;
}
if (act === 'direct') {
var d = await directProbe(ip, PROBE_PATH, 2500);
state.direct[ip] = d;
render();
return;
}
/* 行本体 = 选择。唯一的写手是 deviceLink.selectDevice,门也在它里面。 */
if (ip) {
var rec = { ip: ip, source: 'lan' };
var det = state.detail[ip];
if (det && det.version) { rec.version = det.version; rec.git_hash = det.git_hash; }
var res = deviceLink.selectDevice(rec);
if (!res.ok) { say('error', '未切换:' + (res.reason || '未知原因')); return; }
say('info', '已选 ' + ip + ' —— 页面能不能连它,看顶栏灯(Owner 下一次 1Hz 轮询即出,≤1s)');
lastListHtml = null;
render();
}
});
var cidrIn = panelEl.querySelector('.lan-cidr');
if (cidrIn) {
cidrIn.addEventListener('change', function () { state.cidr = cidrIn.value.trim(); });
}
var pub = panelEl.querySelector('.lan-pub');
if (pub) {
pub.addEventListener('change', function () { state.allowPublic = pub.checked; });
}
}
function mountHost(anchor) {
panelEl = document.createElement('div');
panelEl.innerHTML = PANEL_HTML;
(anchor || document.body).appendChild(panelEl);
bindPanel();
/* 弹出式:遮罩铺满 + 周围高斯模糊;点遮罩空白处 / Esc / 关闭按钮都收起。 */
function openPanel() {
state.open = true;
render();
var box = panelEl.querySelector('.lan-box');
if (box && box.focus) { try { box.focus({ preventScroll: true }); } catch (_) { box.focus(); } }
if (!state.scan && !state.scanning) runScan('fast');
}
function closePanel() { state.open = false; render(); }
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && state.open) { state.cancel = true; closePanel(); }
});
pillEl = document.getElementById('lanPill');
pillTxtEl = document.getElementById('lanPillTxt');
var btn = document.getElementById('lanScanBtn');
if (btn) {
btn.addEventListener('click', function () {
if (state.open) closePanel(); else openPanel();
});
}
/* 外壳是扫描的宿主:iframe 里的入口只发消息叫它开面板。 */
window.addEventListener('message', function (ev) {
var d = ev && ev.data;
if (!d || d.__dcuLan !== 'open') return;
openPanel();
});
/* Owner 判离线后的一次性提候选:只扫种子,绝不自动改端点。 */
deviceLink.onAutoScan(function () {
if (state.scanning) return;
var now = Date.now();
if (now - state.autoScanned < 60000) return;
state.autoScanned = now;
say('warn', '当前端点连续拉不到 status,自动补一轮快扫(只列候选,不自动换设备)');
runScan('fast');
});
deviceLink.subscribe(function () { renderPill(); });
render();
setInterval(function () {
if (state.open) render(); else renderPill();
}, 2000);
}
function mountGuest() {
var btn = document.getElementById('lanScanBtnPage') || document.getElementById('lanScanBtn');
if (!btn) return;
btn.addEventListener('click', function () {
try {
window.parent.postMessage({ __dcuLan: 'open' }, window.location.origin);
} catch (e) {
say('error', '叫不开外壳面板:' + (e && e.message ? e.message : e));
}
});
}
function boot() {
if (window.location.protocol !== 'http:' && window.location.protocol !== 'https:') {
/* file:// 下 localStorage 与 http://127.0.0.1:2625 是两份,flashBusyNow()
* 会静默失效、门成空的。不挂载,直接说清楚怎么打开。 */
var body = document.body;
if (body) {
var w = document.createElement('div');
w.className = 'lan-note lan-note-error';
w.textContent = '设备发现未挂载:页面必须经本地服务打开(双击 esp32_app.vbs,或 http://127.0.0.1:2625/tabs.html)。';
body.insertBefore(w, body.firstChild);
}
return;
}
if (window.self === window.top) {
mountHost(document.getElementById('lanMount'));
} else {
mountGuest();
}
}
boot();
+2 -1
View File
@@ -430,7 +430,8 @@ setTimeout(function () {
ipInput.addEventListener('change', function () {
window.consecutivePollFailures = 0;
networkDiagDone = false;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
/* 不在这里说"IP 已修改":写入可能已被 deviceLink.setIp 的校验/刷写门拒掉,
那句准确的成功/失败日志由 js/app.js 的同一个 change 事件负责,别重复播报。 */
if (!statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
scheduleStatusPoll(0);
+33
View File
@@ -105,3 +105,36 @@ export function setQuietMode(quiet) {
document.documentElement.dataset.dcuCalm = v;
}
}
/**
* @description 更新数据格右上角来源角标(WiFi / ECAT / BLE)。
* 双通路无感切换:哪条链路**最后写入**本格,角标就标哪条(last-writer-wins)。
* 同格若两路都在写,后到的会盖掉先到的来源——切换瞬间角标跟着变是预期行为。
* @param {string|Element} cell 带 data-cell 的格子,或格子内任意元素
* @param {'wifi'|'ecat'|'ble'|'off'} src 来源
*/
export function setCellSource(cell, src) {
var root = cell;
if (typeof cell === 'string') {
root = document.querySelector('[data-cell="' + cell + '"]');
} else if (cell && cell.nodeType === 1 && !cell.classList.contains('src-badge')) {
root = typeof cell.closest === 'function' ? cell.closest('[data-cell]') : null;
}
if (!root || !root.querySelector) return;
/* 不用 :scope —— 部分环境对它支持不稳,角标是格子内唯一直属 .src-badge */
var badges = root.getElementsByClassName('src-badge');
var badge = badges && badges[0];
if (!badge) return;
/* dual = WiFi 与 ECAT 同时在线(双通路) */
var map = {
wifi: 'WiFi', ecat: 'ECAT', ble: 'BLE', off: '--',
dual: 'WiFi·ECAT',
};
var label = map[src] || '--';
var kind = map[src] ? src : 'off';
/* dual 用 wifi 同色系加粗边,避免第三套色 */
var cls = 'src-badge src-' + (kind === 'dual' ? 'dual' : kind);
if (badge.className !== cls) badge.className = cls;
if (badge.textContent !== label) badge.textContent = label;
if (root.dataset.src !== kind) root.dataset.src = kind;
}
+33 -12
View File
@@ -41,7 +41,7 @@ import { escapeHtml, flashBusyNow } from "../common/utils.js";
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
import {
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
} from "./transport.js?v=20260923i";
} from "./transport.js?v=20261011b";
import { deviceLink } from "../common/device-link.js";
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
@@ -52,7 +52,7 @@ import {
} from "./refresh-rate.js";
import { createScopeSys } from "./scope.js?v=20260924f";
import { createSeqSys } from "./seq.js?v=20260924f";
import { createOtaSys } from "./ota.js?v=20260924g";
import { createOtaSys } from "./ota.js?v=20261011c";
/* ─── 控制模式定义 ─── */
@@ -780,7 +780,9 @@ function bytesToHexStr(bytes) {
const GD32_ERR_DESC = {
0xE0: "payload 过短", 0xE1: "通道号非法",
0xE2: "BUS_OFF(总线离线)", 0xE3: "TEC 过高",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xEE: "SPI 帧解析失败",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xE6: "只听不发(该通道收发器已 Silent,接收仍正常)",
0xE7: "该通道正被电机 OTA 独占(OTA 结束自动恢复;不是总线故障,重试也没用)",
0xEE: "SPI 帧解析失败",
0xFF: "无有效 SPI 响应(陈旧/空帧,非总线故障)",
};
/* -3 仅表示“GD32 未回 ACK”;是否真为总线故障看 gd32_err:0xE2/0xE3 才是总线,0xFF 是陈旧 SPI 帧 */
@@ -829,6 +831,14 @@ async function sendBatch(frames, opts) {
await sleep(backoff);
return sendBatch(frames, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE6) {
/* 只听不发(收发器 Silent):确定性拒发,不是总线故障。
* 不冷却也不重试——冷却治不了,要人把静默切回去;只停周期与序列。 */
stopRepeat(true);
stopSeq("该通道处于只听不发,命令序列已停止", true);
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
@@ -906,6 +916,13 @@ async function sendBurst(actions, opts) {
await sleep(backoff);
return sendBurst(actions, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE6) {
/* 只听不发(收发器 Silent):同 sendBatch,确定性拒发,不冷却不重试 */
stopRepeat(true);
stopSeq("该通道处于只听不发,命令序列已停止", true);
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
@@ -1117,16 +1134,16 @@ function applyTransportUi() {
if (show) ad.value = tp.getAdapter();
}
/* OTA 只有 WiFi 链路有(ESP32 固件上传 + GD32 本地 relay)。
* 不支持时禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清"换回 WiFi 链路即可用"。 */
/* 电机 OTA 由传输层给整套钩子(WiFi = ESP32 上传 + SPI 中继;EtherCAT = CoE 邮箱
* 灌进 GD32 暂存区)。哪条链路缺钩子就禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清缺在哪。 */
const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]');
if (otaBtn) {
const off = !tp.supportsOta;
const off = !tp.supportsOta || typeof tp.otaUploadAttempt !== "function";
otaBtn.disabled = off;
otaBtn.classList.toggle("tab-disabled", off);
otaBtn.title = off
? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用"
? "当前链路没有电机 OTA 通路(需要固件进料 + GD32 relay 两套钩子)"
: "";
/* 当前正停在 OTA 页而链路不支持 → 退回配置页,避免面对一个点不动的界面 */
if (off && otaBtn.classList.contains("active")) {
@@ -2282,9 +2299,11 @@ function busStateText(bs) {
if (bs === 0) return "ACTIVE";
const parts = [];
if (bs & 1) parts.push("INIT");
if (bs & 2) parts.push("SLEEP");
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");
if (bs & 32) parts.push("只听不发");
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
@@ -2344,9 +2363,11 @@ function onFeedback(data) {
busEl.textContent = bsText;
const isBusOff = valid && ch.bus_state != null && (ch.bus_state & 8);
const isPassive = valid && ch.bus_state != null && (ch.bus_state & 4);
busEl.style.color = isBusOff ? "var(--danger)" : isPassive ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const isSilent = valid && ch.bus_state != null && (ch.bus_state & 32);
busEl.style.color = isBusOff ? "var(--danger)" : (isPassive || isSilent) ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const sbLed = $("#sbLed");
sbLed.className = "led " + (isBusOff ? "led-err" : (valid ? "led-on" : ""));
/* 静默=能收不能发,灯不能继续给"全绿":与右侧文字同源判定 */
sbLed.className = "led " + (isBusOff ? "led-err" : isSilent ? "led-warn" : (valid ? "led-on" : ""));
}
/* ═══════════════════════ 控制模式联动 ═══════════════════════ */
@@ -3069,7 +3090,7 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
* 实现见 js/motor/ota.js(P1 重做:机械外提)。 */
const otaSys = createOtaSys({
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, stopRepeat, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
});
+160 -101
View File
@@ -7,7 +7,7 @@
export function createOtaSys(ctx) {
const {
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
sendBatch, sendOneFrameWhenFree, stopSeq, stopRepeat, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
} = ctx;
@@ -223,13 +223,14 @@ function resetOtaUi() {
* 后端 motor_ota_state_t 不含 upload/boot 阶段(纯前端驱动),后端状态从 handshake_req 起。
* 顺序:上传固件(stage0) → 进BOOT(stage1) → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */
const OTA_STATE_STAGE = {
idle: -1, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finished: 5, error: -1,
idle: -1, staging: 0, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finishing: 5, finished: 5, error: -1,
};
const OTA_STATE_ZH = {
idle: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
idle: "空闲", staging: "灌入从站暂存区…", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输",
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…",
finishing: "已刷完,正在恢复过程数据通路…", finished: "完成", error: "错误",
};
$("#otaFileBtn").addEventListener("click", async () => {
@@ -289,24 +290,51 @@ $("#otaFileInput").addEventListener("change", async (e) => {
toast(`固件已加载: ${file.name}`, "ok");
});
$("#otaStartBtn").addEventListener("click", startOta);
$("#otaStartBtn").addEventListener("click", () => {
/* startOta 是 async:任何逃逸出来的异常都会把 ota.running 永久留在 true,
* 之后每次点击都在入口 return —— 完全静默。这里兜最后一道:异常必须落日志,
* 并且走 otaDone() 复位状态。 */
Promise.resolve().then(startOta).catch((e) => {
otaLog(`<span class="log-err">[异常]</span> OTA 流程抛出未捕获异常:` +
escapeHtml(e && e.message ? e.message : String(e)) + `,状态已复位,可重新点击`);
toast("OTA 异常,详见 OTA 日志", "err");
otaDone();
});
});
$("#otaCancelBtn").addEventListener("click", cancelOta);
async function startOta() {
if (ota.running) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
/* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
* EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
* 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
if (!state.tp.supportsOta) {
toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
/* 每个拒绝启动的出口都往 OTA 日志区写一行原因。toast 只显示 3 秒,
* 而 ota.running 卡死时原先是完全静默的 return —— 表现就是"按钮能点、
* 日志一个字都不加",无从定位。 */
if (ota.running) {
otaLog('<span class="log-err">[未启动]</span> 上一次 OTA 状态未复位(ota.running=true),本次点击已忽略。' +
'点"取消"或刷新页面即可复位;若经常走到这里,看下面 [校验] 行的异常。');
return;
}
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
if (!state.connected) {
otaLog('<span class="log-err">[未启动]</span> 设备未连接(state.connected=false),本次点击已忽略。');
toast("设备未连接", "err"); return;
}
/* 电机 OTA 的进料通路按链路不同,但收尾都是同一个 GD32 relay 引擎:
* WiFi → POST ESP32 /motor/ota 上传 + SPI 0xD0/0xD1 喂数
* EtherCAT → CoE 0x2511 邮箱灌进 GD32 暂存区(只插网线也能刷)
* 缺钩子的链路在这里硬拦,不要去打一个必然超时的端点。 */
if (!state.tp.supportsOta || typeof state.tp.otaUploadAttempt !== "function") {
otaLog('<span class="log-err">[未启动]</span> 当前链路 ' + escapeHtml(String(state.tp.label || state.tpId)) +
' 没有电机 OTA 通路。');
toast("当前链路(" + state.tp.label + ")没有电机 OTA 通路", "err");
return;
}
if (!ota.file) {
otaLog('<span class="log-err">[未启动]</span> 未选择固件文件。');
toast("请先选择固件 (.bin)", "warn"); return;
}
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
const node = state.selectedNode;
if (node == null || node < 1) {
otaLog('<span class="log-err">[未启动]</span> 未选中目标节点(当前 selectedNode=' + escapeHtml(String(node)) + ')。');
toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
return;
}
@@ -337,10 +365,18 @@ async function startOta() {
return;
}
/* 弹窗文案按链路口径如实写:WiFi 是「ESP32 回读 flash 算 SHA-256 比对」,
* EtherCAT 是「从站在电机进 BOOT 之前核整镜像 CRC32」。
* 把另一条的校验说法套过来就是假话,用户会按假话决定要不要断电。 */
const verifyText = state.tp.otaIntegrity === "sha256-esp32-flash"
? `先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到 ESP32,设备从 flash 读回算 SHA-256 与本地比对`
: `先把固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 经 EtherCAT 邮箱灌进 GD32 暂存区,逐块 CRC32 + 整镜像 CRC32 由从站核对`;
const bootText = state.tp.otaHostSendsBootFrame
? `再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader`
: `核对通过后由 GD32 在已独占的通道上发 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader`;
const ok = await showModal("开始电机 OTA",
`将先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备并校验 SHA-256,` +
`再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` +
`由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`);
`${verifyText},${bootText},由 GD32 本地完成 AES 握手与 CAN 停等传输。` +
`OTA 期间本链路的过程数据与轮询会暂停(其他电机也收不到运动指令),请勿断电。`);
if (!ok) return;
stopRepeat(true);
@@ -370,91 +406,88 @@ async function startOta() {
* OTA 完成后 otaDone→startPolling 恢复。 */
stopDataPolling();
/* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)── */
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
const fwShaHex = Array.from(new Uint8Array(fwSha256))
.map(b => b.toString(16).padStart(2, "0")).join("");
/* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)──
* ★ 必须在 try 内。这句原先落在 try/catch 之外,而 ota.running 已在上面置 true:
* 一旦抛异常(最常见是非安全上下文里 crypto.subtle 为 undefined),本函数以
* 未处理的 rejection 结束,otaDone() 不跑 → ota.running 永久停在 true,
* 之后每次点击都在入口静默 return,界面表现就是"点了没反应"。 */
let fwShaHex = "";
try {
if (!window.crypto || !window.crypto.subtle || !window.crypto.subtle.digest) {
throw new Error("本浏览器上下文没有 WebCrypto(crypto.subtle 不可用):当前源是 " +
location.origin + ",非安全上下文。请用 http://127.0.0.1:2625/tabs.html 打开本页" +
"(桌面 APP 的入口),或改用 https 访问设备。");
}
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
fwShaHex = Array.from(new Uint8Array(fwSha256))
.map(b => b.toString(16).padStart(2, "0")).join("");
} catch (e) {
otaLog(`<span class="log-err">[校验]</span> 本地固件 SHA-256 计算失败:${escapeHtml(e.message || e)},OTA 未启动`);
toast("固件校验计算失败,见 OTA 日志", "err");
otaDone();
return;
}
otaLog(`<span class="log-info">[校验]</span> 本地固件 SHA-256: ${fwShaHex}`);
try {
/* ── 步骤 2:上传 + SHA 比对,不通过重传(最多 3 次尝试 = 首发 + 重发 2 次)──
* POST /motor/ota 只收固件写 flash(不触发 SPI),ESP32 收尾从 flash 读回算 SHA 随响应返回;
* 前端比对不一致 = flash 写坏 → 重传(每次上传都全盘擦除重写);
* 连续 3 次不过才中断——此时电机仍在 APP 正常运行,重试/中断都不影响电机。 */
/* ── 步骤 2:进料 + 完整性核对(按链路口径,最多 3 次尝试)──
* 通路交给传输钩子,本文件不再假设「一定能打到设备 IP」:
* WiFi POST /motor/ota → ESP32 写 flash 后读回算 SHA-256,浏览器比对;
* EtherCAT CoE 0x2511 灌进 GD32 暂存区,整镜像 CRC32 由**从站**在电机进 BOOT
* 之前核对(这一步只报「主站是否完整持有镜像」,不冒充比过 SHA)。
* 两种失败都发生在电机仍跑 APP 的阶段:重试与中断都不影响电机。 */
const kUploadMaxAttempts = 3;
let uploadOk = false;
for (let attempt = 1; attempt <= kUploadMaxAttempts && ota.running; attempt++) {
otaLog(`<span class="log-info">[上传]</span> 上传固件到 ESP32 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
let upJson = {};
try {
const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: ota.bytes,
signal: AbortSignal.timeout(15000),
});
upJson = await upResp.json().catch(() => ({}));
if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`);
} catch (e) {
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次上传失败:${escapeHtml(e.message || e)}`);
if (attempt < kUploadMaxAttempts) await sleep(300);
continue;
}
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${escapeHtml(String(upJson.size || ota.bytes.length))} B)`);
/* 比对:本地 SHA vs ESP32 从 flash 读回算的 SHA(upJson.sha256) */
if (!upJson.sha256 || upJson.sha256.length !== 64) {
/* 旧 ESP32 固件无 sha256 字段:无法本地比对,且旧固件 relay 还要求 body(新前端已不发),
* 需刷新 ESP32 固件;继续尝试,若 relay 被拒会报错中止。 */
otaLog(`<span class="log-err">[校验]</span> 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`);
otaLog(`<span class="log-info">[上传]</span> ${escapeHtml(String(state.tp.label || state.tpId))} 通路进料 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
const up = await state.tp.otaUploadAttempt(ota.bytes, fwShaHex, { ch, node });
if (up && up.ok) {
otaLog(`<span class="log-ok">[上传]</span> ${escapeHtml(String(up.note || "固件已就位"))}` +
(up.size ? ` (${Number(up.size).toLocaleString()} B)` : ""));
uploadOk = true;
break;
}
if (upJson.sha256.toLowerCase() === fwShaHex) {
otaLog(`<span class="log-ok">[校验]</span> flash SHA 比对一致,允许进 BOOT`);
uploadOk = true;
break;
}
otaLog(`<span class="log-err">[校验]</span> 第 ${attempt} 次 flash SHA 不一致(device=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`);
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次未通过:` +
escapeHtml(String((up && up.note) || "未知原因")) +
(attempt < kUploadMaxAttempts ? ",重试…" : ""));
if (attempt < kUploadMaxAttempts) await sleep(300);
}
if (!uploadOk) {
throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
throw new Error(`进料/完整性核对连续 ${kUploadMaxAttempts} 次未通过,中断(电机未进 BOOT)`);
}
/* ── 步骤 3:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)──
* 固件已收完且 flash SHA 比对通过,此刻才让电机离开 APP。
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
setOtaStage(1); /* 进入 BOOT 阶段 */
const bootId = canId(node, 0x0000); /* 0x000N0000 */
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
if (state.tp.otaHostSendsBootFrame) {
/* ── 步骤 3(WiFi 链路的既有顺序):上位机发 0x000N0000,再触发中继 ──
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
const bootId = canId(node, 0x0000); /* 0x000N0000 */
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
} else {
/* EtherCAT:进 BOOT 帧由 GD32 自己在**已加锁**的通道上发。这条链路的周期运动帧
* 与它挤同一批 CAN 邮箱;先解锁让上位机发帧,等于把窗口留给运动帧(单发模式
* 不重传,固件包一旦被 abort 就是白等 100ms 重发,握手期更会被打成 0x05)。 */
otaLog(`<span class="log-info">[进BOOT]</span> 交 GD32 在已独占的 CH${ch} 上发 CANFD_BOOT 0x000${nodeHex(node)}0000(运动帧已被通道锁挡住)`);
}
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出,立即触发转发…`);
/* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据(纯启动扳机,无 body)──
* SHA 校验已在步骤 2 闭环(比对+重传),relay 不再传/比 SHA。
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。
* SPI 0xC0(进 BOOT) 与 0xD0(START) 经 SPI worker 串行,顺序有保证。 */
const rResp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${ch}`, {
method: "POST",
signal: AbortSignal.timeout(8000),
});
const rJson = await rResp.json().catch(() => ({}));
if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
otaLog(`<span class="log-ok">[转发]</span> GD32 开始握手…`);
/* ── 步骤 4:触发转发/灌写(纯启动扳机,不带镜像内容)──
* 完整性核对已在上一步与从站启动前闭环,这里只下发起止指令。
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。 */
await state.tp.otaStartRelay(ch, node);
otaLog(`<span class="log-ok">[转发]</span> 已触发:从站核对→锁通道→进 BOOT→起握手…`);
startOtaPoll();
} catch (e) {
otaLog(`<span class="log-err">[失败]</span> ${escapeHtml(e.message || e)}`);
@@ -469,10 +502,10 @@ function startOtaPoll() {
let lastLoggedPkt = -1;
ota.pollTimer = setInterval(async () => {
try {
const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
signal: AbortSignal.timeout(2000), cache: "no-store",
});
const st = await resp.json();
/* 进度按链路取:WiFi = GET /motor/ota/status(ESP32 缓存 GD32 的 0xD2 推送);
* EtherCAT = native 发布的会话读数(不碰总线),状态已由传输层翻译成
* 同一套字符串,本文件不需要知道料是怎么进去的。 */
const st = await state.tp.otaStatus();
updateOtaUi(st, startTime);
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
if (st.state && st.state !== lastState) {
@@ -497,11 +530,28 @@ function startOtaPoll() {
if (st.state === "finished" || st.state === "error" || st.state === "idle") {
clearInterval(ota.pollTimer);
ota.pollTimer = null;
if (st.state === "finished") {
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
const pkTotal = Number(st.total_packets) || 0;
const pkAcked = Number(st.packets_acked) || 0;
/* finished 必须有真实传输撑着:包数 > 0 且全部 ACK。
* 只看 state 会放行「一帧都没上线却回了 finished」这类假成功——
* 2026-10-11 实测就是这样:从站 OOM 拒收,前端把 HTTP 信封的 ok 当成刷写结果,
* 0 包也报「OTA 成功」,因为查版本必然答得上(电机根本没被动过)。 */
if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) {
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${pkAcked}/${pkTotal} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
} else if (st.state === "finished") {
otaLog(`<span class="log-err">[可疑]</span> 回执 finished 但没有真实传输(acked=${pkAcked}/total=${pkTotal}),不判成功` +
`<span class="log-info"> · </span>看 [状态] 行与 native 读数:料没进去或启动被挡`
+ (st.note ? ` · ${escapeHtml(String(st.note))}` : ""));
toast("OTA 未发生真实传输,不能算成功", "err");
} else if (st.state === "error") {
const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
/* 两种病分开报:低字节是电机 BOOT 回来的协议码(查电机与 CAN);
* session_error 高字节是 DCU 本端把料挡了(0x8x,查链路与参数)。
* 混成一句「OTA 失败 0xXX」历史上会把人支去查一台没接的电机。 */
const sess = Number(st.session_error) || 0;
const sessHint = sess ? " · 本端会话码 0x" + sess.toString(16).toUpperCase() + "(料被 DCU 挡下,不是电机报错)" : "";
const note = st.note ? " · " + escapeHtml(String(st.note)) : "";
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${sessHint}${errHint}${note}`);
toast("电机 OTA 失败 (0x" + Number(st.error || 0).toString(16) + ")", "err");
}
/* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
@@ -509,10 +559,19 @@ function startOtaPoll() {
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s → APP 看似卡死。
* 只有 error/idle/异常退出才发 cancel 兜底清理设备端残留状态。 */
otaDone(st.state !== "finished");
if (st.state === "idle") {
/* 会话回到空闲却没给出 finished/error 结论:这条出口原先是静默的,
* 表现就是"进度条停住、日志不再动"。至少把「没有结论」这件事说出来,
* 别让人去猜是刷完了还是根本没开始。 */
const idleNote = st.note ? " · " + escapeHtml(String(st.note)) : "";
otaLog("<span class='log-err'>[已停]</span> 会话回到空闲且未给出结果(多为启动指令未生效或已被清理),可重新点开始" + idleNote);
}
/* ★ finished = GD32 已收到电机新 APP 上电自主发的 [0A AC] 运行确认
* (kWaitAppRun → kFinished,无确认 3s 超时报 0x25)——APP 已确认在跑。
* verifyNewApp 只做最后一步:查版本号核对并回填 UI。 */
if (st.state === "finished") verifyNewApp(ota.node);
/* 只有确认过真实传输才去核对版本:否则查到的就是电机现在跑的旧版本,
* 那会让「没刷」看起来像「刷成功了」——自证判据。 */
if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) verifyNewApp(ota.node);
}
} catch (e) { /* 轮询瞬时失败静默 */ }
}, OTA_POLL_MS);
@@ -599,12 +658,13 @@ async function cancelOta() {
* 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
let ok = false;
for (let i = 1; i <= 3 && !ok; i++) {
try {
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
ok = r.ok;
} catch (e) {
otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
}
/* 走传输钩子:WiFi 是 POST /motor/ota/cancel,EtherCAT 是请 native worker 下一拍
* 写 0x2510:01=3(从站发 CAN 取消帧并释放通道锁)。单次失败不能静默吞掉,
* 否则设备端残留(ESP32 feedTask / GD32 会话与通道锁),后续 OTA 全被拒。 */
let r = null;
try { r = await state.tp.otaCancel(); } catch (e) { r = { ok: false, note: e.message || String(e) }; }
ok = !!(r && r.ok);
if (!ok) otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(String((r && r.note) || "未确认"))}`);
if (!ok && i < 3) await sleep(500);
}
if (!ok) otaLog(`<span class="log-err">[取消]</span> 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
@@ -637,9 +697,8 @@ function otaDone(sendCancel = true) {
/* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
* 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
* 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
if (sendCancel && state.connected) {
fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
.catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
if (sendCancel && state.connected && typeof state.tp.otaCancel === "function") {
state.tp.otaCancel().catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
}
}
+146 -13
View File
@@ -55,6 +55,31 @@ import {
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
const MOT_SLOTS_PER_PDO = 9;
/** GD32 `MotorOtaRelay::State` 的数字状态 → WiFi 链路那套字符串(ota.js 的阶段条与
* 日志只认字符串,两条链路必须翻成同一个口径,否则 UI 要为两种传输各写一遍判据)。
* 序号改了要同步 DCU_APP sys/motor_ota_relay.h 的枚举 —— 那边是唯一真源。 */
const RELAY_STATE_NAMES = [
"idle", "handshake_req", "handshake_nonce", "handshake_confirm", "erase",
"transfer", "finish_ota", "finished", "error", "pre_cancel", "wait_app_run",
];
/** 字节 → 小写 hex(native 的 hex_decode 两种大小写都吃,这里与 WiFi 上传的字节流口径无关)。 */
function bytesToHexLower(u8) {
let s = "";
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0");
return s;
}
/** 电机 OTA(EtherCAT 灌写)正在占用 exclusive 总线 —— 外壳 Owner 与本页的
* motor/start 自愈都读同一个标志,判据只在这一处。
* 写成 typeof 守卫是因为 scripts/test_motor_transport.mjs 在 Node 里直接 import
* 本模块,那里没有 window,裸读会抛 ReferenceError 把整轮契约自测打断。 */
function motorOtaEcatBusy() {
return (typeof window !== "undefined") && !!window.motorOtaEcatBusy;
}
function setMotorOtaEcatBusy(v) {
if (typeof window !== "undefined") window.motorOtaEcatBusy = !!v;
}
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
const MOTOR_SLOT_COUNT = 27;
@@ -367,6 +392,13 @@ export function createEcatTransport(ctx) {
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
async function ensureStarted(reqPeriodMs) {
/* 电机 OTA 灌写中:native worker 正独占总线(g_bus_lock)并停着 PDO 循环。
* 这时候再打 motor/start 只会在锁上排队,排到时把刚恢复的循环又停一遍,
* 表现是刷写结束瞬间链路「假掉线」。判据只在这一处挡,外壳 Owner 那道门
* (device-link 的 bootstrapEcatBus)读同一个 window.motorOtaEcatBusy。 */
if (motorOtaEcatBusy() && !reqPeriodMs) {
return { ok: true, period_ms: periodMs, spin: spinOn, motor_ota_busy: true };
}
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
if (!reqPeriodMs) {
@@ -394,20 +426,19 @@ export function createEcatTransport(ctx) {
localStorage.setItem(LS_PERIOD_MIG, "1");
}
const v = parseFloat(localStorage.getItem(LS_PERIOD));
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
if (v > 0 && v !== 1) body.period_ms = v;
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
if (v > 0 && v >= 0.125 && v <= 4) body.period_ms = v;
else body.period_ms = 0.25;
} catch (_) { body.period_ms = 0.25; }
}
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
/* PDO 周期与 DC SYNC0 同值:周期可随时热改(0.125–4ms),
* 不一致时 GD32 processOutput 消费会拖慢,所以启动时强制对齐。 */
{
let per = Number(body.period_ms) || 0.25;
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
if (per < 0.125) per = 0.25;
if (per > 4) per = 4;
body.period_ms = per;
body.dc_cycle_us = Math.round(per * 1000);
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
body.dc_shift_us = 0;
}
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
@@ -571,8 +602,15 @@ export function createEcatTransport(ctx) {
return {
id: "ecat",
label: "EtherCAT",
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
supportsOta: false,
/** 电机 OTA:走 CoE 邮箱把固件灌进 GD32 暂存区,中继引擎与 WiFi 链路同一个
* (见下 ota* 四个钩子与 DCU_APP comm/ecat_motor_ota.cpp)。 */
supportsOta: true,
/** 进 BOOT 帧(0x000N0000)由 GD32 在**已加锁**的通道上自己发,上位机不要再发一遍:
* 这条链路的运动帧和它挤同一批 CAN 邮箱,先解锁让上位机发帧等于把窗口留给运动帧。 */
otaHostSendsBootFrame: false,
/** 完整性口径(如实告诉 UI,不冒充 SHA-256 那条):
* crc32-verify-in-slave = 分块 CRC32 + 整镜像 CRC32 都在从站核,核过才让电机擦除。 */
otaIntegrity: "crc32-verify-in-slave",
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
supportsPeriodSync: false,
@@ -740,7 +778,7 @@ export function createEcatTransport(ctx) {
err: linkOk ? b.drop : null,
tec: null, // 过程数据里没有
rec: null, // 过程数据里没有
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : (b.tx_result === 0xE6 ? 32 : 0)) : null,
});
}
return { channels };
@@ -891,14 +929,14 @@ export function createEcatTransport(ctx) {
},
/**
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
* 热换 PDO 周期(毫秒)。合法区间 0.125–4ms(与 UI 档位一致)。
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
*/
async setPdoPeriod(ms) {
let v = Number(ms);
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
if (v > 0.25) v = 0.25; /* 钉死 250µs,杜绝 0.5/1ms */
if (v < 0.0625) v = 0.25;
if (v < 0.125) v = 0.125;
if (v > 4) v = 4;
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
if (typeof r.period_ms === "number") periodMs = r.period_ms;
else periodMs = v;
@@ -907,6 +945,101 @@ export function createEcatTransport(ctx) {
return periodMs;
},
/* ── 电机 OTA(EtherCAT 邮箱进料)────────────────────────────────
* 通路:这里 → /ecat/api/motor/ota(或桌面壳 pywebview 桥)→ ecat_native 的
* worker → CoE SDO 0x2511 数据窗 / 0x2510 会话 → GD32 48KB SRAM 暂存
* → **从站先核整镜像 CRC32** → 通道锁 + GD32 自己发 0x000N0000 进 BOOT
* → 既有 MotorOtaRelay 停等(AES、单发、重发、0AAC 运行确认一个都没换)。
* 换的只是「明文怎么进 GD32」:WiFi 那条是 ESP32 经 SPI 0xD1 喂,
* 这条是主站经邮箱喂,所以纯网线(ESP32 HTTP 不可达)也能刷电机。
*
* 刷写期间 native worker 独占总线并停掉 PDO 循环 ⇒ 各通道运动帧一起暂停,
* 与 WiFi 模式「OTA 期间请勿操作电机」等效。进度读的是 worker 发布的 volatile
* 全局(op=status 不碰总线),所以轮询频率再高也不会干扰灌写。
*/
/** 一次「上传尝试」:把整份镜像推到主站缓冲。
* WiFi 那条在这步做 ESP32 flash 回读 SHA-256 比对;这条没有对端可回读,
* shaHex 参数用不上(刻意不假装比过),真校验在从站的启动前一步。
* @returns {Promise<{ok:boolean, note:string}>} */
async otaUploadAttempt(bytes, shaHex, opts) {
const ch = (opts && opts.ch) | 0;
const node = (opts && opts.node) | 0;
const size = bytes.length;
unwrap(await api("motor/ota", { op: "open", size, ch, node }, 8000), "开电机灌写会话");
/* push 受 native 请求体 65535B 限制:16KB 二进制 = 32768 hex 字符,余量充足 */
const STEP = 16384;
for (let off = 0; off < size; off += STEP) {
const part = bytes.subarray(off, Math.min(off + STEP, size));
unwrap(await api("motor/ota", { op: "push", hex: bytesToHexLower(part) }, 15000),
"推送镜像分片");
}
const st = unwrap(await api("motor/ota", { op: "status" }, 5000), "读灌写状态");
const recv = Number(st.srv_recv) || 0;
return {
ok: recv === size,
note: recv === size
? `主站已持有 ${size} B(分块 CRC32 已逐块核对,整镜像 CRC 由从站在电机进 BOOT 前核)`
: `主站只收到 ${recv}/${size} B`,
};
},
/** 起会话:worker 后台跑(灌块 → 从站核 CRC → 锁通道 → 进 BOOT → 停等传输)。
* 这一步只负责「派活」,成败由 otaStatus 的终态给结论。 */
async otaStartRelay() {
const r = unwrap(await api("motor/ota", { op: "run" }, 8000), "启动电机灌写");
setMotorOtaEcatBusy(true); /* 挡住外壳 Owner 与本页的 motor/start 重拉 */
return { ok: !!r.ok };
},
/** 进度:把 native 的 stage + 从站 relay 数字状态翻译成 WiFi 那套字符串,
* 好让 ota.js 的阶段条/日志只用一套判据。 */
async otaStatus() {
const st = unwrap(await api("motor/ota", { op: "status" }, 4000), "读电机刷写进度");
const stage = Number(st.stage) || 0;
const relay = Number(st.state) || 0;
const errWord = Number(st.err) || 0;
const sessErr = (errWord >> 8) & 0xFF;
const protoErr = errWord & 0xFF;
let state;
if (stage === 0) state = "idle";
else if (stage === 1) state = "staging"; /* 还在灌从站暂存区,引擎尚未启动 */
else if (stage === 2 && relay === 7) state = "finishing";
/* relay 已 kFinished 但 worker 还没把过程数据循环与 OP 补回来。
* 此刻就报 finished 的话,上层的版本查询会打在「主站在发、从站不在 OP」
* 的空档里,白丢一次应答(实测会表现成「OTA 完成但查不到版本」)。 */
else if (stage === 2) state = RELAY_STATE_NAMES[relay] || "pre_cancel";
else state = st.flash_ok ? "finished" : "error";
if (stage === 3) setMotorOtaEcatBusy(false);
const reason = String(st.reason || "");
return {
state,
error: state === "error" ? (protoErr || sessErr || 0x01) : protoErr,
/* 会话码 0x8x 与电机协议码 0x0x/0x3x 是两种病,必须分开摆出来,
* 否则「从站把料挡了」会被读成「电机报错」。 */
session_error: sessErr,
packets_acked: Number(st.acked) || 0,
total_packets: Number(st.pk_total) || 0,
fw_size: Number(st.claimed) || 0,
stage,
note: reason,
};
},
/** 取消:请 worker 下一拍写 0x2510:01=3,从站发 CAN 取消帧并释放通道锁。
* 等 native 报 running=false 才放开 motorOtaEcatBusy,避免外壳 Owner 在
* 会话还挂着的时候抢去 motor/start。 */
async otaCancel() {
const r = await api("motor/ota", { op: "cancel" }, 5000).catch(() => null);
for (let i = 0; i < 6; i++) {
const st = await api("motor/ota", { op: "status" }, 3000).catch(() => null);
if (!st || !st.running) break;
await new Promise((res) => setTimeout(res, 400));
}
setMotorOtaEcatBusy(false);
return { ok: !!(r && r.ok) };
},
/** 诊断用(控制台 motorDbg.transport)。 */
debug() {
return {
+75 -1
View File
@@ -134,8 +134,12 @@ export function createWifiTransport(ctx) {
return {
id: "wifi",
label: "WiFi / HTTP",
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay),EtherCAT 侧没有这条链路 */
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay) */
supportsOta: true,
/** 进 BOOT 帧由上位机发(既有顺序:先帧后 relay),锁还没上,通道此刻是空的 */
otaHostSendsBootFrame: true,
/** 完整性口径:ESP32 从 flash 读回算 SHA-256,与浏览器算的比 */
otaIntegrity: "sha256-esp32-flash",
/** 0xC5 推送周期可与前端刷新率对齐 */
supportsPeriodSync: true,
@@ -229,6 +233,76 @@ export function createWifiTransport(ctx) {
return data.nodes.find((n) => n.node === node) || null;
},
/* ── 电机 OTA(ESP32 上传 + SPI 中继)────────────────────────────
* 还是原来那三条 HTTP:POST /motor/ota(写 ESP32 flash 并回读算 SHA-256)、
* POST /motor/ota/relay(SPI 0xD0 START + feedTask 喂数)、GET /motor/ota/status。
* 收进传输契约只有一个原因:ota.js 原先硬写 getBaseUrl(),等于假设「设备 IP
* 一定打得通」——纯 EtherCAT 场合那条前提不成立。行为与外提前完全一致。 */
/** 一次上传尝试:POST 整份镜像,拿设备回读的 flash SHA-256 与浏览器算的比。
* 不抛错——失败是这里的正常出口(ota.js 据此决定重传,最多 3 次)。 */
async otaUploadAttempt(bytes, shaHex, opts) {
const ch = (opts && opts.ch) | 0;
let json = {};
try {
const resp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: bytes,
signal: AbortSignal.timeout(15000),
});
json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
} catch (e) {
return { ok: false, note: (e && e.message) || String(e) };
}
const size = Number(json.size) || bytes.length;
if (!json.sha256 || String(json.sha256).length !== 64) {
/* 旧 ESP32 固件不回 SHA:没有对端读数可比,如实说明后放行(沿用既有行为),
* 后续 relay 若被拒会由那条报错兜住。 */
return { ok: true, size, note: "设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对" };
}
const dev = String(json.sha256).toLowerCase();
return {
ok: dev === String(shaHex).toLowerCase(),
size,
note: dev === String(shaHex).toLowerCase()
? "flash 回读 SHA-256 一致"
: `flash SHA 不一致(设备 ${dev.slice(0, 16)}…)`,
};
},
/** 触发中继:GD32 开始握手,ESP32 起 feedTask 喂 SPI。失败抛错(上层按启动失败处理)。 */
async otaStartRelay(ch) {
const resp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${(ch | 0)}`, {
method: "POST",
signal: AbortSignal.timeout(8000),
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
return { ok: true };
},
/** 进度:ESP32 已经给字符串状态,直接透传;session_error/note 是 ECAT 才有的字段,
* 这里补空值,让上层的显示逻辑不用分链路写两遍。 */
async otaStatus() {
const st = await getJson("/motor/ota/status", 2000);
return Object.assign({ session_error: 0, note: "" }, st);
},
/** 兜底清理:让 ESP32 退出 feedTask + GD32 resetToIdle。 */
async otaCancel() {
try {
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", {
method: "POST",
signal: AbortSignal.timeout(5000),
});
return { ok: r.ok };
} catch (e) {
return { ok: false, note: (e && e.message) || String(e) };
}
},
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
async setPeriod(ms) {
const v = Math.max(20, Math.min(65535, Math.round(ms)));
+15 -2
View File
@@ -17,6 +17,8 @@
* reportedIp(data) -> string|null 设备回报 IP(ECAT 恒 null)
* fetchMotorState() -> {have_data, age_ms, active_count, boot_active, motors[]}
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
* bus_state 位图:bit0=init bit1=控制器睡眠 bit2=error_passive bit3=bus_off
* bit4=error_warning bit5=只听不发(收发器 Silent,本端 S 脚)
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
* sendBurst(frames, {suffix,dlc}) -> results[]
* fetchVersionNode(node) -> {node,seq,version,at?}|null
@@ -25,13 +27,24 @@
* teardown() -> void 切走时收尾
* 可选:takeNodeIdReport(node)、debug()
*
* 电机 OTA(supportsOta=true 时必须整套实现;流程只在 ota.js 里写一遍):
* otaHostSendsBootFrame: bool 进 BOOT 帧由上位机发(WiFi)还是 GD32 在已加锁
* 的通道上自己发(EtherCAT)
* otaIntegrity: string 完整性口径,UI 文案照它写,不许互相冒充:
* 'sha256-esp32-flash' | 'crc32-verify-in-slave'
* otaUploadAttempt(bytes, shaHex, {ch, node}) -> {ok, note, size?}
* otaStartRelay(ch, node) -> {ok} 抛错=没起来
* otaStatus() -> {state, error, session_error, packets_acked, total_packets, note}
* state 用 WiFi 那套字符串(ECAT 由传输层翻译)
* otaCancel() -> {ok, note?}
*
* motors[] 每项的字段契约(ECAT 实现负责补平差异):
* {node, ch, online, dlc, rx_count, seq, data(64hex), config_data(48hex|"")}
*/
"use strict";
import { createWifiTransport } from "./transport-wifi.js";
import { createEcatTransport } from "./transport-ecat.js?v=20260923i";
import { createWifiTransport } from "./transport-wifi.js?v=20261011b";
import { createEcatTransport } from "./transport-ecat.js?v=20261011b";
/** localStorage key:记住上次选的传输,刷新页面后保持 */
export const LS_TRANSPORT = "dcu_motor_transport";
+177 -35
View File
@@ -33,7 +33,7 @@
* native 的两级邮箱自愈,最坏 7s+。**R1 后 _ecat_proxy 不对非幂等路径自动重发**
* (假超时/服务刚拉起时返回 503,需用户/脚本显式重试);run 超时给 300s、其余 60s。
*/
import { ECAT_DEFAULT_ADAPTER } from "../common/ecat-bus.js";
import { ECAT_DEFAULT_ADAPTER, runEcatBootstrap } from "../common/ecat-bus.js";
var BOOT_API = '/ecat/api/ota/ecat';
var PUSH_BYTES = 24576; /* 转 HEX 后 49152 字符,留在 native 65535 的 body 上限内 */
@@ -49,6 +49,25 @@ function el(id) { return document.getElementById(id); }
function setTxt(id, t) { var n = el(id); if (n && n.textContent !== t) n.textContent = t; }
function bytes(n) { return (n >>> 0).toLocaleString() + ' B'; }
/**
* @description 写 bootEcatBusy / bootEcatIsBoot 的唯一出口,只为留一条带时刻的读数。
* 为什么收在一处:这两个标志就是外壳 Owner 要不要重拉总线的门
* (js/common/device-link.js 里 `if (window.bootEcatBusy || ...) return;` 与
* `if (window.bootEcatIsBoot === true) return;`)。2026-10-12「交接后 EtherCAT 连不上、
* 要重启才行」要看的就是「这扇门被谁在什么时候按住、按了多久」——散在八处各打一条
* 日志必然漂,所以判据只留一个执行点。
* 只在值真的翻转时打印:反复赋同一个值不是事件,刷屏反而盖掉真读数。
*/
function setEcatFlag(name, val) {
var next = !!val;
if (window[name] === next) return;
window[name] = next;
if (window.addLog) {
window.addLog('I', 'ECAT刷写',
'[门] ' + name + '=' + (next ? 'true' : 'false') + '(t=+' + Math.round(performance.now()) + 'ms)');
}
}
function hexOf(u8, start, end) {
var s = '';
for (var k = start; k < end; k++) s += HEX2[u8[k]];
@@ -98,6 +117,12 @@ async function post(path, body, timeoutMs) {
* /api/open 内部会先 loop_stop 再 ec_init + 重枚举,所以它一定掐掉正在跑 PDO 的电机循环;
* 但刷写期间这不构成互相打架:BOOT 钉在 PREOP,电机页那套 2s 自愈探测器打 /motor/start
* 也进不了 OP,循环起不来 —— 结构上就让位了,不需要额外互斥标志。
* @note 必须带重试(最多 3 次,间隔 2s):commit 后新 APP 起板看到 warm 残值会自己补
* 一发 LAN9252 RST# 对齐 POR(esc_lan9252.cpp:441-446),芯片+PHY 整颗复位 → I210 链路
* 重协商 1~2s,第一拍 open 大概率落在抖动窗口里。而 /api/open 是非幂等路径,代理到点
* 只回 503「请重试一次」、绝不自动重发(ecat_client.py R1/P0)—— 这句"请重试"只能在
* 这里执行;不重试的话后面 6 轮 probe 全打在没重建的会话上,读数必然为空,却会被
* 当成"从站 EtherCAT 没恢复"报给用户(2026-10-09 21:49 就是这么误判的)。
*/
async function ensureBusOpen(force) {
if (!force) {
@@ -112,9 +137,23 @@ async function ensureBusOpen(force) {
var s = localStorage.getItem('dcu_ecat_adapter');
if (s && s.trim()) substr = s.trim();
} catch (_) {}
var r = await post('open', { substr: substr }, 25000);
showLog(r);
return !!(r && r.ok === true);
for (var i = 0; i < 3; i++) {
if (i > 0) {
window.addLog('I', 'ECAT刷写', '重开总线 第 ' + (i + 1) + '/3 次(等链路重协商)…');
await new Promise(function (res) { setTimeout(res, 2000); });
}
var r = null;
try {
/* 35s:必须比代理的 30s 长,否则浏览器先断 —— 那样连后端那句结构化的
* 「请重试一次」都拿不到,只剩一条没信息量的超时。 */
r = await post('open', { substr: substr }, 35000);
} catch (e) {
r = { ok: false, log: ['重开请求异常: ' + e] };
}
showLog(r);
if (r && r.ok === true) return true;
}
return false;
}
/** @description 把 native 回的多行 log 逐条落到控制台日志(与 WiFi/BLE 那条同一个日志池)。 */
@@ -200,14 +239,15 @@ export async function bootEcatProbe(verbose) {
setTxt('bootEcatSw', ver);
setTxt('bootEcatArm', r.armed ? '允许' : '不允许');
setTxt('bootEcatRecv', (r.srv_recv != null ? bytes(r.srv_recv) : '—') + ' / ' + bytes(r.slave_recv || 0));
window.bootEcatIsBoot = !!r.boot;
setEcatFlag('bootEcatIsBoot', !!r.boot);
refreshBootEcatUi();
if (verbose) {
window.addLog('I', 'ECAT刷写',
'0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' +
(r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)'
: (r.name ? '是应用,点主按钮写 0x2502 进 BOOT'
: '邮箱没回应,多半是老 BOOT 或栈没起来')));
: '读不到 0x1008(reason=' + (r.reason || '旧 native 无此字段') +
(r.detail ? ',' + r.detail : '') + ')')));
}
return r;
}
@@ -261,16 +301,53 @@ function setEcatMsg(msg) {
/**
* @description 交接之后等 BOOT 真的回到总线,把身份读回来。
* 这里刻意**不**再打一次 /api/open:probe 内部那两级自愈(单播活性判据 → 必要时全量
* 重枚举)就是为"从站刚硬复位、ESC 里 STADR/SM/邮箱配置全被清成 0"准备的,实测只多付
* ~180ms;而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。
* 这里刻意**不**再无条件打一次 /api/open:probe 内部那两级自愈(单播活性判据 → 必要时
* 全量重枚举)就是为"从站刚硬复位、ESC 里 STADR/SM/邮箱配置全被清成 0"准备的;
* 而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。
* @note 旧注释写的"probe 实测只多付 ~180ms"只在**从站健康**时成立(2026-10-13 正极
* 实测 15-69ms)。从站推不进 PREOP 时它最坏要 28.5s,比前端 20s 超时还长 —— 那才是
* 页面卡死、要重启主站的成因,native 侧已按墙钟预算封顶(最坏 ≈10.6s)。
* @return {object|null} 最后一次 probe 读数;拿到 boot:true 就提前返回
*/
async function waitBootOnBus(maxTries, gapMs) {
var last = null;
var t0 = performance.now();
for (var k = 0; k < maxTries; k++) {
var rp = performance.now();
last = await bootEcatProbe(false);
/* 每轮都留一行,别只留最后一句总结:native 的 reason 分型(no_slave / no_preop /
* reopen_fail / sdo_timeout / app)是"链路没恢复"和"probe 没重试"唯一的区分读数,
* 以前 verbose=false 把它整轮丢弃,2026-10-12 那轮就是因此定不下断点。
* 耗时也一起打:2026-10-13 实测「从站能枚举到、但 AL 推不进 PREOP」时单轮 probe
* 要 28.5s(三级 dl_pull_preop × 4 次 2s statecheck),比前端 API_TMO.probe=20s
* 还长 —— 浏览器先 abort、native 仍在锁内跑完它,于是等锁实测到 54s。native 现已
* 按「必须短于 20s」配了墙钟预算(最坏 ≈10.6s)。没有这一列就看不出窗口被谁吃掉。 */
if (window.addLog) {
var s = (last && last.stage_ms) || {};
window.addLog('I', 'ECAT刷写',
'[盯BOOT] #' + (k + 1) + '/' + maxTries +
' t=+' + Math.round(performance.now() - t0) + 'ms' +
' 本轮=' + Math.round(performance.now() - rp) + 'ms' +
' boot=' + (last && last.boot ? 'Y' : 'N') +
' reason=' + ((last && last.reason) || '无响应') +
' name="' + ((last && last.name) || '') + '"' +
(s.total_ms != null
? ' 分段[preop=' + s.preop_ms + '/第' + s.preop_try +
' enum=' + s.enum_ms + '/第' + s.enum_try +
' reopen=' + s.reopen_ms +
' sdo=' + s.sdo_name_ms + ']'
: ''));
}
if (last && last.boot) return last;
/* 主站会话被它自己的全量重连兜底关掉了(ec_close 后 do_open 失败 → g_bus_open 留在
* 0),之后每条请求都只会回 bus_closed,角色格永远停在「未探测」—— 2026-10-13 实测
* t=+15s 就是这一格。这里立刻按上电那条路重开一次(复用 ensureBusOpen,不另写一套
* 恢复逻辑),否则剩下几轮全打在死会话上,白烧 BOOT 的窗口。 */
if (last && last.bus_open === 0) {
window.addLog('W', 'ECAT刷写',
'[盯BOOT] 主站会话已被兜底重连关掉(bus_open=0),按上电路径重开总线…');
await ensureBusOpen(true);
}
await new Promise(function (res) { setTimeout(res, gapMs); });
}
return last;
@@ -284,7 +361,7 @@ async function waitBootOnBus(maxTries, gapMs) {
*/
export async function bootEcatReboot() {
if (window.bootEcatBusy) return;
window.bootEcatBusy = true;
setEcatFlag('bootEcatBusy', true);
refreshBootEcatUi();
var btn = el('bootEcatRebootBtn');
var isBoot = window.bootEcatIsBoot === true;
@@ -310,11 +387,11 @@ export async function bootEcatReboot() {
}
if (r.already) {
if (op === 'jump_app') {
window.bootEcatIsBoot = false;
setEcatFlag('bootEcatIsBoot', false);
setEcatStatus('ok', '对面本来就是应用');
setEcatMsg('从站已在 APP,无需跳回');
} else {
window.bootEcatIsBoot = true;
setEcatFlag('bootEcatIsBoot', true);
setEcatStatus('ok', '对面本来就是引导程序');
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
}
@@ -324,11 +401,15 @@ export async function bootEcatReboot() {
}
if (op === 'jump_app') {
setEcatMsg('跳回已受理,等 APP 上总线…');
var a = await waitAppOnBus(10, 800);
var a = await waitAppOnBus(12, 300);
if (a && a.name && !a.boot) {
window.bootEcatIsBoot = false;
setEcatStatus('ok', '已回到应用');
setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + ')');
setEcatFlag('bootEcatIsBoot', false);
setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + '),恢复连接…');
var restored = await restoreEcatLikePowerOn();
setEcatStatus(restored ? 'ok' : 'warn', restored ? '已回到应用' : 'APP 已起,连接未完全恢复');
setEcatMsg(restored
? 'APP 已回到总线,连接已按上电流程恢复(0x100A = ' + (a.sw || '—') + ')'
: 'APP 已应答但 open/motor/start 未全部成功 —— 见日志,可再点『探测』或手动连接');
} else {
setEcatStatus('warn', '跳回后没等到 APP');
setEcatMsg('写了 0x2503 但没等到应用 —— 看串口,或 A 区 CRC 不过');
@@ -341,7 +422,7 @@ export async function bootEcatReboot() {
setEcatMsg('交接已受理,等 BOOT 上总线…');
var b = await waitBootOnBus(10, 800);
if (b && b.boot) {
window.bootEcatIsBoot = true;
setEcatFlag('bootEcatIsBoot', true);
setEcatStatus('ok', '已进引导程序');
setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写');
} else {
@@ -359,24 +440,53 @@ export async function bootEcatReboot() {
setEcatMsg('交接/跳回请求异常');
window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e);
} finally {
window.bootEcatBusy = false;
setEcatFlag('bootEcatBusy', false);
refreshBootEcatUi();
}
}
/**
* @description 跳回之后等 APP 真的回到总线(与 waitBootOnBus 对称)。
* 首轮前先短等 300ms(覆盖 BOOT 80ms 静默 + APP init),gap 收紧到 300ms:
* 旧 800ms 会让「其实已经好了」多愣一拍。
*/
async function waitAppOnBus(maxTries, gapMs) {
await new Promise(function (res) { setTimeout(res, 300); });
var last = null;
for (var k = 0; k < maxTries; k++) {
last = await bootEcatProbe(false);
if (last && last.name && !last.boot) return last;
await new Promise(function (res) { setTimeout(res, gapMs); });
await new Promise(function (res) { setTimeout(res, gapMs || 300); });
}
return last;
}
/**
* @description APP 回总线后,按「刚上电」同一条链拉起连接:
* health → open 网卡 → motor/start(PREOP→SAFEOP→OP + PDO 循环)。
* 与 device-link Owner 的 bootstrapEcatBus / runEcatBootstrap 同一实现,
* 不在本文件另写一套恢复逻辑。
*/
async function restoreEcatLikePowerOn() {
setEcatMsg('按上电流程恢复连接(open + motor/start)…');
try {
var r = await runEcatBootstrap({
ecatPost: post,
fromLocalStorage: true,
reason: 'user_probe',
});
if (r && r.ok) {
window.addLog('I', 'ECAT刷写', r.reused
? 'PDO 循环本已在跑,复用(与上电幂等语义一致)'
: '已按上电流程恢复:open + motor/start(OP + PDO)');
return true;
}
} catch (e) {
window.addLog('W', 'ECAT刷写', '按上电流程恢复连接失败:' + (e && e.message ? e.message : e));
}
return false;
}
/** @description 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */
export async function bootEcatAbort() {
if (window.bootEcatBusy) return;
@@ -480,7 +590,7 @@ export async function bootEcatFlash() {
}
clearArm();
window.bootEcatBusy = true;
setEcatFlag('bootEcatBusy', true);
/* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP
* 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、
* 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。
@@ -667,9 +777,16 @@ export async function bootEcatFlash() {
var up = !!(after && after.name && !after.boot);
if (up) {
setEcatStage(6, 'done', '已复位');
setEcatStage(7, 'done', '完成');
setEcatStatus('ok', '刷写完成');
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
setEcatStage(7, 'live', '恢复连接');
setEcatStatus('active', '按上电流程恢复连接…');
setEcatPct(100, '新 APP 已应答,恢复 OP/PDO…');
var restoredAfterFlash = await restoreEcatLikePowerOn();
setEcatStage(7, restoredAfterFlash ? 'done' : 'warn', restoredAfterFlash ? '完成' : '部分恢复');
setEcatStatus(restoredAfterFlash ? 'ok' : 'warn',
restoredAfterFlash ? '刷写完成' : '刷写完成,连接未完全恢复');
setEcatPct(100, restoredAfterFlash
? ('新 APP 已在总线 · 0x100A = ' + (after.sw || '—') + ' · 连接已按上电流程恢复')
: ('新 APP 已应答 0x100A = ' + (after.sw || '—') + ',但 open/motor/start 未全部成功'));
/* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包),
* 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */
if (pre && pre.sw && after.sw && after.sw === pre.sw) {
@@ -679,17 +796,42 @@ export async function bootEcatFlash() {
window.showToast('刷前刷后版本号相同,请确认刷的是最新固件', 'warning');
}
} else if (okRun) {
/* commit 成功只证明 A 区写进去了;邮箱读不到 APP 名 = EtherCAT 没恢复,
* 对用户就是「不能用」。这里禁止画成半成功:进度钉在提交完成那一档,
* 阶段 6/7 全部警告态,文案直接说清副作用。 */
setEcatStage(6, 'warn', '已复位');
setEcatStage(7, 'err', 'EtherCAT 未恢复');
setEcatStatus('err', '刷写已提交,但 EtherCAT 不可用');
setEcatPct(100, '镜像已写入,但总线邮箱读不到 APP —— EtherCAT 当前不可用');
window.addLog('E', 'ECAT刷写',
'commit OK,但 0x1008 读不到 APP 名 = EtherCAT 未恢复(HBI/邮箱路径死)。' +
'板上若仍是 1.6.39,warm jump 后 IDX 残值会让旧探针假阴性 —— 需刷 1.6.40;' +
'若已是 1.6.40 仍这样,看串口 [ECAT][ESC] init 是 FAIL BYTE_TEST 还是 core not ready');
/* commit 成功只证明 A 区写进去了;这一支里三种病必须分开说,且只有
* reason=sdo_timeout(AL 已到位、0x1008 不答)才允许指向从站:
* - 有 name 且 boot=true → 新 APP 没跑起来,还在引导程序(A 区/向量表的事)
* - bus_closed/no_slave/no_preop/reopen_fail → 主站压根没重连上,读数不自证
* - sdo_timeout → 邮箱路径真没应答,才轮到查固件
* 以前这里不分 reason 一律写"HBI/邮箱路径死",2026-10-09 21:49 那次就是
* 主站 6s 假超时后 6 轮 probe 全打在没重建的会话上,被误判成固件没恢复。 */
var rsn = (after && after.reason) || 'bus_closed';
var isBootNow = !!(after && after.name && after.boot);
var slaveBlamed = (rsn === 'sdo_timeout');
setEcatStage(6, isBootNow ? 'err' : 'warn', isBootNow ? '仍在引导程序' : '已复位');
setEcatStage(7, 'err', slaveBlamed ? '邮箱无回应'
: (isBootNow ? '没跳进 APP' : '主站未重连'));
setEcatStatus('err', slaveBlamed ? '刷写已提交,但从站邮箱无回应'
: (isBootNow ? '刷写已提交,但从站仍在引导程序'
: '刷写已提交,但主站没能重连上总线'));
setEcatPct(100, slaveBlamed
? '镜像已写入,AL 到位但 0x1008 不答 —— 从站邮箱当前不可用'
: (isBootNow ? '镜像已写入,对面仍是 BOOT —— 新 APP 没接棒'
: '镜像已写入,主站这侧会话没重建(不是从站的读数)'));
if (isBootNow) {
window.addLog('E', 'ECAT刷写',
'commit OK,但 0x1008 仍是 "' + after.name + '"(引导程序)—— ' +
'新 APP 没跳起来:先点「探测」看 BOOT 侧 stay_in_ota/校验位,再看串口 [CRC]/[J5] 有没有走到跳转');
} else if (slaveBlamed) {
window.addLog('E', 'ECAT刷写',
'commit OK,AL 已推进到 PREOP 但 0x1008 读不到(reason=sdo_timeout)= 从站邮箱无回应。' +
'串口看 [ECAT][ESC] init 是 FAIL BYTE_TEST 还是 core not ready' +
(after.detail ? ';native 侧原文:' + after.detail : ''));
} else {
window.addLog('E', 'ECAT刷写',
'commit OK,但主站没重连上总线(reason=' + rsn + (after && after.detail ? ',' + after.detail : '') +
')—— 这个读数只说明主站这侧没人,**不能**据此说从站 EtherCAT 死了。' +
'等链路重协商(新 APP 起板会自己补一发 LAN9252 RST#)后点「探测」复核;' +
'同时看 ESP32 侧 SPI 的 INIT_STATUS state/mod/fault_codes 定从站好坏');
}
} else {
setEcatStage(6, 'err', '未确认');
setEcatStage(7, 'err', '未完成');
@@ -710,7 +852,7 @@ export async function bootEcatFlash() {
setEcatPct(0, '刷写请求异常:' + (e && e.name === 'TimeoutError' ? '超时' : e));
window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e);
} finally {
window.bootEcatBusy = false;
setEcatFlag('bootEcatBusy', false);
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {}
if (runBtn) {
+5
View File
@@ -36,6 +36,7 @@
* 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。
*/
import { deviceLink } from '../common/device-link.js';
import { setCellSource } from '../common/utils.js';
import {
proveEcatLoop, ensureEcatLoopRepair, noteEcatLoopUp,
} from '../common/ecat-bus.js';
@@ -79,6 +80,7 @@ function render(d) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
setCellSource('mGd32Load', 'off');
return false;
}
var perm = Number(d.load_perm);
@@ -86,6 +88,7 @@ function render(d) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
setCellSource('mGd32Load', 'off');
return false;
}
var pct = perm / 10;
@@ -93,6 +96,8 @@ function render(d) {
/* ≥85% 变琥珀提示余量吃紧;其余用正常强调色 */
setCls(node, pct >= 85 ? 'v warn' : 'v ok');
setTitle(node, TIP_BASE + ';窗口序号 ' + d.seq + ',' + d.age_ms + 'ms 前更新');
/* /api/app/load 走 EtherCAT → 标 ECAT 来源 */
setCellSource('mGd32Load', 'ecat');
return true;
}
+18 -2
View File
@@ -20,8 +20,8 @@
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/
import { formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { formatUptime, setCellSource } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, setWifiPillOverride } from '../common/connection.js';
import { setActivePhase, setProgress } from './progress.js';
import { otaSessionReset, OtaSessionState } from './session.js';
@@ -154,9 +154,17 @@ export function clearLiveMetricsOnDisconnect() {
function applyDashboardUi(data, ota, wifi) {
updateHeapAndTaskStatusUi(data);
var connected = !!wifi.connected;
/* 正常状态轮询:清掉「重启中/上传中/设备忙」覆盖,WiFi 文案回到 updateLinkPills 唯一生成 */
try {
var busy = (typeof window.otaSessionActive === 'function' && window.otaSessionActive())
|| window.isEsp32Rebooting;
if (!busy) setWifiPillOverride(null);
} catch (_) {}
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(data.health, data.free_heap);
/* 连接状态角标由 updateConnectionUi 按双通路在线情况标注,这里只标健康(WiFi /system/status) */
setCellSource('mHealth', 'wifi');
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
@@ -168,6 +176,7 @@ function applyDashboardUi(data, ota, wifi) {
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
setCellSource('mGd32App', 'wifi');
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
setNodeText(mGd32App, ota.gd32_app);
setNodeClass(mGd32App, 'v ok');
@@ -177,6 +186,7 @@ function applyDashboardUi(data, ota, wifi) {
}
}
if (mGd32Boot) {
setCellSource('mGd32Boot', 'wifi');
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
setNodeText(mGd32Boot, ota.gd32_boot);
setNodeClass(mGd32Boot, 'v ok');
@@ -193,6 +203,7 @@ function applyDashboardUi(data, ota, wifi) {
* 1.5.36+ fault_codes[] 给出每模块具体原因,直接显示无需悬停。 */
var mGd32InitEl = document.getElementById('mGd32Init');
if (mGd32InitEl) {
setCellSource('mGd32Init', 'wifi');
var gi = data.gd32_init || null;
/* 与 GD32 sys/initial.h AppInitializer::ModuleBit 位序一致 */
var kModNames = ['nvic','systick','esp32_hold','usart0','adc','canfd',
@@ -276,6 +287,7 @@ function applyDashboardUi(data, ota, wifi) {
var mTempEl = document.getElementById('mTemp');
if (mTempEl) {
setCellSource('mTemp', 'wifi');
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
@@ -300,6 +312,10 @@ function applyDashboardUi(data, ota, wifi) {
var mGd32TempEl = document.getElementById('mGd32Temp');
var mVccEl = document.getElementById('mVcc');
var mVddaEl = document.getElementById('mVdda');
/* gd32_adc 经 /system/status(ESP32 HTTP)→ WiFi */
setCellSource('mGd32Temp', 'wifi');
setCellSource('mVcc', 'wifi');
setCellSource('mVdda', 'wifi');
function setMetricUi(el, text, cls, title) {
if (!el) return;