2026-09-16 02:50:12 +08:00
|
|
|
|
/**
|
2026-09-16 12:12:24 +08:00
|
|
|
|
* device-link.js — 全链路连接中心(ConnectionHub)
|
2026-09-16 02:50:12 +08:00
|
|
|
|
*
|
2026-09-16 12:12:24 +08:00
|
|
|
|
* 统一管理页面上的 WiFi / EtherCAT / BLE 三条链路:
|
|
|
|
|
|
* - 健康轮询:外壳 Owner 常驻拉 ESP32 /system/status 与 EtherCAT status+load
|
|
|
|
|
|
* - 端点配置:设备 IP(dcu_esp_ip)、网卡子串(dcu_ecat_adapter)只在此读写
|
|
|
|
|
|
* - 通道适配:wifiBaseUrl / ecatPost 供各页复用,禁止再散落 localStorage
|
2026-09-16 02:50:12 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 角色:
|
2026-09-16 12:12:24 +08:00
|
|
|
|
* - Owner:tabs.html 加载前设 window.__dcuDeviceLinkOwner = true
|
2026-09-16 02:50:12 +08:00
|
|
|
|
* - Client:任意页 import { deviceLink }
|
2026-09-16 12:12:24 +08:00
|
|
|
|
* 独立直开页无 Owner 时,client 仍可读 IP / 发 ecatPost,只是无跨页快照。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 命令仍由业务页自己发;连接灯只认本层健康态(布尔优先,文案保留调用方语义)。
|
2026-09-16 02:50:12 +08:00
|
|
|
|
*/
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
2026-09-16 12:12:24 +08:00
|
|
|
|
import {
|
2026-10-13 00:48:34 +08:00
|
|
|
|
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp,
|
|
|
|
|
|
isValidIpv4, getDeviceMeta, saveDeviceMeta
|
2026-09-16 12:12:24 +08:00
|
|
|
|
} from "./base-url.js";
|
2026-10-13 00:48:34 +08:00
|
|
|
|
import { flashBusyNow } from "./utils.js";
|
2026-09-21 02:26:39 +08:00
|
|
|
|
import {
|
|
|
|
|
|
runEcatBootstrap, repairEcatDc, getEcatAdapterOrDefault,
|
|
|
|
|
|
ECAT_DEFAULT_ADAPTER,
|
|
|
|
|
|
} from "./ecat-bus.js";
|
2026-09-16 12:12:24 +08:00
|
|
|
|
|
2026-09-16 02:50:12 +08:00
|
|
|
|
var CH_NAME = "dcu-device-link";
|
|
|
|
|
|
var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
|
|
|
|
|
|
var ESP32_POLL_MS = 1000;
|
|
|
|
|
|
var ECAT_POLL_MS = 2000;
|
2026-09-16 12:24:45 +08:00
|
|
|
|
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
|
2026-10-13 00:48:34 +08:00
|
|
|
|
var AUTO_SCAN_COOLDOWN_MS = 60000; /* 判离线后最多每 60s 提一次候选设备 */
|
2026-09-20 22:58:00 +08:00
|
|
|
|
var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */
|
2026-09-16 12:24:45 +08:00
|
|
|
|
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
|
|
|
|
|
|
var LS_ECAT_SPIN = "dcu_ecat_spin";
|
2026-09-16 12:12:24 +08:00
|
|
|
|
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* R6:网卡默认常量只在 ecat-bus.js 定义;此处 re-export 保持旧 import 路径可用 */
|
|
|
|
|
|
var DEFAULT_ECAT_ADAPTER = ECAT_DEFAULT_ADAPTER;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
|
|
|
|
|
|
function nowMs() { return Date.now(); }
|
|
|
|
|
|
|
|
|
|
|
|
function readIp() {
|
2026-09-16 12:12:24 +08:00
|
|
|
|
return getDeviceIp();
|
2026-09-16 02:50:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function writeIp(ip) {
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 校验在 base-url.saveDeviceIp 单点做:非法值不改写现值,返回 false。 */
|
|
|
|
|
|
return saveDeviceIp(ip);
|
2026-09-16 12:12:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readAdapter() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var s = localStorage.getItem(LS_ECAT_ADAPTER);
|
|
|
|
|
|
if (s && s.trim()) return s.trim();
|
|
|
|
|
|
} catch (_) {}
|
2026-09-21 02:26:39 +08:00
|
|
|
|
return getEcatAdapterOrDefault();
|
2026-09-16 12:12:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function writeAdapter(substr) {
|
|
|
|
|
|
if (!substr || !String(substr).trim()) return;
|
|
|
|
|
|
try { localStorage.setItem(LS_ECAT_ADAPTER, String(substr).trim()); } catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 统一 /ecat/api/* 代理请求(同源,esp32_server 转发到 ecat_native)。
|
|
|
|
|
|
* @param {string} path 相对路径,如 "status" / "app/load" / "open"
|
|
|
|
|
|
* @param {object} body JSON body,缺省 {}
|
|
|
|
|
|
* @param {number} timeoutMs 默认 5000
|
|
|
|
|
|
* @returns {Promise<object>} 解析后的 JSON
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function ecatPost(path, body, timeoutMs) {
|
|
|
|
|
|
var p = String(path || "").replace(/^\/+/, "");
|
|
|
|
|
|
var resp = await fetch("/ecat/api/" + p, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify(body || {}),
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
signal: AbortSignal.timeout(timeoutMs || 5000)
|
|
|
|
|
|
});
|
|
|
|
|
|
var text = await resp.text();
|
|
|
|
|
|
var json = {};
|
|
|
|
|
|
try { json = text ? JSON.parse(text) : {}; } catch (_) {
|
|
|
|
|
|
throw new Error("响应非合法 JSON");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
var err = new Error("HTTP " + resp.status);
|
|
|
|
|
|
err.status = resp.status;
|
|
|
|
|
|
err.json = json;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return json;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emptySnapshot() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ts: 0,
|
|
|
|
|
|
ip: readIp(),
|
|
|
|
|
|
links: {
|
|
|
|
|
|
esp32: { online: false, ageMs: -1, failStreak: 0 },
|
|
|
|
|
|
ecat: { online: false, busOpen: false, op: false, loop: false,
|
2026-10-13 00:48:34 +08:00
|
|
|
|
loadValid: false, loadPerm: 0, ageMs: -1, deviceName: "",
|
|
|
|
|
|
slaveState: null, alStatus: null },
|
2026-09-16 02:50:12 +08:00
|
|
|
|
ble: { online: false, deviceName: "", owner: null }
|
|
|
|
|
|
},
|
2026-10-13 00:48:34 +08:00
|
|
|
|
esp32Status: null,
|
|
|
|
|
|
/* 端点一致性:'match' | 'mismatch' | 'unverified'(见 checkEndpoint) */
|
|
|
|
|
|
endpointCheck: 'unverified',
|
|
|
|
|
|
endpointCheckNote: ''
|
2026-09-16 02:50:12 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ───────────────────────── Owner ───────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function createOwner() {
|
|
|
|
|
|
var ch = null;
|
|
|
|
|
|
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
|
|
|
|
|
var snap = emptySnapshot();
|
|
|
|
|
|
var esp32Fails = 0;
|
|
|
|
|
|
var lastEsp32OkAt = 0;
|
|
|
|
|
|
var esp32Timer = null;
|
|
|
|
|
|
var ecatTimer = null;
|
|
|
|
|
|
var stopped = false;
|
2026-09-16 12:24:45 +08:00
|
|
|
|
/* 外壳启动即强制拉起总线(open + motor/start),不必等电机页切 EtherCAT。
|
|
|
|
|
|
* 成功后不再重复 motor/start;掉线后按 ECAT_BUS_RETRY_MS 退避重试。 */
|
|
|
|
|
|
var ecatBusReady = false;
|
|
|
|
|
|
var ecatBusBusy = false;
|
|
|
|
|
|
var ecatBusNextTryAt = 0;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
var uptimeBase = null; /* {ip, up} 只用于 uptime 倒退检测(换板/复位) */
|
|
|
|
|
|
var autoScanAt = 0; /* 上次自动提候选的时刻,见 maybeAutoScan */
|
2026-09-16 02:50:12 +08:00
|
|
|
|
|
|
|
|
|
|
function publish() {
|
|
|
|
|
|
snap.ts = nowMs();
|
|
|
|
|
|
snap.ip = readIp();
|
|
|
|
|
|
if (ch) {
|
|
|
|
|
|
try { ch.postMessage({ type: "snapshot", snap: snap }); } catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setEsp32(status, ok) {
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
esp32Fails = 0;
|
|
|
|
|
|
lastEsp32OkAt = nowMs();
|
|
|
|
|
|
snap.links.esp32.online = true;
|
|
|
|
|
|
snap.links.esp32.ageMs = 0;
|
|
|
|
|
|
snap.esp32Status = status || null;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
snap.endpointCheck = checkEndpoint(status);
|
2026-09-16 02:50:12 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
esp32Fails++;
|
|
|
|
|
|
snap.links.esp32.failStreak = esp32Fails;
|
|
|
|
|
|
snap.links.esp32.ageMs = lastEsp32OkAt ? (nowMs() - lastEsp32OkAt) : -1;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
if (esp32Fails >= ESP32_FAIL_ONLINE) {
|
|
|
|
|
|
snap.links.esp32.online = false;
|
|
|
|
|
|
snap.endpointCheck = checkEndpoint(null);
|
|
|
|
|
|
maybeAutoScan();
|
|
|
|
|
|
}
|
2026-09-16 02:50:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
snap.links.esp32.failStreak = esp32Fails;
|
|
|
|
|
|
publish();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「现在连的还是我选的那台吗」——判据必须有灵敏度,不能是自证:
|
|
|
|
|
|
* 版本变 / 设备自报 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 (_) {} }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 02:50:12 +08:00
|
|
|
|
function pollEsp32() {
|
|
|
|
|
|
if (stopped) return;
|
|
|
|
|
|
var ac = null;
|
|
|
|
|
|
try { ac = new AbortController(); } catch (_) {}
|
|
|
|
|
|
var tmo = null;
|
2026-09-16 12:12:24 +08:00
|
|
|
|
fetch(baseUrlFromIp() + "/system/status" + (window._memLayout ? "" : "?layout=1"), {
|
2026-09-16 02:50:12 +08:00
|
|
|
|
signal: ac ? ac.signal : undefined,
|
|
|
|
|
|
cache: "no-store"
|
|
|
|
|
|
}).then(function (r) {
|
|
|
|
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
}).then(function (j) {
|
|
|
|
|
|
if (ac) { clearTimeout(tmo); }
|
|
|
|
|
|
setEsp32(j, true);
|
|
|
|
|
|
}).catch(function () {
|
|
|
|
|
|
if (ac) { clearTimeout(tmo); }
|
|
|
|
|
|
setEsp32(null, false);
|
|
|
|
|
|
}).then(function () {
|
|
|
|
|
|
esp32Timer = setTimeout(pollEsp32, ESP32_POLL_MS);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (ac) {
|
|
|
|
|
|
tmo = setTimeout(function () { try { ac.abort(); } catch (_) {} }, 4000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 12:24:45 +08:00
|
|
|
|
/**
|
2026-09-21 02:26:39 +08:00
|
|
|
|
* 开总线:R6 —— 策略只在 js/common/ecat-bus.js(health→open→motor/start,
|
|
|
|
|
|
* DC 250µs,已健康则复用)。外壳 Owner 启动时强制执行,用户不必先去电机页。
|
2026-10-13 00:48:34 +08:00
|
|
|
|
* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
|
2026-09-16 12:24:45 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function bootstrapEcatBus() {
|
|
|
|
|
|
if (stopped || ecatBusBusy) return;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 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;
|
2026-09-16 12:24:45 +08:00
|
|
|
|
var now = nowMs();
|
|
|
|
|
|
if (ecatBusReady) return;
|
|
|
|
|
|
if (now < ecatBusNextTryAt) return;
|
|
|
|
|
|
ecatBusBusy = true;
|
|
|
|
|
|
ecatBusNextTryAt = now + ECAT_BUS_RETRY_MS;
|
|
|
|
|
|
|
|
|
|
|
|
var adapter = readAdapter();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
var period = null;
|
|
|
|
|
|
var spin = null;
|
2026-09-16 12:24:45 +08:00
|
|
|
|
try {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
period = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
|
2026-09-16 12:24:45 +08:00
|
|
|
|
var sp = localStorage.getItem(LS_ECAT_SPIN);
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (sp === "0") spin = false;
|
|
|
|
|
|
else if (sp === "1") spin = true;
|
|
|
|
|
|
} catch (_) {}
|
2026-09-16 12:24:45 +08:00
|
|
|
|
|
2026-09-21 02:26:39 +08:00
|
|
|
|
runEcatBootstrap({
|
|
|
|
|
|
ecatPost: ecatPost,
|
|
|
|
|
|
adapter: adapter,
|
|
|
|
|
|
period_ms: period,
|
|
|
|
|
|
spin: spin,
|
2026-09-16 12:24:45 +08:00
|
|
|
|
}).then(function (r) {
|
2026-09-21 02:26:39 +08:00
|
|
|
|
if (r && r.ok) ecatBusReady = true;
|
2026-09-16 12:24:45 +08:00
|
|
|
|
}).catch(function () {
|
|
|
|
|
|
/* 网线没插/从站未上电:退避后由 pollEcat 再触发 */
|
|
|
|
|
|
}).then(function () {
|
|
|
|
|
|
ecatBusBusy = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 02:50:12 +08:00
|
|
|
|
function pollEcat() {
|
|
|
|
|
|
if (stopped) return;
|
2026-09-16 12:12:24 +08:00
|
|
|
|
var stP = ecatPost("status", {}, 4000).catch(function () { return null; });
|
|
|
|
|
|
var ldP = ecatPost("app/load", {}, 4000).catch(function () { return null; });
|
2026-09-16 02:50:12 +08:00
|
|
|
|
|
|
|
|
|
|
Promise.all([stP, ldP]).then(function (arr) {
|
|
|
|
|
|
var st = arr[0], ld = arr[1];
|
|
|
|
|
|
var e = snap.links.ecat;
|
|
|
|
|
|
e.online = !!(st && st.ok && st.slave);
|
2026-09-16 12:24:45 +08:00
|
|
|
|
e.busOpen = !!(st && st.ok);
|
2026-10-13 00:48:34 +08:00
|
|
|
|
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;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
/* native status 不直接给 op/loop;用 load valid + slave state 粗判 */
|
2026-09-16 02:53:53 +08:00
|
|
|
|
e.op = !!(st && st.slave && st.slave.state != null && (st.slave.state & 0x0F) === 8);
|
2026-09-16 02:50:12 +08:00
|
|
|
|
e.loadValid = !!(ld && ld.ok && ld.valid === true
|
|
|
|
|
|
&& Number.isFinite(ld.age_ms) && ld.age_ms >= 0 && ld.age_ms <= 3000);
|
|
|
|
|
|
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;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 与刚上电同判据:bus_open + loop 才算就绪。
|
|
|
|
|
|
* jump_app / commit 后 loop 被 loop_stop 掉、bus 仍开 —— 旧逻辑只在
|
|
|
|
|
|
* !busOpen 时清 ready,于是 Owner 永远不再 motor/start,连接要等外部
|
|
|
|
|
|
* 自愈才回来。loop 一掉就允许 bootstrap 重拉(刷写中由上面 busy 门拦住)。 */
|
2026-09-16 12:24:45 +08:00
|
|
|
|
if (e.busOpen && e.loop) ecatBusReady = true;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
else ecatBusReady = false;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
publish();
|
2026-09-21 02:26:39 +08:00
|
|
|
|
/* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc */
|
2026-09-20 22:58:00 +08:00
|
|
|
|
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
|
|
|
|
|
|
ecatDcGuardBusy = true;
|
2026-09-21 02:26:39 +08:00
|
|
|
|
repairEcatDc(ecatPost).catch(function () {}).then(function () {
|
2026-09-20 22:58:00 +08:00
|
|
|
|
ecatDcGuardBusy = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-16 12:24:45 +08:00
|
|
|
|
bootstrapEcatBus();
|
2026-09-16 02:53:53 +08:00
|
|
|
|
}).catch(function () {
|
|
|
|
|
|
/* 字段访问/异常不得让 ECAT 轮询永久停摆 */
|
2026-09-16 02:50:12 +08:00
|
|
|
|
}).then(function () {
|
2026-09-16 02:53:53 +08:00
|
|
|
|
if (!stopped) ecatTimer = setTimeout(pollEcat, ECAT_POLL_MS);
|
2026-09-16 02:50:12 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onMsg(ev) {
|
|
|
|
|
|
var m = ev && ev.data;
|
|
|
|
|
|
if (!m || !m.type) return;
|
|
|
|
|
|
if (m.type === "hello") { publish(); return; }
|
|
|
|
|
|
if (m.type === "set-ip") {
|
|
|
|
|
|
writeIp(m.ip);
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 换了端点=换了问题:旧的核对结论必须作废,否则新端点还没答过话,
|
|
|
|
|
|
面板就拿着上一个设备的 status 报"不符"。 */
|
|
|
|
|
|
snap.endpointCheck = 'unverified';
|
|
|
|
|
|
snap.endpointCheckNote = '';
|
|
|
|
|
|
uptimeBase = null; /* uptime 倒退检测只在"同一个端点连续观察"内成立 */
|
|
|
|
|
|
/* 无论采纳还是拒绝都把"实际生效的 IP"广播回去:否则各页输入框会停在
|
|
|
|
|
|
用户刚打的脏值上,显示连着、实际打的还是旧端点——那正是自证判据。 */
|
|
|
|
|
|
publish();
|
2026-09-16 12:12:24 +08:00
|
|
|
|
try {
|
|
|
|
|
|
fetch(baseUrlFromIp() + "/system/status", {
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
signal: AbortSignal.timeout(2000)
|
|
|
|
|
|
}).then(function (r) { return r.json(); })
|
|
|
|
|
|
.then(function (j) { setEsp32(j, true); })
|
|
|
|
|
|
.catch(function () { setEsp32(null, false); });
|
2026-09-16 02:50:12 +08:00
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m.type === "ble-state") {
|
|
|
|
|
|
snap.links.ble.online = !!m.online;
|
|
|
|
|
|
snap.links.ble.deviceName = m.deviceName || "";
|
|
|
|
|
|
snap.links.ble.owner = m.owner || (m.online ? "page" : null);
|
|
|
|
|
|
publish();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (ch) ch.addEventListener("message", onMsg);
|
|
|
|
|
|
pollEsp32();
|
|
|
|
|
|
pollEcat();
|
2026-09-16 12:24:45 +08:00
|
|
|
|
bootstrapEcatBus();
|
2026-09-16 02:50:12 +08:00
|
|
|
|
publish();
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
stop: function () {
|
|
|
|
|
|
stopped = true;
|
|
|
|
|
|
if (esp32Timer) clearTimeout(esp32Timer);
|
|
|
|
|
|
if (ecatTimer) clearTimeout(ecatTimer);
|
|
|
|
|
|
if (ch) { try { ch.close(); } catch (_) {} }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ───────────────────────── Client ───────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function createClient() {
|
|
|
|
|
|
var ch = null;
|
|
|
|
|
|
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
|
|
|
|
|
var listeners = [];
|
2026-10-13 00:48:34 +08:00
|
|
|
|
var autoScanFns = [];
|
2026-09-16 02:50:12 +08:00
|
|
|
|
var last = null;
|
|
|
|
|
|
|
2026-10-13 00:48:34 +08:00
|
|
|
|
function emitAutoScan(m) {
|
|
|
|
|
|
for (var i = 0; i < autoScanFns.length; i++) {
|
|
|
|
|
|
try { autoScanFns[i](m || {}); } catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 02:50:12 +08:00
|
|
|
|
function emit() {
|
|
|
|
|
|
for (var i = 0; i < listeners.length; i++) {
|
|
|
|
|
|
try { listeners[i](last); } catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (ch) {
|
|
|
|
|
|
ch.addEventListener("message", function (ev) {
|
|
|
|
|
|
var m = ev && ev.data;
|
2026-10-13 00:48:34 +08:00
|
|
|
|
if (!m || !m.type) return;
|
|
|
|
|
|
if (m.type === "auto-scan") { emitAutoScan(m); return; }
|
|
|
|
|
|
if (m.type !== "snapshot" || !m.snap) return;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
last = m.snap;
|
|
|
|
|
|
emit();
|
|
|
|
|
|
});
|
|
|
|
|
|
try { ch.postMessage({ type: "hello" }); } catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 12:12:24 +08:00
|
|
|
|
function applyLocalBle(online, deviceName) {
|
|
|
|
|
|
if (last) {
|
|
|
|
|
|
last.links.ble.online = !!online;
|
|
|
|
|
|
last.links.ble.deviceName = deviceName || "";
|
|
|
|
|
|
last.links.ble.owner = online ? "iframe" : null;
|
|
|
|
|
|
last.ts = nowMs();
|
|
|
|
|
|
emit();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 02:50:12 +08:00
|
|
|
|
return {
|
2026-09-16 12:12:24 +08:00
|
|
|
|
/* ── 快照 ── */
|
2026-09-16 02:50:12 +08:00
|
|
|
|
getSnapshot: function () { return last; },
|
|
|
|
|
|
subscribe: function (fn) {
|
|
|
|
|
|
if (typeof fn !== "function") return function () {};
|
|
|
|
|
|
listeners.push(fn);
|
|
|
|
|
|
if (last) { try { fn(last); } catch (_) {} }
|
|
|
|
|
|
return function () {
|
|
|
|
|
|
var i = listeners.indexOf(fn);
|
|
|
|
|
|
if (i >= 0) listeners.splice(i, 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
2026-09-16 12:12:24 +08:00
|
|
|
|
/** 指定通道当前是否在线;无快照时回退 false(调用方自行降级) */
|
|
|
|
|
|
channelOnline: function (name) {
|
|
|
|
|
|
if (!last || !last.links) return false;
|
|
|
|
|
|
var L = last.links[name];
|
|
|
|
|
|
return !!(L && L.online);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* ── WiFi 端点(唯一写手;各页输入框/发现面板都走这里) ── */
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description 换设备。**唯一**能改当前端点的入口,门也只在这里判一次:
|
|
|
|
|
|
* - IPv4 合法性(base-url.saveDeviceIp 单点校验,非法不改写现值)
|
|
|
|
|
|
* - 刷写/OTA 进行中不换(motor/start 会 loop_stop、灌写会话会被掐死)
|
|
|
|
|
|
* @returns {{ok:boolean, ip?:string, reason?:string}}
|
|
|
|
|
|
*/
|
2026-09-16 02:50:12 +08:00
|
|
|
|
setIp: function (ip) {
|
2026-10-13 00:48:34 +08:00
|
|
|
|
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 失败' };
|
2026-09-16 02:50:12 +08:00
|
|
|
|
if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} }
|
2026-10-13 00:48:34 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-09-16 02:50:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
getIp: function () { return readIp(); },
|
2026-09-16 12:12:24 +08:00
|
|
|
|
wifiBaseUrl: function () { return baseUrlFromIp(); },
|
|
|
|
|
|
|
|
|
|
|
|
/* ── EtherCAT 端点与请求 ── */
|
|
|
|
|
|
getEcatAdapter: function () { return readAdapter(); },
|
|
|
|
|
|
setEcatAdapter: function (substr) {
|
|
|
|
|
|
writeAdapter(substr);
|
|
|
|
|
|
return readAdapter();
|
|
|
|
|
|
},
|
|
|
|
|
|
ecatPost: ecatPost,
|
|
|
|
|
|
|
|
|
|
|
|
/* ── BLE 状态上报 ── */
|
2026-09-16 02:50:12 +08:00
|
|
|
|
reportBle: function (online, deviceName) {
|
|
|
|
|
|
var payload = {
|
|
|
|
|
|
type: "ble-state",
|
|
|
|
|
|
online: !!online,
|
|
|
|
|
|
deviceName: deviceName || "",
|
|
|
|
|
|
owner: "iframe"
|
|
|
|
|
|
};
|
|
|
|
|
|
if (ch) { try { ch.postMessage(payload); } catch (_) {} }
|
2026-09-16 12:12:24 +08:00
|
|
|
|
applyLocalBle(online, deviceName);
|
2026-09-16 02:50:12 +08:00
|
|
|
|
},
|
2026-09-16 12:12:24 +08:00
|
|
|
|
|
2026-09-16 02:50:12 +08:00
|
|
|
|
hasOwner: function () { return !!last; }
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ───────────────────────── bootstrap ───────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
var deviceLink;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.__dcuDeviceLinkOwner) {
|
2026-10-13 00:48:34 +08:00
|
|
|
|
/* 把标记消费掉:同一文档里本模块若因入口 ?v= 与 JS 间 import 的 URL 不同被
|
|
|
|
|
|
* 实例化两次(tabs.html 就是这种:入口标签带 stamp,lan-picker.js 不带),
|
|
|
|
|
|
* 只允许有一个 Owner —— 两个 Owner 就是两份 1Hz status 轮询 + 两份快照互相打架。 */
|
|
|
|
|
|
window.__dcuDeviceLinkOwner = false;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
window.__dcuDeviceLinkInstance = createOwner();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
|
|
deviceLink = createClient();
|
2026-09-16 12:12:24 +08:00
|
|
|
|
export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter };
|
2026-09-21 02:26:39 +08:00
|
|
|
|
export { ECAT_DEFAULT_ADAPTER };
|
2026-09-16 02:50:12 +08:00
|
|
|
|
export default deviceLink;
|