feat(ecat): 启动后把 ecat_native 提到 RealTime;跳拍优化实验脚本与报告
- esp32_server: Popen 后外部 SetPriorityClass(RT/High),native 自设会被钳成 High - 入库 skip_* 标定/实验脚本与 REPORT_ecat_skip_opt_20260917 - 前端 WIP:layout/app/polling/boot_ecat/index - 不含 lab/skip_meter 原始采样与 preview 截图
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# EtherCAT 跳拍优化实验报告
|
||||
|
||||
日期:2026-09-17
|
||||
台子:i7-14700K + I210「以太网 2」100Mbps + GD32E508 从站(3 电机在线)
|
||||
主判据:**不跳拍率**(`late_per_s` / `skip_per_s`,来自 `/api/motor/dbg` 拍环 `k>0`)
|
||||
原始数据:`lab/skip_meter/*.json`
|
||||
|
||||
## 一、结论(按性价比)
|
||||
|
||||
| 措施 | 1ms late/s | 判定 |
|
||||
|---|---|---|
|
||||
| 基线(进程 Normal,原绑定) | ~10–25(漂) | 起点 |
|
||||
| 进程 RealTime | 均值更好(~8–12) | **有效,保留** |
|
||||
| 关 I210 上 VMware Bridge 等协议 | 有助(单独测过 4.1/s 一枪;混方差) | **有效,保留** |
|
||||
| 关中断裁决 ITR=0 | **70/s** | **有害,已还原** |
|
||||
| 关 EEE | **98/s** | **有害,已还原** |
|
||||
| ITR=50(非法)/固定低 ITR | 更差或无效 | **已还原** |
|
||||
| 关 IPv4/IPv6(本卡仅链路本地) | 无可测收益 | **已重新打开** |
|
||||
| **周期 2ms** | **0.06/s ≈ 不跳** | **主判据下的最优运行点** |
|
||||
| 周期 4ms | 0 | 同上,更宽裕 |
|
||||
|
||||
**主判据一句话**:
|
||||
- 必须 **1ms**:当前约 **10–17 late/s**(work p50≈366µs,余量只有 ~630µs,偶发 1ms 尖峰必跳)。
|
||||
- 允许 **2ms**:约 **0.06 late/s(16s 仅 1 拍)**,达成 500.00Hz,**不跳拍率优先时选这个**。
|
||||
|
||||
## 二、机理回顾(本轮再确认)
|
||||
|
||||
跳拍 = `pdo_worker` 自旋到绝对截止点时 `next_t <= now`,补 `k` 个周期。
|
||||
本轮形态:
|
||||
|
||||
1. **send 长尾**(`pcap_sendpacket` ≈1ms,rxc=1)— Npcap/驱动/DPC,不是从站。
|
||||
2. **rx 长尾**(等回包 ≈1ms,rxc≈200+)— 主机接收路径,不是总线被占。
|
||||
|
||||
从站 `load_perm≈336‰`、WKC 3/3、layout=dense,**没有被别的 EtherCAT 用户抢走**。
|
||||
|
||||
注意:本轮中后期 `rx p50` 从早晨的 ~90µs 变成 ~330µs(进程重启/绑定调整后未回到 90)。
|
||||
这压缩了 1ms 档余量,是 late 从 ~10 抬到 ~15–25 的主要原因之一;机理未完全闭合(怀疑桌面负载/DPC 形态漂移,非网卡高级属性)。
|
||||
|
||||
## 三、已落地的改动(可回滚)
|
||||
|
||||
### 3.1 保留
|
||||
|
||||
1. **`ecat_native` 启动尝试 RealTime**(`tools/ecat/ecat_native.c`)
|
||||
- 先提 `SE_INC_BASE_PRIORITY_NAME` 再 `SetPriorityClass(RT)`。
|
||||
- **实测自设仍会被钳成 High**(令牌无该特权时 API 仍可能返回成功/或被系统钳位)。
|
||||
- 因此 **`esp32_server.py` 在 Popen 后从外部 `SetPriorityClass(RT)`**(能粘住,已验证)。
|
||||
- 备份:`tools/ecat/build_native/ecat_native.pre-rt.exe`。
|
||||
|
||||
2. **I210 绑定裁剪**(仅「以太网 2」)
|
||||
- 已禁用:`vmware_bridge`, `ms_pacer`, `ms_lldp`, `ms_rspndr`, `ms_lltdio`, `ms_server`, `ms_msclient`
|
||||
- 仍启用:`INSECURE_NPCAP`(必须)、`ms_tcpip`/`ms_tcpip6`(已恢复)
|
||||
- 快照:`lab/skip_meter/nic_bind_*.json`
|
||||
|
||||
3. **I210 高级属性**:已还原为实验前原值(ITR=适应性,EEE 按系统,FlowControl Rx+Tx 等)。
|
||||
|
||||
### 3.2 运行态
|
||||
|
||||
- 周期:**已恢复 1ms**(与实验前一致,避免破坏你当前用法)
|
||||
- WKC 3/3、OP、loop=1、`pcore_mask=0x50`
|
||||
- 进程优先级:当前为 **RealTime**(外部设置)
|
||||
|
||||
## 四、回滚
|
||||
|
||||
```powershell
|
||||
# 1) 恢复 I210 协议绑定
|
||||
python scripts/skip_bind_experiment.py --restore
|
||||
python scripts/skip_bind_noip.py --restore # 若曾关过 IP,脚本会 Enable
|
||||
|
||||
# 2) 恢复 I210 高级属性(用任一 nic_before_*.json)
|
||||
python scripts/skip_nic_experiment.py --restore --snap lab/skip_meter/nic_before_1789580690.json
|
||||
|
||||
# 3) 还原旧主站二进制
|
||||
# 先停 ecat_native,再:
|
||||
Copy-Item tools\ecat\build_native\ecat_native.pre-rt.exe tools\ecat\build_native\ecat_native.exe -Force
|
||||
# 然后由 esp32_server 自动重拉,或手动跑 ecat_web.bat
|
||||
|
||||
# 4) 进程优先级改回 High(不必还原绑定时用)
|
||||
Get-Process ecat_native | ForEach-Object { $_.PriorityClass = 'High' }
|
||||
```
|
||||
|
||||
网卡高级属性原始快照:`lab/skip_meter/nic_before_1789580690.json`(含 ITR=65535 等)。
|
||||
|
||||
## 五、标定/实验脚本
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|---|---|
|
||||
| `scripts/skip_rate_meter.py` | 标准跳拍率标定(主判据) |
|
||||
| `scripts/skip_host_snapshot.py` | 主机/I210/进程现场快照 |
|
||||
| `scripts/skip_prio_ab.py` | High vs RealTime A/B |
|
||||
| `scripts/skip_quiet_periods.py` | 1/2/4ms 静默对比 |
|
||||
| `scripts/skip_nic_experiment.py` | 网卡高级属性实验 + `--restore` |
|
||||
| `scripts/skip_bind_experiment.py` | 绑定裁剪 + `--restore` |
|
||||
| `scripts/skip_restart_native.py` | 进程热重启对照 |
|
||||
|
||||
## 六、建议
|
||||
|
||||
1. **不跳拍优先 → 生产用 2ms**(本轮实测 16s 仅 1 拍 late)。若必须 1ms,接受 ~1%/拍 late,并保证 RealTime + 无 VMware 桥。
|
||||
2. **重启 `esp32_server` 后**,新代码会在拉起 `ecat_native` 时自动提 RealTime;当前若只重启 native 不重启 server,需再手动设一次。
|
||||
3. 勿再关 ITR/EEE 做“低延迟”优化——本台子上是反向的。
|
||||
4. `rx p50` 90→330µs 未闭合,下一轮可专查桌面 DPC(关 360/同步盘/任务管理器常刷)对照。
|
||||
|
||||
## 七、我在这轮里错过又改掉的判断
|
||||
|
||||
1. 「关中断裁决能降 send 长尾」→ 实测 70/s,立刻还原。
|
||||
2. 「关 EEE 能降功耗相关抖动」→ 实测 98/s,立刻还原。
|
||||
3. 「进程自设 RealTime 会粘住」→ 日志显示成功但 `PriorityClass=High`,改为启动方外部设置。
|
||||
4. 首版标定器拿 unix 时间去比 `now_s()` 拍环 → 恒 0 late,已修。
|
||||
+122
@@ -373,6 +373,128 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
background: rgba(252,191,63,.12);
|
||||
color: var(--gd32-hi);
|
||||
}
|
||||
|
||||
/* ---- APP ↔ BOOT 跳转控制台 ---- */
|
||||
.jump-role-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@media (min-width: 420px) {
|
||||
.jump-role-panel { grid-template-columns: 140px 1fr; align-items: stretch; }
|
||||
}
|
||||
.jump-role-badge {
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
gap: 2px; min-height: 92px; padding: 12px 10px;
|
||||
border-radius: 12px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); text-align: center;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.jump-role-badge:hover {
|
||||
border-color: var(--gd32);
|
||||
box-shadow: 0 0 0 3px rgba(252,191,63,.12);
|
||||
}
|
||||
.jump-role-badge:active { transform: scale(0.98); }
|
||||
.jump-role-badge .jump-role-label {
|
||||
font-family: var(--font-display); font-size: 10px; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--text-label);
|
||||
}
|
||||
.jump-role-badge strong {
|
||||
font-family: var(--font-display); font-size: 18px; font-weight: 700;
|
||||
letter-spacing: .04em; color: var(--text);
|
||||
}
|
||||
.jump-role-badge .jump-role-sw {
|
||||
font-family: var(--font-mono); font-size: 11px; color: var(--text-dim);
|
||||
}
|
||||
.jump-role-badge[data-role="app"] {
|
||||
border-color: rgba(47,216,193,.55);
|
||||
background: rgba(47,216,193,.1);
|
||||
box-shadow: 0 0 0 3px rgba(47,216,193,.08);
|
||||
}
|
||||
.jump-role-badge[data-role="app"] strong { color: var(--esp32); }
|
||||
.jump-role-badge[data-role="boot"] {
|
||||
border-color: rgba(252,191,63,.6);
|
||||
background: rgba(252,191,63,.12);
|
||||
box-shadow: 0 0 0 3px rgba(252,191,63,.1);
|
||||
}
|
||||
.jump-role-badge[data-role="boot"] strong { color: var(--gd32); }
|
||||
.jump-role-badge[data-role="unknown"] strong { color: var(--text-dim); font-size: 15px; }
|
||||
.jump-role-meta {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.jump-role-meta > div {
|
||||
display: flex; flex-direction: column; gap: 2px; min-width: 0;
|
||||
}
|
||||
.jump-role-meta span {
|
||||
color: var(--text-label); font-family: var(--font-display);
|
||||
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
|
||||
}
|
||||
.jump-role-meta b {
|
||||
font-family: var(--font-mono); font-weight: 600; color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.jump-action-row {
|
||||
display: grid; grid-template-columns: 88px 1fr; gap: 8px; margin-top: 4px;
|
||||
}
|
||||
.jump-btn-primary {
|
||||
min-height: 44px;
|
||||
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
|
||||
color: #2b1d02 !important; font-weight: 700;
|
||||
box-shadow: 0 4px 14px rgba(252,191,63,.28);
|
||||
}
|
||||
.jump-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
color: #2b1d02 !important;
|
||||
}
|
||||
.jump-btn-secondary { min-height: 44px; }
|
||||
.jump-btn-test {
|
||||
margin-top: 8px; min-height: 40px;
|
||||
border-color: rgba(252,191,63,.35); color: var(--gd32);
|
||||
background: transparent;
|
||||
}
|
||||
.jump-btn-test:hover:not(:disabled) {
|
||||
background: rgba(252,191,63,.1); border-color: var(--gd32);
|
||||
}
|
||||
.jump-btn-test:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.jump-history {
|
||||
margin-top: 12px; border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm); background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.jump-history-head {
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-display); font-size: 11px; letter-spacing: .1em;
|
||||
text-transform: uppercase; color: var(--text-label);
|
||||
}
|
||||
.jump-history-list {
|
||||
max-height: 148px; overflow-y: auto; padding: 6px 0;
|
||||
}
|
||||
.jump-history-empty {
|
||||
padding: 14px 12px; font-size: 12px; color: var(--text-dim); text-align: center;
|
||||
}
|
||||
.jump-history-item {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 6px 12px; font-size: 12px; line-height: 1.45;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.jump-history-item + .jump-history-item { border-top: 1px dashed var(--border-subtle); }
|
||||
.jump-history-item .jh-tag {
|
||||
flex-shrink: 0; min-width: 42px; text-align: center;
|
||||
padding: 1px 6px; border-radius: 999px; font-size: 10px; font-weight: 700;
|
||||
background: var(--bg-input); color: var(--text-dim);
|
||||
}
|
||||
.jump-history-item.ok .jh-tag { background: rgba(61,220,142,.18); color: var(--can-hi); }
|
||||
.jump-history-item.err .jh-tag { background: rgba(255,107,107,.16); color: var(--red); }
|
||||
.jump-history-item.info .jh-tag { background: rgba(47,216,193,.14); color: var(--esp32); }
|
||||
.jump-history-item .jh-msg { flex: 1; color: var(--text); word-break: break-all; }
|
||||
.jump-history-item .jh-time {
|
||||
flex-shrink: 0; color: var(--text-dim); font-size: 10px;
|
||||
}
|
||||
|
||||
/* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
|
||||
#uploadGd32Btn {
|
||||
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
feature: ecat-rx-stuck
|
||||
status: in-progress
|
||||
updated: 2026-09-16
|
||||
branch: main
|
||||
commits:
|
||||
---
|
||||
|
||||
# EtherCAT 从机帧接收异常(输入全 0 / 发帧后才流动)
|
||||
|
||||
## Report
|
||||
|
||||
真机验收(2026-09-16,主站 kick 已上线):`motor/start` **不发业务帧** 即
|
||||
`wkc=3/3`、`rxraw.layout=dense`、magic=0xA5、gseq=429、motors=1、wr=0。
|
||||
日志:`OP 后输入已流动(magic=1 diag=1 rx 431→431),踢帧 8 拍`。
|
||||
|
||||
## [S1] Problem
|
||||
|
||||
EtherCAT 进入 OP 后,主站 `/api/motor/pdoin` 与 `/api/motor/rxraw` **整包 600B 全 0**:
|
||||
无 dense magic(0xA5)、gseq=0、桥 rx_count=0、段 5 诊断 seq=0、`app/load.valid=false`。
|
||||
现场 `wkc=2/3`(期望 3)。用户一旦经 `/api/motor/send` 发出任意帧,输入立刻流动:
|
||||
`wkc=3/3`、magic=A5、电机槽/桥状态出现。之后 `wr` 楔死恢复次数持续上涨。
|
||||
|
||||
真机复现(2026-09-16,GD32 APP 1.6.41 + 主站 native):
|
||||
1. `motor/start` → 日志「OP 后输入无流动…重走 INIT→OP」→ 仍全 0
|
||||
2. `motor/send` 一帧 → 同一秒内 pdoin 出现 dense 布局与真实电机状态
|
||||
3. SPI `/spi/speedtest` 正常(ESP32↔GD32 链路本身无病)
|
||||
|
||||
## [S2] Design
|
||||
|
||||
### 根因(主)
|
||||
|
||||
从站 SSC 在 **SM-Synchron** 下应用与输入映射靠 SM2 事件(PDI_Isr)驱动;
|
||||
MainLoop 的 free-run 分支在 `bEcatFirstOutputsReceived==TRUE` 后整段跳过。
|
||||
主站 idle 输出 `seq=0` 时,从站 `processOutput`/冷启动路径迟迟不把输入 PDO
|
||||
真正填进 ESC——`buildInput`+`fillDiagTail` 依赖 `EcatPdo_Process()` 被调用。
|
||||
主站一旦发送(seq 沿变化)→ outputProbe 全量读 → 冷启动/应用路径苏醒 → 输入开始流动。
|
||||
|
||||
### 次因
|
||||
|
||||
1. **主站 OP 活性判据过窄**(`do_op_start`):只看两次 `pump_cycles` 之间
|
||||
`bridge.rx_count` 是否变化。无 CAN 流量时 rx_count 恒 0 → 必判「无流动」→
|
||||
无意义重走 INIT→OP;有流量时 12ms 窗口也可能采不到 → 同样误判。
|
||||
2. **从站 1.6.33 新鲜度救援只写不填**(`EcatDbg_OpInputFreshness`):输入冻结时
|
||||
只调 `PDO_InputMapping()`,从空的 `s_inBuf` 拷 0 写 ESC,等于把「冻结」固化成「全 0」。
|
||||
3. 从站 1.6.41 warm-jump 跳过 RST# 的路径本身可接受(BYTE_TEST 门),但与上述
|
||||
「应用未醒」叠加后,首帧前输入窗口长期全 0。
|
||||
|
||||
### 修复契约
|
||||
|
||||
| 侧 | 行为 |
|
||||
|----|------|
|
||||
| 主站 | 进 OP 后、起循环前:用 **dlc=0 + seq 沿** 的空输出连打数拍,制造 outputProbe 边沿、不发 CAN。活性判据改为「输入含 dense magic 或 diag seq 非 0 或 rx_count 变化」。 |
|
||||
| 从站 | `EcatDbg_OpInputFreshness` 强制刷新前先 `EcatPdo_Process()`,再 `PDO_InputMapping()`。 |
|
||||
| 从站版本 | patch → 1.6.42(仅救援路径,无布局变更,可与主站独立升级)。 |
|
||||
|
||||
## [S3] Out of Scope
|
||||
|
||||
- 不改 PDO 线格式 / SII / 600B 布局
|
||||
- 不改 SPI 协议(0xE8 撕裂已有 ESP32 侧未提交修复,另案)
|
||||
- 不回退 1.6.41 warm-jump 探针策略(BYTE_TEST 门保留)
|
||||
- 不改电机/CAN 桥业务语义
|
||||
|
||||
## Tasks
|
||||
- [x] T1: 主站 OP 后 seq 沿踢帧 + 活性判据加宽 — acceptance: 不发业务帧时 pdoin 出现 0xA5 且 gseq 可前进 (covers: S2)
|
||||
- [x] T2: 从站新鲜度救援先 Process 再 InputMapping — acceptance: PDI 停摆时主站读到非全 0 输入 (covers: S2)
|
||||
- [x] T3: 真机复验 start 后不 send 即有输入 — acceptance: motor/start 后 2s 内 rxraw.layout=dense (covers: S2; depends: T1)
|
||||
@@ -178,6 +178,35 @@ g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注
|
||||
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
|
||||
|
||||
|
||||
def _set_ecat_realtime(pid: int) -> bool:
|
||||
"""把 ecat_native 提到 RealTime 进程优先级。
|
||||
|
||||
native 自己 SetPriorityClass(RT) 会被系统静默钳成 High(令牌无
|
||||
SeIncreaseBasePriorityPrivilege)。由本服务(启动方)从外部设置才能粘住。
|
||||
pdo 线程已是 TIME_CRITICAL:进程类 High→线程 15,RealTime→线程 31,
|
||||
后者才扛得住桌面 DPC/调度抢占。实测 1ms 档 late ~12/s → 个位数。
|
||||
失败返回 False,不影响启动。
|
||||
"""
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
PROCESS_SET_INFORMATION = 0x0200
|
||||
REALTIME_PRIORITY_CLASS = 0x00000100
|
||||
HIGH_PRIORITY_CLASS = 0x00000080
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
h = k32.OpenProcess(PROCESS_SET_INFORMATION, False, int(pid))
|
||||
if not h:
|
||||
return False
|
||||
try:
|
||||
if k32.SetPriorityClass(h, REALTIME_PRIORITY_CLASS):
|
||||
return True
|
||||
return bool(k32.SetPriorityClass(h, HIGH_PRIORITY_CLASS))
|
||||
finally:
|
||||
k32.CloseHandle(h)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class _GuiApi:
|
||||
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
|
||||
|
||||
@@ -342,6 +371,10 @@ def _ecat_autostart():
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
|
||||
if _set_ecat_realtime(g_ecat_child.pid):
|
||||
logs.append(f"EtherCAT 进程优先级已提到 RealTime(pid {g_ecat_child.pid})")
|
||||
else:
|
||||
logs.append("EtherCAT 进程 RealTime 提权失败(保持默认/High, 可手动设)")
|
||||
except OSError as e:
|
||||
return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ document.addEventListener('click', function (ev) {
|
||||
case 'ble-pick-file': var fi = document.getElementById('firmwareFile'); if (fi) fi.click(); break;
|
||||
case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break;
|
||||
case 'start-upload': startUpload(arg); break;
|
||||
case 'boot-ecat-probe': bootEcatProbe(true); break;
|
||||
case 'boot-ecat-probe': if (!window.bootEcatBusy) bootEcatProbe(true); break;
|
||||
case 'boot-ecat-pick': bootEcatPick(); break;
|
||||
case 'boot-ecat-run': bootEcatFlash(); break;
|
||||
case 'boot-ecat-abort': bootEcatAbort(); break;
|
||||
|
||||
+14
-3
@@ -58,6 +58,7 @@ var statusPollInFlight = false; // 是否正在请求 /system/status,防止
|
||||
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
|
||||
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
|
||||
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
|
||||
var networkDiagDone = false; // 本轮离线期间是否已做过网关诊断(防每秒刷屏)
|
||||
|
||||
/**
|
||||
* 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后;
|
||||
@@ -101,6 +102,7 @@ function scheduleLogsPoll(delayMs) {
|
||||
|
||||
export function startPolling() {
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
statusPollIntervalMs = 1000;
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
@@ -112,6 +114,7 @@ export function pausePollingForUpload() {
|
||||
window.pollingPausedForUpload = true;
|
||||
uploadPollChip = window.activeUploadChip || window.flashTarget || null;
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
// ★ 上传大固件期间必须停掉 status/logs 轮询。
|
||||
// httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉
|
||||
// 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完),
|
||||
@@ -135,6 +138,7 @@ export function resumePollingAfterUpload() {
|
||||
window.pollingPausedForUpload = false;
|
||||
uploadPollChip = null;
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
statusPollIntervalMs = 1000;
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
|
||||
@@ -201,6 +205,7 @@ export async function pollStatus() {
|
||||
// 恢复:之前有失败计数,说明设备曾离线,现在恢复在线
|
||||
if (window.consecutivePollFailures > 0) {
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
|
||||
showToast('设备已恢复在线', 'success');
|
||||
// 仅清理终态残留。禁止对活跃会话 otaSessionReset:
|
||||
@@ -278,13 +283,17 @@ export async function pollStatus() {
|
||||
var otaOngoing = window.pollingPausedForUpload ||
|
||||
sessionActive || sessionFailed ||
|
||||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy;
|
||||
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) {
|
||||
/* EtherCAT 刷写/链路时不跑 WiFi 网关诊断:设备可能根本不在 WiFi 上,
|
||||
* 反复弹「网关可达;设备不响应」只会干扰。仍走下面的「未连接」UI。 */
|
||||
var ecatActive = window.connectionMode === 'ecat' || !!window.bootEcatBusy;
|
||||
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing &&
|
||||
!ecatActive && !networkDiagDone) {
|
||||
networkDiagDone = true;
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
addLog('W', '诊断', '诊断结果: ' + diag);
|
||||
updateConnectionUi(false, '诊断中');
|
||||
window.applyDisconnectedRuntimeState();
|
||||
addLog('W', '诊断', '诊断结果: ' + diag);
|
||||
}
|
||||
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
|
||||
if (sessionFailed) {
|
||||
@@ -353,6 +362,7 @@ document.addEventListener('visibilitychange', function () {
|
||||
pageVisible = !document.hidden;
|
||||
if (!pageVisible) return;
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
// 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排
|
||||
if (statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
@@ -419,6 +429,7 @@ setTimeout(function () {
|
||||
if (ipInput) {
|
||||
ipInput.addEventListener('change', function () {
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
|
||||
if (!statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
|
||||
+87
-29
@@ -124,22 +124,42 @@ function showLog(res) {
|
||||
/**
|
||||
* @description 按"有没有已选 GD32 镜像 + 有没有任务在跑"重算按钮可用性。
|
||||
* 幂等,供 file-handle / console-mode / upload 各处调用;不缓存判据、不复制 arm 判断。
|
||||
* 交接按钮文案由最后一次 probe/reboot 结果驱动(window.bootEcatIsBoot)。
|
||||
*/
|
||||
export function refreshBootEcatUi() {
|
||||
var probeBtn = el('bootEcatProbeBtn');
|
||||
var runBtn = el('bootEcatRunBtn');
|
||||
var abortBtn = el('bootEcatAbortBtn');
|
||||
var rebootBtn = el('bootEcatRebootBtn');
|
||||
/* 别的通道在刷就互斥:三条传输共用同一块 flash,没有并发可言 */
|
||||
var otherBusy = !!window.isUploading ||
|
||||
!!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading());
|
||||
var hasImg = !!window.gd32File;
|
||||
if (probeBtn) probeBtn.disabled = window.bootEcatBusy;
|
||||
if (rebootBtn) rebootBtn.disabled = window.bootEcatBusy || otherBusy;
|
||||
if (rebootBtn) {
|
||||
rebootBtn.disabled = window.bootEcatBusy || otherBusy;
|
||||
if (!window.bootEcatBusy) {
|
||||
var isBoot = window.bootEcatIsBoot === true;
|
||||
rebootBtn.textContent = isBoot ? '跳回应用' : '让应用回引导程序';
|
||||
rebootBtn.title = isBoot
|
||||
? '写 BOOT 的 CoE 0x2503:00=1,跳回 APP(BOOT ≥1.1.0.20)'
|
||||
: '写 APP 的 CoE 0x2502:00=1,让它复位进引导程序';
|
||||
}
|
||||
}
|
||||
if (runBtn) runBtn.disabled = window.bootEcatBusy || otherBusy || !hasImg;
|
||||
if (abortBtn) abortBtn.disabled = window.bootEcatBusy;
|
||||
}
|
||||
|
||||
function setRoleBadge(role, sw) {
|
||||
var badge = el('bootEcatRoleBadge');
|
||||
var txt = el('bootEcatRoleTxt');
|
||||
var swEl = el('bootEcatRoleSw');
|
||||
if (!badge) return;
|
||||
var r = role === 'boot' ? 'boot' : role === 'app' ? 'app' : 'unknown';
|
||||
badge.setAttribute('data-role', r);
|
||||
if (txt) {
|
||||
txt.textContent = r === 'boot' ? 'BOOT' : r === 'app' ? 'APP' : '未探测';
|
||||
}
|
||||
if (swEl) swEl.textContent = sw || (r === 'unknown' ? '—' : '版本未知');
|
||||
}
|
||||
|
||||
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */
|
||||
export function bootEcatPick() {
|
||||
var input = el('fileInputGd32');
|
||||
@@ -156,26 +176,23 @@ export async function bootEcatProbe(verbose) {
|
||||
try {
|
||||
r = await api({ op: 'probe' });
|
||||
} catch (e) {
|
||||
setTxt('bootEcatWho', '后端无响应');
|
||||
setEcatConn('err', '后端无响应');
|
||||
setRoleBadge('unknown', null);
|
||||
if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e);
|
||||
return null;
|
||||
}
|
||||
var who = r.boot ? '引导程序' : (r.name ? '应用' : '读不到');
|
||||
var ver = r.sw || '—';
|
||||
setTxt('bootEcatWho', who);
|
||||
setRoleBadge(r.boot ? 'boot' : (r.name ? 'app' : 'unknown'), r.sw || null);
|
||||
setTxt('bootEcatName', r.name || '—');
|
||||
setTxt('bootEcatSw', ver);
|
||||
setTxt('bootEcatArm', r.armed ? '允许' : '不允许');
|
||||
setTxt('bootEcatRecv', bytes(r.slave_recv || 0));
|
||||
if (r.boot) setEcatConn('ok', '引导程序 ' + ver + ' · 可以刷写');
|
||||
else if (r.name) setEcatConn('warn', '应用 ' + ver + ' · 刷写会自动交接回 BOOT');
|
||||
else setEcatConn('err', '邮箱无回应');
|
||||
setTxt('bootEcatRecv', (r.srv_recv != null ? bytes(r.srv_recv) : '—') + ' / ' + bytes(r.slave_recv || 0));
|
||||
window.bootEcatIsBoot = !!r.boot;
|
||||
refreshBootEcatUi();
|
||||
if (verbose) {
|
||||
window.addLog('I', 'ECAT刷写',
|
||||
'0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' +
|
||||
(r.boot ? '是引导程序,可以刷写'
|
||||
: (r.name ? '是应用,刷写链里第 3 步会写它的 0x2502 让它回 BOOT(1.6.34 以前的 APP 没这个对象,只能走 SPI 0xB0 / CAN 0x320)'
|
||||
(r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)'
|
||||
: (r.name ? '是应用,点主按钮写 0x2502 进 BOOT'
|
||||
: '邮箱没回应,多半是老 BOOT 或栈没起来')));
|
||||
}
|
||||
return r;
|
||||
@@ -243,42 +260,71 @@ async function waitBootOnBus(maxTries, gapMs) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 单独一次「让应用回引导程序」:写 APP 的 CoE 0x2502:00 = 1。
|
||||
* 刷写链里本来就带这一步,这个按钮是给"只想把板子停在 BOOT 上等人查"的场合 ——
|
||||
* 交接一个字节 flash 都不写,代价只是从站重启一次、停在 BOOT 的 10s 窗口里等。
|
||||
* 跑着老 APP(<1.6.34)的机器字典里没有 0x2502,主站会把从站的 SDO abort 码原样带回来,
|
||||
* 日志里直接就能看出该退回 SPI 0xB0 / CAN 0x320。
|
||||
* @description 交接按钮总入口:按对端身份自动选方向。
|
||||
* - 已在 BOOT → op=jump_app(写 0x2503)
|
||||
* - 在 APP / 未知 → op=reboot(写 0x2502)
|
||||
* 刷写链里本来就带 reboot 这一步;本按钮给"只想手动来回切换"的场合。
|
||||
*/
|
||||
export async function bootEcatReboot() {
|
||||
if (window.bootEcatBusy) return;
|
||||
window.bootEcatBusy = true;
|
||||
refreshBootEcatUi();
|
||||
var btn = el('bootEcatRebootBtn');
|
||||
if (btn) btn.textContent = '交接中…';
|
||||
setEcatMsg('正在让应用回引导程序…');
|
||||
var isBoot = window.bootEcatIsBoot === true;
|
||||
if (btn) btn.textContent = isBoot ? '跳转中…' : '交接中…';
|
||||
setEcatMsg(isBoot ? '正在让引导程序跳回应用…' : '正在让应用回引导程序…');
|
||||
try {
|
||||
if (!await ensureBusOpen()) {
|
||||
setEcatStatus('err', '总线没打开');
|
||||
setEcatMsg('主站没能打开总线(见日志)');
|
||||
return;
|
||||
}
|
||||
var r = await api({ op: 'reboot' });
|
||||
/* 先用本次动作的方向;若 probe 结果与缓存不符,会话内纠正 */
|
||||
var op = window.bootEcatIsBoot === true ? 'jump_app' : 'reboot';
|
||||
var r = await api({ op: op });
|
||||
showLog(r);
|
||||
if (!r || r.ok !== true) {
|
||||
setEcatStatus('err', '交接被拒');
|
||||
setEcatMsg('交接没被受理(见日志里从站的 abort 码)');
|
||||
setEcatStatus('err', op === 'jump_app' ? '跳回被拒' : '交接被拒');
|
||||
setEcatMsg(op === 'jump_app'
|
||||
? '跳回没被受理(BOOT 需 ≥1.1.0.20;见日志 abort 码)'
|
||||
: '交接没被受理(见日志里从站的 abort 码)');
|
||||
await bootEcatProbe(false);
|
||||
return;
|
||||
}
|
||||
if (r.already) {
|
||||
setEcatStatus('ok', '对面本来就是引导程序');
|
||||
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
|
||||
if (op === 'jump_app') {
|
||||
window.bootEcatIsBoot = false;
|
||||
setEcatStatus('ok', '对面本来就是应用');
|
||||
setEcatMsg('从站已在 APP,无需跳回');
|
||||
} else {
|
||||
window.bootEcatIsBoot = true;
|
||||
setEcatStatus('ok', '对面本来就是引导程序');
|
||||
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
|
||||
}
|
||||
refreshBootEcatUi();
|
||||
await bootEcatProbe(true);
|
||||
return;
|
||||
}
|
||||
if (op === 'jump_app') {
|
||||
setEcatMsg('跳回已受理,等 APP 上总线…');
|
||||
var a = await waitAppOnBus(10, 800);
|
||||
if (a && a.name && !a.boot) {
|
||||
window.bootEcatIsBoot = false;
|
||||
setEcatStatus('ok', '已回到应用');
|
||||
setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + ')');
|
||||
} else {
|
||||
setEcatStatus('warn', '跳回后没等到 APP');
|
||||
setEcatMsg('写了 0x2503 但没等到应用 —— 看串口,或 A 区 CRC 不过');
|
||||
window.addLog('E', 'ECAT刷写',
|
||||
'跳回请求已受理却等不到 APP 应答 —— 确认 BOOT ≥1.1.0.20 且 A 区镜像完整');
|
||||
}
|
||||
refreshBootEcatUi();
|
||||
return;
|
||||
}
|
||||
setEcatMsg('交接已受理,等 BOOT 上总线…');
|
||||
var b = await waitBootOnBus(10, 800);
|
||||
if (b && b.boot) {
|
||||
window.bootEcatIsBoot = true;
|
||||
setEcatStatus('ok', '已进引导程序');
|
||||
setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写');
|
||||
} else {
|
||||
@@ -293,15 +339,27 @@ export async function bootEcatReboot() {
|
||||
}
|
||||
} catch (e) {
|
||||
setEcatStatus('err', '请求异常');
|
||||
setEcatMsg('交接请求异常');
|
||||
window.addLog('E', 'ECAT刷写', '交接请求异常:' + e);
|
||||
setEcatMsg('交接/跳回请求异常');
|
||||
window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e);
|
||||
} finally {
|
||||
window.bootEcatBusy = false;
|
||||
if (btn) btn.textContent = '让应用回引导程序';
|
||||
refreshBootEcatUi();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 跳回之后等 APP 真的回到总线(与 waitBootOnBus 对称)。
|
||||
*/
|
||||
async function waitAppOnBus(maxTries, gapMs) {
|
||||
var last = null;
|
||||
for (var k = 0; k < maxTries; k++) {
|
||||
last = await bootEcatProbe(false);
|
||||
if (last && last.name && !last.boot) return last;
|
||||
await new Promise(function (res) { setTimeout(res, gapMs); });
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** @description 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */
|
||||
export async function bootEcatAbort() {
|
||||
if (window.bootEcatBusy) return;
|
||||
|
||||
+21
-28
@@ -13,7 +13,7 @@
|
||||
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
|
||||
<link rel="stylesheet" href="../css/layout.css?v=targettabs20260914g">
|
||||
<link rel="stylesheet" href="../css/layout.css?v=jumpconsole20260916b">
|
||||
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
|
||||
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
|
||||
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
|
||||
@@ -145,34 +145,27 @@
|
||||
<div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- EtherCAT 模式:这条链路没有 IP / 配对概念,"连接对象"就是本机 EtherCAT 主站。
|
||||
总线的打开/关闭由主站侧(ecat_native)负责,本页只在下面几个读数里反映现状。 -->
|
||||
<!-- EtherCAT 模式:APP ↔ BOOT 角色徽章 + 一键切换 -->
|
||||
<div id="ecatModeCards" style="display:none;">
|
||||
<div class="ecat-mode-note">
|
||||
主站经引导程序邮箱刷写 GD32 APP,<b>ESP32 离线不影响</b>
|
||||
</div>
|
||||
<div class="form-label">总线对面</div>
|
||||
<div class="conn-indicator" id="bootEcatConn" style="margin-bottom:10px;">
|
||||
<span class="light" id="bootEcatConnLight"></span>
|
||||
<span id="bootEcatConnTxt">未探测</span>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-block" id="bootEcatProbeBtn" data-action="boot-ecat-probe">探测对面是谁</button>
|
||||
<button class="btn btn-outline btn-block" id="bootEcatRebootBtn" data-action="boot-ecat-reboot"
|
||||
style="margin-top:8px;" title="写 APP 的 CoE 0x2502:00=1,让它复位进引导程序">让应用回引导程序</button>
|
||||
<div class="file-info-grid" style="margin-top:10px;">
|
||||
<div class="file-meta-item"><span>对面是谁</span><b id="bootEcatWho">--</b></div>
|
||||
<div class="file-meta-item"><span>从站是否放行</span><b id="bootEcatArm">--</b></div>
|
||||
</div>
|
||||
<div class="file-info-grid" style="margin-top:8px;">
|
||||
<div class="file-meta-item"><span>主站已缓存</span><b id="bootEcatSrv">0 B</b></div>
|
||||
<div class="file-meta-item"><span>从站已写 flash</span><b id="bootEcatRecv">--</b></div>
|
||||
</div>
|
||||
<div class="file-meta-item" style="margin-top:8px;">
|
||||
<span>设备名(0x1008)</span><b id="bootEcatName" style="word-break:break-all;">—</b>
|
||||
</div>
|
||||
<div class="file-meta-item">
|
||||
<span>软件版本(0x100A)</span><b id="bootEcatSw">—</b>
|
||||
<div class="jump-role-panel">
|
||||
<div class="jump-role-badge" id="bootEcatRoleBadge" data-role="unknown"
|
||||
data-action="boot-ecat-probe" role="button" tabindex="0"
|
||||
title="点击重新探测对端角色(APP / BOOT)">
|
||||
<span class="jump-role-label">当前角色 · 点击探测</span>
|
||||
<strong id="bootEcatRoleTxt">未探测</strong>
|
||||
<span class="jump-role-sw" id="bootEcatRoleSw">—</span>
|
||||
</div>
|
||||
<div class="jump-role-meta">
|
||||
<div><span>设备名 0x1008</span><b id="bootEcatName">—</b></div>
|
||||
<div><span>软件版本 0x100A</span><b id="bootEcatSw">—</b></div>
|
||||
<div><span>从站放行</span><b id="bootEcatArm">—</b></div>
|
||||
<div><span>主站缓存 / 已写 flash</span><b id="bootEcatRecv">—</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block jump-btn-primary" id="bootEcatRebootBtn"
|
||||
data-action="boot-ecat-reboot"
|
||||
title="按对端身份自动选方向:APP→0x2502 进 BOOT;BOOT→0x2503 跳回 APP">让应用回引导程序</button>
|
||||
</div>
|
||||
<!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 -->
|
||||
</div>
|
||||
@@ -569,7 +562,7 @@
|
||||
必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。
|
||||
与 tabs.html / motor.html 并存时连接数>1,互不影响。 -->
|
||||
<script src="../js/common/app-lease.js"></script>
|
||||
<script type="module" src="../js/app.js?v=20260916e"></script>
|
||||
<script type="module" src="../js/app.js?v=20260916h"></script>
|
||||
<script type="module" src="../js/ota/console-mode.js?v=20260916d"></script>
|
||||
<script type="module" src="../js/ble_ota.js?v=20260901d"></script>
|
||||
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""模拟页面 pollStatus:5s 超时算失败,连续失败即离线。"""
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
URL = "http://192.168.31.127/system/status"
|
||||
TIMEOUT = 5.0
|
||||
POLL = 1.0
|
||||
WINDOW = 75
|
||||
|
||||
|
||||
def probe():
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(URL, timeout=TIMEOUT) as r:
|
||||
r.read(64)
|
||||
return True, "HTTP %s" % r.status, time.time() - t0
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "HTTP %s" % e.code, time.time() - t0
|
||||
except Exception as e:
|
||||
return False, "%s: %s" % (type(e).__name__, str(e)[:80]), time.time() - t0
|
||||
|
||||
|
||||
def main():
|
||||
print("=== monitor start (mimic pollStatus) ===", flush=True)
|
||||
prev = None
|
||||
fails = 0
|
||||
t_end = time.time() + WINDOW
|
||||
while time.time() < t_end:
|
||||
ok, msg, dt = probe()
|
||||
now = time.strftime("%H:%M:%S")
|
||||
if ok:
|
||||
fails = 0
|
||||
state = "ONLINE"
|
||||
else:
|
||||
fails += 1
|
||||
state = "FAIL#%d" % fails + (" -> OFFLINE" if fails >= 1 else "")
|
||||
mark = " *" if state != prev else ""
|
||||
print("[%s] %s (%s, %.0fms)%s" % (now, state, msg, dt * 1000, mark), flush=True)
|
||||
if state != prev:
|
||||
prev = state
|
||||
time.sleep(POLL)
|
||||
print("=== monitor end ===", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""在「以太网 2」(I210) 上关闭非 EtherCAT 绑定,测跳拍率;可 --restore 还原。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
|
||||
def ps(cmd, timeout=60):
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
|
||||
)
|
||||
return (r.stdout or "").strip(), (r.stderr or "").strip()
|
||||
|
||||
|
||||
def snapshot():
|
||||
out, err = ps(
|
||||
f"Get-NetAdapterBinding -Name '{NIC}' | "
|
||||
"Select-Object DisplayName,ComponentID,Enabled | ConvertTo-Json -Compress"
|
||||
)
|
||||
data = json.loads(out)
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def disable(component_ids):
|
||||
logs = []
|
||||
for cid in component_ids:
|
||||
out, err = ps(
|
||||
f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
|
||||
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
|
||||
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
|
||||
)
|
||||
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
|
||||
return logs
|
||||
|
||||
|
||||
def enable(component_ids):
|
||||
logs = []
|
||||
for cid in component_ids:
|
||||
out, err = ps(
|
||||
f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
|
||||
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
|
||||
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
|
||||
)
|
||||
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
|
||||
return logs
|
||||
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(subprocess.check_output(
|
||||
["powershell", "-NoProfile", "-Command", "(Get-Process ecat_native).Id"],
|
||||
text=True).strip())
|
||||
subprocess.check_output(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
|
||||
text=True)
|
||||
return p
|
||||
|
||||
|
||||
def meter(tag, secs=18):
|
||||
r = subprocess.run(
|
||||
[sys.executable, os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json"))
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
|
||||
# Offloads that are useless/dangerous for raw EtherCAT on a dedicated NIC
|
||||
OFFLOADS = [
|
||||
"vmware_bridge", # VMware Bridge — intercepts/injects, classic ECAT killer
|
||||
"ms_pacer", # QoS packet scheduler
|
||||
"ms_lldp", # LLDP
|
||||
"ms_rspndr", # LLTD responder
|
||||
"ms_lltdio", # LLTD mapper
|
||||
"ms_server", # file/printer sharing
|
||||
"ms_msclient", # Microsoft network client
|
||||
# keep ms_tcpip / ms_tcpip6 for now in case anything uses IP on this NIC
|
||||
# Npcap must stay: INSECURE_NPCAP
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(LAB, exist_ok=True)
|
||||
snap = snapshot()
|
||||
snap_path = os.path.join(LAB, f"nic_bind_{int(time.time())}.json")
|
||||
json.dump(snap, open(snap_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("bind snapshot", snap_path)
|
||||
|
||||
if "--restore" in sys.argv:
|
||||
print(enable(OFFLOADS))
|
||||
return 0
|
||||
|
||||
print("disable", OFFLOADS)
|
||||
print(json.dumps(disable(OFFLOADS), ensure_ascii=False, indent=2))
|
||||
print("set RealTime")
|
||||
print("pid", set_prio("RealTime"))
|
||||
# binding change usually doesn't reset link; brief settle
|
||||
time.sleep(3)
|
||||
data = meter("bind_off", 18)
|
||||
row = {
|
||||
"late_per_s": data["late_per_s"],
|
||||
"skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"],
|
||||
"achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"],
|
||||
"send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": data["window_cyc_us"]["work"]["p90"],
|
||||
}
|
||||
print("ROW", json.dumps(row, ensure_ascii=False, indent=2))
|
||||
out = os.path.join(LAB, f"summary_bind_off_{int(time.time())}.json")
|
||||
json.dump({"row": row, "disabled": OFFLOADS, "snap": snap_path},
|
||||
open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, sys, time
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
def ps(cmd):
|
||||
r = subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
return r.strip()
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass")
|
||||
return p
|
||||
|
||||
def meter(tag, secs=18):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def main():
|
||||
restore = "--restore" in sys.argv
|
||||
ids = ["ms_tcpip", "ms_tcpip6"]
|
||||
for cid in ids:
|
||||
if restore:
|
||||
ps(f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
|
||||
else:
|
||||
ps(f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
|
||||
print(ps(f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"))
|
||||
if restore:
|
||||
return
|
||||
print("pid", set_prio("RealTime"))
|
||||
time.sleep(3)
|
||||
data = meter("bind_noip", 18)
|
||||
row = {
|
||||
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"], "achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"],
|
||||
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
}
|
||||
print(json.dumps(row, ensure_ascii=False, indent=2))
|
||||
out = os.path.join(LAB, f"summary_bind_noip_{int(time.time())}.json")
|
||||
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", out)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.25ms 下 A/B:静默 vs 模拟 spi_test 前端轮询。"""
|
||||
import json, time, urllib.request, os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
def post(p, b=None, timeout=5):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
|
||||
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def snap():
|
||||
st = post("/api/motor/state")
|
||||
dbg = post("/api/motor/dbg")
|
||||
return {
|
||||
"cycles": st.get("cycles") or 0,
|
||||
"skip_total": dbg.get("skip_total") or 0,
|
||||
"gseq": (st.get("head") or {}).get("global_seq") or 0,
|
||||
"period": st.get("period_ms"),
|
||||
"wkc": st.get("wkc"),
|
||||
"pcore": st.get("pcore_mask"),
|
||||
"cyc_us": st.get("cyc_us"),
|
||||
}
|
||||
|
||||
def rate(a, b, dt):
|
||||
dc = b["cycles"] - a["cycles"]
|
||||
ds = (b["skip_total"] - a["skip_total"]) # may wrap? long long, no
|
||||
return {
|
||||
"dt": round(dt, 3),
|
||||
"pdo_hz": round(dc / dt, 1) if dt else None,
|
||||
"late_per_s": round(ds / dt, 2) if dt else None,
|
||||
"skip_frac": round(100.0 * ds / dc, 2) if dc else None,
|
||||
"gseq_per_s": round(((b["gseq"] - a["gseq"]) & 0xFFFFFFFF) / dt, 1) if dt else None,
|
||||
"dcyc": dc, "dskip": ds,
|
||||
}
|
||||
|
||||
def arm_period(ms):
|
||||
try:
|
||||
post("/api/motor/stop")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/period", {"period_ms": ms})
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/start")
|
||||
time.sleep(0.5)
|
||||
|
||||
print("ensure 0.25ms")
|
||||
arm_period(0.25)
|
||||
time.sleep(3)
|
||||
|
||||
# --- A: fully quiet ---
|
||||
a0 = snap()
|
||||
t0 = time.time()
|
||||
# no HTTP
|
||||
time.sleep(10)
|
||||
a1 = snap()
|
||||
A = rate(a0, a1, time.time() - t0)
|
||||
print("QUIET", json.dumps(A, ensure_ascii=False))
|
||||
print(" cyc_us", json.dumps(a1.get("cyc_us"), ensure_ascii=False))
|
||||
|
||||
# --- B: hammer like spi_test (state+load+dbg+rxraw) ---
|
||||
time.sleep(1)
|
||||
b0 = snap()
|
||||
t0 = time.time()
|
||||
n = 0
|
||||
while time.time() - t0 < 10:
|
||||
try:
|
||||
post("/api/motor/state")
|
||||
post("/api/app/load")
|
||||
post("/api/motor/dbg")
|
||||
post("/api/motor/rxraw")
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print("hammer err", e)
|
||||
break
|
||||
time.sleep(0.05) # ~20Hz full set, similar to busy panel
|
||||
b1 = snap()
|
||||
B = rate(b0, b1, time.time() - t0)
|
||||
print("HAMMER", json.dumps(B, ensure_ascii=False), "polls", n)
|
||||
print(" cyc_us", json.dumps(b1.get("cyc_us"), ensure_ascii=False))
|
||||
|
||||
# --- C: only motor/state at max (no dbg/rxraw) ---
|
||||
time.sleep(1)
|
||||
c0 = snap()
|
||||
t0 = time.time()
|
||||
n = 0
|
||||
while time.time() - t0 < 10:
|
||||
try:
|
||||
post("/api/motor/state")
|
||||
n += 1
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
c1 = snap()
|
||||
C = rate(c0, c1, time.time() - t0)
|
||||
print("STATE_ONLY", json.dumps(C, ensure_ascii=False), "polls", n)
|
||||
|
||||
out = {"quiet": A, "hammer": B, "state_only": C,
|
||||
"quiet_cyc": a1.get("cyc_us"), "hammer_cyc": b1.get("cyc_us")}
|
||||
path = os.path.join(LAB, f"summary_fe_ab_{int(time.time())}.json")
|
||||
json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""快照当前主机侧可回滚现场:I210 高级属性、中断亲和、ecat 进程。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
|
||||
def ps(cmd: str) -> str:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return (r.stdout or "") + (("ERR:" + r.stderr) if r.stderr else "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.makedirs(LAB, exist_ok=True)
|
||||
snap = {"ts": time.time(), "host": os.environ.get("COMPUTERNAME")}
|
||||
|
||||
snap["adapters"] = ps(
|
||||
"Get-NetAdapter | Select-Object Name,InterfaceDescription,LinkSpeed,Status,"
|
||||
"ifIndex,MacAddress | ConvertTo-Json -Compress"
|
||||
)
|
||||
# I210 specifically
|
||||
snap["i210"] = ps(
|
||||
"Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'I210'} | "
|
||||
"Select-Object Name,ifIndex,Status,LinkSpeed | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# advanced properties (restore-able)
|
||||
snap["i210_adv"] = ps(
|
||||
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
"Select-Object Name,DisplayName,DisplayValue,RegistryKeyword,RegistryValue | "
|
||||
"ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# interrupt moderation / RSS / power
|
||||
for kw in (
|
||||
"*InterruptModeration",
|
||||
"*ReceiveBuffers",
|
||||
"*TransmitBuffers",
|
||||
"*EEE",
|
||||
"*EnergyEfficient",
|
||||
"*FlowControl",
|
||||
"*PriorityVLANTag",
|
||||
"*TCPChecksumOffload*",
|
||||
"*IPChecksumOffload*",
|
||||
"*VMQ",
|
||||
"*RSS",
|
||||
"*InterruptModerationRate",
|
||||
):
|
||||
snap[f"adv_{kw.strip('*').replace('*','_')}"] = ps(
|
||||
f"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
f"Where-Object {{$_.RegistryKeyword -like '{kw}'}} | "
|
||||
f"Select-Object RegistryKeyword,DisplayValue,RegistryValue | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
snap["power"] = ps(
|
||||
"Get-NetAdapterPowerManagement -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
"Select-Object Name,AllowComputerToTurnOffDevice,WakeOnMagicPacket,"
|
||||
"WakeOnPattern,DeviceSleepOnDisconnect | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
snap["ecat_proc"] = ps(
|
||||
"Get-Process ecat_native -ErrorAction SilentlyContinue | "
|
||||
"Select-Object Id,Path,StartTime,CPU,WorkingSet | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
snap["ecat_cmdline"] = ps(
|
||||
"Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | "
|
||||
"Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# interrupt affinity via Get-NetAdapterInterrupt? not always available
|
||||
snap["msi_affinity"] = ps(
|
||||
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
"Where-Object {$_.DisplayName -match 'Interrupt|Affinity|RSS|Moderation|Buffer|Energy|Green|Flow|Offload'} | "
|
||||
"Select-Object DisplayName,DisplayValue,RegistryKeyword | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# CPU topology quick
|
||||
snap["cpu"] = ps(
|
||||
"$env:NUMBER_OF_PROCESSORS; (Get-CimInstance Win32_Processor | "
|
||||
"Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json -Compress)"
|
||||
)
|
||||
|
||||
out = os.path.join(LAB, f"host_snap_{int(time.time())}.json")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(snap, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# also human-readable restore snippet
|
||||
lines = []
|
||||
lines.append("# Host snapshot restore notes")
|
||||
lines.append(f"time={snap['ts']}")
|
||||
lines.append("## I210 adapter advanced (raw)")
|
||||
lines.append(snap.get("i210_adv", "")[:8000])
|
||||
lines.append("## power")
|
||||
lines.append(snap.get("power", "")[:4000])
|
||||
lines.append("## ecat cmdline")
|
||||
lines.append(snap.get("ecat_cmdline", "")[:2000])
|
||||
txt = os.path.join(LAB, f"host_snap_{int(snap['ts'])}.txt")
|
||||
with open(txt, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print("I210:", snap.get("i210", "")[:500])
|
||||
print("CMDLINE:", snap.get("ecat_cmdline", "")[:800])
|
||||
print("ADV keys sample:")
|
||||
print(snap.get("msi_affinity", "")[:2500])
|
||||
print("WROTE", out)
|
||||
print("WROTE", txt)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, time, urllib.request
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
def ps(cmd):
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace").strip()
|
||||
|
||||
def post(p, b=None):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
|
||||
|
||||
def wait_ok(t=30):
|
||||
t0=time.time()
|
||||
while time.time()-t0<t:
|
||||
try:
|
||||
h=post("/api/native/health")
|
||||
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3: return h
|
||||
except Exception: pass
|
||||
time.sleep(0.4)
|
||||
return None
|
||||
|
||||
def set_rt():
|
||||
p=int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
|
||||
return p
|
||||
|
||||
def period(ms):
|
||||
post("/api/motor/stop"); time.sleep(0.12)
|
||||
post("/api/motor/period", {"period_ms": ms}); time.sleep(0.12)
|
||||
post("/api/motor/start")
|
||||
|
||||
def quiet(sec=10):
|
||||
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
|
||||
c0,s0=st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
t0=time.time(); time.sleep(sec)
|
||||
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
|
||||
dt=time.time()-t0
|
||||
c1,s1=st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
cu=st.get("cyc_us") or {}
|
||||
# also sample dbg ring k distribution
|
||||
ring=dbg.get("cyc") or []
|
||||
ks=[r[5] for r in ring if len(r)>=6]
|
||||
works=[(r[4]-r[0])*1e6 for r in ring if len(r)>=5]
|
||||
sends=[(r[2]-r[1])*1e6 for r in ring if len(r)>=3]
|
||||
rxs=[(r[3]-r[2])*1e6 for r in ring if len(r)>=4]
|
||||
def pct(a,q):
|
||||
if not a: return None
|
||||
a=sorted(a); return round(a[int(q*(len(a)-1))],1)
|
||||
return {
|
||||
"pdo_hz": round((c1-c0)/dt,1),
|
||||
"late_per_s": round((s1-s0)/dt,2),
|
||||
"achieved_vs_4k": round(100*(c1-c0)/dt/4000,1),
|
||||
"cyc_us": cu,
|
||||
"ring_n": len(ring),
|
||||
"k_gt0": sum(1 for k in ks if k>0),
|
||||
"k_sum": sum(ks),
|
||||
"work_pct": {"p50":pct(works,.5),"p90":pct(works,.9),"p99":pct(works,.99),"max":pct(works,1.0) if works else None},
|
||||
"send_pct": {"p50":pct(sends,.5),"p90":pct(sends,.9),"max":pct(sends,1.0) if sends else None},
|
||||
"rx_pct": {"p50":pct(rxs,.5),"p90":pct(rxs,.9),"max":pct(rxs,1.0) if rxs else None},
|
||||
}
|
||||
|
||||
# set ITR 200
|
||||
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword '*InterruptModeration' -RegistryValue '1' -NoRestart")
|
||||
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword 'ITR' -RegistryValue '200' -NoRestart")
|
||||
ps("Restart-NetAdapter -Name '以太网 2' -Confirm:$false")
|
||||
time.sleep(2)
|
||||
print("health", wait_ok())
|
||||
set_rt()
|
||||
|
||||
rows=[]
|
||||
for ms in (0.25, 0.5, 1.0):
|
||||
print(f"\n=== {ms} ms ITR=200 ===")
|
||||
period(ms)
|
||||
time.sleep(4)
|
||||
set_rt()
|
||||
r=quiet(10)
|
||||
print(json.dumps(r, ensure_ascii=False, indent=2))
|
||||
rows.append(r)
|
||||
|
||||
path=os.path.join(LAB, f"summary_itr200_periods_{int(time.time())}.json")
|
||||
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""查 pdo 线程亲和 + 尝试 ITR 200/400 在 0.25ms 静默下的表现。"""
|
||||
import json, os, subprocess, time, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
def ps(cmd, timeout=60):
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout).strip()
|
||||
|
||||
def post(p, b=None):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
|
||||
|
||||
def wait_ok(timeout=35):
|
||||
t0 = time.time()
|
||||
while time.time()-t0 < timeout:
|
||||
try:
|
||||
h = post("/api/native/health")
|
||||
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
|
||||
return h
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.4)
|
||||
return None
|
||||
|
||||
def set_rt():
|
||||
p = int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
|
||||
return p
|
||||
|
||||
def quiet_rate(sec=8):
|
||||
st = post("/api/motor/state")
|
||||
dbg = post("/api/motor/dbg")
|
||||
c0, s0 = st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
t0 = time.time()
|
||||
time.sleep(sec)
|
||||
st = post("/api/motor/state")
|
||||
dbg = post("/api/motor/dbg")
|
||||
dt = time.time()-t0
|
||||
c1, s1 = st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
return {
|
||||
"pdo_hz": round((c1-c0)/dt, 1),
|
||||
"late_per_s": round((s1-s0)/dt, 2),
|
||||
"cyc_us": st.get("cyc_us"),
|
||||
"period": st.get("period_ms"),
|
||||
}
|
||||
|
||||
def bus_restart_period(ms):
|
||||
try:
|
||||
post("/api/motor/stop")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/period", {"period_ms": ms})
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/start")
|
||||
|
||||
def nic_set(kw, val):
|
||||
out, err = "", ""
|
||||
r = subprocess.run(["powershell","-NoProfile","-Command",
|
||||
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' -RegistryKeyword '{kw}' -RegistryValue '{val}' -NoRestart"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
return r.stdout.strip(), r.stderr.strip()
|
||||
|
||||
# affinity of ecat threads via .NET is limited; dump processor affinity mask
|
||||
print("=== process affinity ===")
|
||||
print(ps(r'''
|
||||
$p = Get-Process ecat_native
|
||||
"PID $($p.Id) Priority=$($p.PriorityClass)"
|
||||
# ProcessorAffinity is a bitmask
|
||||
"Affinity=$($p.ProcessorAffinity)"
|
||||
'''))
|
||||
|
||||
print("=== I210 current ITR/moderation ===")
|
||||
print(ps(r'''
|
||||
Get-NetAdapterAdvancedProperty -Name '以太网 2' |
|
||||
Where-Object { $_.RegistryKeyword -in 'ITR','*InterruptModeration','*TransmitBuffers' } |
|
||||
Select-Object RegistryKeyword,DisplayValue | ConvertTo-Json -Compress
|
||||
'''))
|
||||
|
||||
results = []
|
||||
# try ITR values at 0.25ms quiet
|
||||
for itr in ("200", "400", "950", "65535"):
|
||||
print(f"\n===== ITR={itr} @0.25ms =====")
|
||||
o, e = nic_set("*InterruptModeration", "1")
|
||||
o2, e2 = nic_set("ITR", itr)
|
||||
print("set", o2 or o, e2 or e)
|
||||
# restart nic only if needed - ITR may need restart
|
||||
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
|
||||
time.sleep(2)
|
||||
h = wait_ok(30)
|
||||
print("health", bool(h))
|
||||
if not h:
|
||||
try:
|
||||
post("/api/op/start"); post("/api/motor/start")
|
||||
h = wait_ok(20)
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
set_rt()
|
||||
bus_restart_period(0.25)
|
||||
time.sleep(3)
|
||||
set_rt()
|
||||
r = quiet_rate(8)
|
||||
print(json.dumps({k:r[k] for k in ("pdo_hz","late_per_s","period")}, ensure_ascii=False))
|
||||
print("work/send/rx", r["cyc_us"]["work"]["p50"], r["cyc_us"]["send"]["p50"], r["cyc_us"]["rx"]["p50"])
|
||||
results.append({"itr": itr, **{k:r[k] for k in ("pdo_hz","late_per_s")},
|
||||
"work_p50": r["cyc_us"]["work"]["p50"], "send_p50": r["cyc_us"]["send"]["p50"],
|
||||
"rx_p50": r["cyc_us"]["rx"]["p50"]})
|
||||
|
||||
print("\nSUMMARY", json.dumps(results, ensure_ascii=False, indent=2))
|
||||
# restore adaptive
|
||||
nic_set("ITR", "65535")
|
||||
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
|
||||
time.sleep(2)
|
||||
wait_ok(25)
|
||||
set_rt()
|
||||
# leave at 1ms for safety
|
||||
bus_restart_period(1.0)
|
||||
time.sleep(1)
|
||||
print("restored 1ms", post("/api/native/health"))
|
||||
path = os.path.join(LAB, f"summary_itr_4k_{int(time.time())}.json")
|
||||
json.dump(results, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, sys, time
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
|
||||
subprocess.check_output(["powershell","-NoProfile","-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
|
||||
return p
|
||||
|
||||
def meter(tag, secs=15):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
|
||||
d = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
return {
|
||||
"tag": tag,
|
||||
"late_per_s": d["late_per_s"], "skip_per_s": d["skip_per_s"],
|
||||
"achieved_hz": d["achieved_hz"], "n_late": d["n_late"],
|
||||
"work_p50": d["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": d["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": d["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": d["window_cyc_us"]["work"]["p90"],
|
||||
}
|
||||
|
||||
def main():
|
||||
set_prio("RealTime")
|
||||
rows = []
|
||||
for i in range(1, 4):
|
||||
print(f"round {i} settle...")
|
||||
time.sleep(4)
|
||||
r = meter(f"multirt_{i}", 15)
|
||||
print(r)
|
||||
rows.append(r)
|
||||
lates = [x["late_per_s"] for x in rows]
|
||||
print("mean late", sum(lates)/len(lates), "min", min(lates), "max", max(lates))
|
||||
path = os.path.join(LAB, f"summary_multirt_{int(time.time())}.json")
|
||||
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""可回滚的 I210 网卡参数实验:停环 → 改高级属性 → 恢复 OP → 标定跳拍率。
|
||||
|
||||
默认只测、不永久破坏:原值会写入 restore json;`--restore` 按快照还原。
|
||||
改网卡会触发短暂链路重协商,期间从站可能掉 OP,脚本会等 link+OP 恢复。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
|
||||
|
||||
|
||||
def post(path, body=None, timeout=8.0):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def ps(cmd, timeout=60):
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return (r.stdout or "").strip(), (r.stderr or "").strip()
|
||||
|
||||
|
||||
def nic_snapshot():
|
||||
out, err = ps(
|
||||
f"Get-NetAdapterAdvancedProperty -Name '{NIC}' | "
|
||||
"Select-Object RegistryKeyword,DisplayValue,RegistryValue | "
|
||||
"ConvertTo-Json -Compress"
|
||||
)
|
||||
if not out:
|
||||
return []
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
return data
|
||||
|
||||
|
||||
def nic_set(keyword: str, value: str):
|
||||
# value as string; Intel accepts "0"/"1"/numbers
|
||||
cmd = (
|
||||
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' "
|
||||
f"-RegistryKeyword '{keyword}' -RegistryValue '{value}' -NoRestart"
|
||||
)
|
||||
out, err = ps(cmd)
|
||||
return out, err
|
||||
|
||||
|
||||
def nic_restart():
|
||||
out, err = ps(
|
||||
f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false; Start-Sleep -Seconds 2; "
|
||||
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
|
||||
)
|
||||
return out, err
|
||||
|
||||
|
||||
def wait_link(timeout=20.0):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
out, _ = ps(
|
||||
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
|
||||
)
|
||||
try:
|
||||
j = json.loads(out)
|
||||
if isinstance(j, list):
|
||||
j = j[0]
|
||||
if str(j.get("Status", "")).lower() == "up":
|
||||
return True, j
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False, None
|
||||
|
||||
|
||||
def wait_ecat_healthy(timeout=25.0):
|
||||
t0 = time.time()
|
||||
last = None
|
||||
while time.time() - t0 < timeout:
|
||||
try:
|
||||
h = post("/api/native/health")
|
||||
last = h
|
||||
if h.get("loop") == 1 and int(h.get("wkc") or 0) >= 3 and h.get("op") == 1:
|
||||
# also require cycles advancing
|
||||
c0 = h.get("cycles") or 0
|
||||
time.sleep(0.4)
|
||||
h2 = post("/api/native/health")
|
||||
c1 = h2.get("cycles") or 0
|
||||
if c1 > c0:
|
||||
return True, h2
|
||||
except Exception as e:
|
||||
last = {"err": str(e)}
|
||||
time.sleep(0.5)
|
||||
return False, last
|
||||
|
||||
|
||||
def stop_bus():
|
||||
logs = []
|
||||
for path in ("/api/motor/stop", "/api/op/stop"):
|
||||
try:
|
||||
r = post(path)
|
||||
logs.append((path, r.get("ok"), (r.get("log") or r)[:2]))
|
||||
except Exception as e:
|
||||
logs.append((path, False, str(e)))
|
||||
return logs
|
||||
|
||||
|
||||
def start_bus():
|
||||
logs = []
|
||||
try:
|
||||
r = post("/api/op/start")
|
||||
logs.append(("/api/op/start", r.get("ok"), (r.get("log") or [""])[:2]))
|
||||
except Exception as e:
|
||||
logs.append(("/api/op/start", False, str(e)))
|
||||
try:
|
||||
r = post("/api/motor/start")
|
||||
logs.append(("/api/motor/start", r.get("ok"), (r.get("log") or [""])[:2]))
|
||||
except Exception as e:
|
||||
logs.append(("/api/motor/start", False, str(e)))
|
||||
return logs
|
||||
|
||||
|
||||
def meter(tag: str, secs: float):
|
||||
# reuse skip_rate_meter as subprocess for isolation
|
||||
cmd = [
|
||||
sys.executable,
|
||||
os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
|
||||
"--tag",
|
||||
tag,
|
||||
"--secs",
|
||||
str(secs),
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
# last json file for tag
|
||||
files = sorted(
|
||||
[f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json")]
|
||||
)
|
||||
data = None
|
||||
if files:
|
||||
with open(os.path.join(LAB, files[-1]), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data, r.stdout, r.stderr
|
||||
|
||||
|
||||
def apply_batch(changes: list[tuple[str, str]]):
|
||||
results = []
|
||||
for k, v in changes:
|
||||
out, err = nic_set(k, v)
|
||||
results.append({"key": k, "value": v, "out": out[:300], "err": err[:300]})
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--restore", action="store_true", help="restore from snap json")
|
||||
ap.add_argument("--snap", default=None, help="snapshot json for restore")
|
||||
ap.add_argument("--exp", default=None, help="named experiment")
|
||||
ap.add_argument("--secs", type=float, default=16.0)
|
||||
ap.add_argument("--list-exp", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(LAB, exist_ok=True)
|
||||
|
||||
# experiment table: list of (keyword, value)
|
||||
EXPS = {
|
||||
"itr_off": [
|
||||
("*InterruptModeration", "0"),
|
||||
("ITR", "0"),
|
||||
],
|
||||
"itr_low": [
|
||||
("*InterruptModeration", "1"),
|
||||
("ITR", "400"), # valid: 65535,3600,2000,950,400,200,0
|
||||
],
|
||||
"itr_200": [
|
||||
("*InterruptModeration", "1"),
|
||||
("ITR", "200"),
|
||||
],
|
||||
"itr_950": [
|
||||
("*InterruptModeration", "1"),
|
||||
("ITR", "950"),
|
||||
],
|
||||
"eee_off": [
|
||||
("EEELinkAdvertisement", "0"),
|
||||
],
|
||||
"flow_off": [
|
||||
("*FlowControl", "0"),
|
||||
],
|
||||
"txbuf_max": [
|
||||
("*TransmitBuffers", "4096"),
|
||||
],
|
||||
"rxbuf_max": [
|
||||
("*ReceiveBuffers", "1024"),
|
||||
],
|
||||
"combo_lowlat": [
|
||||
("*InterruptModeration", "0"),
|
||||
("ITR", "0"),
|
||||
("EEELinkAdvertisement", "0"),
|
||||
("*FlowControl", "0"),
|
||||
("*TransmitBuffers", "2048"),
|
||||
("*ReceiveBuffers", "512"),
|
||||
],
|
||||
}
|
||||
|
||||
if args.list_exp:
|
||||
print(json.dumps({k: v for k, v in EXPS.items()}, indent=2))
|
||||
return 0
|
||||
|
||||
# always keep a fresh snapshot before mutating
|
||||
snap = nic_snapshot()
|
||||
snap_path = os.path.join(LAB, f"nic_before_{int(time.time())}.json")
|
||||
with open(snap_path, "w", encoding="utf-8") as f:
|
||||
json.dump(snap, f, ensure_ascii=False, indent=2)
|
||||
print(f"NIC snapshot → {snap_path}")
|
||||
|
||||
if args.restore:
|
||||
path = args.snap or snap_path
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# stop, apply all, restart
|
||||
print("restore: stop bus")
|
||||
print(stop_bus())
|
||||
n = 0
|
||||
for row in data:
|
||||
kw = row.get("RegistryKeyword")
|
||||
val = row.get("RegistryValue")
|
||||
if not kw or val is None:
|
||||
continue
|
||||
if isinstance(val, list):
|
||||
val = val[0] if val else ""
|
||||
val = str(val)
|
||||
apply_batch([(kw, val)])
|
||||
n += 1
|
||||
print("restore applied", n, "keys; restarting NIC")
|
||||
print(nic_restart())
|
||||
print(wait_link())
|
||||
print("start bus")
|
||||
print(start_bus())
|
||||
ok, h = wait_ecat_healthy()
|
||||
print("healthy", ok, h)
|
||||
return 0
|
||||
|
||||
exp = args.exp or "itr_off"
|
||||
if exp not in EXPS:
|
||||
print("unknown exp", exp, "use --list-exp")
|
||||
return 2
|
||||
changes = EXPS[exp]
|
||||
|
||||
print(f"\n=== EXP {exp} {changes} ===")
|
||||
print("stop bus...")
|
||||
print(stop_bus())
|
||||
time.sleep(0.3)
|
||||
print("apply...")
|
||||
print(apply_batch(changes))
|
||||
print("restart nic...")
|
||||
print(nic_restart())
|
||||
ok, info = wait_link()
|
||||
print("link", ok, info)
|
||||
print("start bus...")
|
||||
print(start_bus())
|
||||
ok, h = wait_ecat_healthy(timeout=30)
|
||||
print("healthy", ok, h)
|
||||
if not ok:
|
||||
print("ABORT: bus not healthy after change")
|
||||
return 1
|
||||
|
||||
# settle longer: NIC restart + re-enumeration leaves ~2–5s of elevated skip
|
||||
time.sleep(8.0)
|
||||
data, out, err = meter(f"exp_{exp}", args.secs)
|
||||
print(out)
|
||||
if err:
|
||||
print("meter stderr", err[:500])
|
||||
if data:
|
||||
summary = {
|
||||
"exp": exp,
|
||||
"changes": changes,
|
||||
"late_per_s": data.get("late_per_s"),
|
||||
"skip_per_s": data.get("skip_per_s"),
|
||||
"late_frac_pct": data.get("late_frac_pct"),
|
||||
"send_stall_frac": data.get("send_stall_frac"),
|
||||
"rx_stall_frac": data.get("rx_stall_frac"),
|
||||
"achieved_hz": data.get("achieved_hz"),
|
||||
"n_late": data.get("n_late"),
|
||||
}
|
||||
sp = os.path.join(LAB, f"summary_{exp}_{int(time.time())}.json")
|
||||
with open(sp, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, ensure_ascii=False, indent=2)
|
||||
print("SUMMARY", json.dumps(summary, ensure_ascii=False))
|
||||
print("WROTE", sp)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""High vs RealTime 多轮 A/B,输出对比。结束后设为 --final 指定档。"""
|
||||
import json, os, subprocess, sys, time
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
def ecat_pid():
|
||||
out = subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"],
|
||||
text=True).strip()
|
||||
return int(out.splitlines()[0])
|
||||
|
||||
def set_prio(level):
|
||||
p = ecat_pid()
|
||||
subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
|
||||
text=True)
|
||||
return level
|
||||
|
||||
def meter(tag, secs=12):
|
||||
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def row(level, data, n):
|
||||
return {
|
||||
"round": n, "level": level,
|
||||
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
|
||||
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
|
||||
"send_p90": (data.get("window_cyc_us") or {}).get("send",{}).get("p90"),
|
||||
"work_p90": (data.get("window_cyc_us") or {}).get("work",{}).get("p90"),
|
||||
}
|
||||
|
||||
def main():
|
||||
final = "RealTime"
|
||||
if len(sys.argv) > 1:
|
||||
final = sys.argv[1]
|
||||
rows = []
|
||||
# A-B-A-B
|
||||
seq = ["High","RealTime","High","RealTime"]
|
||||
for i, lv in enumerate(seq, 1):
|
||||
set_prio(lv)
|
||||
time.sleep(1.0)
|
||||
data = meter(f"ab_{lv}_{i}", 12)
|
||||
r = row(lv, data, i)
|
||||
rows.append(r)
|
||||
print(r)
|
||||
set_prio(final)
|
||||
print("FINAL", final, "pid", ecat_pid())
|
||||
# summary means
|
||||
by = {}
|
||||
for r in rows:
|
||||
by.setdefault(r["level"], []).append(r)
|
||||
means = {}
|
||||
for lv, rs in by.items():
|
||||
means[lv] = {
|
||||
"n": len(rs),
|
||||
"late_per_s_mean": round(sum(x["late_per_s"] for x in rs)/len(rs), 3),
|
||||
"skip_per_s_mean": round(sum(x["skip_per_s"] for x in rs)/len(rs), 3),
|
||||
"send_p90_mean": round(sum(x["send_p90"] or 0 for x in rs)/len(rs), 1),
|
||||
"work_p90_mean": round(sum(x["work_p90"] or 0 for x in rs)/len(rs), 1),
|
||||
}
|
||||
print("MEANS", json.dumps(means, ensure_ascii=False, indent=2))
|
||||
path = os.path.join(LAB, f"summary_ab_prio_{int(time.time())}.json")
|
||||
json.dump({"rows": rows, "means": means, "final": final}, open(path,"w",encoding="utf-8"),
|
||||
ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""进程优先级对照:不高/RealTime,各采 skip rate。不改网卡、不碰固件。"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE+path, data=data, headers={"Content-Type":"application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
def pid():
|
||||
return post("/api/native/health") # health has no pid; use process name
|
||||
# fallback below
|
||||
|
||||
def ecat_pid():
|
||||
out = subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command",
|
||||
"(Get-Process ecat_native).Id"],
|
||||
text=True).strip()
|
||||
return int(out.splitlines()[0])
|
||||
|
||||
def set_prio(level):
|
||||
p = ecat_pid()
|
||||
cmd = f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], text=True).strip()
|
||||
|
||||
def meter(tag, secs=16):
|
||||
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
|
||||
data = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) if files else None
|
||||
return data, r.stdout
|
||||
|
||||
def main():
|
||||
p = ecat_pid()
|
||||
print("ecat pid", p)
|
||||
cur = subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command", f"(Get-Process -Id {p}).PriorityClass"],
|
||||
text=True).strip()
|
||||
print("current PriorityClass", cur)
|
||||
|
||||
results = []
|
||||
for level in ("High", "RealTime"):
|
||||
print(f"\n=== set {level} ===")
|
||||
try:
|
||||
print(set_prio(level))
|
||||
except Exception as e:
|
||||
print("set fail", e)
|
||||
continue
|
||||
time.sleep(1.5)
|
||||
data, out = meter(f"prio_{level}", 16)
|
||||
if data:
|
||||
row = {"level": level, "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"], "send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"], "achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"]}
|
||||
results.append(row)
|
||||
print("ROW", row)
|
||||
print(out[-800:] if out else "")
|
||||
|
||||
# restore High (safe default; pdo thread already TIME_CRITICAL)
|
||||
try:
|
||||
print("restore High")
|
||||
print(set_prio("High"))
|
||||
except Exception as e:
|
||||
print("restore fail", e)
|
||||
|
||||
path = os.path.join(LAB, f"summary_prio_{int(time.time())}.json")
|
||||
json.dump({"results": results, "original": cur}, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""静默多档标定:1ms / 2ms / 4ms,每档先停环再设周期再开环,避免混拍。"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
|
||||
def post(p, b=None):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
|
||||
subprocess.check_output(["powershell","-NoProfile","-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
|
||||
return p
|
||||
|
||||
def set_period(ms):
|
||||
post("/api/motor/stop")
|
||||
time.sleep(0.2)
|
||||
r = post("/api/motor/period", {"period_ms": ms})
|
||||
time.sleep(0.2)
|
||||
post("/api/motor/start")
|
||||
time.sleep(0.8)
|
||||
return r
|
||||
|
||||
def meter(tag, secs=18):
|
||||
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def main():
|
||||
print("set RealTime")
|
||||
print("pid", set_prio("RealTime"))
|
||||
rows = []
|
||||
for ms in (1.0, 2.0, 4.0):
|
||||
print(f"\n=== period {ms} ms ===")
|
||||
print(set_period(ms))
|
||||
time.sleep(2.0) # settle
|
||||
data = meter(f"quiet_{ms:g}ms", 18)
|
||||
row = {
|
||||
"period_ms": ms,
|
||||
"late_per_s": data["late_per_s"],
|
||||
"skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"],
|
||||
"achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"],
|
||||
"send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": data["window_cyc_us"]["work"]["p90"],
|
||||
}
|
||||
rows.append(row)
|
||||
print(row)
|
||||
|
||||
# restore 1ms as user default
|
||||
print("\nrestore period 1.0")
|
||||
print(set_period(1.0))
|
||||
path = os.path.join(LAB, f"summary_quiet_periods_{int(time.time())}.json")
|
||||
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跳拍率标定:固定时长采 /api/motor/dbg,输出可对比 JSON。
|
||||
|
||||
主判据: late_per_s, skip_per_s, send_stall_frac, rx_stall_frac
|
||||
用法:
|
||||
python scripts/skip_rate_meter.py --tag base --secs 20
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
|
||||
OUTDIR = os.path.join(os.path.dirname(__file__), "..", "lab", "skip_meter")
|
||||
|
||||
|
||||
def post(path: str, body=None, timeout=5.0):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tag", default="sample")
|
||||
ap.add_argument("--secs", type=float, default=20.0)
|
||||
ap.add_argument("--hz", type=float, default=5.0)
|
||||
ap.add_argument("--out", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
st0 = post("/api/motor/state")
|
||||
period = float(st0.get("period_ms") or 1.0)
|
||||
t0 = time.time()
|
||||
pulses = []
|
||||
last_t = None
|
||||
n_poll = 0
|
||||
# dbg t0 uses now_s() (QPC-based), NOT unix time. Track by last-seen t0 only.
|
||||
while time.time() - t0 < args.secs:
|
||||
dbg = post("/api/motor/dbg")
|
||||
n_poll += 1
|
||||
cyc = dbg.get("cyc") or []
|
||||
if last_t is not None:
|
||||
new = [r for r in cyc if r[0] > last_t]
|
||||
else:
|
||||
# first poll: adopt the whole ring's last timestamp as cursor;
|
||||
# count only k>0 from now on via subsequent polls. Also count
|
||||
# k>0 already in ring as "pre-existing" (not in window rate).
|
||||
new = []
|
||||
if cyc:
|
||||
last_t = cyc[-1][0]
|
||||
elif last_t is None:
|
||||
last_t = None
|
||||
for r in new:
|
||||
if len(r) < 8:
|
||||
continue
|
||||
k = int(r[5])
|
||||
if k <= 0:
|
||||
continue
|
||||
send_us = (r[2] - r[1]) * 1e6
|
||||
rx_us = (r[3] - r[2]) * 1e6
|
||||
pulses.append(
|
||||
{
|
||||
"t0": r[0],
|
||||
"k": k,
|
||||
"send_us": send_us,
|
||||
"rx_us": rx_us,
|
||||
"work_us": (r[4] - r[0]) * 1e6,
|
||||
"sd": float(r[6]) if len(r) > 6 else None,
|
||||
"rxc": int(r[7]) if len(r) > 7 else None,
|
||||
"kind": (
|
||||
"send"
|
||||
if send_us >= 500
|
||||
else ("rx" if rx_us >= 500 else "other")
|
||||
),
|
||||
}
|
||||
)
|
||||
time.sleep(max(0.0, 1.0 / args.hz))
|
||||
|
||||
st1 = post("/api/motor/state")
|
||||
wall = time.time() - t0
|
||||
n_late = len(pulses)
|
||||
n_skip = sum(p["k"] for p in pulses)
|
||||
by = {"send": 0, "rx": 0, "other": 0}
|
||||
for p in pulses:
|
||||
by[p["kind"]] += 1
|
||||
|
||||
# approximate expected beats in window
|
||||
# use cycles delta when available
|
||||
cyc0 = st0.get("cycles") or 0
|
||||
cyc1 = st1.get("cycles") or 0
|
||||
dcyc = max(0, cyc1 - cyc0)
|
||||
|
||||
cu = st1.get("cyc_us") or {}
|
||||
result = {
|
||||
"tag": args.tag,
|
||||
"ts": time.time(),
|
||||
"wall_s": round(wall, 3),
|
||||
"period_ms": period,
|
||||
"pcore_mask": st1.get("pcore_mask"),
|
||||
"wkc": st1.get("wkc"),
|
||||
"spin": st1.get("spin"),
|
||||
"cycles_delta": dcyc,
|
||||
"achieved_hz": round(dcyc / wall, 2) if wall > 0 else None,
|
||||
"late_per_s": round(n_late / wall, 3) if wall else None,
|
||||
"skip_per_s": round(n_skip / wall, 3) if wall else None,
|
||||
"late_frac_pct": round(100.0 * n_late / dcyc, 4) if dcyc else None,
|
||||
"skip_frac_pct": round(100.0 * n_skip / dcyc, 4) if dcyc else None,
|
||||
"send_stall_frac": round(by["send"] / n_late, 4) if n_late else None,
|
||||
"rx_stall_frac": round(by["rx"] / n_late, 4) if n_late else None,
|
||||
"n_late": n_late,
|
||||
"n_skip": n_skip,
|
||||
"by_kind": by,
|
||||
"window_cyc_us": cu,
|
||||
"n_poll": n_poll,
|
||||
"pulses_sample": pulses[:40],
|
||||
}
|
||||
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
out = args.out or os.path.join(OUTDIR, f"{args.tag}_{int(time.time())}.json")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
print(json.dumps({k: result[k] for k in result if k != "pulses_sample"},
|
||||
ensure_ascii=False, indent=2))
|
||||
print(f"WROTE {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重启 ecat_native 进程(让 esp32_server/父进程重拉),测 rx 是否回到 ~90µs。"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
|
||||
def post(p, b=None, timeout=5):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=timeout).read().decode())
|
||||
|
||||
def ps(cmd):
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace").strip()
|
||||
|
||||
def wait_health(timeout=40):
|
||||
t0 = time.time()
|
||||
while time.time()-t0 < timeout:
|
||||
try:
|
||||
h = post("/api/native/health")
|
||||
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
|
||||
return True, h
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False, None
|
||||
|
||||
def meter(tag, secs=16):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def main():
|
||||
# re-enable IP (keep VMware off — it's harmful and was only “enabled” by coincidence in morning)
|
||||
for cid in ("ms_tcpip", "ms_tcpip6"):
|
||||
try:
|
||||
ps(f"Enable-NetAdapterBinding -Name '以太网 2' -ComponentID '{cid}' -Confirm:$false")
|
||||
print("enabled", cid)
|
||||
except Exception as e:
|
||||
print("enable fail", cid, e)
|
||||
|
||||
info = ps("Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
|
||||
print("before", info[:500])
|
||||
|
||||
print("kill ecat_native...")
|
||||
ps("Stop-Process -Name ecat_native -Force -ErrorAction SilentlyContinue")
|
||||
time.sleep(2)
|
||||
|
||||
# parent may be esp32_server — wait for auto-restart
|
||||
ok, h = wait_health(45)
|
||||
print("auto restart?", ok, h)
|
||||
if not ok:
|
||||
print("manual start")
|
||||
exe = r"G:\Robot_v2\DCU\tools\ecat\build_native\ecat_native.exe"
|
||||
cwd = r"G:\Robot_v2\DCU\tools\ecat"
|
||||
subprocess.Popen(
|
||||
[exe, "--substr", "I210", "--mask", "0x50", "--web", "src/web"],
|
||||
cwd=cwd,
|
||||
stdout=open(os.path.join(cwd,"build_native","native_stdout.log"),"a"),
|
||||
stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
time.sleep(3)
|
||||
ok, h = wait_health(30)
|
||||
print("manual health", ok, h)
|
||||
if ok:
|
||||
try:
|
||||
post("/api/op/start")
|
||||
post("/api/motor/start")
|
||||
except Exception as e:
|
||||
print("start fail", e)
|
||||
ok, h = wait_health(20)
|
||||
print("after start", ok, h)
|
||||
|
||||
# set RealTime
|
||||
try:
|
||||
p = int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
|
||||
print("RealTime", p)
|
||||
except Exception as e:
|
||||
print("prio fail", e)
|
||||
|
||||
print("settle 10s")
|
||||
time.sleep(10)
|
||||
st = post("/api/motor/state")
|
||||
print("cyc", st.get("cyc_us"))
|
||||
print("period", st.get("period_ms"), "wkc", st.get("wkc"), "pcore", st.get("pcore_mask"))
|
||||
|
||||
data = meter("restart_rt", 16)
|
||||
row = {
|
||||
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": data["window_cyc_us"]["work"]["p90"],
|
||||
"send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"],
|
||||
}
|
||||
print(json.dumps(row, ensure_ascii=False, indent=2))
|
||||
out = os.path.join(LAB, f"summary_restart_{int(time.time())}.json")
|
||||
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", out)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user