docs: 新增 .qoder Repowiki 知识库文档

This commit is contained in:
cbb
2026-09-07 12:19:26 +08:00
parent 43d42ad891
commit ef9472334a
105 changed files with 1313322 additions and 263 deletions
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""事后分析 rec_ecat_slots.py 落盘的 ECAT 原始流:相邻数据大跳变检测。
思路(按用户指定):不看协议判据,只看数值形态。同一台电机的 STATUS 是连续量,
相邻两帧(按 seq 排序)之间出现"跳出去又立刻跳回来"的孤立尖峰,就是这一帧的字节
被拼坏了 —— 真突变会保持住(下一帧仍在新值附近),不会一帧就回头。
指标:每个 (node,字段) 先取全体相邻差的稳健尺度 scale = median(|Δ|),
大跳变 : |v[i]-v[i-1]| > K*scale
孤立尖峰 : 大跳变 且 |v[i+1]-v[i-1]| <= 2*K*scale ← 撕裂签名
跳变保持 : 大跳变 但下一帧不回头的(真实突变/新参考值),只计数不算异常
--selftest 会把"前 j 字节取新帧、其余取上一帧"的混合帧注入实测数据,
逐 j 报告检出率:证明这套判据对撕裂本身有灵敏度(否则 0 报警没有意义)。
用法:
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8]
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv --selftest
"""
import csv
import statistics
import struct
import sys
# (名称, 偏移, 解码器)
FIELDS = [
("mcuTemp", 0, lambda b: struct.unpack_from("<h", b, 0)[0] * 0.01),
("motTemp", 2, lambda b: struct.unpack_from("<h", b, 2)[0] * 0.01),
("uCur", 4, lambda b: struct.unpack_from("<h", b, 4)[0]),
("vCur", 6, lambda b: struct.unpack_from("<h", b, 6)[0]),
("wCur", 8, lambda b: struct.unpack_from("<h", b, 8)[0]),
("flags", 10, lambda b: b[10]),
("pwr_V", 11, lambda b: struct.unpack_from("<H", b, 11)[0] * 0.001),
("rtAngle", 13, lambda b: struct.unpack_from("<f", b, 13)[0]),
("rtVel", 17, lambda b: struct.unpack_from("<f", b, 17)[0]),
("rtCur", 21, lambda b: struct.unpack_from("<f", b, 21)[0]),
("testF", 25, lambda b: struct.unpack_from("<f", b, 25)[0]),
("testU16", 29, lambda b: struct.unpack_from("<H", b, 29)[0]),
("cpu%", 31, lambda b: b[31]),
("initSt", 32, lambda b: struct.unpack_from("<I", b, 32)[0]),
]
def load(path):
"""返回 {node: [(seq, 36B bytes), ...]} 按 seq 升序。"""
per = {}
with open(path, "r", encoding="ascii", errors="ignore") as f:
for row in csv.reader(f):
if not row or row[0] != "M":
continue
try:
node, ch, seq = int(row[2]), int(row[3]), int(row[4])
payload = bytes.fromhex(row[7])
except (ValueError, IndexError):
continue
if node <= 0 or len(payload) < 36:
continue
per.setdefault(node, []).append((seq, payload))
for v in per.values():
v.sort(key=lambda x: x[0])
return per
def decode(payload):
out = {}
for name, off, fn in FIELDS:
try:
out[name] = float(fn(payload))
except Exception:
out[name] = None
return out
def analyze(per, k=8.0, verbose=True):
"""逐 (node,字段) 找孤立尖峰。返回总尖峰数与明细列表。"""
spikes, holds, details = 0, 0, []
tails = []
for node, rows in sorted(per.items()):
if len(rows) < 40:
continue
cols = [decode(p) for _, p in rows]
for name, _off, _fn in FIELDS:
v = [c[name] for c in cols]
finite = [x for x in v if x is not None and x == x and abs(x) != float("inf")]
if len(finite) < 40:
continue
deltas = [abs(v[i] - v[i - 1]) for i in range(1, len(v))
if v[i] is not None and v[i - 1] is not None
and v[i] == v[i] and v[i - 1] == v[i - 1]]
if not deltas:
continue
# 尺度取 |Δ| 的 p99 分位数(不是 median):量化字段(mcuTemp 这类)
# 的 median(|Δ|)=0,用 median 会让阈值塌到 eps,正常的 1LSB 抖动
# 全被判成尖峰。p99 代表"全体相邻差的尾部",阈值 = K×p99 才是
# "远超正常抖动尾巴"的意思。
ordered = sorted(deltas)
scale = max(ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))], 1e-9)
mx, mxctx = 0.0, None
for i in range(1, len(v) - 1):
a, b, c = v[i - 1], v[i], v[i + 1]
if None in (a, b, c) or any(x != x for x in (a, b, c)):
continue
d = abs(b - a)
if d > mx:
mx, mxctx = d, (a, b, c)
if d <= k * scale:
continue
back = abs(c - a) <= 2 * k * scale
if back:
spikes += 1
if len(details) < 25:
details.append((node, name, rows[i][0], scale, a, b, c,
rows[i][1].hex()))
else:
holds += 1
if mxctx and mx > 3 * scale and len(tails) < 60:
tails.append((node, name, scale, mx, mxctx))
if verbose:
print(f"孤立尖峰(撕裂签名) = {spikes} 跳变保持(真实突变) = {holds}")
for node, name, seq, scale, a, b, c, hexs in details[:12]:
print(f" node{node} {name}: seq={seq} 尺度={scale:.4g} "
f"{a:.6g} -> {b:.6g} -> {c:.6g}")
print(f" payload={hexs}")
if tails:
tails.sort(key=lambda x: -(x[3] / max(x[2], 1e-12)))
print("各字段最大相邻差(倍数 = 最大|Δ| / p99 尺度):")
for node, name, scale, mx, (a, b, c) in tails[:14]:
print(f" node{node} {name:9s} p99尺度={scale:.4g} 最大|Δ|={mx:.4g} "
f"({mx/max(scale,1e-12):.0f}×) {a:.6g} -> {b:.6g} -> {c:.6g}")
return spikes, holds, details
def selftest(per, k=8.0, n_inject=40):
"""注入混合帧,测各边界 j 的检出率(证明判据有灵敏度)。"""
import random
random.seed(7)
base_sp, _bh, _bd = analyze(per, k=k, verbose=False)
nodes = [n for n, r in per.items() if len(r) >= 60]
if not nodes:
print("自检跳过:数据太短(每节点需 >=60 帧)")
return
print(f"自检:基线孤立尖峰 = {base_sp};向实测流注入『新帧前缀 j 字节 + 上一帧后缀』"
f"混合帧,每节点各 {n_inject} 次")
print(" j(字节) 净增尖峰/注入总数 检出率")
for j in (1, 4, 8, 12, 13, 16, 17, 20, 21, 24, 25, 28, 31, 32, 35):
rnd = random.Random(j)
merged = {}
for node in nodes:
rows = per[node]
idxs = rnd.sample(range(2, len(rows) - 2), min(n_inject, len(rows) - 6))
new = list(rows)
for i in idxs:
cur, prev = rows[i][1], rows[i - 1][1]
new[i] = (rows[i][0], cur[:j] + prev[j:36])
merged[node] = new
sp, _h, det = analyze(merged, k=k, verbose=False)
inj_n = n_inject * len(nodes)
net = sp - base_sp
worst = max((abs(d[5] - d[4]) for d in det), default=0.0)
print(f" j={j:<4d} {net:5d}/{inj_n:<5d} {100.0*net/inj_n:5.1f}% "
f"最大偏离≈{worst:.4g}")
print("(j 越大越靠尾部字节,混合后数值偏离越小、越难检出 —— 这是形态判据的物理上限)")
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print(__doc__)
return
path = args[0]
k = 8.0
if "--k" in sys.argv:
k = float(sys.argv[sys.argv.index("--k") + 1])
per = load(path)
tot = sum(len(v) for v in per.values())
print(f"读入 {path}: {tot} 帧,节点 {sorted(per)}")
for node, rows in sorted(per.items()):
print(f" node{node}: {len(rows)} 个不同 seq,"
f"seq 范围 {rows[0][0]}..{rows[-1][0]}")
if "--selftest" in sys.argv:
selftest(per, k=k)
return
analyze(per, k=k)
if __name__ == "__main__":
main()
@@ -1,10 +1,11 @@
/**
* 浏览器实测:ECAT 刷新速度 1ms(不限速档)。
* 浏览器实测:ECAT 刷新速度下限档 6ms(不限速档)。
* 单测只验了纯逻辑;这里验真机行为:
* - 输入框 min/step 随链路变(否则浏览器把合法的 1ms 标成 :invalid)
* - 1ms 真的比 50ms 快,且拿到实测频率而不是理论 1000Hz
* - 输入框 min/step 随链路变(否则浏览器把合法的 6ms 标成 :invalid)
* - 6ms 真的比 50ms 快,且拿到实测频率而不是理论 166Hz
* - 存量缓存里的旧 1ms 会被抬到 6ms(绘制上限之上没有消费者)
* - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为)
* - 切回 WiFi 时 1ms 不会被带过去(wifi 下限 50ms)
* - 切回 WiFi 时 6ms 不会被带过去(wifi 下限 50ms)
*/
const CDP = "http://127.0.0.1:9333";
const list = await (await fetch(CDP + "/json/list")).json();
@@ -23,8 +24,8 @@ let pass = 0, fail = 0; const out = [];
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
await send("Runtime.enable"); await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
const PORT = Number(process.argv[2] || 8080);
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点,直接进 ECAT */
@@ -49,28 +50,35 @@ const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn:
out.push("\n[前置] " + JSON.stringify(pre));
check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre));
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 1");
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 6/1");
let dom = JSON.parse(await ev(`JSON.stringify({min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
check("min=1(否则浏览器校验会把 1ms 判成非法)", dom.min === "1", JSON.stringify(dom));
check("step=1(微调箭头能落到 1ms 档)", dom.step === "1", JSON.stringify(dom));
check("min=6(否则浏览器校验会把 6ms 判成非法)", dom.min === "6", JSON.stringify(dom));
check("step=1(下限档要能逐 ms 微调)", dom.step === "1", JSON.stringify(dom));
out.push("\n[B] 基线:ECAT 默认 50ms");
const hz50 = await measure(3);
out.push(` 实测 ${hz50.toFixed(1)} Hz`);
check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`);
out.push("\n[C] 设成 1ms —— 应被接受且明显更快");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
out.push("\n[C] 设成 6ms —— 应被接受且明显更快");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="6"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(800);
let st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, valid: document.querySelector("#refreshRate").checkValidity(), map: motorDbg.state.refreshByTp})`));
check("1ms 被接受(没被钳回 50)", st.eff === 1, JSON.stringify(st));
check("输入框显示 1", st.val === "1", JSON.stringify(st));
check("6ms 被接受(没被钳回 50)", st.eff === 6, JSON.stringify(st));
check("输入框显示 6", st.val === "6", JSON.stringify(st));
check("通过浏览器原生校验(min 已同步)", st.valid === true, JSON.stringify(st));
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 1 && st.map.wifi === 250, JSON.stringify(st));
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 6 && st.map.wifi === 250, JSON.stringify(st));
const hz1 = await measure(4);
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,实测约 125Hz 量级)`);
check("1ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `1ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
check("但达不到 1000Hz(受每拍 HTTP 往返限制,符合预期)", hz1 < 400, `实测 ${hz1.toFixed(1)}`);
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,6ms 档约 160Hz 量级)`);
check("6ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `6ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
check("但达不到理论 166Hz 以上(受每拍 HTTP 往返限制)", hz1 < 200, `实测 ${hz1.toFixed(1)}`);
out.push("\n[C2] 硬填旧的 1ms —— 应被抬到下限 6ms");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
check("旧 1ms 被抬到 6ms", st.eff === 6, JSON.stringify(st));
check("输入框回填 6", st.val === "6", JSON.stringify(st));
out.push("\n[D] 不限速档不应亮卡顿黄标");
st = JSON.parse(await ev(`JSON.stringify({stuck: motorDbg.state.tickStuck, cls: document.querySelector("#chSelect").classList.contains("stuck"), title: document.querySelector("#chSelect").title})`));
@@ -78,23 +86,23 @@ check("tickStuck 保持 0(每拍撞上在途请求是预期行为,不算卡
check("通道下拉框没有 .stuck 黄标", st.cls === false, JSON.stringify(st));
check("没有误导性的卡顿提示文案", !st.title, JSON.stringify(st));
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 1000Hz");
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 166Hz");
const hzText = await ev(`document.querySelector("#statusIdLabel").textContent`);
out.push(` 面板显示: ${hzText}`);
const m = String(hzText).match(/([\d.]+)\s*Hz/);
check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText);
if (m) {
const shown = parseFloat(m[1]);
check("显示的不是谎报的 1000Hz", shown < 400, `显示 ${shown}Hz`);
check("显示的不是谎报的理论值", shown < 200, `显示 ${shown}Hz`);
check("显示值与实测频率同量级(±60%)",
Math.abs(shown - hz1) / Math.max(hz1, 1) < 0.6, `显示 ${shown} vs 实测 ${hz1.toFixed(1)}`);
}
out.push("\n[F] 切到 WiFi:1ms 不能带过去(wifi 下限 50ms)");
out.push("\n[F] 切到 WiFi:6ms 不能带过去(wifi 下限 50ms)");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
check("WiFi 生效值 250(不是 ECAT 的 1)", st.eff === 250, JSON.stringify(st));
check("WiFi 生效值 250(不是 ECAT 的 6)", st.eff === 250, JSON.stringify(st));
check("WiFi 下 min 收回 50", st.min === "50", JSON.stringify(st));
check("WiFi 下 step 收回 50", st.step === "50", JSON.stringify(st));
/* 在 WiFi 下硬填 1ms,应被钳到 50 */
@@ -103,16 +111,16 @@ await sleep(500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
check("WiFi 下填 1ms 被钳到 50(无线往返撑不住)", st.eff === 50, JSON.stringify(st));
out.push("\n[G] 切回 ECAT:仍记着自己的 1ms");
out.push("\n[G] 切回 ECAT:仍记着自己的 6ms");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(2500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min})`));
check("ECAT 生效值回到 1ms", st.eff === 1, JSON.stringify(st));
check("min 重新放开到 1", st.min === "1", JSON.stringify(st));
/* 刷新页面后 1ms 应持久化 */
check("ECAT 生效值回到 6ms", st.eff === 6, JSON.stringify(st));
check("min 重新放开到 6", st.min === "6", JSON.stringify(st));
/* 刷新页面后 6ms 应持久化 */
await send("Page.navigate", { url: PAGE }); await sleep(3000);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`));
check("刷新页面后 ECAT 仍是 1ms(持久化 + 加载时不被钳高)", st.eff === 1, JSON.stringify(st));
check("刷新页面后 ECAT 仍是 6ms(持久化 + 加载时不被钳高)", st.eff === 6, JSON.stringify(st));
check("刷新后 wifi 那一份仍是 50(上面手动改过)", st.map.wifi === 50, JSON.stringify(st));
console.log(out.join("\n"));
+1 -1
View File
@@ -24,7 +24,7 @@ let pass = 0, fail = 0; const out = [];
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
await send("Runtime.enable"); await send("Page.enable");
const PORT = Number(process.argv[2] || 8080);
const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点:ECAT 链路,清掉周期/节拍残留偏好(后端保持当前值不动) */
+2 -2
View File
@@ -44,8 +44,8 @@ function check(name, cond, detail) {
await send("Runtime.enable");
await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
const PORT = Number(process.argv[2] || 8080);
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
async function goto(u) {
+2 -2
View File
@@ -20,8 +20,8 @@ let pass = 0, fail = 0; const out = [];
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
await send("Runtime.enable"); await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
const PORT = Number(process.argv[2] || 8080);
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */
+59
View File
@@ -0,0 +1,59 @@
param([int]$Seconds = 8)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class W {
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string c, string t);
}
"@
$SW_RESTORE = 9
$SW_MINIMIZE = 6
$win = $null
Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -match 'ESP32' } | ForEach-Object {
$win = $_; Write-Output ("found window: pid={0} title='{1}'" -f $_.Id, $_.MainWindowTitle)
}
if (-not $win) { Write-Output 'NO ESP32 WINDOW FOUND'; exit 1 }
$hwnd = $win.MainWindowHandle
Write-Output ("before: minimized=" + [W]::IsIconic($hwnd))
function Snap {
$h = @{}
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match 'msedgewebview2|ecat_native|pythonw' })) {
try { $h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = [math]::Round($p.WorkingSet64/1MB,0) } } catch {}
}
return $h
}
function Measure-Phase($label, $dt) {
$a = Snap
Start-Sleep -Seconds $dt
$b = Snap
$rows = foreach ($k in $b.Keys) {
if ($a.ContainsKey($k)) {
$d = ($b[$k].cpu - $a[$k].cpu)
if ($d -gt 0) {
[pscustomobject]@{ Phase = $label; Pid = [int]$k; Name = $b[$k].name; Pct1Core = [math]::Round($d/$dt*100,1); WS_MB = $b[$k].ws }
}
}
}
return $rows
}
$r1 = Measure-Phase 'visible' $Seconds
Write-Output '--- minimize ---'
[void][W]::ShowWindow($hwnd, $SW_MINIMIZE)
Start-Sleep -Seconds 2
$r2 = Measure-Phase 'minimized' $Seconds
Write-Output '--- restore ---'
[void][W]::ShowWindow($hwnd, $SW_RESTORE)
Start-Sleep -Seconds 2
$r3 = Measure-Phase 'restored' $Seconds
$all = @() + $r1 + $r2 + $r3
$out = $all | Sort-Object Phase, Pct1Core -Descending
$out | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
foreach ($ph in @('visible','minimized','restored')) {
$s = ($out | Where-Object { $_.Phase -eq $ph } | Measure-Object -Property Pct1Core -Sum).Sum
Write-Output ("TOTAL {0,-10} = {1,7:N1} % of one core" -f $ph, $s)
}
+31
View File
@@ -0,0 +1,31 @@
$ErrorActionPreference = 'Stop'
$cpus = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
Write-Output "logical_cpus=$cpus"
$pat = 'msedgewebview2|ecat_native|pythonw|python'
function Snap {
$h = @{}
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match $pat })) {
$h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = $p.WorkingSet64 }
}
return $h
}
$a = Snap
Start-Sleep -Seconds 8
$b = Snap
$dt = 8
$rows = foreach ($k in $b.Keys) {
if ($a.ContainsKey($k)) {
$d = ($b[$k].cpu - $a[$k].cpu)
if ($d -gt 0) {
[pscustomobject]@{
pid = [int]$k
name = $b[$k].name
pct = [math]::Round($d / $dt * 100, 1)
wsMB = [math]::Round($b[$k].ws / 1MB, 0)
}
}
}
}
$rows | Where-Object { $_.pct -ge 0.3 } | Sort-Object pct -Descending | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
$total = ($rows | Measure-Object -Property pct -Sum).Sum
Write-Output ("total_pct_of_one_core=" + [math]::Round($total,1))
View File
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""GD32 固件经 ESP32 中继刷写(tools/flash_gd32.py 删除后的等价实现)。
流程(与 ESP32 侧 http_handlers.cpp 的 ota_upload_handler /
ota_relay_trigger_handler 契约一致):
1. Release 的 Intel HEX 展平成从 0x08048000 起的连续 BIN(间隙填 0xFF);
2. POST /ota/upload,请求体 = {"total_size":N} 紧跟 BIN 字节;
3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做
包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒);
4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是
期望版本、gd32_adc.uptime_sec 已归零;
5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。
用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0]
"""
import hashlib
import json
import os
import struct
import sys
import time
import urllib.error
import urllib.request
ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127")
APP_BASE = 0x08048000
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
HEX = os.path.join(ROOT, "DCU_APP", "out", "GD32E508_DCU_APP", "GD32E508VE",
"Release", "GD32E508_DCU_APP.hex")
LEDGER = os.path.join(HERE, "..", "lab", "_last_gd32_sha.txt")
def hex_to_flat(path):
"""Intel HEX → 从 APP_BASE 起的连续字节。间隙填 0xFF(擦除态)。"""
mem = {}
base = 0
with open(path, "r", encoding="ascii", errors="ignore") as f:
for ln, line in enumerate(f, 1):
line = line.strip()
if not line.startswith(":"):
continue
raw = bytes.fromhex(line[1:])
cnt, addr, rtype = raw[0], struct.unpack(">H", raw[1:3])[0], raw[3]
data = raw[4:4 + cnt]
if len(raw) != cnt + 5 or (sum(raw) & 0xFF) != 0:
raise SystemExit(f"{path}:{ln} 校验和不对")
if rtype == 0x00:
for i, b in enumerate(data):
mem[base + addr + i] = b
elif rtype == 0x02:
base = struct.unpack(">H", data[:2])[0] << 4
elif rtype == 0x04:
base = struct.unpack(">H", data[:2])[0] << 16
elif rtype == 0x01:
break
if not mem:
raise SystemExit("HEX 里没有数据记录")
lo, hi = min(mem), max(mem)
if lo > APP_BASE:
raise SystemExit(f"镜像起点 0x{lo:08X} 高于 APP 基址 0x{APP_BASE:08X},不是 APP 包")
if lo < APP_BASE:
raise SystemExit(f"镜像含 0x{lo:08X} 的地址,落在 APP 区之外(疑似 BOOT 包)")
out = bytearray(b"\xFF" * (hi - APP_BASE + 1))
for a, b in mem.items():
out[a - APP_BASE] = b
return bytes(out)
def post(path, body, timeout=60):
req = urllib.request.Request(f"http://{ESP}{path}", data=body, method="POST")
req.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def get(path, timeout=10):
with urllib.request.urlopen(f"http://{ESP}{path}", timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def main():
want = sys.argv[1] if len(sys.argv) > 1 else None
if not os.path.isfile(HEX):
raise SystemExit(f"找不到 {HEX}")
blob = hex_to_flat(HEX)
sha = hashlib.sha256(blob).hexdigest()
print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}")
hdr = json.dumps({"total_size": len(blob)}).encode("ascii")
t0 = time.time()
st, body = post("/ota/upload", hdr + blob, timeout=120)
print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)")
if st != 200:
raise SystemExit("上传失败")
t0 = time.time()
st, body = post("/ota/relay", json.dumps({"sha256": sha}).encode("ascii"), 60)
print(f"relay -> {st} {body[:300]} ({time.time()-t0:.1f}s)")
deadline = time.time() + 90
last = ""
while time.time() < deadline:
time.sleep(2)
try:
s = get("/system/status")
except Exception as e:
print(f" 等待中… status 读失败 {type(e).__name__}")
continue
o = s.get("ota", {})
line = (f" sys_state={o.get('sys_state')} gd32_app={o.get('gd32_app')} "
f"alive={o.get('gd32_app_alive')} uptime={s.get('uptime_sec')}")
if line != last:
print(line)
last = line
if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"):
ver = str(o["gd32_app"]).lstrip("v")
if want and ver != want:
raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效")
with open(LEDGER, "w", encoding="ascii") as f:
f.write(sha)
print(f"完成: 板上 gd32_app={o['gd32_app']},台账已写 {os.path.normpath(LEDGER)}")
return
raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试")
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ECAT 段2 撕裂 A/B 判据(修复前后的唯一对照指标)。
主判据(零假阳性,直接来自写侧契约):
固件 MotorSlot 的 seq 与 data 属同一次更新,seq 先自增、data 后 memcpy。
⇒ 同一 seq 在总线上只能对应唯一一份 48B payload。
若同一 seq 先后被读到两份不同 payload,只可能是消费者(PDI_Isr 的 buildInput)
在生产者 memcpy 中途取走了半成品 —— 即撕裂(含"seq 新、data 整体旧"的错拍型)。
1.6.5 的 seqlock 把整个更新段(seq 自增 + data 拷贝)都盖进奇数区间,
故修复后该不变量必须零例外。
功效条件:只有当该节点的 seq 在相邻两拍之间不前进时,同一 seq 才可能被读两次。
所以脚本同时报"同 seq 重复读"的覆盖率——重复率低则本次判定功效不足,需换工况重跑。
旁判据(与速率无关):字段值的"跳一下又回来"(median 偏离 >8× 局部抖动带且下一帧
回到带内)——真撕裂必然表现为这种形态。
"""
import json
import os
import socket
import statistics
import struct
import sys
import time
HOST = "127.0.0.1"
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
DUR_S = float(sys.argv[2]) if len(sys.argv) > 2 else 150.0
LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ecat_torn_hits.jsonl")
FLOATS = (("rtAngle", 13), ("rtVelocity", 17), ("rtCurrent", 21), ("testFloat", 25))
I16 = (("mcuTemp", 0), ("motTemp", 2), ("uCur", 4), ("vCur", 6), ("wCur", 8))
def once():
s = socket.create_connection((HOST, PORT))
try:
s.sendall(REQ)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
_, _, d = bytes(buf).partition(b"\r\n\r\n")
return json.loads(d.decode("utf-8", "replace"))
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
f"Content-Type: application/json\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\n{{}}").encode("ascii")
def main():
# per node: 当前 seq 的首见/末见 payload、上一 seq 的确定 payload、字段历史
st = {}
viol, spikes, polls, err = [], [], 0, 0
cov = 0
t0 = time.time()
lf = open(LOG, "w", encoding="utf-8")
while time.time() - t0 < DUR_S:
try:
snap = once()
except Exception:
err += 1
continue
polls += 1
for m in snap.get("motors") or []:
if not m.get("online") or not m.get("data"):
continue
nd, seq = m["node"], m["seq"]
d = bytes.fromhex(m["data"])
e = st.setdefault(nd, {"cur": None, "first": None, "last": None,
"done": None, "hist": {}, "seen": 0})
if e["cur"] == seq:
cov += 1 # 一次"同一 seq 被读第二遍"的判定机会
if e["first"] != d:
rec = {"t": round(time.time() - t0, 2), "node": nd, "seq": seq,
"first": e["first"].hex(), "later": d.hex(),
"prev_seq_data": (e["done"].hex() if e["done"] else None)}
# 分类:整体等于上一帧 = 错拍型;前缀/后缀混合 = 真混合
a, b, p = e["first"], d, e["done"]
rec["cls"] = ("STALE(错拍)" if (p is not None and a == p) else
"MATCH" if a == b else "VIOLATION")
if p is not None and a != p:
n = min(len(a), len(b), len(p))
rec["mix_j"] = next((j for j in range(n + 1)
if a[:j] == b[:j] and a[j:n] == p[j:n]), None)
viol.append(rec)
lf.write(json.dumps(rec, ensure_ascii=False) + "\n")
lf.flush()
e["last"] = d
else:
# seq 前进:上一 seq 的末见即其确定内容
if e["cur"] is not None and e["last"] is not None:
e["done"] = e["last"]
if len(e["last"]) >= 29: # 覆盖到 testFloat 末字节才做形态判据
for name, off in FLOATS:
e["hist"].setdefault(name, []).append(
struct.unpack_from("<f", e["last"], off)[0])
if len(e["hist"][name]) > 400:
e["hist"][name].pop(0)
# 形态判据:本帧各字段是否"跳出带又回来"需要下一帧,留到下次比较
for name, off in FLOATS:
h = e["hist"].get(name) or []
if len(h) >= 4:
a, b2 = h[-3], h[-2] # 上上帧 / 上一帧(刚确定)
# 检查上一帧相对其邻居是否孤立尖峰(其前其后由 hist 提供)
if len(h) >= 4:
pre, post = h[-4], h[-1]
band = max(abs(b2 - pre), abs(post - b2), 1e-6)
if abs(b2 - (pre + post) / 2) > 20 * band:
spikes.append({"t": round(time.time() - t0, 2),
"node": nd, "field": name,
"pre": pre, "spike": b2, "post": post})
e["cur"], e["first"], e["last"], e["seen"] = seq, d, d, 0
lf.close()
print(f"轮询 {polls} 次 / {DUR_S:.0f}s({polls/DUR_S:.0f} 次/s,连接错误 {err})")
print(f"判定功效:同一 seq 被读第二遍的机会 = {cov} 次")
print(f"主判据违规(同一 seq 出现两份不同 48B)= {len(viol)}")
for r in viol[:6]:
print(" ", r["cls"], f"t={r['t']}s node={r['node']} seq={r['seq']} mix_j={r.get('mix_j')}")
print(f" first={r['first'][:64]}")
print(f" later={r['later'][:64]}")
print(f"旁判据(孤立尖峰,形态学,可能有真突变混入)= {len(spikes)}")
for s in spikes[:4]:
print(f" t={s['t']}s node={s['node']} {s['field']}: {s['pre']:.6g} -> {s['spike']:.6g} -> {s['post']:.6g}")
print(f"明细: {LOG}")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ECAT 与 WiFi 两条链路对同一 seq 的 48B STATUS 做逐字节对照。
两条链路读的是 GD32 里同一张 MotorState 表,但读取上下文不同:
* ECAT 段2 由 buildInput 取表,跑在 PDI_Isr —— 会抢占主循环的写侧(本次修复点);
* WiFi 0xC5/buildPayload 与写侧同在主循环 —— 结构上不可能读到中间态。
所以 WiFi 侧可当参照:同一 seq 在两边必须逐字节相同(seq 由 global_seq 唯一确定,
一次更新只产生一个 seq)。不等 ⇒ ECAT 侧那一份是没写完的半成品。
用法: python scripts/probe_ecat_vs_wifi.py [秒数]
"""
import json
import socket
import struct
import sys
import time
import urllib.request
HOST, PORT = "127.0.0.1", 8090
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
ESP = "192.168.31.127"
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
"Content-Type: application/json\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\n{}").encode("ascii")
def ecat_once():
s = socket.create_connection((HOST, PORT))
try:
s.sendall(REQ)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
_, _, d = bytes(buf).partition(b"\r\n\r\n")
return json.loads(d.decode("utf-8", "replace"))
def wifi_once():
"""返回 {node: (seq, 48B)}。协议 ver=2:entry 92B,data 在 +12。"""
b = urllib.request.urlopen(f"http://{ESP}/motor/state?bin=1", timeout=3).read()
if len(b) < 12 or struct.unpack_from("<H", b, 0)[0] != 0x4D53 or b[2] != 2:
return {}
out, off = {}, 12
for _ in range(b[9]):
if off + 92 > len(b):
break
out[b[off]] = (struct.unpack_from("<I", b, off + 8)[0],
b[off + 12:off + 60])
off += 92
return out
def main():
cache = {} # node -> {seq: payload}(ECAT 侧见过的每一帧)
matched = mism = polls = wpolls = 0
bad = []
t0 = time.time()
while time.time() - t0 < DUR_S:
try:
snap = ecat_once()
polls += 1
for m in snap.get("motors") or []:
if m.get("online") and m.get("data"):
cache.setdefault(m["node"], {})[m["seq"]] = bytes.fromhex(m["data"])
if len(cache[m["node"]]) > 4000:
for k in sorted(cache[m["node"]])[:2000]:
del cache[m["node"]][k]
except Exception:
continue
try:
w = wifi_once()
wpolls += 1
except Exception:
continue
for node, (seq, payload) in w.items():
d = cache.get(node, {}).get(seq)
if d is None:
continue # 该 seq 未被 ECAT 侧采到,跳过
matched += 1
if d != payload:
mism += 1
bad.append((round(time.time() - t0, 2), node, seq, d.hex(), payload.hex()))
print(f"轮询 ECAT {polls} 次 / WiFi {wpolls} 次 / {DUR_S:.0f}s")
print(f"两侧同 seq 可比对 = {matched} 逐字节不一致 = {mism}")
for t, node, seq, e, w in bad[:6]:
j = next((i for i in range(48) if e[i] != w[i]), 48)
print(f" t={t}s node={node} seq={seq} 首字节差={j}")
print(f" ECAT={e}")
print(f" WiFi={w}")
if not matched:
print("功效不足:ECAT 帧率远高于 WiFi 采样,同 seq 交集为 0,本次判定不作数。")
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 ECAT 侧收到的每一拍信号全量落盘,供事后分析(不在线判优劣)。
去重规则只有一条:同一 (node,seq) 或同一 (id,data) 帧只在第一次出现时写一行 ——
重复采样不携带新信息,去掉后可以把时长拉长。其余一律保留。
行格式(CSV,第 1 列是相对起始的秒):
M,t,node,ch,seq,online,dlc,hex36 段2 电机槽(STATUS 前 36B,含 cpu%@31/init@32-35)
F,t,kind,ch,dlc,id,hex36 段3 xrx / 段4 rx_batch 的帧
H,t,cycles,wkc,gseq,win_base,win_count,active,rx,tx,drop 每 20 拍一条头部与桥计数
用法: python scripts/rec_ecat_slots.py [秒数=120] [端口=8090] [输出文件]
"""
import csv
import json
import socket
import struct
import sys
import time
HOST = "127.0.0.1"
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 120.0
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
OUT = sys.argv[3] if len(sys.argv) > 3 else "ecat_slots_raw.csv"
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
"Content-Type: application/json\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\n{}").encode("ascii")
def once():
s = socket.create_connection((HOST, PORT))
try:
s.sendall(REQ)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
_, _, d = bytes(buf).partition(b"\r\n\r\n")
return json.loads(d.decode("utf-8", "replace"))
def main():
seen_m, seen_f = set(), set()
polls = rows = 0
t0 = time.time()
with open(OUT, "w", newline="", encoding="ascii") as f:
w = csv.writer(f)
w.writerow(["#kind", "t", "a", "b", "c", "d", "e", "f"])
while True:
dt = time.time() - t0
if dt >= DUR_S:
break
try:
snap = once()
except Exception:
continue
polls += 1
h = snap.get("head") or {}
b = snap.get("bridge") or {}
if polls % 20 == 1:
w.writerow(["H", f"{dt:.6f}", snap.get("cycles"), snap.get("wkc"),
h.get("global_seq"), h.get("win_base"), h.get("win_count"),
h.get("active"), b.get("rx_count"), b.get("tx_count"),
b.get("drop")])
for m in snap.get("motors") or []:
if not m.get("data"):
continue
key = (m["node"], m["ch"], m["seq"])
if key in seen_m:
continue
seen_m.add(key)
if len(seen_m) > 400000: # 去重集合本身的内存上限
seen_m = set(list(seen_m)[-200000:])
d = bytes.fromhex(m["data"])[:36]
w.writerow(["M", f"{dt:.6f}", m["node"], m["ch"], m["seq"],
int(bool(m.get("online"))), m.get("dlc"), d.hex()])
rows += 1
def emit_frames(fr, kind):
if not fr or not fr.get("data"):
return
d = bytes.fromhex(fr["data"])[:36]
key = (fr.get("id"), d.hex())
if key in seen_f:
return
seen_f.add(key)
w.writerow([kind, f"{dt:.6f}", fr.get("ch"), fr.get("dlc"),
fr.get("id"), "", "", d.hex()])
emit_frames(snap.get("xrx"), "X")
for x in ((snap.get("rx_batch") or {}).get("frames") or []):
emit_frames(x, "B")
if len(seen_f) > 400000:
seen_f = set(list(seen_f)[-200000:])
print(f"落盘 {OUT}: 轮询 {polls} 次 / {DUR_S:.0f}s,写入 {rows} 行 "
f"(电机槽 {len(seen_m)} 帧, 接收帧 {len(seen_f)})")
if __name__ == "__main__":
main()
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
*
* 跑法:node scripts/test_ecat_autostart.mjs [--port 8080]
* 跑法:node scripts/test_ecat_autostart.mjs [--port 2625]
*
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
* 四级链条的每一级都被正确驱动,且失败原因可读。
@@ -108,7 +108,7 @@ section("[1] probe() 自动开网卡 + 自动起循环(四级链条的 2/3/4
un(); srv.close();
check("先探 status 发现未连接", st.calls[0] === "/ecat/api/status", st.calls[0]);
check("自动调用 /api/open(代替用户去点连接按钮)", st.calls.includes("/ecat/api/open"));
check("open 用默认网卡子串 I226", st.openSubstr === "I226", String(st.openSubstr));
check("open 用默认网卡子串 I210", st.openSubstr === "I210", String(st.openSubstr));
check("随后自动调用 /api/motor/start(进 OP + 起后台循环)", st.started === true);
check("open 在 start 之前", st.calls.indexOf("/ecat/api/open") < st.calls.indexOf("/ecat/api/motor/start"));
check("periodMs 由后端回报写入", tp.debug().periodMs === 4, String(tp.debug().periodMs));
@@ -133,7 +133,7 @@ section("[2] 已连接则不重复 open(幂等)");
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
section("[3] 打不开网卡时的错误语义");
{
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I226' 的适配器" });
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I210' 的适配器" });
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
let err = null;
+27 -22
View File
@@ -10,11 +10,11 @@
*
* 覆盖:
* 1 默认值:wifi 250 / ecat 50,且 defaultRefreshMap 每次给新对象
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 1)
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 6)
* 3 旧数据迁移:单个 refreshRate → 只进 wifi,ecat 保持默认
* 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值
* 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义)
* 6 不限速谓词:1ms 档要关掉卡顿黄标与理论 Hz 显示(1ms ≠ 1000Hz)
* 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz)
*/
import {
REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP,
@@ -45,11 +45,11 @@ check("defaultRefreshMap 不污染 REFRESH_DEFAULTS 常量", REFRESH_DEFAULTS.wi
/* ── 1b. 下限也按链路 ── */
section("[1b] 每链路最小间隔");
check("ecat 下限 1ms(读本机 PDO 快照,无总线代价)", REFRESH_MIN_BY_TP.ecat === 1,
`实际 ${REFRESH_MIN_BY_TP.ecat}`);
check("ecat 下限 6ms(≈160Hz,与本机 160Hz 屏的绘制上限对齐)",
REFRESH_MIN_BY_TP.ecat === 6, `实际 ${REFRESH_MIN_BY_TP.ecat}`);
check("wifi 下限仍 50ms(每拍是一次无线 HTTP 往返,再快只会积压)",
REFRESH_MIN_BY_TP.wifi === 50, `实际 ${REFRESH_MIN_BY_TP.wifi}`);
check("minRefreshFor('ecat') = 1", minRefreshFor("ecat") === 1);
check("minRefreshFor('ecat') = 6", minRefreshFor("ecat") === 6);
check("minRefreshFor('wifi') = 50", minRefreshFor("wifi") === 50);
check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope") === REFRESH_MIN_MS);
@@ -57,10 +57,14 @@ check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope")
section("[2] 归一与钳制(下限按链路)");
const norm = normalizeRefreshMap({ wifi: 400, ecat: 20 });
check("正常值原样保留", norm.wifi === 400);
check("ecat 的 20ms 合法(下限 1ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
check("ecat 可以设到 1ms", normalizeRefreshMap({ ecat: 1 }).ecat === 1);
check("ecat 的 0/负数被抬到 1ms", normalizeRefreshMap({ ecat: 0 }).ecat === 1);
check("ecat 的 -5 被抬到 1ms", normalizeRefreshMap({ ecat: -5 }).ecat === 1);
check("ecat 的 20ms 合法(下限 6ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
check("ecat 可以设到 6ms", normalizeRefreshMap({ ecat: 6 }).ecat === 6);
/* 存量缓存里还留着旧的 1ms 档:读到就按新下限抬起来,不能原样放行 */
check("存量 1ms 被抬到 6ms(绘制上限之上没有消费者)",
normalizeRefreshMap({ ecat: 1 }).ecat === 6,
`实际 ${normalizeRefreshMap({ ecat: 1 }).ecat}`);
check("ecat 的 0/负数被抬到 6ms", normalizeRefreshMap({ ecat: 0 }).ecat === 6);
check("ecat 的 -5 被抬到 6ms", normalizeRefreshMap({ ecat: -5 }).ecat === 6);
check("wifi 的 1ms 仍被抬到 50(无线往返撑不住)",
normalizeRefreshMap({ wifi: 1 }).wifi === 50, `实际 ${normalizeRefreshMap({ wifi: 1 }).wifi}`);
check("wifi 的 20ms 也被抬到 50", normalizeRefreshMap({ wifi: 20 }).wifi === 50);
@@ -68,7 +72,7 @@ check("高于上限被压到 5000", normalizeRefreshMap({ wifi: 99999 }).wifi ==
check("ecat 高于上限同样压到 5000", normalizeRefreshMap({ ecat: 99999 }).ecat === REFRESH_MAX_MS);
check("字符串数字可用(localStorage 里存的是字符串)",
normalizeRefreshMap({ wifi: "300" }).wifi === 300);
check("ecat 字符串 \"1\" 可用", normalizeRefreshMap({ ecat: "1" }).ecat === 1);
check("ecat 字符串 \"8\" 可用", normalizeRefreshMap({ ecat: "8" }).ecat === 8);
const dirty = normalizeRefreshMap({ wifi: "abc", ecat: null });
check("脏值回落该链路默认(wifi)", dirty.wifi === 250, `实际 ${dirty.wifi}`);
check("脏值回落该链路默认(ecat)", dirty.ecat === 50, `实际 ${dirty.ecat}`);
@@ -97,7 +101,8 @@ check("正常取值", pickRefresh({ wifi: 300, ecat: 60 }, "ecat") === 60);
check("缺该链路 → 回落该链路默认(不是另一条的值)",
pickRefresh({ wifi: 300 }, "ecat") === 50);
check("map 为 null 不炸", pickRefresh(null, "wifi") === 250);
check("ecat 取出 1ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 1 }, "ecat") === 1);
check("ecat 取出 8ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 8 }, "ecat") === 8);
check("ecat 取出旧的 1ms 抬到下限 6ms", pickRefresh({ ecat: 1 }, "ecat") === 6);
check("wifi 取出 1ms 被抬到 50", pickRefresh({ wifi: 1 }, "wifi") === 50);
check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
pickRefresh({}, "somethingnew") === 250);
@@ -105,24 +110,24 @@ check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
/* ── 5. 隔离:本次改动的核心 ── */
section("[5] 两条链路互不污染");
const map = defaultRefreshMap();
/* 模拟:用户在 ECAT 下把刷新速度拉到 1ms(applyRefreshRate 只写当前链路那一份) */
map.ecat = 1;
check("改 ecat 到 1ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
/* 模拟:用户在 ECAT 下把刷新速度拉到下限 6ms(applyRefreshRate 只写当前链路那一份) */
map.ecat = 6;
check("改 ecat 到 6ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
/* 模拟:切回 WiFi,装载的应是 wifi 那一份 */
check("切回 wifi 装载 250 而非 1", pickRefresh(map, "wifi") === 250);
check("切回 wifi 装载 250 而非 6", pickRefresh(map, "wifi") === 250);
/* 模拟:在 WiFi 下改成 1000ms,再切去 ECAT */
map.wifi = 1000;
check("改 wifi 后 ecat 仍是 1ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 1);
check("切到 ecat 装载 1 而非 1000", pickRefresh(map, "ecat") === 1);
check("改 wifi 后 ecat 仍是 6ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 6);
check("切到 ecat 装载 6 而非 1000", pickRefresh(map, "ecat") === 6);
/* 存盘 → 读回一圈后仍各自独立(saveUiState/loadUiState 的往返) */
const roundTrip = normalizeRefreshMap(JSON.parse(JSON.stringify(map)));
check("存盘再读回:wifi 保留 1000", roundTrip.wifi === 1000);
check("存盘再读回:ecat 保留 1ms(不被 wifi 的下限 50 顺手抬高)",
roundTrip.ecat === 1, `实际 ${roundTrip.ecat}`);
check("存盘再读回:ecat 保留 6ms(不被 wifi 的下限 50 顺手抬高)",
roundTrip.ecat === 6, `实际 ${roundTrip.ecat}`);
/* ── 6. 不限速谓词 ──
* 1ms 档下节拍源必然比请求快,每拍都撞上"上一拍还在飞"。那是预期行为,
* 所以要据此关掉卡顿黄标;同时 1ms 硬算成 1000Hz 是谎报(浏览器实测约 125Hz)。 */
* 下限档(6ms)贴着往返耗时,节拍源仍会比请求完成得快,撞上"上一拍还在飞"是
* 预期行为,所以要据此关掉卡顿黄标;6ms 硬算成 166Hz 也偏乐观(实测 ≈160Hz)。 */
section("[6] 不限速谓词(关黄标 + 不谎报 Hz)");
check("阈值 20ms(50Hz):比浏览器实测每拍耗时(~8ms)宽裕,又远小于常用档",
UNTHROTTLED_MAX_MS === 20, `实际 ${UNTHROTTLED_MAX_MS}`);
@@ -135,7 +140,7 @@ check("ECAT 默认 50ms 是正常限速档(黄标与 Hz 显示都保留)",
check("WiFi 默认 250ms 是正常限速档", isUnthrottled(REFRESH_DEFAULTS.wifi) === false);
check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)",
isUnthrottled(REFRESH_MIN_BY_TP.wifi) === false);
check("ECAT 下限 1ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false);
console.log(out.join("\n"));
+15 -14
View File
@@ -150,7 +150,8 @@ if (liveOk) {
typeof m.rx_count === "number" && typeof m.seq === "number" &&
typeof m.data === "string" && typeof m.config_data === "string";
check("motors[] 每项字段名/类型与 WiFi 一致", shapeOk, JSON.stringify(m).slice(0, 150));
check("data 是 64 字符 hex(32B STATUS)", m.data.length === 64, `len=${m.data.length}`);
check("data 是 96 字符 hex(48B STATUS,v0.74 含 InitStatus)", m.data.length === 96,
`len=${m.data.length}`);
results.push(` \u2139 在线电机: ` + st.motors
.map((x) => `N${x.node}@CH${x.ch}${x.online ? "" : "(离线)"}`).join(" "));
} else {
@@ -172,8 +173,8 @@ if (liveOk) {
typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null);
const dbg = ecat.debug();
check("debug() 暴露 PDO 周期与槽位统计",
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 7 &&
dbg.slotCount === 27 && dbg.scanCycles === 4, JSON.stringify(dbg && {
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 5 &&
dbg.slotCount === 27 && dbg.scanCycles === 6, JSON.stringify(dbg && {
periodMs: dbg.periodMs, slots: dbg.slots }));
}
@@ -226,15 +227,15 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
sent_seq: null, sent_frames: 0, queue_depth: 0,
bridge: { valid: true, ack: 0, rx_count: 1000, tx_count: 5, drop: 2, tx_result: 0 },
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 7, global_seq: 42 },
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 5, global_seq: 42 },
motors,
}, extra || {});
const STATUS_HEX = "AB".repeat(32); /* 32B STATUS */
const STATUS_HEX = "AB".repeat(48); /* 48B STATUS(v0.74) */
const CONFIG_HEX = "5A".repeat(24); /* 24B CONFIG(0x0101) */
/* (a) 首次:seq 在动 → online=true,rx_count 从 1 起累 */
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 500, data: STATUS_HEX }]);
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 500, data: STATUS_HEX }]);
let st = await tp.fetchMotorState();
let m = st.motors[0];
check("(a) 从站 online + seq 在动 → online=true", m.online === true);
@@ -242,7 +243,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
check("(a) active_count 取自 head.active", st.active_count === 1);
/* (b) seq 递增 → rx_count 继续累加 */
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 501, data: STATUS_HEX }]);
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 501, data: STATUS_HEX }]);
st = await tp.fetchMotorState();
check("(b) seq 递增 → rx_count 累加到 2", st.motors[0].rx_count === 2,
`rx=${st.motors[0].rx_count}`);
@@ -250,7 +251,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
/* (c) CONFIG 从批量接收区累积 → 回填 motors[].config_data。
* 这是 WiFi 有、EtherCAT 没有的字段,回填不了就等于 CONFIG 面板永久空白。 */
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x0101, data: CONFIG_HEX }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 502, data: STATUS_HEX }],
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 502, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
st = await tp.fetchMotorState();
check("(c) CONFIG(0x0101) 回填到 config_data(48 hex)",
@@ -260,13 +261,13 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答" */
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 503, data: STATUS_HEX }],
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 503, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
await tp.fetchMotorState();
const v1 = await tp.fetchVersionNode(3);
check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX);
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 504, data: STATUS_HEX }],
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 504, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
await tp.fetchMotorState();
const v2 = await tp.fetchVersionNode(3);
@@ -275,7 +276,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
/* (e) 节点 ID 应答:取走即清 */
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 505, data: STATUS_HEX }],
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 505, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
await tp.fetchMotorState();
check("(e) 节点 ID 应答可取出", tp.takeNodeIdReport(3) === "0700");
@@ -283,7 +284,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
/* (f) STATUS 已由状态窗口聚合,批量区里的 0x0100 必须跳过(否则重复解一遍) */
rxFrames = [{ ch: 1, dlc: 32, valid: true, id: (3 << 16) | 0x0100, data: "FF".repeat(32) }];
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX }],
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 506, data: STATUS_HEX }],
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
st = await tp.fetchMotorState();
check("(f) 批量区的 STATUS(0x0100) 不污染 config_data",
@@ -292,7 +293,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
/* (g) xrx 长帧快照也要分派:CONFIG/版本是 DLC24,短快照会截断 */
const CFG2 = "77".repeat(24);
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 32, seq: 10, data: STATUS_HEX }],
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 48, seq: 10, data: STATUS_HEX }],
{ xrx: { ch: 1, dlc: 24, id: (4 << 16) | 0x0101, data: CFG2 } });
st = await tp.fetchMotorState();
check("(g) xrx 长帧快照的 CONFIG 也回填",
@@ -301,7 +302,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
/* (h) 关键:seq 停止不动超过阈值 → online 转 false。
* 后端的快照**永不删除**见过的槽(电机拔了也还在),
* 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX };
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 506, data: STATUS_HEX };
snap = mkSnap([stale]);
st = await tp.fetchMotorState();
check("(h) seq 未变但仍在阈值内 → 暂时仍算在线",
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""test_pram_batch_sim.py - 主机侧仿真:验证 pramRead/pramWrite 批量化(1.6.0)
与旧实现(逐 DWORD 轮询)逐字节等价。
模型依据 LAN9252 DS00001909A §12.11.3/12.11.4:
- PRAM FIFO 16 deep x 32bit;AVAIL_CNT(CMD bits12:8)=可连续读/写的 DWORD 数
- 读引擎按 DWORD 对齐向下取源:第 i 个 FIFO DWORD = 源地址 aligned(addr)+4i 起 4 字节;
线性有效窗口 = [0, len),即 DWORD i 的字节 k 有效 iff 0 <= 4i+k-lead < len
- 写引擎按同一线性窗口落盘,主站推满一个 DWORD 计一次
- 引擎以有限速率搬运(rate),主站 respect AVAIL_CNT 就不会溢 FIFO/读空洞
对全部 (addr 对齐 x len 1..70/96/384/640 x 引擎节奏) 断言:
新旧算法产出逐字节一致,且主站从不超出 AVAIL_CNT(引擎内 assert)。
"""
import random
FIFO_DW = 16 # FIFO 深度 16 DWORD
WINDOW_DW = 8 # 别名窗口一程 8 DWORD (0x00-0x1C / 0x20-0x3C)
PRAM_BASE = 0x1000
PRAM_SIZE = 0x1000
def byte_valid(i, k, lead, ln):
"""DWORD i 的字节 k 是否落在线性有效窗口 [0, ln) 内。"""
return 0 <= 4 * i + k - lead < ln
class ReadEngine:
def __init__(self, mem, addr, ln, rate):
self.mem = mem
self.lead = addr & 3
self.ln = ln
self.rate = max(1, rate)
self.total_dw = (ln + self.lead + 3) // 4
self.start = (addr & ~3) - PRAM_BASE # mem 内偏移
self.pushed = 0
self.popped = 0
self.data = []
def pump(self):
n = 0
while (self.pushed - self.popped < FIFO_DW and self.pushed < self.total_dw
and n < self.rate):
i = self.pushed
dw = bytearray(4)
for k in range(4):
if byte_valid(i, k, self.lead, self.ln):
dw[k] = self.mem[self.start + 4 * i + k]
self.data.append(bytes(dw))
self.pushed += 1
n += 1
return n
def avail_cnt(self):
return self.pushed - self.popped
def pop(self):
assert self.popped < self.pushed, "主站弹出超出 AVAIL_CNT!"
dw = self.data[self.popped]
self.popped += 1
return dw
def pram_read_old(eng, addr, ln, poll_budget=2000):
out = bytearray()
done = 0
polls = 0
while done < ln:
while eng.avail_cnt() == 0:
polls += 1
if polls > poll_budget:
return bytes(out), False
eng.pump()
dw = eng.pop()
lead = (addr & 3) if done == 0 else 0
take = min(ln - done, 4 - lead)
out += dw[lead:lead + take]
done += take
eng.pump()
return bytes(out), True
def pram_read_new(eng, addr, ln, poll_budget=2000):
out = bytearray()
done = 0
polls = 0
while done < ln:
while eng.avail_cnt() == 0:
polls += 1
if polls > poll_budget:
return bytes(out), False
eng.pump()
batch = eng.avail_cnt()
if batch == 0 or batch > WINDOW_DW:
batch = 1
lead0 = (addr & 3) if done == 0 else 0
need_dw = (ln - done + lead0 + 3) // 4
batch = min(batch, need_dw)
for _ in range(batch):
dw = eng.pop()
lead = (addr & 3) if done == 0 else 0
take = min(ln - done, 4 - lead)
out += dw[lead:lead + take]
done += take
eng.pump()
return bytes(out), True
class WriteEngine:
def __init__(self, addr, ln, rate):
self.lead = addr & 3
self.ln = ln
self.rate = max(1, rate)
self.total_dw = (ln + self.lead + 3) // 4
self.start = (addr & ~3) - PRAM_BASE
self.fifo = []
self.consumed_dw = 0
self.mem = bytearray(PRAM_SIZE)
def pump(self):
n = 0
while self.fifo and n < self.rate:
dw = self.fifo.pop(0)
i = self.consumed_dw
for k in range(4):
if byte_valid(i, k, self.lead, self.ln):
self.mem[self.start + 4 * i + k] = dw[k]
self.consumed_dw += 1
n += 1
return n
def space_cnt(self):
return FIFO_DW - len(self.fifo)
def push(self, dw):
assert len(self.fifo) < FIFO_DW, "主站写溢出 FIFO!"
self.fifo.append(bytes(dw))
def _write_common(eng, addr, buf, poll_budget, batched):
ln = len(buf)
done = 0
polls = 0
while done < ln:
while eng.space_cnt() == 0:
polls += 1
if polls > poll_budget:
return False
eng.pump()
n_iter = 1
if batched:
batch = eng.space_cnt()
if batch == 0 or batch > WINDOW_DW:
batch = 1
lead0 = (addr & 3) if done == 0 else 0
need_dw = (ln - done + lead0 + 3) // 4
n_iter = min(batch, need_dw)
for _ in range(n_iter):
lead = (addr & 3) if done == 0 else 0
take = min(ln - done, 4 - lead)
dw = bytearray(4)
for i in range(take):
dw[lead + i] = buf[done + i]
eng.push(dw)
done += take
eng.pump()
budget = poll_budget
while eng.consumed_dw < eng.total_dw and budget:
eng.pump()
budget -= 1
return eng.consumed_dw == eng.total_dw
def pram_write_old(eng, addr, buf, poll_budget=2000):
return _write_common(eng, addr, buf, poll_budget, batched=False)
def pram_write_new(eng, addr, buf, poll_budget=2000):
return _write_common(eng, addr, buf, poll_budget, batched=True)
def run():
random.seed(20260905)
cases = 0
lens = list(range(1, 71)) + [96, 384, 640] # 覆盖 SM2(384B)/SM3(640B) 实际长度
for align in range(4):
for ln in lens:
for rate in (1, 3, 16, 64):
src = bytes(random.randrange(1, 256) for _ in range(ln))
base = random.randrange(PRAM_SIZE - ln - 8)
addr = PRAM_BASE + base + align
mem = bytearray(PRAM_SIZE)
mem[base + align: base + align + ln] = src
# 读等价:新旧都须完整取回 src
e1 = ReadEngine(mem, addr, ln, rate); e1.pump()
r_old, ok1 = pram_read_old(e1, addr, ln)
e2 = ReadEngine(mem, addr, ln, rate); e2.pump()
r_new, ok2 = pram_read_new(e2, addr, ln)
assert ok1 and ok2, f"read 未完成 align={align} len={ln} rate={rate}"
assert r_old == src, f"read 旧实现不符 align={align} len={ln} rate={rate}"
assert r_new == src, f"read 新实现不符 align={align} len={ln} rate={rate}"
# 写等价:新旧都须把 src 落到同一片 PRAM
w1 = WriteEngine(addr, ln, rate); w1.pump()
assert pram_write_old(w1, addr, src), f"write 未完成 {align}/{ln}/{rate}"
w2 = WriteEngine(addr, ln, rate); w2.pump()
assert pram_write_new(w2, addr, src), f"write 未完成 {align}/{ln}/{rate}"
assert bytes(w1.mem[base + align: base + align + ln]) == src, \
f"write 旧不符 {align}/{ln}/{rate}"
assert bytes(w2.mem[base + align: base + align + ln]) == src, \
f"write 新不符 {align}/{ln}/{rate}"
cases += 1
print(f"OK: {cases} 组 (对齐x长度x引擎节奏) 新旧算法逐字节等价")
# 统计新实现的轮询次数收益(代表性场景:SM3 640B DWORD 对齐,引擎速率 16)
for rate in (1, 3, 16):
src = bytes(random.randrange(1, 256) for _ in range(640))
e = ReadEngine(bytearray(PRAM_SIZE), PRAM_BASE, 640, rate); e.pump()
_, p1 = _count_polls(e, 640)
print(f" 640B@rate{rate}: 新算法主站外圈轮询 {p1} 次")
def _count_polls(eng, ln):
done = 0
polls = 0
while done < ln:
while eng.avail_cnt() == 0:
polls += 1
eng.pump()
batch = min(eng.avail_cnt(), WINDOW_DW)
lead0 = 0 if done == 0 else 0
batch = min(batch, (ln - done + lead0 + 3) // 4)
for _ in range(batch):
eng.pop()
done += 4
return None, polls
if __name__ == "__main__":
run()