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;
});
}
// 初始化按钮状态与提示