feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应
反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式, 选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms): - 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期, 取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准 - WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries): GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新 一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写) - ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK - GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答 (SET DLC2 旧格式行为不变) - transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照) EtherCAT/基础设施(密排槽固件 1.6.21 配套): - 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔 自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线; STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉) - esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例, 双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘 - flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零), 防"写入≠在跑"假台账 - lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板 并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时 优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
This commit is contained in:
+177
-87
@@ -43,9 +43,8 @@
|
||||
--border:#21304a; --border-strong:#2e4262; --border-subtle:#1a2740;
|
||||
--text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99;
|
||||
--esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14);
|
||||
--gd32:#fcbf3f; --gd32-hi:#ffd97a; --gd32-dim:rgba(252,191,63,.14);
|
||||
--can:#43d97b; --can-dim:rgba(67,217,123,.14);
|
||||
--motor:#22d3ee; --motor-hi:#67e8f9; --motor-dim:rgba(34,211,238,.14);
|
||||
--gd32:#fcbf3f; --gd32-dim:rgba(252,191,63,.14);
|
||||
--can:#43d97b;
|
||||
--mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14);
|
||||
--green:#3ddc8e; --amber:#fcbf3f; --red:#ff6b6b; --red-dim:rgba(255,107,107,.14);
|
||||
--spi:var(--esp32); --ecat:var(--mem); --ecat-hi:var(--mem-hi); --ecat-dim:var(--mem-dim);
|
||||
@@ -266,7 +265,7 @@
|
||||
<div class="metric"><div class="k">等效吞吐</div><div class="v" id="mTput">—</div><div class="f">KB/s</div></div>
|
||||
<div class="metric"><div class="k">失败帧</div><div class="v" id="mFail">—</div><div class="f">必须为 0</div></div>
|
||||
</div>
|
||||
<div id="segWrap" style="margin-top:12px">
|
||||
<div style="margin-top:12px">
|
||||
<div class="k" style="font-size:11px; color:var(--text-label); margin-bottom:5px">单帧分段平均(固件五段拆解,占总往返比例)</div>
|
||||
<div id="segBar" style="display:flex; height:20px; border-radius:6px; overflow:hidden; background:var(--bg-input)"></div>
|
||||
<div id="segLegend" class="hint" style="margin-top:6px">跑一次测速后出现</div>
|
||||
@@ -333,18 +332,18 @@
|
||||
<div class="card">
|
||||
<h2>刷新率 <span class="hint2">实测 = Δcycles/Δt;名义 = 1000/period_ms 仅作对照,两者差值就是软件调度开销</span></h2>
|
||||
<div class="metrics">
|
||||
<div class="metric hero" id="cHz"><div class="k">实测 PDO 刷新率</div><div class="v" id="eHz">—<small>Hz</small></div><div class="f" id="eHzF">cycles 差分</div></div>
|
||||
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v" id="eAchieve">—</div><div class="f" id="eAchieveF">名义 — Hz</div></div>
|
||||
<div class="metric" id="cBus"><div class="k">单拍耗时</div><div class="v" id="eBus">—<small>µs</small></div><div class="f" id="eBusF">p50 / p90 / max</div></div>
|
||||
<div class="metric" id="cDuty"><div class="k">主站占空比</div><div class="v" id="eDuty">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
|
||||
<div class="metric" id="cSlot"><div class="k">单电机状态刷新</div><div class="v" id="eSlot">—<small>Hz</small></div><div class="f" id="eSlotF">slot_period_ms —</div></div>
|
||||
<div class="metric" id="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v" id="eLambda">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div>
|
||||
<div class="metric" id="cDrop"><div class="k">真丢帧</div><div class="v" id="eDrop">—<small>帧/拍</small></div><div class="f" id="eDropF">主站 seq 空洞 · 从站 OVF(抽样单拍值)</div></div>
|
||||
<div class="metric" id="cSkip"><div class="k">跳拍</div><div class="v" id="eSkip">—<small>/s</small></div><div class="f" id="eSkipF">累计 —</div></div>
|
||||
<div class="metric" id="cLoad"><div class="k">从站 CPU 负载</div><div class="v" id="eLoad">—</div><div class="f" id="eLoadF">load_perm · age —</div></div>
|
||||
<div class="metric" id="cWkc"><div class="k">WKC</div><div class="v" id="eWkc">—</div><div class="f" id="eWkcF">期望 —</div></div>
|
||||
<div class="metric" id="cTorn"><div class="k">撕裂 / 重入</div><div class="v" id="eTorn">—</div><div class="f" id="eTornF">rx_torn / pdo_reentry</div></div>
|
||||
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v" id="eRtt">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div>
|
||||
<div class="metric hero" id="cHz"><div class="k">实测 PDO 刷新率</div><div class="v">—<small>Hz</small></div><div class="f">cycles 差分</div></div>
|
||||
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v">—</div><div class="f">名义 — Hz</div></div>
|
||||
<div class="metric" id="cBus"><div class="k">单拍耗时</div><div class="v">—<small>µs</small></div><div class="f">p50 / p90 / max</div></div>
|
||||
<div class="metric" id="cDuty"><div class="k">主站占空比</div><div class="v">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
|
||||
<div class="metric" id="cSlot"><div class="k">单电机状态刷新</div><div class="v">—<small>Hz</small></div><div class="f">slot_period_ms —</div></div>
|
||||
<div class="metric" id="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div>
|
||||
<div class="metric" id="cDrop"><div class="k">真丢帧</div><div class="v">—<small>帧/拍</small></div><div class="f">主站 seq 空洞 · GD32 OVF(抽样单拍值)</div></div>
|
||||
<div class="metric" id="cSkip"><div class="k">跳拍</div><div class="v">—<small>/s</small></div><div class="f">累计 —</div></div>
|
||||
<div class="metric" id="cLoad"><div class="k">从站 CPU 负载</div><div class="v">—</div><div class="f">load_perm · age —</div></div>
|
||||
<div class="metric" id="cWkc"><div class="k">WKC</div><div class="v">—</div><div class="f">期望 —</div></div>
|
||||
<div class="metric" id="cTorn"><div class="k">撕裂 / 重入</div><div class="v">—</div><div class="f">rx_torn / pdo_reentry</div></div>
|
||||
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
判读:<b>达成率 < 100%</b> = 节拍被软件拖住;<b>λ ÷ 实测Hz</b> = 每拍到达几帧 CAN(当前 1ms 档实测 6 帧/拍)。<br>
|
||||
@@ -356,7 +355,7 @@
|
||||
<b>真丢帧逐层看</b>:① CAN 控制器 err/TEC/REC;② CAN 软件 RX 队列满(深度 16)的
|
||||
<code>rx_drop_count</code> —— 只在 ESP32 <code>/can/feedback</code> 有,过程数据里没有;
|
||||
③ 段 4 覆盖窗口 <u>主站侧</u>按 seq 空洞算的 <code>rx_batch.lost</code>(<code>step/2−1</code>,
|
||||
发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>从站侧</u>未取先覆
|
||||
发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>GD32 侧</u>未取先覆
|
||||
<code>rx_batch.pending</code>(= OVF,与 ③ 是两个独立仪表,正常应当同步增长);
|
||||
⑤ CAN 取帧撕裂 <code>rx_torn</code>。③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。
|
||||
撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。
|
||||
@@ -405,9 +404,9 @@
|
||||
B32-35 —— 补 0 会把"无数据"显示成"初始化正常"。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>收到的东西 ② 批量接收区 <span class="hint2">rxraw.parsed(与下面槽表同一拍快照)· 段 4 覆盖窗口去重后的事件流</span></h2>
|
||||
<h2>收到的东西 ② 密排整轮 + 长帧事件 <span class="hint2">rxraw.parsed · 1.6.21 密排:段2整轮槽 + 段3长帧变更</span></h2>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / 从站 OVF —</span>
|
||||
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / GD32 OVF —</span>
|
||||
<span id="rxbSeen" class="pill">本页帧样本 —</span>
|
||||
<button id="btnRxbClear" class="sm ghost">清零计数</button>
|
||||
</div>
|
||||
@@ -417,13 +416,12 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="font-size:11px; color:var(--text-label); margin:11px 0 5px">
|
||||
覆盖窗口 5 槽原样(从站每拍整块拷出,写游标绕圈覆盖 —— <b>槽下标 ≠ 到达顺序</b>,seq 才是顺序)。
|
||||
末列「本拍新上报」:<b>是</b> = 这一拍第一次见到这帧,主站把它交给上层;<b>否</b> = 它是上一拍就交过的旧帧
|
||||
(窗口只留"最近 5 帧",一帧会连续几拍都躺在里面,但不会被重复上报)。</div>
|
||||
<div style="font-size:11px; color:var(--text-label); margin:11px 0 5px" id="winSlotCap">
|
||||
密排整轮槽(GD32 每拍原样拷出 count×56B:node ch online dlc seq + 48B STATUS)。
|
||||
末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);<b>否</b> = seq 未变,固件本拍跳过填充。</div>
|
||||
<div class="scroll" style="height:170px">
|
||||
<table id="tblWinSlots">
|
||||
<thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新上报</th></tr></thead>
|
||||
<thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新数据</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -433,18 +431,14 @@
|
||||
</div>
|
||||
</details>
|
||||
<div class="hint">
|
||||
<b>同一 ID + 同一载荷出现两次不是重复显示</b>:主站已按每槽 seq 去重,同一帧不会报第二次。
|
||||
实测 120 个窗口快照里「同 ID 且 seq 相同」0 次、「同 ID 但 seq 不同」240 次 —— 那是<b>两帧真的各到达一次</b>,
|
||||
只是电机空闲时 CONFIG 回报的 24B 逐字节相同,肉眼看一模一样。看下面槽表的 seq 列即可分辨。
|
||||
<br><b>"前面像少了一帧"也不是丢帧</b>:① 主站每拍只把 seq 比上次大的槽交给上层,上一拍已经交过的帧
|
||||
仍躺在窗口里(就是末列的"否"),但不会再进上面的表;② 页面 10Hz 采样、PDO 1kHz,两次采样之间约
|
||||
100 拍的窗口内容已被覆盖,看不见(所以"样本数"只占全量约 0.8%,全量看 λ 那张卡)。
|
||||
本表能看到的丢帧判据只有两个:<code>seq 空洞</code>(主站侧)与 <code>OVF</code>(从站侧),
|
||||
上面 pill 里实时显示,实测同为 0;<u>本表看不到的还有三层</u> —— CAN 控制器 err/TEC/REC、
|
||||
GD32 软件 RX 队列满丢(深度 16,只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂
|
||||
<code>rx_torn</code>。逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。
|
||||
<br>长帧(CONFIG 24B / 版本应答)在 8B 短快照里会被截断,所以后端另开 <code>state.xrx</code> 长帧槽,
|
||||
逐字节的真凭实据在下面第 ③ 区的原始窗口里。</div>
|
||||
<b>密排整轮(1.6.21)</b>:接 N 个电机本拍就发 N 个状态,空槽不发送。
|
||||
STATUS 全在上方状态槽解码;本表是同一拍的<b>线级原样</b>(seq / online / 前 8B 载荷),
|
||||
用来核对"差分是否真的跳过了未变电机"。<br>
|
||||
<b>长帧(CONFIG 24B / 版本应答)</b>:段 3「最近一帧」,内容变了才进上面的事件表
|
||||
(主站按 ch/dlc/id/data 逐字节比对,相同不重复上报)。逐字节的真凭实据在下面第 ③ 区。<br>
|
||||
本表看不到的还有三层 —— CAN 控制器 err/TEC/REC、GD32 软件 RX 队列满丢(深度 16,
|
||||
只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂 <code>rx_torn</code>。
|
||||
逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -453,8 +447,8 @@
|
||||
<div class="paneFlex">
|
||||
<div class="kv" id="rawKv"></div>
|
||||
<div class="hint cap" style="height:2.6em">
|
||||
<code>layout</code>:win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,解析器必须按 layout 选,
|
||||
否则拿变长游标解定长窗口会整片错位。</div>
|
||||
<code>layout</code>:dense=1.6.21 密排整轮 / win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,
|
||||
解析器必须按 layout 选,否则拿变长游标解定长窗口会整片错位。</div>
|
||||
<div class="grow" style="margin-top:6px; min-height:0; display:flex; flex-direction:column">
|
||||
<div class="lbl">xrx 长帧完整载荷(kv 里那行是截断的,这里不截)</div>
|
||||
<pre id="xrxFull" class="grow" style="margin-top:4px">—</pre>
|
||||
@@ -469,7 +463,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="lbl">覆盖窗口原始字节 —— 按 44B/槽分组:头 12B(ch dlc flags pad ‖ id LE ‖ seq LE)+ 数据 32B(两行)</div>
|
||||
<div class="lbl">接收区原始字节 —— dense:8B 整轮头 + count×56B 槽;win:44B/槽 覆盖窗口</div>
|
||||
<div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,6 +539,11 @@ function kv(boxId, rows) {
|
||||
box.appendChild(d);
|
||||
}
|
||||
}
|
||||
/** 状态胶囊:三处(SPI / ECAT / 测速跑批)共用同一套 led + 文案结构 */
|
||||
function setPill(sel, cls, text) {
|
||||
const n = $(sel); n.className = "pill" + (cls ? " " + cls : "");
|
||||
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
|
||||
}
|
||||
const isNum = (v) => typeof v === "number" && isFinite(v);
|
||||
const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—");
|
||||
const f2 = (v) => (isNum(v) ? v.toFixed(2) : "—");
|
||||
@@ -603,7 +602,7 @@ function showPane(name) {
|
||||
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求
|
||||
* 会把自己的测量抬高 —— 先排除自己造的假象。 */
|
||||
const was = E.live; stopEcat(); E.wantLive = was;
|
||||
ecatPill("#ecatState", "", "已暂停(在 SPI 页)");
|
||||
setPill("#ecatState", "", "已暂停(在 SPI 页)");
|
||||
}
|
||||
}
|
||||
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
|
||||
@@ -611,7 +610,7 @@ for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
|
||||
/* ═══════════════════ SPI 测速面板 ═══════════════════
|
||||
* 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。
|
||||
* 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */
|
||||
const spiOut = $("#out"), spiHist = $("#hist"), spiPill = $("#spiState"), btnAbort = $("#btnAbort");
|
||||
const spiOut = $("#out"), spiHist = $("#hist"), btnAbort = $("#btnAbort");
|
||||
const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")];
|
||||
let spiCtl = null;
|
||||
try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {}
|
||||
@@ -621,9 +620,7 @@ if (!ls("spi_test_ip")) { const shared = ls("dcu_esp_ip"); if (shared) $("#ip").
|
||||
function spiBusy(busy, cls, text) {
|
||||
for (const b of spiBtns) b.disabled = busy;
|
||||
btnAbort.hidden = !busy;
|
||||
spiPill.className = "pill" + (cls ? " " + cls : "");
|
||||
spiPill.innerHTML = '<span class="led"></span>';
|
||||
spiPill.appendChild(el("span", null, text));
|
||||
setPill("#spiState", cls, text);
|
||||
}
|
||||
/** 固件返回是分行的中文纯文本;逐行取数,缺行就留 null(不能补 0 —— 会把"没这项数据"显示成"0µs") */
|
||||
function spiParse(text) {
|
||||
@@ -804,21 +801,20 @@ class RateWindow {
|
||||
/** 返回窗口内的各类速率;无法判定的一律给 null,不猜 */
|
||||
calc() {
|
||||
const s = this.s, out = {
|
||||
span: 0, pairs: 0, hz: null, lambda: null, drop: null, rx: null, skip: null,
|
||||
span: 0, hz: null, lambda: null, drop: null, skip: null,
|
||||
untrusted: 0, motorStep: {}, motorChanges: {},
|
||||
rxbLostMax: 0, rxbPendingMax: 0, samples: s.length,
|
||||
rxbLostMax: 0, rxbPendingMax: 0,
|
||||
};
|
||||
if (s.length < 2) return out;
|
||||
let dCyc = 0, dLam = 0, dDrop = 0, dRx = 0, dSkip = null, dt = 0;
|
||||
let dCyc = 0, dLam = 0, dDrop = 0, dSkip = null, dt = 0;
|
||||
const steps = {}, changes = {};
|
||||
for (let i = 1; i < s.length; i++) {
|
||||
const a = s[i - 1], b = s[i];
|
||||
const pair = (b.t - a.t) / 1000;
|
||||
if (!(pair > 0)) continue;
|
||||
dt += pair; out.pairs++;
|
||||
dt += pair;
|
||||
if (isNum(a.cycles) && isNum(b.cycles)) dCyc += (b.cycles - a.cycles);
|
||||
if (isNum(a.gseq) && isNum(b.gseq)) dLam += wrapDelta(a.gseq, b.gseq, 32);
|
||||
if (isNum(a.rx) && isNum(b.rx)) dRx += wrapDelta(a.rx, b.rx, 32);
|
||||
if (isNum(a.drop) && isNum(b.drop)) {
|
||||
const d = wrapDelta(a.drop, b.drop, 16);
|
||||
/* 单拍增量超过 u16 半程 = 采样率不足以分辨回绕,该点对 drop 作废并计数 */
|
||||
@@ -840,7 +836,7 @@ class RateWindow {
|
||||
}
|
||||
out.span = dt;
|
||||
if (dt > 0) {
|
||||
out.hz = dCyc / dt; out.lambda = dLam / dt; out.rx = dRx / dt; out.drop = dDrop / dt;
|
||||
out.hz = dCyc / dt; out.lambda = dLam / dt; out.drop = dDrop / dt;
|
||||
if (dSkip !== null) out.skip = dSkip / dt;
|
||||
for (const k in steps) {
|
||||
out.motorStep[k] = quantile(steps[k], 0.5);
|
||||
@@ -882,7 +878,7 @@ const pcores = (mask) => {
|
||||
const out = []; let m = mask >>> 0;
|
||||
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
|
||||
let hi = Math.floor(mask / 4294967296);
|
||||
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (m & 1) out.push(b + 32);
|
||||
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
|
||||
return out;
|
||||
};
|
||||
const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs" : ms + "ms"));
|
||||
@@ -895,7 +891,7 @@ const E = {
|
||||
lastState: null, status: null, health: null, load: null, raw: null, dbg: null,
|
||||
tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false,
|
||||
lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
|
||||
lastSeq: {}, seqAt: {},
|
||||
lastSeq: {}, seqAt: {}, densLast: null,
|
||||
chart: { t: [], hz: [], us: [] }, errors: 0, errText: "",
|
||||
};
|
||||
/** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */
|
||||
@@ -905,11 +901,6 @@ const tblMotorsBody = $("#tblMotors tbody"), tblFramesBody = $("#tblFrames tbody
|
||||
const tblSubsBody = $("#tblSubs tbody"), tblBeatsBody = $("#tblBeats tbody");
|
||||
const tblWinBody = $("#tblWinSlots tbody");
|
||||
|
||||
function ecatPill(id, cls, text) {
|
||||
const n = $(id); n.className = "pill" + (cls ? " " + cls : "");
|
||||
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
|
||||
}
|
||||
|
||||
/** 节拍源:隐藏标签页会把主线程 setTimeout 节流到 ≥1s(实测被压到 1Hz,
|
||||
* 采样密度一掉,所有"按相邻两拍差分"的判据就跟着失真)。电机页同款问题
|
||||
* 用 js/motor/tick.worker.js 解决,Worker 的定时器不受此限;本页要保住
|
||||
@@ -997,7 +988,7 @@ async function handleTick(st) {
|
||||
for (const k in E.seqAt) if (!(k in seq)) delete E.seqAt[k];
|
||||
E.win.push({
|
||||
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
|
||||
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null,
|
||||
drop: b.valid && isNum(b.drop) ? b.drop : null,
|
||||
skip: dbg && isNum(dbg.skip_total) ? dbg.skip_total : null, seq,
|
||||
rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
|
||||
});
|
||||
@@ -1020,7 +1011,7 @@ function startEcatIfIdle() {
|
||||
E.live = true; E.everStarted = true; E.wantLive = true; tickPrev = null; E.pollDts = [];
|
||||
E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null;
|
||||
$("#btnLive").textContent = "暂停观测";
|
||||
ecatPill("#ecatState", "warn", "观测中");
|
||||
setPill("#ecatState", "warn", "观测中");
|
||||
eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); })
|
||||
.catch((e) => { E.status = null; E.errText = e.message; });
|
||||
eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; });
|
||||
@@ -1031,7 +1022,7 @@ function stopEcat() {
|
||||
E.live = false; E.wantLive = false;
|
||||
ticker.stop();
|
||||
$("#btnLive").textContent = "开始实时观测";
|
||||
ecatPill("#ecatState", "", "已暂停");
|
||||
setPill("#ecatState", "", "已暂停");
|
||||
}
|
||||
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
|
||||
$("#btnRing").onclick = () => {
|
||||
@@ -1084,18 +1075,28 @@ function renderMetrics(st, load, dbg, r, rtt) {
|
||||
isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : "");
|
||||
/* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧,
|
||||
* 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做,
|
||||
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与从站
|
||||
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与 GD32
|
||||
* OVF(rxbPendingMax) 取抽样单拍最大值(两者独立、正常应同步增长),颜色叠上撕裂累计。 */
|
||||
const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0);
|
||||
const tornN = E.tornAccum || 0;
|
||||
metric("cDrop", holes + "<small>帧/拍</small>",
|
||||
"主站空洞 " + (r.rxbLostMax || 0) + " · 从站OVF " + (r.rxbPendingMax || 0) +
|
||||
"主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) +
|
||||
" · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" +
|
||||
(r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")",
|
||||
(r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good"));
|
||||
metric("cSkip", f1(r.skip) + "<small>/s</small>",
|
||||
isNum(r.skip) ? "累计 " + ((dbg && dbg.skip_total) ?? "—") : "需开「逐拍环」才有 skip_total",
|
||||
r.skip === null ? "" : (r.skip < 0.5 ? "good" : "bad"));
|
||||
/* 超期/跳拍:cyc_us.late/lost 随 state 每拍就有(最近 n 拍窗口),不必开逐拍环。
|
||||
* 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值,
|
||||
* 旧构建恒报 0 —— 看到 0 先确认后端版本,别当"没有超期"。 */
|
||||
const cu = (st && st.cyc_us) || {};
|
||||
const lateN = isNum(cu.late) ? cu.late : null, lostN = isNum(cu.lost) ? cu.lost : null;
|
||||
const winN = isNum(cu.n) ? cu.n : 0;
|
||||
const lateHz = (lateN !== null && winN > 1 && isNum(period)) ? lateN / (winN * period / 1000) : null;
|
||||
metric("cSkip", (lateHz === null ? "—" : f1(lateHz)) + "<small>/s</small>",
|
||||
lateN === null ? "cyc_us.late 未上报(旧后端恒 0 占位,不可当无超期)"
|
||||
: "窗口 " + winN + " 拍:超期 " + lateN + " · 跳过 " + (lostN ?? "—") +
|
||||
(isNum(r.skip) ? " · skip_total 累计 " + ((dbg && dbg.skip_total) ?? "—")
|
||||
: " · 开逐拍环可看自启动累计"),
|
||||
lateN === null ? "" : (lateHz < 0.5 ? "good" : "bad"));
|
||||
|
||||
const loadFresh = load && load.valid && isNum(load.age_ms) && load.age_ms < 1000;
|
||||
const loadPct = loadFresh ? load.load_perm / 10 : null;
|
||||
@@ -1127,19 +1128,19 @@ function renderMetrics(st, load, dbg, r, rtt) {
|
||||
rtt === null ? "bad" : rtt < 30 ? "good" : "warn");
|
||||
|
||||
if (st && st.ok === false) {
|
||||
ecatPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
|
||||
setPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
|
||||
} else if (!exchanging) {
|
||||
ecatPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
|
||||
setPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
|
||||
"," + (((st && st.motors) || []).length - mv.length) + " 槽 seq 停更)→ 去电机页/EtherCAT 页连一次");
|
||||
} else {
|
||||
ecatPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新");
|
||||
setPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新");
|
||||
}
|
||||
const s = E.status && E.status.slave;
|
||||
if (s) {
|
||||
const al = AL_NAME[s.state] || ("0x" + (s.state || 0).toString(16));
|
||||
ecatPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
|
||||
setPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
|
||||
} else {
|
||||
ecatPill("#ecatAl", "", "AL —");
|
||||
setPill("#ecatAl", "", "AL —");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,10 +1183,9 @@ function renderBridge(st) {
|
||||
["rx_valid / truncated", b.valid ? b.rx_valid + " / " + b.truncated : null, (b.valid && b.truncated) ? "warn" : null],
|
||||
["backlog", isNum(b.backlog) ? b.backlog : null, b.backlog ? "warn" : "good"],
|
||||
["tx_result", b.valid ? (b.tx_result + " " + (b.tx_result_text || "")) : null, (b.valid && b.tx_result === 0) ? "good" : "warn"],
|
||||
["rx_batch count / lost(主站空洞) / pending(从站OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending,
|
||||
["rx_batch count / lost(主站空洞) / pending(GD32 OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending,
|
||||
(rb.lost || rb.pending) ? "warn" : "good"],
|
||||
["xrx 长帧", (st && st.xrx && st.xrx.data) ? "0x" + st.xrx.id.toString(16).toUpperCase() + " · " + st.xrx.dlc + "B" : null],
|
||||
["TEC / REC", null, "dim"],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1240,7 +1240,6 @@ function renderMotors(st, r) {
|
||||
const hx = m.data || "";
|
||||
for (let i = 0; i < 96; i += 32) mcell(" " + (hx.substr(i, 32) ? hexGrp(hx.substr(i, 32)) : "—"));
|
||||
}
|
||||
if (!motors.length) mcell("无槽", "hdr");
|
||||
}
|
||||
|
||||
/* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */
|
||||
@@ -1251,7 +1250,7 @@ function renderFrames(st, raw) {
|
||||
const frames = (raw && raw.parsed) || [];
|
||||
const rb = (st && st.rx_batch) || {};
|
||||
$("#rxbPill").textContent = "rxraw 本拍新帧 " + frames.length + " · state 侧计数 新帧 " + rb.count +
|
||||
" / seq 空洞 " + rb.lost + " / 从站 OVF " + rb.pending +
|
||||
" / seq 空洞 " + rb.lost + " / GD32 OVF " + rb.pending +
|
||||
" · layout " + (raw && raw.layout) + " · cycle " + (raw && raw.cycle);
|
||||
/* 轮询只看得到"这一拍的覆盖窗口",全量帧永远采不完 —— 必须把采样占空比摊开说,
|
||||
* 否则展示时这串数字会被当成总帧数。 */
|
||||
@@ -1282,7 +1281,10 @@ function renderFrames(st, raw) {
|
||||
const k = (s.id >>> 0) + ":" + s.data;
|
||||
if (!best.has(k) || s.seq > best.get(k).seq) best.set(k, s);
|
||||
}
|
||||
if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim", "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)"));
|
||||
if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim",
|
||||
raw && raw.layout === "dense"
|
||||
? "本拍无长帧变更(STATUS 在上方状态槽 + 下方整轮槽表;CONFIG/版本变了才会进这里)"
|
||||
: "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)"));
|
||||
for (const f of frames) {
|
||||
const node = (f.id >> 16) & 0xF, sub = f.id & 0xFFFF;
|
||||
const src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase());
|
||||
@@ -1317,6 +1319,32 @@ function renderFrames(st, raw) {
|
||||
|
||||
/* 覆盖窗口单槽布局:44B = ch@0 dlc@1 flags@2 (pad@3) id@4..7 seq@8..11 data@12..43 */
|
||||
const WIN_SLOT_SIZE = 44, WIN_SLOT_COUNT = 5;
|
||||
/* 1.6.21 密排整轮 raw:8B 头(count active boot magic gseqLE) + count×56B 槽
|
||||
* 槽 = node@0 ch@1 online@2 dlc@3 seq@4..7 + 48B STATUS@8..55 */
|
||||
const DENSE_HDR = 8, DENSE_SLOT = 56, DENSE_STATUS = 48;
|
||||
function decodeDenseRound(hex) {
|
||||
if (!hex || hex.length < DENSE_HDR * 2) return null;
|
||||
const b = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
const dv = new DataView(b.buffer);
|
||||
const head = { count: b[0], active: b[1], boot: b[2], magic: b[3], gseq: dv.getUint32(4, true) };
|
||||
const slots = [];
|
||||
for (let s = 0; s < head.count; s++) {
|
||||
const o = DENSE_HDR + s * DENSE_SLOT;
|
||||
if (o + DENSE_SLOT > b.length) break;
|
||||
const dlc = b[o + 3];
|
||||
let data = "";
|
||||
const nd = Math.min(dlc || 0, 8);
|
||||
for (let i = 0; i < nd; i++) data += b[o + 8 + i].toString(16).padStart(2, "0");
|
||||
/* 48B 全长 hex(详情用) */
|
||||
let full = "";
|
||||
const nf = Math.min(dlc || 0, DENSE_STATUS);
|
||||
for (let i = 0; i < nf; i++) full += b[o + 8 + i].toString(16).padStart(2, "0");
|
||||
slots.push({ slot: s, node: b[o], ch: b[o + 1], online: b[o + 2] !== 0, dlc,
|
||||
seq: dv.getUint32(o + 4, true), data: data.toUpperCase(), full: full.toUpperCase() });
|
||||
}
|
||||
return { head, slots };
|
||||
}
|
||||
function decodeWinSlots(hex) {
|
||||
if (!hex) return [];
|
||||
const b = new Uint8Array(hex.length / 2);
|
||||
@@ -1335,8 +1363,11 @@ function decodeWinSlots(hex) {
|
||||
}
|
||||
function renderWinSlots(raw, slots, best) {
|
||||
const body = tblWinBody;
|
||||
slots = slots || ((raw && raw.layout === "win") ? decodeWinSlots(raw.raw) : []);
|
||||
body.textContent = "";
|
||||
if (raw && raw.layout === "dense") {
|
||||
renderDenseRound(raw, body);
|
||||
return;
|
||||
}
|
||||
if (!slots.length) {
|
||||
const tr = el("tr");
|
||||
tr.appendChild(el("td", "dim", raw && raw.layout !== "win"
|
||||
@@ -1384,20 +1415,67 @@ function renderWinSlots(raw, slots, best) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 1.6.21 密排整轮槽表:同一拍的线级原样,与上方状态槽解码结果对账用 */
|
||||
function renderDenseRound(raw, body) {
|
||||
const round = decodeDenseRound(raw.raw);
|
||||
if (!round || !round.slots.length) {
|
||||
const tr = el("tr");
|
||||
tr.appendChild(el("td", "dim", "密排整轮 raw 为空或 count=0(这一拍没有电机槽)"));
|
||||
tr.children[0].colSpan = 10;
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
if (!E.densLast) E.densLast = new Map();
|
||||
const cap = document.getElementById("winSlotCap");
|
||||
if (cap) cap.innerHTML =
|
||||
"密排整轮槽 · count=" + round.head.count + " active=" + round.head.active +
|
||||
" boot=" + round.head.boot + " magic=0x" + round.head.magic.toString(16).toUpperCase() +
|
||||
" gseq=" + round.head.gseq.toLocaleString("en-US") +
|
||||
"(node ch online dlc seq + 48B STATUS,空槽不发送)。" +
|
||||
" 末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);" +
|
||||
"<b>否</b> = seq 未变,固件本拍跳过填充。";
|
||||
for (const s of round.slots) {
|
||||
const prev = E.densLast.get(s.node + ":" + s.ch);
|
||||
const dseq = (prev === undefined) ? null : (s.seq - prev);
|
||||
const isNew = dseq !== null && dseq !== 0;
|
||||
E.densLast.set(s.node + ":" + s.ch, s.seq);
|
||||
/* STATUS CAN ID:0x180+node(DBC v0.74,默认扩展帧在 MotorState 侧,
|
||||
* 这里只标语义,真 ID 以状态槽/DBC 为准) */
|
||||
const tr = el("tr");
|
||||
const td = (v, cls) => tr.appendChild(el("td", cls, v));
|
||||
td(s.slot, "num");
|
||||
td(s.online ? "在线" : "离线", s.online ? "good" : "warn");
|
||||
td(s.seq.toLocaleString("en-US"), "num");
|
||||
td(dseq === null ? "—" : (dseq === 0 ? "0(未变)" : "+" + dseq), "num " + (dseq ? "good" : "dim"));
|
||||
td(s.ch, "num");
|
||||
td(s.dlc, "num");
|
||||
td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num");
|
||||
td("N" + s.node + " · STATUS");
|
||||
td(hexShort(s.data, 16), "hex");
|
||||
td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"),
|
||||
isNew || prev === undefined ? "good" : "dim");
|
||||
tr.title = "48B STATUS: " + (s.full || s.data);
|
||||
body.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
|
||||
function renderRaw(st, raw) {
|
||||
const x = (st && st.xrx) || null;
|
||||
/* rxraw 的三个头字节是"原样 dump 不翻译":win 布局下它们是
|
||||
* slot_count / wr_idx(写游标) / ovf(从站侧未取先覆计数),沿用旧名会把写游标读成丢帧数 */
|
||||
/* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf;
|
||||
* dense=count/active/boot(lost/pending 对 STATUS 不适用) */
|
||||
const isWin = raw && raw.layout === "win";
|
||||
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "从站 OVF 未取先覆"]
|
||||
: ["count", "lost", "pending"];
|
||||
const isDense = raw && raw.layout === "dense";
|
||||
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "GD32 OVF 未取先覆"]
|
||||
: isDense ? ["count 密排电机数", "active", "boot"]
|
||||
: ["count", "lost", "pending"];
|
||||
kv("rawKv", [
|
||||
["rxraw cycle", isNum(raw && raw.cycle) ? raw.cycle.toLocaleString("en-US") : null],
|
||||
["layout", raw ? raw.layout : null],
|
||||
[hLbl[0], raw ? raw.count : null],
|
||||
[hLbl[1], raw ? raw.lost : null],
|
||||
[hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null],
|
||||
["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : null],
|
||||
["len (字节)", raw ? raw.len : null],
|
||||
["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null],
|
||||
["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null],
|
||||
@@ -1416,6 +1494,20 @@ function renderRaw(st, raw) {
|
||||
grid.textContent = "";
|
||||
const cell = (t, cls) => grid.appendChild(el("i", cls, t));
|
||||
if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; }
|
||||
if (raw.layout === "dense") {
|
||||
/* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */
|
||||
const hex = raw.raw;
|
||||
if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr");
|
||||
for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) {
|
||||
const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2);
|
||||
if (!slotHex) break;
|
||||
cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot");
|
||||
cell(" " + hexGrp(slotHex.substr(16, 32)));
|
||||
if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32)));
|
||||
if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (raw.layout !== "win") {
|
||||
for (let i = 0; i < raw.raw.length; i += 32)
|
||||
cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32)));
|
||||
@@ -1524,11 +1616,8 @@ function drawChart() {
|
||||
window.addEventListener("resize", layoutChart);
|
||||
|
||||
/* ═══════════════════ 热改 PDO 周期 ═══════════════════ */
|
||||
const runPill = $("#runPill"), runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
|
||||
function runBusy(cls, text) {
|
||||
runPill.className = "pill" + (cls ? " " + cls : "");
|
||||
runPill.innerHTML = '<span class="led"></span>'; runPill.appendChild(el("span", null, text));
|
||||
}
|
||||
const runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
|
||||
function runBusy(cls, text) { setPill("#runPill", cls, text); }
|
||||
$("#btnPeriod").onclick = async () => {
|
||||
const v = parseFloat($("#periodPick").value);
|
||||
if (!(v > 0)) { runBusy("warn", "先在「PDO 周期档位」里选一档"); return; }
|
||||
@@ -1607,7 +1696,7 @@ async function measureOnce(kind, seconds, hz, extraNote) {
|
||||
}
|
||||
win.push({
|
||||
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
|
||||
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null,
|
||||
drop: b.valid && isNum(b.drop) ? b.drop : null,
|
||||
seq, rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
|
||||
});
|
||||
if (cyc.total) { p50s.push(cyc.total.p50); p90s.push(cyc.total.p90); pmaxs.push(cyc.total.max); }
|
||||
@@ -1689,7 +1778,7 @@ async function withLivePaused(fn) {
|
||||
finally {
|
||||
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false;
|
||||
btnEcatAbort.hidden = true;
|
||||
if (wasLive) { E.live = true; tickPrev = null; ecatPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
|
||||
if (wasLive) { E.live = true; tickPrev = null; setPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
|
||||
else runBusy("", "空闲");
|
||||
}
|
||||
}
|
||||
@@ -1761,6 +1850,7 @@ $("#btnSweep").onclick = () => withLivePaused(async () => {
|
||||
$("#btnRxbClear").onclick = () => {
|
||||
E.seenFrames = 0; E.subs.clear(); E.lastRawCycle = null; E.tornPrev = null; E.reentryPrev = null;
|
||||
E.tornAccum = 0; E.reentryAccum = 0; E.tornCap = false; E.reentryCap = false;
|
||||
E.densLast = null;
|
||||
E.seenT0 = performance.now();
|
||||
/* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */
|
||||
$("#rxbSeen").textContent = "本页轮询采到的帧样本 0 条";
|
||||
|
||||
Reference in New Issue
Block a user