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:
cbb
2026-09-17 02:32:20 +08:00
parent b3a3a365bb
commit e91d53e0fc
22 changed files with 1956 additions and 61 deletions
+107
View File
@@ -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
View File
@@ -373,6 +373,128 @@ select option { background: var(--bg-panel); color: var(--text); }
background: rgba(252,191,63,.12); background: rgba(252,191,63,.12);
color: var(--gd32-hi); 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 青绿) */ /* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
#uploadGd32Btn { #uploadGd32Btn {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%); background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
+67
View File
@@ -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)
+33
View File
@@ -178,6 +178,35 @@ g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 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: class _GuiApi:
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。""" """暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
@@ -342,6 +371,10 @@ def _ecat_autostart():
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) )
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})") 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: except OSError as e:
return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"] return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
+1 -1
View File
@@ -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-pick-file': var fi = document.getElementById('firmwareFile'); if (fi) fi.click(); break;
case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break; case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break;
case 'start-upload': startUpload(arg); 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-pick': bootEcatPick(); break;
case 'boot-ecat-run': bootEcatFlash(); break; case 'boot-ecat-run': bootEcatFlash(); break;
case 'boot-ecat-abort': bootEcatAbort(); break; case 'boot-ecat-abort': bootEcatAbort(); break;
+14 -3
View File
@@ -58,6 +58,7 @@ var statusPollInFlight = false; // 是否正在请求 /system/status,防止
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
var networkDiagDone = false; // 本轮离线期间是否已做过网关诊断(防每秒刷屏)
/** /**
* 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后; * 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后;
@@ -101,6 +102,7 @@ function scheduleLogsPoll(delayMs) {
export function startPolling() { export function startPolling() {
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
networkDiagDone = false;
statusPollIntervalMs = 1000; statusPollIntervalMs = 1000;
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
if (logsTimerId) clearTimeout(logsTimerId); if (logsTimerId) clearTimeout(logsTimerId);
@@ -112,6 +114,7 @@ export function pausePollingForUpload() {
window.pollingPausedForUpload = true; window.pollingPausedForUpload = true;
uploadPollChip = window.activeUploadChip || window.flashTarget || null; uploadPollChip = window.activeUploadChip || window.flashTarget || null;
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
networkDiagDone = false;
// ★ 上传大固件期间必须停掉 status/logs 轮询。 // ★ 上传大固件期间必须停掉 status/logs 轮询。
// httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉 // httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉
// 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完), // 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完),
@@ -135,6 +138,7 @@ export function resumePollingAfterUpload() {
window.pollingPausedForUpload = false; window.pollingPausedForUpload = false;
uploadPollChip = null; uploadPollChip = null;
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
networkDiagDone = false;
statusPollIntervalMs = 1000; statusPollIntervalMs = 1000;
if (logsTimerId) clearTimeout(logsTimerId); if (logsTimerId) clearTimeout(logsTimerId);
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续 // 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
@@ -201,6 +205,7 @@ export async function pollStatus() {
// 恢复:之前有失败计数,说明设备曾离线,现在恢复在线 // 恢复:之前有失败计数,说明设备曾离线,现在恢复在线
if (window.consecutivePollFailures > 0) { if (window.consecutivePollFailures > 0) {
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
networkDiagDone = false;
addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
showToast('设备已恢复在线', 'success'); showToast('设备已恢复在线', 'success');
// 仅清理终态残留。禁止对活跃会话 otaSessionReset: // 仅清理终态残留。禁止对活跃会话 otaSessionReset:
@@ -278,13 +283,17 @@ export async function pollStatus() {
var otaOngoing = window.pollingPausedForUpload || var otaOngoing = window.pollingPausedForUpload ||
sessionActive || sessionFailed || sessionActive || sessionFailed ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy; 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 次无响应,正在诊断网络...'); addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork(); var diag = await diagnoseNetwork();
addLog('W', '诊断', diag); addLog('W', '诊断', '诊断结果: ' + diag);
updateConnectionUi(false, '诊断中'); updateConnectionUi(false, '诊断中');
window.applyDisconnectedRuntimeState(); window.applyDisconnectedRuntimeState();
addLog('W', '诊断', '诊断结果: ' + diag);
} }
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接" // ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) { if (sessionFailed) {
@@ -353,6 +362,7 @@ document.addEventListener('visibilitychange', function () {
pageVisible = !document.hidden; pageVisible = !document.hidden;
if (!pageVisible) return; if (!pageVisible) return;
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
networkDiagDone = false;
// 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排 // 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排
if (statusPollInFlight) { if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
@@ -419,6 +429,7 @@ setTimeout(function () {
if (ipInput) { if (ipInput) {
ipInput.addEventListener('change', function () { ipInput.addEventListener('change', function () {
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
networkDiagDone = false;
addLog('I', '系统', 'IP 已修改,重新启动轮询...'); addLog('I', '系统', 'IP 已修改,重新启动轮询...');
if (!statusPollInFlight) { if (!statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
+87 -29
View File
@@ -124,22 +124,42 @@ function showLog(res) {
/** /**
* @description 按"有没有已选 GD32 镜像 + 有没有任务在跑"重算按钮可用性。 * @description 按"有没有已选 GD32 镜像 + 有没有任务在跑"重算按钮可用性。
* 幂等,供 file-handle / console-mode / upload 各处调用;不缓存判据、不复制 arm 判断。 * 幂等,供 file-handle / console-mode / upload 各处调用;不缓存判据、不复制 arm 判断。
* 交接按钮文案由最后一次 probe/reboot 结果驱动(window.bootEcatIsBoot)。
*/ */
export function refreshBootEcatUi() { export function refreshBootEcatUi() {
var probeBtn = el('bootEcatProbeBtn');
var runBtn = el('bootEcatRunBtn'); var runBtn = el('bootEcatRunBtn');
var abortBtn = el('bootEcatAbortBtn'); var abortBtn = el('bootEcatAbortBtn');
var rebootBtn = el('bootEcatRebootBtn'); var rebootBtn = el('bootEcatRebootBtn');
/* 别的通道在刷就互斥:三条传输共用同一块 flash,没有并发可言 */
var otherBusy = !!window.isUploading || var otherBusy = !!window.isUploading ||
!!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading()); !!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading());
var hasImg = !!window.gd32File; var hasImg = !!window.gd32File;
if (probeBtn) probeBtn.disabled = window.bootEcatBusy; if (rebootBtn) {
if (rebootBtn) rebootBtn.disabled = window.bootEcatBusy || otherBusy; 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 (runBtn) runBtn.disabled = window.bootEcatBusy || otherBusy || !hasImg;
if (abortBtn) abortBtn.disabled = window.bootEcatBusy; 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 与镜像校验。 */ /** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */
export function bootEcatPick() { export function bootEcatPick() {
var input = el('fileInputGd32'); var input = el('fileInputGd32');
@@ -156,26 +176,23 @@ export async function bootEcatProbe(verbose) {
try { try {
r = await api({ op: 'probe' }); r = await api({ op: 'probe' });
} catch (e) { } catch (e) {
setTxt('bootEcatWho', '后端无响应'); setRoleBadge('unknown', null);
setEcatConn('err', '后端无响应');
if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e); if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e);
return null; return null;
} }
var who = r.boot ? '引导程序' : (r.name ? '应用' : '读不到');
var ver = r.sw || '—'; var ver = r.sw || '—';
setTxt('bootEcatWho', who); setRoleBadge(r.boot ? 'boot' : (r.name ? 'app' : 'unknown'), r.sw || null);
setTxt('bootEcatName', r.name || '—'); setTxt('bootEcatName', r.name || '—');
setTxt('bootEcatSw', ver); setTxt('bootEcatSw', ver);
setTxt('bootEcatArm', r.armed ? '允许' : '不允许'); setTxt('bootEcatArm', r.armed ? '允许' : '不允许');
setTxt('bootEcatRecv', bytes(r.slave_recv || 0)); setTxt('bootEcatRecv', (r.srv_recv != null ? bytes(r.srv_recv) : '—') + ' / ' + bytes(r.slave_recv || 0));
if (r.boot) setEcatConn('ok', '引导程序 ' + ver + ' · 可以刷写'); window.bootEcatIsBoot = !!r.boot;
else if (r.name) setEcatConn('warn', '应用 ' + ver + ' · 刷写会自动交接回 BOOT'); refreshBootEcatUi();
else setEcatConn('err', '邮箱无回应');
if (verbose) { if (verbose) {
window.addLog('I', 'ECAT刷写', window.addLog('I', 'ECAT刷写',
'0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' + '0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' +
(r.boot ? '是引导程序,可以刷写' (r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)'
: (r.name ? '是应用,刷写链里第 3 步会写它的 0x2502 让它回 BOOT(1.6.34 以前的 APP 没这个对象,只能走 SPI 0xB0 / CAN 0x320)' : (r.name ? '是应用,点主按钮写 0x2502 进 BOOT'
: '邮箱没回应,多半是老 BOOT 或栈没起来'))); : '邮箱没回应,多半是老 BOOT 或栈没起来')));
} }
return r; return r;
@@ -243,42 +260,71 @@ async function waitBootOnBus(maxTries, gapMs) {
} }
/** /**
* @description 单独一次「让应用回引导程序」:写 APP 的 CoE 0x2502:00 = 1。 * @description 交接按钮总入口:按对端身份自动选方向。
* 刷写链里本来就带这一步,这个按钮是给"只想把板子停在 BOOT 上等人查"的场合 —— * - 已在 BOOT → op=jump_app(写 0x2503)
* 交接一个字节 flash 都不写,代价只是从站重启一次、停在 BOOT 的 10s 窗口里等。 * - 在 APP / 未知 → op=reboot(写 0x2502)
* 跑着老 APP(<1.6.34)的机器字典里没有 0x2502,主站会把从站的 SDO abort 码原样带回来, * 刷写链里本来就带 reboot 这一步;本按钮给"只想手动来回切换"的场合。
* 日志里直接就能看出该退回 SPI 0xB0 / CAN 0x320。
*/ */
export async function bootEcatReboot() { export async function bootEcatReboot() {
if (window.bootEcatBusy) return; if (window.bootEcatBusy) return;
window.bootEcatBusy = true; window.bootEcatBusy = true;
refreshBootEcatUi(); refreshBootEcatUi();
var btn = el('bootEcatRebootBtn'); var btn = el('bootEcatRebootBtn');
if (btn) btn.textContent = '交接中…'; var isBoot = window.bootEcatIsBoot === true;
setEcatMsg('正在让应用回引导程序…'); if (btn) btn.textContent = isBoot ? '跳转中…' : '交接中…';
setEcatMsg(isBoot ? '正在让引导程序跳回应用…' : '正在让应用回引导程序…');
try { try {
if (!await ensureBusOpen()) { if (!await ensureBusOpen()) {
setEcatStatus('err', '总线没打开'); setEcatStatus('err', '总线没打开');
setEcatMsg('主站没能打开总线(见日志)'); setEcatMsg('主站没能打开总线(见日志)');
return; return;
} }
var r = await api({ op: 'reboot' }); /* 先用本次动作的方向;若 probe 结果与缓存不符,会话内纠正 */
var op = window.bootEcatIsBoot === true ? 'jump_app' : 'reboot';
var r = await api({ op: op });
showLog(r); showLog(r);
if (!r || r.ok !== true) { if (!r || r.ok !== true) {
setEcatStatus('err', '交接被拒'); setEcatStatus('err', op === 'jump_app' ? '跳回被拒' : '交接被拒');
setEcatMsg('交接没被受理(见日志里从站的 abort 码)'); setEcatMsg(op === 'jump_app'
? '跳回没被受理(BOOT 需 ≥1.1.0.20;见日志 abort 码)'
: '交接没被受理(见日志里从站的 abort 码)');
await bootEcatProbe(false); await bootEcatProbe(false);
return; return;
} }
if (r.already) { if (r.already) {
setEcatStatus('ok', '对面本来就是引导程序'); if (op === 'jump_app') {
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写'); window.bootEcatIsBoot = false;
setEcatStatus('ok', '对面本来就是应用');
setEcatMsg('从站已在 APP,无需跳回');
} else {
window.bootEcatIsBoot = true;
setEcatStatus('ok', '对面本来就是引导程序');
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
}
refreshBootEcatUi();
await bootEcatProbe(true); await bootEcatProbe(true);
return; 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 上总线…'); setEcatMsg('交接已受理,等 BOOT 上总线…');
var b = await waitBootOnBus(10, 800); var b = await waitBootOnBus(10, 800);
if (b && b.boot) { if (b && b.boot) {
window.bootEcatIsBoot = true;
setEcatStatus('ok', '已进引导程序'); setEcatStatus('ok', '已进引导程序');
setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写'); setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写');
} else { } else {
@@ -293,15 +339,27 @@ export async function bootEcatReboot() {
} }
} catch (e) { } catch (e) {
setEcatStatus('err', '请求异常'); setEcatStatus('err', '请求异常');
setEcatMsg('交接请求异常'); setEcatMsg('交接/跳回请求异常');
window.addLog('E', 'ECAT刷写', '交接请求异常:' + e); window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e);
} finally { } finally {
window.bootEcatBusy = false; window.bootEcatBusy = false;
if (btn) btn.textContent = '让应用回引导程序';
refreshBootEcatUi(); 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 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */ /** @description 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */
export async function bootEcatAbort() { export async function bootEcatAbort() {
if (window.bootEcatBusy) return; if (window.bootEcatBusy) return;
+21 -28
View File
@@ -13,7 +13,7 @@
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) --> <!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5"> <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/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/can.css?v=c06f5688"> <link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/responsive.css?v=46449483"> <link rel="stylesheet" href="../css/responsive.css?v=46449483">
@@ -145,34 +145,27 @@
<div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div> <div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div>
</div> </div>
</div> </div>
<!-- EtherCAT 模式:这条链路没有 IP / 配对概念,"连接对象"就是本机 EtherCAT 主站。 <!-- EtherCAT 模式:APP ↔ BOOT 角色徽章 + 一键切换 -->
总线的打开/关闭由主站侧(ecat_native)负责,本页只在下面几个读数里反映现状。 -->
<div id="ecatModeCards" style="display:none;"> <div id="ecatModeCards" style="display:none;">
<div class="ecat-mode-note"> <div class="jump-role-panel">
主站经引导程序邮箱刷写 GD32 APP,<b>ESP32 离线不影响</b> <div class="jump-role-badge" id="bootEcatRoleBadge" data-role="unknown"
</div> data-action="boot-ecat-probe" role="button" tabindex="0"
<div class="form-label">总线对面</div> title="点击重新探测对端角色(APP / BOOT)">
<div class="conn-indicator" id="bootEcatConn" style="margin-bottom:10px;"> <span class="jump-role-label">当前角色 · 点击探测</span>
<span class="light" id="bootEcatConnLight"></span> <strong id="bootEcatRoleTxt">未探测</strong>
<span id="bootEcatConnTxt">未探测</span> <span class="jump-role-sw" id="bootEcatRoleSw">—</span>
</div> </div>
<button class="btn btn-outline btn-block" id="bootEcatProbeBtn" data-action="boot-ecat-probe">探测对面是谁</button> <div class="jump-role-meta">
<button class="btn btn-outline btn-block" id="bootEcatRebootBtn" data-action="boot-ecat-reboot" <div><span>设备名 0x1008</span><b id="bootEcatName">—</b></div>
style="margin-top:8px;" title="写 APP 的 CoE 0x2502:00=1,让它复位进引导程序">让应用回引导程序</button> <div><span>软件版本 0x100A</span><b id="bootEcatSw">—</b></div>
<div class="file-info-grid" style="margin-top:10px;"> <div><span>从站放行</span><b id="bootEcatArm">—</b></div>
<div class="file-meta-item"><span>对面是谁</span><b id="bootEcatWho">--</b></div> <div><span>主站缓存 / 已写 flash</span><b id="bootEcatRecv">—</b></div>
<div class="file-meta-item"><span>从站是否放行</span><b id="bootEcatArm">--</b></div> </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> </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> </div>
<!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 --> <!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 -->
</div> </div>
@@ -569,7 +562,7 @@
必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。 必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。
与 tabs.html / motor.html 并存时连接数>1,互不影响。 --> 与 tabs.html / motor.html 并存时连接数>1,互不影响。 -->
<script src="../js/common/app-lease.js"></script> <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/ota/console-mode.js?v=20260916d"></script>
<script type="module" src="../js/ble_ota.js?v=20260901d"></script> <script type="module" src="../js/ble_ota.js?v=20260901d"></script>
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked <!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
+48
View File
@@ -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()
+130
View File
@@ -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()
+53
View File
@@ -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()
+105
View File
@@ -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)
+126
View File
@@ -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())
+86
View File
@@ -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)
+128
View File
@@ -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)
+44
View File
@@ -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()
+312
View File
@@ -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())
+76
View File
@@ -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()
+79
View File
@@ -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()
+71
View File
@@ -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()
+136
View File
@@ -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())
+110
View File
@@ -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()