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:
+162
-9
@@ -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 (_) {}
|
||||
|
||||
Reference in New Issue
Block a user