diff --git a/js/common/connection.js b/js/common/connection.js index 48ef6f1..ee33536 100644 --- a/js/common/connection.js +++ b/js/common/connection.js @@ -12,7 +12,10 @@ * 复用的函数(通过 window.* 引用): * window.getBaseUrl / window.updateSystemStatusUi / window.formatPercent */ -import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js'; +import { + formatBytes, formatTime, formatUptime, escapeHtml, + setTextIfChanged, setClassIfChanged, setQuietMode, +} from './utils.js'; import { addLog } from './log.js'; import { showToast } from './toast.js'; import { @@ -20,6 +23,10 @@ import { } from './heap-low-history.js'; import { deviceLink } from './device-link.js'; +/* R7:setQuietMode / setTextIfChanged / setClassIfChanged 全仓唯一实现在 utils.js。 + * 此处 re-export setQuietMode,保持既有 `import { setQuietMode } from './connection.js'` 可用。 */ +export { setQuietMode }; + // ==================== 渲染缓存(性能) ==================== // /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建 // CPU/任务栈列表 + 无条件写几十个节点,引发周期性样式重算/重排/重绘掉帧。 @@ -40,12 +47,6 @@ function setTextById(id, value) { var v = String(value); if (el.textContent !== v) el.textContent = v; } -function setClassIfChanged(el, cls) { - if (el && el.className !== cls) el.className = cls; -} -function setTextIfChanged(el, txt) { - if (el && el.textContent !== txt) el.textContent = txt; -} /** 写前比较的宽度更新:用元素上的 _lastW 缓存避免读回 style 字符串。 */ function setWidthIfChanged(el, widthStr) { if (el && el._lastW !== widthStr) { @@ -59,13 +60,8 @@ function setWidthIfChanged(el, widthStr) { * 无设备静默态:设备离线且没有传输在跑时,给 挂 data-dcu-calm="1", * 由 responsive.css 统一掐掉常驻动画。160Hz 屏上这些动效每帧触发重算+重绘, * 实测未接设备时 renderer+GPU 各占约一核的七成。 + * (实现在 js/common/utils.js,见文件头注释。) */ -export function setQuietMode(quiet) { - var v = quiet ? '1' : '0'; - if (document.documentElement.dataset.dcuCalm !== v) { - document.documentElement.dataset.dcuCalm = v; - } -} /** 本轮是否有传输/OTA 在跑(此时即使设备短暂不响应也要保留动效)。 */ function transferOngoing() { diff --git a/js/common/device-link.js b/js/common/device-link.js index 0ed1fcb..ec1ffa6 100644 --- a/js/common/device-link.js +++ b/js/common/device-link.js @@ -18,6 +18,10 @@ import { getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp } from "./base-url.js"; +import { + runEcatBootstrap, repairEcatDc, getEcatAdapterOrDefault, + ECAT_DEFAULT_ADAPTER, +} from "./ecat-bus.js"; var CH_NAME = "dcu-device-link"; var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */ @@ -28,7 +32,8 @@ var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */ var LS_ECAT_PERIOD = "dcu_ecat_period_ms"; var LS_ECAT_SPIN = "dcu_ecat_spin"; var LS_ECAT_ADAPTER = "dcu_ecat_adapter"; -var DEFAULT_ECAT_ADAPTER = "I210"; +/* R6:网卡默认常量只在 ecat-bus.js 定义;此处 re-export 保持旧 import 路径可用 */ +var DEFAULT_ECAT_ADAPTER = ECAT_DEFAULT_ADAPTER; function nowMs() { return Date.now(); } @@ -45,7 +50,7 @@ function readAdapter() { var s = localStorage.getItem(LS_ECAT_ADAPTER); if (s && s.trim()) return s.trim(); } catch (_) {} - return DEFAULT_ECAT_ADAPTER; + return getEcatAdapterOrDefault(); } function writeAdapter(substr) { @@ -165,13 +170,8 @@ function createOwner() { } /** - * 开总线:health →(未就绪则)status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。 - * 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在 - * 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。 - * - * 已健康(bus_open + loop)时**直接复用**,不打 motor/start: - * 那条路由会先 loop_stop 再重进 OP,和电机页探测撞车时会假超时 - * (横幅 signal timed out)并平白增加 SSC 楔死风险。 + * 开总线:R6 —— 策略只在 js/common/ecat-bus.js(health→open→motor/start, + * DC 250µs,已健康则复用)。外壳 Owner 启动时强制执行,用户不必先去电机页。 */ function bootstrapEcatBus() { if (stopped || ecatBusBusy) return; @@ -182,47 +182,22 @@ function createOwner() { ecatBusNextTryAt = now + ECAT_BUS_RETRY_MS; var adapter = readAdapter(); - var body = {}; + var period = null; + var spin = null; try { - var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD)); - /* 无显式值 / 值过慢 → 固定 0.25ms。1ms+DC 会让发送出口回到 ~1s。 */ - if (v > 0 && v <= 0.5) body.period_ms = v; - else body.period_ms = 0.25; + period = parseFloat(localStorage.getItem(LS_ECAT_PERIOD)); var sp = localStorage.getItem(LS_ECAT_SPIN); - if (sp === "0") body.spin = false; - else if (sp === "1") body.spin = true; - } catch (_) { body.period_ms = 0.25; } - /* EtherCAT 控制必须 DC SYNC0≈PDO 周期,且周期应保持 250µs 档 */ - { - var per = Number(body.period_ms) || 0.25; - if (per > 0.5) per = 0.25; - body.period_ms = per; - var us = Math.round(per * 1000); - if (us < 250) us = 250; - body.dc_cycle_us = us; - body.dc_shift_us = 0; - } + if (sp === "0") spin = false; + else if (sp === "1") spin = true; + } catch (_) {} - ecatPost("native/health", {}, 3000).then(function (h) { - if (h && h.ok && h.bus_open && h.loop) { - ecatBusReady = true; - return null; /* 已在跑:跳过 open + motor/start */ - } - return ecatPost("status", {}, 6000).then(function (st) { - var open = !!(st && st.ok); - return open ? Promise.resolve(st) - : ecatPost("open", { substr: adapter }, 20000); - }).then(function (r) { - if (r && r.ok === false) { - throw new Error((r.log && r.log.join(" / ")) || "open 失败"); - } - return ecatPost("motor/start", body, 30000); - }); + runEcatBootstrap({ + ecatPost: ecatPost, + adapter: adapter, + period_ms: period, + spin: spin, }).then(function (r) { - if (r && r.ok === false) { - throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败"); - } - if (r) ecatBusReady = true; + if (r && r.ok) ecatBusReady = true; }).catch(function () { /* 网线没插/从站未上电:退避后由 pollEcat 再触发 */ }).then(function () { @@ -250,18 +225,10 @@ function createOwner() { if (e.busOpen && e.loop) ecatBusReady = true; else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */ publish(); - /* 自愈:DC/周期被配错时发送出口秒级 —— 2s 轮询里拉回 250µs */ + /* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc */ if (e.busOpen && e.loop && !ecatDcGuardBusy) { ecatDcGuardBusy = true; - ecatPost("native/health", {}, 2000).then(function (h) { - if (!h || !h.ok) return null; - var dcs = Number(h.dc_cycle_us); - var per = Number(h.period_ms); - if (h.dc === 1 && dcs === 250 && (h.period_ms == null || Math.abs(per - 0.25) < 0.001)) return null; - return ecatPost("motor/start", { - period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 - }, 30000); - }).catch(function () {}).then(function () { + repairEcatDc(ecatPost).catch(function () {}).then(function () { ecatDcGuardBusy = false; }); } @@ -410,4 +377,5 @@ try { deviceLink = createClient(); export { deviceLink, ecatPost, readAdapter as getEcatAdapter, writeAdapter as setEcatAdapter }; +export { ECAT_DEFAULT_ADAPTER }; export default deviceLink; diff --git a/js/common/ecat-bus.js b/js/common/ecat-bus.js new file mode 100644 index 0000000..45ac9d6 --- /dev/null +++ b/js/common/ecat-bus.js @@ -0,0 +1,184 @@ +/** + * ecat-bus.js — EtherCAT 总线 bootstrap / open / motor-start 唯一实现 + * + * 设备链路(device-link 外壳 Owner)与电机传输(transport-ecat)都调用本模块, + * 策略只保留一份: + * health →(未就绪)status →(未开)open 网卡 → motor/start 进 OP + 起 PDO 循环 + * + * 红线: + * - DC 钉死 250µs(period_ms 默认/钳制 0.25,dc_cycle_us = max(250, round(ms*1000))) + * - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start + * (start 会先 loop_stop,热路径假超时 / SSC 楔死风险) + * - 网卡默认常量 ECAT_DEFAULT_ADAPTER 只在此定义 + */ +"use strict"; + +/** 网卡匹配子串默认值(代码/页面/测试的唯一来源;文档漂移以此为准)。 */ +export const ECAT_DEFAULT_ADAPTER = "I210"; + +/** 默认 PDO 周期(ms):1ms+DC 会让发送出口回到 ~1s,必须 250µs 档。 */ +export const ECAT_DEFAULT_PERIOD_MS = 0.25; + +/** DC SYNC0 目标周期(µs)。 */ +export const ECAT_DC_CYCLE_US = 250; + +const LS_ECAT_PERIOD = "dcu_ecat_period_ms"; +const LS_ECAT_SPIN = "dcu_ecat_spin"; +const LS_ECAT_ADAPTER = "dcu_ecat_adapter"; +const LS_PERIOD_MIG = "dcu_ecat_period_mig"; + +function readAdapterRaw() { + try { + var s = localStorage.getItem(LS_ECAT_ADAPTER); + if (s && s.trim()) return s.trim(); + } catch (_) {} + return ECAT_DEFAULT_ADAPTER; +} + +/** + * 构造 motor/start body:period 钳到 ≤0.5ms 且默认 0.25,DC 钉 250µs。 + * @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; + } + body.period_ms = per; + body.dc_cycle_us = Math.max(ECAT_DC_CYCLE_US, Math.round(per * 1000)); + body.dc_shift_us = 0; + var spin = opts.spin; + if (spin == null && opts.fromLocalStorage) { + try { + var sp = localStorage.getItem(LS_ECAT_SPIN); + if (sp === "0") spin = false; + else if (sp === "1") spin = true; + } catch (_) {} + } + if (spin === true || spin === false) body.spin = spin; + return body; +} + +/** 从 localStorage 解析 period;一次性清理 Python 时代误存的 "4"。 */ +export function readStoredEcatPeriod() { + try { + if (!localStorage.getItem(LS_PERIOD_MIG)) { + if (parseFloat(localStorage.getItem(LS_ECAT_PERIOD)) === 4) { + localStorage.removeItem(LS_ECAT_PERIOD); + } + localStorage.setItem(LS_PERIOD_MIG, "1"); + } + var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD)); + /* 用户显式设过才用;否则 0.25ms */ + if (v > 0 && v !== 1) return v; + return ECAT_DEFAULT_PERIOD_MS; + } catch (_) { + return ECAT_DEFAULT_PERIOD_MS; + } +} + +/** @description 只读健康:bus_open + loop = 后台 PDO 循环在跑。 */ +export async function isEcatBusHealthy(ecatPost) { + try { + var h = await ecatPost("native/health", {}, 2500); + return !!(h && h.ok && h.bus_open && h.loop); + } catch (_) { + return false; + } +} + +/** + * 确保网卡已打开。已连接则复用,不重复 open。 + * @param {(path:string,body?:object,timeoutMs?:number)=>Promise} ecatPost + * @param {string} [adapter] + */ +export async function openEcatAdapter(ecatPost, adapter) { + var substr = (adapter && String(adapter).trim()) || readAdapterRaw() || ECAT_DEFAULT_ADAPTER; + var st = await ecatPost("status", {}, 6000); + if (st && st.ok === true) return st; + var r = await ecatPost("open", { substr: substr }, 20000); + if (!r || r.ok !== true) { + var msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败"; + var err = new Error(msg); + err.transient = false; + throw err; + } + return r; +} + +/** + * 启动 OP + PDO 循环。force=false 且总线已健康则跳过 start。 + * @returns {Promise<{reused:boolean, result?:object}>} + */ +export async function startEcatMotorLoop(ecatPost, opts) { + opts = opts || {}; + if (!opts.force && await isEcatBusHealthy(ecatPost)) { + return { reused: true }; + } + var body; + if (opts.body) body = opts.body; + else if (opts.fromLocalStorage) { + body = resolveEcatStartBody({ fromLocalStorage: true, period_ms: readStoredEcatPeriod() }); + } else { + body = resolveEcatStartBody({ period_ms: opts.period_ms, spin: opts.spin }); + } + var r = await ecatPost("motor/start", body, 30000); + if (r && r.ok === false) { + throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败"); + } + return { reused: false, result: r || {}, body: body }; +} + +/** + * 完整 bootstrap:health → open → motor/start。 + * @param {{ + * ecatPost: (path:string,body?:object,timeoutMs?:number)=>Promise, + * adapter?: string, period_ms?: number, spin?: boolean|null, force?: boolean, + * fromLocalStorage?: boolean, + * }} opts + * @returns {Promise<{ok:true, reused:boolean, result?:object}>} + */ +export async function runEcatBootstrap(opts) { + var ecatPost = opts.ecatPost; + if (await isEcatBusHealthy(ecatPost) && !opts.force) { + return { ok: true, reused: true }; + } + await openEcatAdapter(ecatPost, opts.adapter); + var st = await startEcatMotorLoop(ecatPost, { + force: true, + period_ms: opts.period_ms, + spin: opts.spin, + fromLocalStorage: opts.fromLocalStorage, + body: opts.body, + }); + return { ok: true, reused: false, result: st.result }; +} + +/** + * DC/周期自愈:被配错(free-run 或 ≥1ms)时拉回 250µs。 + * @returns {Promise} null=无需修复 + */ +export async function repairEcatDc(ecatPost) { + var h = await ecatPost("native/health", {}, 2000).catch(function () { return null; }); + if (!h || !h.ok) return null; + var dcs = Number(h.dc_cycle_us); + var per = Number(h.period_ms); + if (h.dc === 1 && dcs === ECAT_DC_CYCLE_US && + (h.period_ms == null || Math.abs(per - ECAT_DEFAULT_PERIOD_MS) < 0.001)) { + return null; + } + return ecatPost("motor/start", { + period_ms: ECAT_DEFAULT_PERIOD_MS, + dc_cycle_us: ECAT_DC_CYCLE_US, + dc_shift_us: 0, + }, 30000); +} + +/** @description 读当前网卡子串(localStorage → 默认 I210)。 */ +export function getEcatAdapterOrDefault() { + return readAdapterRaw(); +} diff --git a/js/common/toast-stack.js b/js/common/toast-stack.js new file mode 100644 index 0000000..8f19457 --- /dev/null +++ b/js/common/toast-stack.js @@ -0,0 +1,27 @@ +/** + * toast-stack.js — 电机控制台堆叠 toast(#toastContainer) + * + * motor 页不用 common/toast.js 的单 toast 条,而是左下角堆叠条。 + * setQuietMode 从 utils.js re-export,保证全仓只有一份实现。 + */ +"use strict"; + +import { setQuietMode } from "./utils.js"; + +export { setQuietMode }; + +/** @description 创建电机页 toast 工厂。默认容器 #toastContainer。 */ +export function createToastStack(containerSelector = "#toastContainer") { + const $ = (s) => document.querySelector(s); + return function toast(msg, type = "ok") { + const host = $(containerSelector); + if (!host) return; + const el = document.createElement("div"); + el.className = `toast ${type}`; + const ledCls = type === "ok" ? "led-on" : type === "warn" ? "led-warn" : "led-err"; + el.innerHTML = ``; + el.lastElementChild.textContent = msg; + host.appendChild(el); + setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 220); }, 3000); + }; +} diff --git a/js/common/utils.js b/js/common/utils.js index 1f06c90..a3bd350 100644 --- a/js/common/utils.js +++ b/js/common/utils.js @@ -75,3 +75,33 @@ export function flashBusyNow() { return false; } } + +/** @description 写前比较的 textContent 更新:值未变则不碰 DOM。 */ +export function setTextIfChanged(el, txt) { + if (el && el.textContent !== txt) el.textContent = txt; +} +/** @description 写前比较的 className 更新。 */ +export function setClassIfChanged(el, cls) { + if (el && el.className !== cls) el.className = cls; +} +/** @description 写前比较的 style[prop] 更新。 */ +export function setStyleIfChanged(el, prop, v) { + if (el && el.style && el.style[prop] !== v) el.style[prop] = v; +} +/** @description classList.toggle 前比较,避免无谓样式失效。 */ +export function toggleClsIfChanged(el, cls, on) { + if (!el) return; + var has = el.classList.contains(cls); + if (has !== !!on) el.classList.toggle(cls, !!on); +} + +/** + * @description 无设备静默态:给 挂 data-dcu-calm,由 responsive.css 掐动画。 + * 全仓唯一实现(connection.js / motor main 均 import 此处)。 + */ +export function setQuietMode(quiet) { + var v = quiet ? '1' : '0'; + if (document.documentElement.dataset.dcuCalm !== v) { + document.documentElement.dataset.dcuCalm = v; + } +} diff --git a/js/motor/conn-poll.js b/js/motor/conn-poll.js new file mode 100644 index 0000000..5d0b29e --- /dev/null +++ b/js/motor/conn-poll.js @@ -0,0 +1,751 @@ +/** + * conn-poll.js — 电机页连接稳健性:硬失败计数 / 断线探测 / 三链轮询调度 + * 从 main.js 外提(探测 / 失败计数 / pollLoop Worker 节拍),逻辑行为不变。 + * createConnPoll(ctx) 注入 state/toast/deviceLink/onMotorState 等。 + * late.stopSeq / late.stopRepeat / late.sendMotorPeriod 由 main 装配后回填。 + */ +"use strict"; + +export function createConnPoll(ctx) { + const { + state, $, toast, deviceLink, flashBusyNow, isUnthrottled, + getSharedDeviceIp, setSharedDeviceIp, + onMotorState, onFeedback, updateTickStuckUi, + setQuietMode, late, + } = ctx; + + const FAIL_THRESHOLD = 5; + const FAIL_THRESHOLD_ECAT = 12; + function failThresholdFor(tpId) { + return tpId === "ecat" ? FAIL_THRESHOLD_ECAT : FAIL_THRESHOLD; + } + const CONN_TOAST_GAP_MS = 6000; + const RECOVER_TOAST_MIN_OFFLINE_MS = 8000; + const TICK_INTERVAL_MS = 250; + const TICK_STUCK_WARN = 2; + const PROBE_INTERVAL_MS = 2000; + let tickNextAt = 0; + let tickWorker = null; + let tickUseFallback = false; + const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url); + + const stopRepeat = (...a) => late.stopRepeat(...a); + const stopSeq = (...a) => late.stopSeq(...a); + const sendMotorPeriod = (...a) => late.sendMotorPeriod(...a); + +function connToast(msg, type) { + const now = Date.now(); + if (now - state.lastConnToastAt < CONN_TOAST_GAP_MS) return; + state.lastConnToastAt = now; + toast(msg, type); +} + +/** 500/503 busy、超时:设备忙,不是链路掉线 */ +function isTransientError(e) { + if (!e) return false; + if (e.transient === true) return true; + const st = Number(e.status); + if (st === 500 || st === 503 || st === 429) return true; + const name = e.name || ""; + if (name === "TimeoutError" || name === "AbortError") return true; + const msg = String(e.message || e); + if (/timed?\s*out|aborted|AbortError|signal is aborted/i.test(msg)) return true; + if (/^HTTP\s+(500|503|429)\b/.test(msg)) return true; + /* 网络毛刺:高刷 HTTP 下偶发 Failed to fetch / 连接被重置很常见, + * 算硬失败会在几拍内把整页打成「设备未连接」。真拔线会持续超时/拒连, + * 由 failCount 在更高阈值上兜住(见 noteHardFailure 的网络类加权)。 */ + if (/failed to fetch|networkerror|load failed|err_connection|econnreset|econnrefused|socket hang up|net::/i.test(msg)) { + e.transient = true; + return true; + } + return false; +} + +/** + * 统一 GET JSON(设备直连)。失败抛 Error,附带 status / transient。 + * + * 常规数据链(状态/反馈/发送/版本)已全部走 state.tp 传输层, + * 这里保留给 OTA 那条 WiFi 专属链路复用同一套错误语义。 + * @returns {Promise} + */ +async function fetchDeviceJson(path, timeoutMs) { + let resp; + const t0 = performance.now(); + try { + resp = await fetch(String(state.ip && state.ip.length ? ('http://' + state.ip) : 'http://') + path, { + signal: AbortSignal.timeout(timeoutMs || 3000), + cache: "no-store", + }); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + err.transient = isTransientError(err); + recordSlowReq(path, performance.now() - t0, err.message); + throw err; + } + recordSlowReq(path, performance.now() - t0); + if (!resp.ok) { + const err = new Error("HTTP " + resp.status); + err.status = resp.status; + err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429); + throw err; + } + return resp.json(); +} + +/* 慢请求统计:>200ms 记入 phaseDebug.slowStats[path] = {count, maxMs, lastMs, lastAt}。 + * 用于诊断"卡在哪个 endpoint"——不是看单次 lastSlowReq,而是看各 path 的卡顿分布。 + * 控制台 phaseDebug.slowStats 直接看哪个 path 累计最多。 */ +function recordSlowReq(path, durMs, errMsg) { + if (durMs <= 200) return; + const s = state.phaseDebug.slowStats; + const key = path.split("?")[0]; /* /motor/state?bin=1 → /motor/state */ + if (!s[key]) s[key] = { count: 0, maxMs: 0, lastMs: 0, lastAt: 0, lastErr: null }; + s[key].count++; + s[key].maxMs = Math.max(s[key].maxMs, Math.round(durMs)); + s[key].lastMs = Math.round(durMs); + s[key].lastAt = Date.now(); + if (errMsg) s[key].lastErr = errMsg; + state.phaseDebug.lastSlowReq = { + path, dur: Math.round(durMs), at: Date.now(), err: errMsg + }; +} + +/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */ +function noteHardSuccess() { state.failCount = 0; } + +function noteHardFailure(e, source) { + if (e && e.transient) return; /* 500/503/超时/网络毛刺等瞬时错误不计入 */ + state.failCount++; + /* ECAT:status 1Hz 健康读失败不该和 motor 热路径同等累计 —— + * 从站 AL 抖一下就断电机页会误停周期发送/命令序列。 */ + if (state.tpId === "ecat" && source === "status" && state.connected) { + if (state.failCount < failThresholdFor("ecat") * 2) return; + } + const thr = failThresholdFor(state.tpId); + if (state.failCount >= thr && state.connected) { + try { + state.phaseDebug.lastHardFail = { + source, msg: e && e.message, at: Date.now(), failCount: state.failCount, thr, + }; + } catch (_) {} + enterDisconnected(e && e.message, source); + } +} + +function enterDisconnected(msg, source) { + if (state.probing) return; + const wasConnected = state.connected; + state.connected = false; + state.connecting = false; + state.probing = true; + state.failCount = 0; + state.offlineSince = Date.now(); + state.bootActive = false; + state.slaveAl = null; + state.slaveOp = false; + stopRepeat(true); + stopSeq("连接中断,命令序列已停止", true); + stopDataPolling(); + updateConnUi(); + $("#netBanner").classList.add("show"); + $("#netBannerText").textContent = + "连接中断" + (source ? " (" + source + ")" : "") + + " — 使用主页共享 IP (" + state.ip + ") 自动重连中…"; + if (wasConnected) { + connToast("网络异常:" + (msg || "请求失败") + " — 已停止周期发送,自动重连中", "err"); + } + startProbe(); +} + +function startProbe() { + stopTimer("probe"); + state.probing = true; + if (!state.offlineSince) state.offlineSince = Date.now(); + scheduleProbe(0); +} +function stopProbe() { + stopTimer("probe"); + state.probing = false; +} +function scheduleProbe(delayMs) { + stopTimer("probe"); + state.poll.probe = setTimeout(function () { + state.poll.probe = null; + probeOnce(); + }, delayMs == null ? PROBE_INTERVAL_MS : delayMs); +} + +/** + * 把冗长的链路错误压成一句人能看的话。 + * + * 为什么需要:EtherCAT 后端会把整串诊断塞进 log[],SSC 邮箱楔死那条会带 + * **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。 + * 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办** + * —— 楔死是软件恢复不了的(ecat_native.c 里同样注明要断电复位 MCU)。 + */ +function summarizeLinkError(msg) { + let s = String(msg || "未知错误"); + /* 折叠重复的 MailboxError(…),只留计数 */ + const mb = s.match(/MailboxError\([^)]*\)/g); + if (mb && mb.length > 2) { + s = s.replace(/\[?MailboxError\([^)]*\)(,\s*MailboxError\([^)]*\))*\]?/, + `MailboxError ×${mb.length}`); + } + /* 已知故障 → 给可执行的处置建议 */ + if (/邮箱地址错位楔死|ConfigMapError/.test(s)) { + return "SSC 邮箱楔死(config_map 失败)—— 需复位 DCU 板 MCU 才能恢复(软件无法自愈)"; + } + if (/没有找到描述包含/.test(s)) { + return "找不到匹配的网卡 —— 请在工具栏「链路」旁的输入框改网卡匹配子串"; + } + if (/没有发现从站/.test(s)) { + return "总线上没有从站 —— 检查 DCU 板供电与网线是否接在该网口"; + } + /* 环境类错误原样给,它本身就是指引。token 对齐 native 代理 autostart 的实际文案: + * "找不到...守护进程" / "请先运行 build_native.bat" / "Npcap 未安装或 8090..8095 全被占"。 */ + if (/Npcap|build_native|守护进程|全被占/.test(s)) return s; + /* 兜底:截断,完整内容留在 title 里 */ + return s.length > 140 ? s.slice(0, 140) + "…" : s; +} + +/** 探测成功收尾:置在线、清横幅、开轮询。代际不符时丢弃(防晚到结果覆盖)。 */ +function finishProbeOk(gen, data) { + if (gen !== state.probeGen) return; + const offlineMs = state.offlineSince ? (Date.now() - state.offlineSince) : 0; + stopProbe(); + state.connected = true; + state.connecting = false; + state.offlineSince = 0; + state.failCount = 0; + state.probeErr = null; + state.probeErrFull = null; + $("#netBanner").classList.remove("show"); + applySystemStatus(data); + updateConnUi(); + /* 短离线恢复不弹 toast,避免毛刺「闪一下」 */ + if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) { + connToast("连接已恢复 — " + transportTargetLabel(), "ok"); + } + startPolling(); +} + +async function probeOnce() { + if (!state.probing) return; + const gen = ++state.probeGen; + state.ip = getSharedDeviceIp(); + const label = $("#ipLabel"); + if (label) label.textContent = transportTargetLabel(); + try { + const data = await state.tp.probe(); + if (gen !== state.probeGen || !state.probing) return; /* 已有更新的探测在跑 */ + finishProbeOk(gen, data); + } catch (e) { + if (gen !== state.probeGen) return; /* 晚到的失败:别把后来的成功打成横幅 */ + /* 把失败原因显示出来。 + * 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP), + * 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作** + * (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / exe 没编译或 Npcap 未装), + * 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */ + const raw = (e && e.message) ? String(e.message) : "未知错误"; + /* EtherCAT:motor/start 超时 ≠ 总线没起来。 + * 外壳 Owner / 上一轮 start 可能已把 PDO 循环跑着,probe 里那记 + * motor/start 因 loop_stop+重进 OP 拖过 30s 才报 signal timed out。 + * 此时用只读健康位复核:在跑就当连上,别把「实际已连」标成失败。 */ + if (state.tpId === "ecat" && state.tp.healthCheck && + /timed?\s*out|signal|超时/i.test(raw)) { + try { + const data = await state.tp.healthCheck(); + if (gen === state.probeGen && state.probing) { + finishProbeOk(gen, data); + return; + } + return; /* 代际已变:结果交给新一轮 */ + } catch (_) { /* 真没起来 → 走下面的失败展示 */ } + } + if (gen !== state.probeGen || !state.probing) return; + state.probeErrFull = raw; + state.probeErr = summarizeLinkError(raw); + const bt = $("#netBannerText"); + if (bt) { + bt.textContent = (state.tpId === "ecat" ? "EtherCAT 接入失败: " : "连接失败: ") + + state.probeErr + "(每 2 秒自动重试)"; + bt.title = raw; /* 完整诊断留在悬浮提示里 */ + } + $("#netBanner").classList.add("show"); + updateConnUi(); /* 同步 #devStatus,别让它一直写"探测中…" */ + if (state.probing) scheduleProbe(PROBE_INTERVAL_MS); + } +} + +/** 连接目标的显示文本:WiFi 是设备 IP,EtherCAT 是本地服务 + 从站。 */ +function transportTargetLabel() { + return (state.tpId === "ecat") ? "EtherCAT 从站" : ("IP " + state.ip); +} + +/** 当前通道上在线的电机数(1–9)。 */ +function onlineMotorCount() { + return (state.motors || []).filter(function (m) { + return m && m.online && m.node >= 1 && m.node <= 9; + }).length; +} + +const AL_NAMES = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" }; + +/** + * 目标是否在线 —— 工具栏指示灯的唯一判据。 + * + * 灯的语义保持不变:就是「是否在线」。 + * - WiFi:ESP32 HTTP 可达即在线(标签是设备 IP)。 + * - EtherCAT:标签是「EtherCAT 从站」,在线 = 从站 AL 处于 OP。 + * 卡在 PREOP / INIT / SAFEOP 时,即使主站服务+网卡+PDO 循环在跑、 + * 读得到 AL、界面还有残留槽位,灯也不绿。 + */ +function isTargetOnline() { + if (!state.connected) return false; + if (state.tpId !== "ecat") return true; + /* AL 已读到:只有 OP 算在线。PREOP 等一律不绿。 */ + if (state.slaveAl != null) return state.slaveAl === 8; + /* AL 还没读到:仅当 native health 明确报 op 时才绿 */ + return state.slaveOp === true; +} + +/** 状态栏追加的在线状态提示(灯黄时把原因写出来)。 */ +function onlineStatusParts(data) { + const parts = []; + if (state.tpId === "ecat") { + const al = state.slaveAl; + if (al != null && al !== 8) { + parts.push("从站 " + (AL_NAMES[al] || ("0x" + al.toString(16))) + " · 未在线"); + } + } + if (state.connected && !isTargetOnline()) { + if (!parts.length) parts.push("未在线"); + } + return parts; +} + +/** + * 自动连接当前链路(本页无连接按钮)。 + * WiFi 用主页共享 IP,EtherCAT 打本地 EtherCAT 服务。 + * 失败不阻断页面,进入探测直到链路可用。 + */ +function connectShared() { + state.ip = getSharedDeviceIp(); + state.connecting = true; + state.probing = false; + state.failCount = 0; + state.probeErr = null; /* 新一轮连接:清掉上次的失败原因 */ + state.probeErrFull = null; + state.offlineSince = Date.now(); + state.bootActive = false; + state.slaveAl = null; + state.slaveOp = false; + /* 新一轮探测:递增代际,作废在途的旧 probeOnce(其晚到失败不得再写横幅) */ + state.probeGen += 1; + state.ch = parseInt($("#chSelect").value, 10) || 0; + updateConnUi(); + $("#netBanner").classList.add("show"); + $("#netBannerText").textContent = (state.tpId === "ecat") + ? "正在自动接入 EtherCAT … 按需拉起本地服务 → 打开网卡 → 进 OP → 启动 PDO 循环" + : ("自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP"); + startProbe(); +} + +/** 传输回报的状态 → #devStatus。字段差异由各传输的 describeStatus 消化。 */ +function applySystemStatus(data) { + const ip = state.tp.reportedIp(data); + if (ip) setSharedDeviceIp(ip); + /* 在线判定用的从站状态:AL 优先,native health 的 op 作补充 */ + if (data && data.slave && data.slave.state != null) { + state.slaveAl = Number(data.slave.state) & 0x0F; + state.slaveOp = state.slaveAl === 8; + } else if (data && data.__op != null) { + state.slaveOp = !!data.__op; + } + const parts = (state.tp.describeStatus(data) || []).concat(onlineStatusParts(data)); + const el = $("#devStatus"); + if (el) el.textContent = parts.length ? parts.join(" · ") : "在线"; + updateConnUi(); +} + +/* 无设备静默态:常驻动画在 160Hz 屏上每帧重算+重绘,实测未接设备时 + renderer+GPU 各占约一核的七成。规则见 css/responsive.css 的 + html[data-dcu-calm="1"] 段;本页不 import connection.js,故就地写同一标记。 */ +function setQuietMode(quiet) { + const v = quiet ? "1" : "0"; + if (document.documentElement.dataset.dcuCalm !== v) { + document.documentElement.dataset.dcuCalm = v; + } +} + +function updateConnUi() { + const led = $("#connLed"); + const ipLabel = $("#ipLabel"); + setQuietMode(!state.connected); + if (ipLabel) ipLabel.textContent = transportTargetLabel(); + /* 指示灯 = 是否在线(语义不变)。 + * 绿 = 在线(isTargetOnline) + * 黄 = 主站/链路可达,但目标未在线(从站没进 OP / 收不到帧) + * 黄闪 = 探测中 + * 红 = 未连接 + * 以前 connected 就涂绿:EtherCAT 只要服务+网卡+PDO 循环在就算在线, + * 即使从站卡 PREOP、收不到任何 STATUS 也绿着 —— 灯起不到指示作用。 */ + if (state.connecting || state.probing) { + if (led) { + led.className = "led led-warn led-pulse"; + led.title = "正在探测链路…"; + } + if (!state.connected) { + const ds = $("#devStatus"); + /* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。 + * 摘要截断:SSC 邮箱楔死那类错误会带 64 个 MailboxError + SM 现场, + * 全塞进工具栏会把布局挤爆,完整内容留在横幅和 title 里。 */ + if (ds) { + if (state.probeErr) { + const brief = state.probeErr.length > 44 + ? state.probeErr.slice(0, 44) + "…" : state.probeErr; + ds.textContent = brief; + ds.title = state.probeErrFull || state.probeErr; + } else { + ds.textContent = "探测中…"; + ds.title = ""; + } + } + } + } else if (state.connected) { + if (led) { + if (isTargetOnline()) { + led.className = "led led-on"; + led.title = state.tpId === "ecat" + ? "EtherCAT 从站在线(AL OP)" + : ("设备在线 · " + (state.ip || "")); + } else { + led.className = "led led-warn"; + const alTxt = state.slaveAl != null + ? ("AL " + (AL_NAMES[state.slaveAl] || state.slaveAl)) : "AL 未知"; + led.title = state.tpId === "ecat" + ? ("EtherCAT 从站未在线(" + alTxt + ")— 卡在 PREOP/非 OP 时不亮绿灯;查供电/网线/从站状态") + : "链路可达但设备未就绪"; + } + } + } else { + if (led) { + led.className = "led led-err"; + led.title = "未连接"; + } + const ds = $("#devStatus"); + if (ds) ds.textContent = "未连接"; + } + const sendHint = $("#sendHint"); + if (sendHint) sendHint.textContent = state.tp.sendHint(state.ch); + const sbCh = $("#sbCh"); + if (sbCh) sbCh.textContent = "CH" + state.ch; +} + +/* ═══════════════════════ 轮询调度(单飞递归,永不并发) ═══════════════════════ */ +function stopTimer(key) { + if (state.poll[key]) { + clearTimeout(state.poll[key]); + clearInterval(state.poll[key]); + state.poll[key] = null; + } +} +/** + * 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。 + * + * 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 / + * WiFi 抖动)会拖累另一个,表现为数据卡顿。 + * 现在:motor 和 feedback 各走独立固定节拍定时器, + * - motor 挂起 3s 超时期间,feedback 仍正常刷 + * - feedback 挂起不影响 motor + * 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。 + */ +function stopDataPolling() { + state.pollGen += 1; + stopTimer("tick"); + stopTimer("feedback"); + stopTimer("status"); + state.tickInFlight = false; + state.feedbackInFlight = false; + state.tickStuck = 0; /* 停止轮询 → 清除卡顿标记 */ + /* 清理挂起的版本等待:主 tick 已停,resolve(null) 让调用方走超时分支,避免 Promise 悬挂 */ + if (state.versionWait) { + const w = state.versionWait; + state.versionWait = null; + w.resolve(null); + } + updateTickStuckUi(); + /* 停止 Worker 节拍(后台节流兜底) */ + if (tickWorker) { + tickWorker.postMessage({ type: "stop" }); + try { tickWorker.terminate(); } catch (e) { /* ignore */ } + tickWorker = null; + tickUseFallback = false; + } +} + +/** + * 启动数据链路:HTTP 三链(motor tick / feedback 1Hz / status 1Hz)立即开车。 + */ +function startPolling() { + stopDataPolling(); /* 递增 pollGen,清残留定时器/在途链 */ + state.failCount = 0; + state.tickN = 0; + state.statusTickN = 0; + state.recvTs = []; /* 清除旧的到达时间窗口,Hz 从新数据重新累积 */ + tickNextAt = 0; /* 节拍基准重置:首个 tick 立即执行后按新间隔对齐 */ + const gen = state.pollGen; + schedulePollLoop(gen, 0); + scheduleFeedbackPoll(gen, 0); + scheduleStatusPoll(gen, 1000); + /* 连上后把持久化的刷新速度同步给 GD32,使 0xC5 推送周期与本地轮询一致(无需用户重新拨动) */ + sendMotorPeriod(state.refreshIntervalMs || 250); +} + +function scheduleStatusPoll(gen, delayMs) { + if (gen !== state.pollGen) return; + stopTimer("status"); + state.poll.status = setTimeout(function () { + state.poll.status = null; + if (gen !== state.pollGen || !state.connected || state.probing) return; + /* lite/full 交替:4 次 lite 后 1 次 full,循环。 + * lite ~150B 不调重活;full ~3KB 含 OTA/heap/SPI,5s 一次。 */ + const wantFull = (state.statusTickN % 5) === 0; + state.statusTickN = (state.statusTickN || 0) + 1; + fetchSystemStatus(gen, wantFull).finally(() => { + if (gen === state.pollGen && state.connected && !state.probing) { + scheduleStatusPoll(gen, 1000); + } + }); + }, delayMs == null ? 1000 : delayMs); +} +/* feedback 独立定时器:固定 1000ms(与 GD32 0xC1 CAN 反馈 1Hz 推送对齐)。 + * feedback 是 CAN 错误计数/总线状态快照,GD32 1Hz 推送,前端 1Hz 拉即可, + * 加快只会拿到陈旧数据还跟 motor 抢 ESP32 端 mutex。 + * 与 motor 解耦后,motor 挂起不拖 feedback,feedback 挂起不拖 motor。 */ +function scheduleFeedbackPoll(gen, delayMs) { + if (gen !== state.pollGen) return; + stopTimer("feedback"); + state.poll.feedback = setTimeout(function () { + state.poll.feedback = null; + if (gen !== state.pollGen || !state.connected || state.probing) return; + if (state.feedbackInFlight) { + scheduleFeedbackPoll(gen, 1000); + return; + } + state.feedbackInFlight = true; + fetchFeedback(gen).finally(() => { + state.feedbackInFlight = false; + if (gen === state.pollGen && state.connected && !state.probing) { + scheduleFeedbackPoll(gen, 1000); + } + }); + }, delayMs == null ? 1000 : delayMs); +} +/* ═══════ Web Worker 节拍(后台标签页不节流) ═══════ + * 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 强制 ≥1s 节流, + * 使刷新速度在切后台后降到 ~1Hz。Dedicated Worker 的定时器不受此限制, + * 故把 motor 轮询的节拍源放到 tick.worker.js:Worker 按设定间隔定期发 + * 'tick' 消息,主线程收到后才真正发起一次 fetchMotorState。 + * 前台行为与原来一致;后台也能守住设定的刷新速度。 */ +/* 用 import.meta.url 解析: 本模块在 js/motor/ 下, 与引用它的页面在哪层目录无关 + * (页面已移入 pages/, 文档相对路径会解析成 /pages/js/... 而 404)。 */ +const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url); +let tickWorker = null; /* Web Worker 节拍源(null = 未创建/失败) */ +let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTimeout 兜底 */ + +/** + * tick 统一入口:Worker 每 tick、或 setTimeout 兜底触达时调用。 + * 负责单飞互斥 + 卡顿标记(是否跳过由 tickInFlight 决定)。 + */ +function tickHandler() { + if (!state.connected || state.probing) return; + if (state.tickInFlight) { + /* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。 + * 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着 + * 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。 + * 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成", + * 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */ + if (!isUnthrottled(state.refreshIntervalMs)) { + state.tickStuck = (state.tickStuck || 0) + 1; + updateTickStuckUi(); + } + return; + } + pollLoop(state.pollGen); +} + +/** + * 确保 Worker 就绪并按 interval 校时。Worker 已存在时只更新间隔。 + * Worker 加载失败(如 404 或不被服务端支持)→ 回退到 setTimeout 兜底。 + */ +function ensureTickWorker(interval) { + if (tickUseFallback) return; + if (tickWorker) { + tickWorker.postMessage({ type: "update", interval }); + return; + } + try { + tickWorker = new Worker(TICK_WORKER_URL); + } catch (err) { + tickWorker = null; + tickUseFallback = true; + startTickFallback(state.pollGen); + return; + } + tickWorker.onmessage = (e) => { if (e.data === "tick") tickHandler(); }; + tickWorker.onerror = () => { + try { tickWorker.terminate(); } catch (e) { /* ignore */ } + tickWorker = null; + tickUseFallback = true; + startTickFallback(state.pollGen); + }; + tickWorker.postMessage({ type: "start", interval }); +} + +/* Worker 不可用时的兜底:留用原 setTimeout 固定节拍(前台正常,后台受节流)。 */ +function startTickFallback(gen) { + if (gen !== state.pollGen) return; + stopTimer("tick"); + tickNextAt = 0; + scheduleTickFallbackFrame(gen, 0); +} +function scheduleTickFallbackFrame(gen, delayMs) { + if (gen !== state.pollGen) return; + stopTimer("tick"); + const iv = state.refreshIntervalMs || TICK_INTERVAL_MS; + let delay = delayMs != null ? delayMs : iv; + if (delayMs == null && tickNextAt > 0) { + /* 固定节拍对齐:按绝对时间调度,请求耗时不再拖慢周期 */ + const remain = tickNextAt - Date.now(); + if (remain <= 0) delay = (tickNextAt + iv < Date.now()) ? iv : 10; + else delay = remain; + } + state.poll.tick = setTimeout(function () { + state.poll.tick = null; + if (gen !== state.pollGen) return; + tickHandler(); + tickNextAt = (tickNextAt > 0 ? tickNextAt : Date.now()) + (state.refreshIntervalMs || TICK_INTERVAL_MS); + scheduleTickFallbackFrame(gen, null); + }, delay); +} + +/** + * motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。 + * 节拍由 Web Worker(或兜底 setTimeout)驱动;pollLoop 自身不重排, + * 单飞互斥和卡顿标记在 tickHandler 侧完成。失败/断线由上层停止调度。 + */ +async function pollLoop(gen) { + if (gen !== state.pollGen) return; + state.tickInFlight = true; + try { + await fetchMotorState(gen); + if (gen !== state.pollGen) return; + state.tickN += 1; + } finally { + state.tickInFlight = false; + if (state.tickStuck) { state.tickStuck = 0; updateTickStuckUi(); } /* 请求结束 → 卡顿解除 */ + } + /* R5:版本等待已下沉 transport.waitVersion(各自独立轮询/采样), + * 主 tick 不再挂 state.versionWait 钩子。 */ +} + +/** + * 调度 motor 节拍。delayMs === 0 表示立即触发首拍并启动 Worker; + * 之后每拍由 Worker 自然驱动,不再从这里重排。 + */ +function schedulePollLoop(gen, delayMs) { + if (gen !== state.pollGen) return; + if (tickUseFallback) { + if (delayMs === 0) startTickFallback(gen); + return; + } + ensureTickWorker(state.refreshIntervalMs || TICK_INTERVAL_MS); + if (delayMs === 0) setTimeout(tickHandler, 0); /* 立即首拍,后续靠 Worker tick */ +} + +async function fetchSystemStatus(gen, wantFull) { + if (gen !== state.pollGen || !state.connected || state.probing) return; + try { + /* WiFi:wantFull=false 走 ?lite=1(~150B),不调 ESP32 端 + * heap_caps_get_info / 任务栈遍历 / OTA snapshot,避免 httpd 单线程被 + * 重 handler 阻塞几十~上百 ms;wantFull=true 5s 一次,含 OTA/heap/SPI。 + * EtherCAT:读从站身份/AL 状态,无 lite/full 之分。 */ + const data = await state.tp.fetchStatus(wantFull); + if (gen !== state.pollGen) return; + applySystemStatus(data); + noteHardSuccess(); + } catch (e) { + if (gen !== state.pollGen) return; + noteHardFailure(e, "status"); + } +} + +async function fetchMotorState(gen) { + if (gen != null && gen !== state.pollGen) return false; + if (!state.connected || state.probing) return false; + try { + /* WiFi 走二进制 /motor/state?bin=1(~408B vs JSON ~1200B,省 66%); + * EtherCAT 读后台 PDO 循环的快照。两者都归一成同一份 motors[] 契约, + * 所以 onMotorState 及其下游全部不用区分链路。 */ + const data = await state.tp.fetchMotorState(); + if (gen != null && gen !== state.pollGen) return false; + onMotorState(data); + noteHardSuccess(); + return true; + } catch (e) { + if (gen != null && gen !== state.pollGen) return false; + noteHardFailure(e, "motor"); + return false; + } +} + +async function fetchFeedback(gen) { + if (gen != null && gen !== state.pollGen) return false; + if (!state.connected || state.probing) return false; + /* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,CAN 反馈注定拿不到 + * (WiFi 链上每次 0xC1 都被 ESP32 链路层拒并打日志)。跨 iframe 标记 + * 见 common/utils.js flashBusyNow,随刷写收尾自撤,下轮自动恢复。 */ + if (flashBusyNow()) return false; + try { + /* WiFi:/can/feedback?lite=1 —— 跳过 entries 数组(~3KB → ~200B), + * 本页只用 ch/stats 字段,不需要 CAN 帧快照(那是监控页用的)。 + * EtherCAT:从最近一次过程数据快照的 bridge 段取,不额外打一次后端。 */ + const data = await state.tp.fetchFeedback(); + if (gen != null && gen !== state.pollGen) return false; + onFeedback(data); + noteHardSuccess(); + return true; + } catch (e) { + if (gen != null && gen !== state.pollGen) return false; + noteHardFailure(e, "feedback"); + return false; + } +} + + return { + failThresholdFor, + isTransientError, + recordSlowReq, + connToast, + noteHardSuccess, + noteHardFailure, + enterDisconnected, + startProbe, + stopProbe, + probeOnce, + connectShared, + applySystemStatus, + updateConnUi, + isTargetOnline, + transportTargetLabel, + stopTimer, + stopDataPolling, + startPolling, + schedulePollLoop, + pollLoop, + setTickNextAt(v) { tickNextAt = v; }, + getTickWorker: () => tickWorker, + getTickUseFallback: () => tickUseFallback, + }; +} diff --git a/js/motor/main.js b/js/motor/main.js index ed70332..20cc233 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -1,7 +1,16 @@ /** - * motor/main.js - 电机控制台页面逻辑(ES Module 入口) - * 由 motor.html 内联 - + + \ No newline at end of file