init: DCU LAN9252 EtherCAT 主站工具集

现有工具纳入版本管理:
- ecat_tool.py: 一体化命令行主站(status/flash/eeprom/sdo/send)
- ecat_server.py + ecat.html/css: 网页/桌面控制台(端口 8090)
- ecat_scan.py / ecat_esc_regs.py / ecat_sniff.py: bring-up 扫描、
  网络侧 ESC 寄存器诊断、裸帧抓包对照
- ecat_flash_eeprom.py: 经网线烧写 SII 镜像 + 逐字校验
- *.bat / *.vbs 启动入口

新增 .gitignore(排除 venv/ 与 __pycache__)、.gitattributes(不改写换行符)、
requirements.txt(锁定 pysoem 1.1.13 等依赖)、README.md。
This commit is contained in:
cbb
2026-09-03 01:56:29 +08:00
commit d594b8fd94
18 changed files with 3854 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写
* -text
+23
View File
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
# 虚拟环境(本地生成,勿入库)
venv/
.venv/
env/
# 日志与抓包产物
*.log
*.pcap
*.pcapng
sniff_*.txt
# 编辑器 / 系统
.vscode/
.idea/
*.swp
Thumbs.db
desktop.ini
+43
View File
@@ -0,0 +1,43 @@
# ecat — DCU LAN9252 EtherCAT 主站工具集
在 Windows 上用 Python + pysoem(SOEM)直接从网卡驱动 DCU 的 LAN9252 EtherCAT 从站:
扫描总线、读写 SII EEPROM、CoE SDO 读写、PDO 循环收发,以及一个本地网页/桌面控制台。
## 环境准备
- Python 3.12(venv 不入库)
- [Npcap](https://npcap.com/):pysoem 收发裸 EtherCAT 帧的前提
- 一块直连从站的物理网卡(示例中为 Intel I226)
```bat
py -3.12 -m venv venv
venv\Scripts\python.exe -m pip install -r requirements.txt
```
## 目录内容
| 文件 | 说明 |
|------|------|
| `ecat_tool.py` | 一体化命令行主站:`adapters` / `status` / `flash` / `eeprom` / `sdo` / `send` 等子命令 |
| `ecat_server.py` | 网页控制台后端(REST API + 静态文件),默认端口 **8090**(8080 已被 ESP32 tools 占用) |
| `ecat.html` / `ecat.css` | 网页控制台前端 |
| `ecat_scan.py` | 最小化 bring-up 扫描:从站身份 + SII dump,`--op` 追加 PDO 测试 |
| `ecat_esc_regs.py` | 从**网络侧**直读 LAN9252 ESC 寄存器,绕开 GD32/PDI,用于判断 EEPROM 加载是否真的失败;支持 `--reload` |
| `ecat_flash_eeprom.py` | 通过网线烧写 `gen_eeprom.py` 生成的 SII 镜像,逐字回读校验(烧完需断电重启让 ESC 重新加载) |
| `ecat_sniff.py` | 旁路抓包,hexdump SOEM 自己发出的帧,用于对照裸帧格式 |
| `_repro_hang.py` | 复现 op/start 卡住→闪退的最小脚本,带 faulthandler 看门狗 |
## 启动方式
| 入口 | 效果 |
|------|------|
| `ecat_app.vbs` | pywebview 桌面窗口,无控制台无浏览器标签(已针对部分 GPU 驱动白屏加 `--disable-gpu-compositing`) |
| `ecat_web.vbs` | 后台起服务并打开浏览器标签,无控制台窗口 |
| `ecat_web.bat` | 控制台模式,可见日志,调试用 |
| `ecat.bat` | 命令行工具入口,如 `ecat.bat status`、`ecat.bat flash` |
| `run_scan.bat` | 直接跑 bring-up 扫描,无参数则列出 Npcap 适配器 |
## 注意
- 前端 CSS/JS 改动后记得 bump `?v=` 缓存版本,否则浏览器会加载旧代码。
- EEPROM 烧写后必须断电冷启动,ESC 才会重新加载配置。
+78
View File
@@ -0,0 +1,78 @@
"""_repro_hang.py — 复现 2026-09-03 01:40 "op/start 卡住→闪退"。
复刻 ecat.html 的 UI 操作序列(100ms 周期 + 2s pollSync + 手动 stop/start 节奏):
open → 循环 N 轮{ op/start → 立即 cycle+sync_diag → cycle@100ms → ~2s 后 op/stop } → close
faulthandler 45s 看门狗: 卡住时自动转储所有线程栈(op/start 有界 ~26s, 超过 45s = 真卡死)。
退出码非 0xC0000005 之外的异常退出也会打印。
"""
import faulthandler
import sys
import time
faulthandler.enable() # 捕获原生访问违例(SEH)并打印 Python 栈 = 闪退根因现场
faulthandler.dump_traceback_later(45, exit=False)
import ecat_server as es
def t():
return time.strftime("%H:%M:%S")
def show(res):
for line in res.get("log", []):
print(f" [{t()}] {line}", flush=True)
def main():
rounds = int(sys.argv[1]) if len(sys.argv) > 1 else 30
r = es.api_open({"substr": "I226"})
show(r)
if not r.get("ok"):
print("open 失败, 退出", flush=True)
return 1
for i in range(rounds):
print(f"=== round {i} op/start ===", flush=True)
r = es.api_op_start({"dc_cycle_us": 0})
show(r)
if not r.get("ok"):
print(f" [{t()}] op/start 失败, 继续下一轮", flush=True)
time.sleep(1.0)
continue
# 复刻 doCycle() 立即一次 + pollSync() 立即一次
r = es.api_op_cycle({"outputs": ""})
if not r.get("ok"):
show(r)
r = es.api_sync_diag({})
if not r.get("ok"):
show(r)
# 100ms 周期跑 ~2s, 中途 2s 处再 pollSync 一次
end = time.time() + 2.0
synced = False
while time.time() < end:
r = es.api_op_cycle({"outputs": ""})
if not r.get("ok"):
show(r)
break
if not synced and time.time() > end - 1.0:
synced = True
r = es.api_sync_diag({})
if not r.get("ok"):
show(r)
time.sleep(0.1)
print(f" [{t()}] op/stop", flush=True)
r = es.api_op_stop({})
show(r)
time.sleep(1.0) # 用户点击间隔
es.api_close({})
print(f"[{t()}] 全部 {rounds} 轮完成, 无卡死", flush=True)
faulthandler.cancel_dump_traceback_later()
return 0
if __name__ == "__main__":
rc = main()
print(f"EXIT rc={rc}", flush=True)
sys.exit(rc)
+8
View File
@@ -0,0 +1,8 @@
@echo off
rem EtherCAT master tool for DCU LAN9252 (scan/flash/send/monitor)
rem Examples: ecat.bat status | ecat.bat flash | ecat.bat send --u64 0x1,0x2,0x3,0x4 --duration 5
chcp 65001 >nul
set PYTHONUTF8=1
cd /d "%~dp0"
"%~dp0venv\Scripts\python.exe" ecat_tool.py %*
pause
+797
View File
@@ -0,0 +1,797 @@
/* motor.css - 电机控制台页面样式
* 由 motor.html 内联 <style>(原 L7-L675)机械外提,内容未改。
* 独立主题(seed tokens),不依赖 base.css/components.css。 */
/* ═══════════════════════ SEED TOKENS ═══════════════════════ */
:root {
--seed-bg: #090e15;
--seed-surface: #0f161f;
--seed-fg: #e2e9f2;
--seed-primary: #22d3ee;
--seed-accent: #f59e0b;
--seed-radius: 10px;
/* derived tokens */
--bg-root: var(--seed-bg);
--bg-panel: var(--seed-surface);
--bg-raised: color-mix(in srgb, var(--seed-surface) 82%, #ffffff 4%);
--fg: var(--seed-fg);
--fg-muted: color-mix(in srgb, var(--seed-fg) 55%, transparent);
--fg-dim: color-mix(in srgb, var(--seed-fg) 35%, transparent);
--border: color-mix(in srgb, var(--seed-fg) 10%, transparent);
--border-strong: color-mix(in srgb, var(--seed-fg) 18%, transparent);
--primary: var(--seed-primary);
--primary-dim: color-mix(in srgb, var(--seed-primary) 18%, transparent);
--primary-glow: color-mix(in srgb, var(--seed-primary) 30%, transparent);
--warn: var(--seed-accent);
--warn-dim: color-mix(in srgb, var(--seed-accent) 15%, transparent);
--ok: #34d399;
--ok-dim: color-mix(in srgb, #34d399 14%, transparent);
--danger: #f87171;
--danger-dim: color-mix(in srgb, #f87171 14%, transparent);
--radius: var(--seed-radius);
--radius-sm: calc(var(--seed-radius) * 0.6);
--radius-lg: calc(var(--seed-radius) * 1.4);
--font-body: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif;
--font-mono: "Cascadia Code", "Fira Code", "SF Mono", "JetBrains Mono", Consolas, monospace;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--toolbar-h: 54px;
--statusbar-h: 42px;
}
/* ═══════════════════════ RESET & BASE ═══════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
font-family: var(--font-body);
background: var(--bg-root);
color: var(--fg);
line-height: 1.5;
height: 100vh;
overflow: hidden;
display: grid;
grid-template-rows: var(--toolbar-h) 1fr var(--statusbar-h);
}
/* ambient grid texture */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(color-mix(in srgb, var(--seed-primary) 3%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 3%, transparent) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, black 20%, transparent 80%);
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, select { font: inherit; color: inherit; }
label { display: block; }
/* ═══════════════════════ TOOLBAR ═══════════════════════ */
.toolbar {
grid-row: 1;
grid-column: 1;
position: relative; z-index: 10;
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-5);
background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 7%, var(--bg-panel)), var(--bg-panel) 42%);
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0, 0, 0, .15);
}
.toolbar-brand {
display: flex; align-items: center; gap: var(--sp-3);
font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em;
white-space: nowrap;
}
.toolbar-brand svg { flex-shrink: 0; }
.toolbar-brand .sub {
font-size: 0.72rem; font-weight: 400; color: var(--fg-muted);
letter-spacing: 0.08em; text-transform: uppercase;
font-family: var(--font-mono);
}
.toolbar-sep { width: 1px; height: 26px; background: var(--border-strong); flex-shrink: 0; }
.conn-group { display: flex; align-items: center; gap: var(--sp-2); }
.conn-ip-label {
font-family: var(--font-mono); font-size: 0.82rem;
color: var(--fg-muted); padding: 4px 8px;
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius-sm); letter-spacing: 0.02em;
min-width: 7.5em;
}
.toolbar-right a.btn {
text-decoration: none; color: var(--fg-muted);
border-color: var(--border-strong);
}
.toolbar-right a.btn:hover { color: var(--primary); border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent); }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--radius-sm);
font-size: 0.85rem; font-weight: 550; letter-spacing: 0.02em;
border: 1px solid var(--border-strong);
background: var(--bg-raised);
transition: background .15s, border-color .15s, transform .1s;
white-space: nowrap;
}
.btn:hover { background: color-mix(in srgb, var(--seed-surface) 60%, #ffffff 8%); }
.btn:active { transform: translateY(1px); }
.btn-primary {
background: color-mix(in srgb, var(--seed-primary) 16%, var(--seed-surface));
border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent);
color: var(--primary);
}
.btn-primary:hover { background: color-mix(in srgb, var(--seed-primary) 24%, var(--seed-surface)); }
.btn-danger {
background: var(--danger-dim); border-color: color-mix(in srgb, var(--danger) 40%, transparent);
color: var(--danger);
}
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
.btn:disabled { opacity: .4; pointer-events: none; }
.led {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
background: var(--fg-dim); transition: background .3s, box-shadow .3s;
}
.led-on { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.led-warn { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.led-err { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
.led-pulse { animation: ledPulse 1.2s ease-in-out infinite; }
@keyframes ledPulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.ch-select {
padding: 5px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.82rem; outline: none;
}
.ch-select:focus { border-color: var(--primary); }
/* 轮询卡顿:通道下拉框变黄并呼吸发光(fixed-tick 下某拍未按时完成) */
.ch-select.stuck {
border-color: color-mix(in srgb, var(--warn) 65%, transparent);
animation: stuckPulse 1.2s ease-in-out infinite;
}
@keyframes stuckPulse {
0%, 100% { box-shadow: 0 0 2px color-mix(in srgb, var(--warn) 15%, transparent); }
50% { box-shadow: 0 0 10px color-mix(in srgb, var(--warn) 40%, transparent); }
}
/* 刷新速度输入框:与 .ch-select 同风格,宽度收窄 */
.refresh-input {
width: 64px; padding: 5px 6px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.82rem; outline: none;
text-align: center;
}
.refresh-input:focus { border-color: var(--primary); }
.refresh-input::-webkit-inner-spin-button { opacity: .5; }
.refresh-hint { font-size: .68rem; color: var(--fg-dim); font-family: var(--font-mono); letter-spacing: .04em; }
.toolbar-right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
.node-badge {
font-family: var(--font-mono); font-size: 0.8rem;
padding: 4px 12px; border-radius: 99px;
background: var(--primary-dim); color: var(--primary);
border: 1px solid color-mix(in srgb, var(--seed-primary) 30%, transparent);
letter-spacing: 0.02em;
}
/* ═══════════════════════ MAIN LAYOUT ═══════════════════════ */
.main-grid {
grid-row: 2;
grid-column: 1;
position: relative; z-index: 1;
display: grid;
grid-template-columns: minmax(240px, 290px) minmax(0, 0.9fr) minmax(420px, 1.1fr);
gap: var(--sp-3);
padding: var(--sp-3);
overflow: hidden;
min-height: 0;
}
.panel {
background: linear-gradient(145deg, color-mix(in srgb, var(--bg-panel) 94%, #ffffff 3%), var(--bg-panel) 52%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, .16), inset 0 1px 0 rgba(255, 255, 255, .025);
}
.panel-head {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
background: linear-gradient(90deg, var(--primary-dim), transparent 48%);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-title {
font-size: 0.78rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-muted); font-family: var(--font-mono);
}
.panel-body { flex: 1; overflow-y: auto; min-height: 0; }
/* ═══════════════════════ LEFT: NODE LIST ═══════════════════════ */
.node-list { padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2); }
.node-empty { padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: .8rem; line-height: 1.7; }
.node-card {
position: relative;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-root);
cursor: pointer;
transition: border-color .2s, background .2s, transform .12s;
}
.node-card:hover { border-color: var(--border-strong); transform: translateX(2px); }
.node-card.active {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 6%, var(--bg-root));
}
.node-card.fault { border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
.node-card.fault.active { border-color: var(--danger); }
.node-card.offline { opacity: .5; }
/* 独立 BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳),不归属任何 Nx
* 注意:不要用内联 style="display:none",否则 .show 的 display:flex 盖不过。 */
.boot-badge {
display: none; align-items: center; gap: var(--sp-2);
margin-bottom: var(--sp-2); padding: var(--sp-2) var(--sp-3);
border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent);
border-radius: var(--radius); background: color-mix(in srgb, var(--warn) 12%, transparent);
font-family: var(--font-mono); font-size: .72rem; color: var(--warn);
font-weight: 600; letter-spacing: 0.02em;
}
.boot-badge.show { display: flex; }
.boot-badge .boot-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--warn); box-shadow: 0 0 6px var(--warn);
animation: bootpulse 1s ease-in-out infinite;
}
.boot-badge .boot-sub { opacity: .75; font-weight: 400; margin-left: auto; }
@keyframes bootpulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.nc-top { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.nc-id {
font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem;
letter-spacing: -0.02em; min-width: 2.2em;
}
.nc-mode {
font-size: 0.7rem; padding: 2px 8px; border-radius: 99px;
background: var(--primary-dim); color: var(--primary);
font-weight: 550; letter-spacing: 0.02em;
}
.nc-mode.mode-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
.nc-mode.mode-fault { background: var(--danger-dim); color: var(--danger); }
/* 节点在其他通道在线:橙色通道标签,提示切换顶部通道查看 */
.nc-mode.mode-other-ch { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.nc-status-text {
margin-left: auto; font-size: 0.68rem; color: var(--fg-muted);
font-family: var(--font-mono); max-width: 7.5em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
letter-spacing: 0.01em;
}
.node-card.fault .nc-status-text { color: var(--danger); font-weight: 600; }
.nc-readings {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-1);
font-family: var(--font-mono); font-size: 0.78rem;
}
.nc-reading { display: flex; flex-direction: column; gap: 1px; }
.nc-reading .lbl { font-size: 0.62rem; color: var(--fg-dim); letter-spacing: 0.06em; text-transform: uppercase; }
.nc-reading .val { color: var(--fg); transition: color .3s; }
.nc-faults {
position: absolute; top: var(--sp-2); right: var(--sp-2);
display: flex; gap: 3px;
}
.fault-dot {
width: 7px; height: 7px; border-radius: 2px;
background: var(--danger); opacity: .9;
animation: ledPulse 1s ease-in-out infinite;
}
.fault-dot:nth-child(2) { animation-delay: .2s; }
.fault-dot:nth-child(3) { animation-delay: .4s; }
/* ═══════════════════════ CENTER: CONTROL ═══════════════════════ */
.center-panel { display: flex; flex-direction: column; min-height: 0; }
.cmd-section { padding: var(--sp-4); flex-shrink: 0; }
.cmd-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--sp-4);
}
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label {
font-size: 0.72rem; font-weight: 550; color: var(--fg-muted);
letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono);
}
.field-unit { color: var(--fg-dim); text-transform: none; letter-spacing: 0; }
.field select, .field input[type="number"], .field input[type="text"] {
padding: 7px 10px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.88rem; outline: none;
transition: border-color .2s;
width: 100%;
}
.field select:focus, .field input:focus { border-color: var(--primary); }
.mode-desc {
font-size: 0.75rem; color: var(--fg-dim); margin-top: 2px;
min-height: 1.2em;
}
.pid-group {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3);
}
/* 快捷运行:一键写目标值并立即发送(目标字段随控制模式联动) */
.quick-run {
display: flex; align-items: center; gap: var(--sp-3);
margin-top: var(--sp-4); flex-wrap: wrap;
}
.qr-label {
font-size: 0.72rem; font-weight: 550; color: var(--fg-muted);
letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono);
user-select: none;
}
.qr-target { color: var(--fg-dim); text-transform: none; letter-spacing: 0; }
.qr-btns { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.qr-btn {
min-width: 52px; padding: 6px 12px;
background: var(--bg-root); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); color: var(--fg);
font-family: var(--font-mono); font-size: 0.88rem; font-weight: 600;
cursor: pointer; transition: border-color .15s, color .15s, background .15s;
}
.qr-btn:hover { border-color: var(--primary); color: var(--primary); }
.qr-btn:active { transform: translateY(1px); }
.qr-btn:focus-visible { outline: none; border-color: var(--primary); }
.cmd-actions {
display: flex; gap: var(--sp-3); margin-top: var(--sp-4);
padding-top: var(--sp-3); border-top: 1px solid var(--border);
align-items: center; flex-wrap: wrap;
}
.send-hint { font-size: 0.72rem; color: var(--fg-dim); margin-left: auto; font-family: var(--font-mono); }
.target-hidden { display: none !important; }
.repeat-period {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.75rem; color: var(--fg-muted);
user-select: none;
}
.repeat-period input {
width: 4.6em; padding: 5px 6px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg);
outline: none; text-align: right; transition: border-color .2s;
}
.repeat-period input:focus { border-color: var(--primary); }
/* tabs */
.tabs-bar {
display: flex; gap: 2px; padding: 0 var(--sp-4);
border-top: 1px solid var(--border); flex-shrink: 0;
background: color-mix(in srgb, var(--bg-root) 50%, var(--bg-panel));
}
.tab-btn {
padding: 9px 16px; font-size: 0.8rem; font-weight: 550;
color: var(--fg-muted); border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s; letter-spacing: 0.02em;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-content { flex: 1; overflow-y: auto; min-height: 0; padding: var(--sp-4); }
.tab-page { display: none; }
.tab-page.active { display: block; }
/* config tab */
.config-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); }
.config-btn {
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
padding: var(--sp-4); border-radius: var(--radius);
border: 1px solid var(--border-strong); background: var(--bg-root);
transition: border-color .2s, background .2s, transform .1s;
}
.config-btn:hover { border-color: var(--fg-dim); transform: translateY(-1px); }
.config-btn:active { transform: translateY(1px); }
.config-btn svg { opacity: .8; }
.config-btn .cb-name { font-size: 0.82rem; font-weight: 550; }
.config-btn .cb-sig { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); }
.config-btn.danger-action:hover { border-color: color-mix(in srgb, var(--danger) 50%, transparent); }
/* version tab */
.version-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.ver-card {
padding: var(--sp-4); border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-root); text-align: center;
}
.ver-card .ver-type { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); margin-bottom: var(--sp-2); }
.ver-card .ver-date { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em; }
.ver-empty { color: var(--fg-dim); font-size: 0.85rem; }
/* node id tab */
.nodeid-row { display: flex; gap: var(--sp-3); align-items: flex-end; flex-wrap: wrap; }
.nodeid-result {
margin-top: var(--sp-3); padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-sm); background: var(--bg-root);
border: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.85rem;
color: var(--fg-muted); min-height: 2.6em;
}
/* OTA tab */
.ota-stages {
display: flex; gap: 0; margin-bottom: var(--sp-4); overflow-x: auto;
padding-bottom: var(--sp-1);
}
.ota-stage {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
font-size: 0.72rem; color: var(--fg-dim); white-space: nowrap;
position: relative;
}
.ota-stage::after {
content: ""; width: 20px; height: 1px; background: var(--border-strong);
margin-left: var(--sp-2); flex-shrink: 0;
}
.ota-stage:last-child::after { display: none; }
.ota-stage .st-dot {
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
border: 1.5px solid var(--border-strong);
display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.62rem;
transition: border-color .3s, background .3s, color .3s;
}
.ota-stage.done .st-dot { border-color: var(--ok); background: var(--ok-dim); color: var(--ok); }
.ota-stage.current .st-dot { border-color: var(--primary); background: var(--primary-dim); color: var(--primary); animation: ledPulse 1.4s infinite; }
.ota-stage.current { color: var(--fg); }
.ota-stage.done { color: var(--fg-muted); }
.ota-progress-wrap { margin-bottom: var(--sp-4); }
.ota-progress-bar {
height: 8px; border-radius: 4px; background: var(--bg-root);
border: 1px solid var(--border); overflow: hidden;
}
.ota-progress-fill {
height: 100%; width: 0%; border-radius: 4px;
background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 70%, var(--bg-root)), var(--primary));
transition: width .3s ease;
}
.ota-stats {
display: flex; gap: var(--sp-5); margin-top: var(--sp-2);
font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-muted);
flex-wrap: wrap;
}
.ota-stats .hl { color: var(--primary); }
.ota-file-row { display: flex; gap: var(--sp-3); align-items: center; margin-bottom: var(--sp-4); flex-wrap: wrap; }
.ota-file-name { font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg-muted); }
.ota-log {
margin-top: var(--sp-3); padding: var(--sp-3);
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius-sm); font-family: var(--font-mono);
font-size: 0.72rem; line-height: 1.7; color: var(--fg-muted);
max-height: 160px; overflow-y: auto;
}
.ota-log .log-ok { color: var(--ok); }
.ota-log .log-err { color: var(--danger); }
.ota-log .log-info { color: var(--primary); }
.ota-log-toolbar {
display: flex; justify-content: flex-end; gap: 6px;
margin-top: var(--sp-3); margin-bottom: -2px;
}
.ota-log-toolbar button {
font-size: 0.68rem; padding: 2px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--bg-card); color: var(--fg-muted);
cursor: pointer; transition: background .15s, color .15s;
}
.ota-log-toolbar button:hover { background: rgba(255,255,255,.08); color: var(--fg); }
.ota-log-toolbar button.copied { background: rgba(34,197,94,.18); color: #86efac; }
/* ═══════════════════════ RIGHT: STATUS PANEL ═══════════════════════ */
.status-panel .panel-body { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); }
.big-readings { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.big-reading {
padding: var(--sp-3) var(--sp-3); text-align: center;
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .3s, background .2s;
cursor: pointer; position: relative;
}
.big-reading:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--seed-primary) 4%, var(--bg-root)); }
.big-reading.open { border-color: color-mix(in srgb, var(--seed-primary) 50%, transparent); }
.br-toggle {
font-size: 0.55rem; color: var(--fg-dim); letter-spacing: 0.06em;
opacity: 0; transition: opacity .2s, color .2s; margin-left: 4px;
}
.big-reading:hover .br-toggle { opacity: .8; }
.big-reading.open .br-toggle { opacity: 1; color: var(--primary); }
.br-scope {
max-height: 0; overflow: hidden; margin-top: 0;
transition: max-height .35s cubic-bezier(.4,0,.2,1), margin-top .35s;
}
.big-reading.open .br-scope { max-height: 96px; margin-top: var(--sp-2); }
.br-scope canvas {
width: 100%; height: 84px; display: block;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--seed-bg) 78%, #000000 14%);
border: 1px solid var(--border);
}
.big-reading .br-val {
font-family: var(--font-mono); font-size: clamp(1.5rem, 2vw, 2.4rem);
font-weight: 700; letter-spacing: -0.02em; line-height: 1.2;
transition: color .25s;
}
.big-reading .br-val.flash { color: var(--primary); }
.big-reading .br-unit { font-size: 0.65rem; color: var(--fg-dim); font-family: var(--font-mono); }
.big-reading .br-lbl {
font-size: 0.62rem; color: var(--fg-muted); margin-top: 2px;
letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono);
}
.big-reading .br-target {
font-size: 0.65rem; color: var(--fg-dim); font-family: var(--font-mono); margin-top: 1px;
}
.big-reading .br-target span { color: var(--primary); font-weight: 600; }
.big-reading .br-target span:empty::before { content: "--"; color: var(--fg-dim); }
.status-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.temp-block {
padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .3s, background .2s; cursor: pointer;
}
.temp-block:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--seed-primary) 4%, var(--bg-root)); }
.temp-block.open { border-color: color-mix(in srgb, var(--seed-primary) 50%, transparent); }
.temp-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--sp-2); }
.temp-name { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono); }
.temp-toggle {
font-size: 0.55rem; color: var(--fg-dim); letter-spacing: 0.06em;
opacity: 0; transition: opacity .2s, color .2s; margin-left: 6px;
}
.temp-block:hover .temp-toggle { opacity: .8; }
.temp-block.open .temp-toggle { opacity: 1; color: var(--primary); }
.temp-val { font-family: var(--font-mono); font-size: 0.95rem; font-weight: 600; }
.temp-bar { height: 5px; border-radius: 3px; background: var(--border); overflow: hidden; }
.temp-block .br-scope { max-height: 0; overflow: hidden; margin-top: 0; transition: max-height .35s cubic-bezier(.4,0,.2,1), margin-top .35s; }
.temp-block.open .br-scope { max-height: 80px; margin-top: var(--sp-2); }
.temp-block .br-scope canvas { height: 68px; }
.temp-fill {
height: 100%; border-radius: 3px; transition: width .6s ease, background .6s;
background: var(--ok);
}
.temp-fill.warm { background: var(--warn); }
.temp-fill.hot { background: var(--danger); }
.phase-currents { padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.pc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2); flex-wrap: wrap; }
.pc-title { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono); }
.pc-live-vals { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.pc-chip {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
padding: 2px 8px; border-radius: 99px;
background: color-mix(in srgb, var(--ch-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--ch-color) 30%, transparent);
}
.pc-chip b { color: var(--ch-color); font-weight: 600; min-width: 4.2em; text-align: right; }
.scope-wrap { position: relative; height: 172px; border-radius: var(--radius-sm); overflow: hidden; background: color-mix(in srgb, var(--seed-bg) 80%, #000000 12%); border: 1px solid var(--border); }
.scope-wrap canvas { width: 100%; height: 100%; display: block; }
.scope-grid-labels {
position: absolute; left: 6px; top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: space-between;
font-family: var(--font-mono); font-size: 0.58rem; color: var(--fg-dim);
padding: 4px 0; pointer-events: none;
}
.misc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.misc-item {
padding: var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius);
display: flex; flex-direction: column; gap: 2px;
}
.misc-item .mi-lbl { font-size: 0.62rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); }
.misc-item .mi-val { font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
.flag-lights { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--sp-2); padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.flag-item {
display: flex; align-items: center; gap: var(--sp-2);
font-size: 0.75rem; font-family: var(--font-mono); color: var(--fg-muted);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--seed-surface) 60%, transparent);
transition: background .3s, border-color .3s, color .3s, box-shadow .3s;
}
.flag-item .flag-state { margin-left: auto; font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; opacity: .7; }
.flag-item.is-ok { border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.flag-item.is-ok .flag-state { color: var(--ok); }
.flag-item.is-fault {
background: color-mix(in srgb, var(--danger) 16%, var(--seed-bg));
border-color: color-mix(in srgb, var(--danger) 65%, transparent);
color: var(--danger); font-weight: 700;
box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 25%, transparent), inset 0 0 8px color-mix(in srgb, var(--danger) 8%, transparent);
animation: faultPulse 1.1s ease-in-out infinite;
}
.flag-item.is-fault .flag-state { color: var(--danger); opacity: 1; }
@keyframes faultPulse {
0%, 100% { box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 30%, transparent), inset 0 0 8px color-mix(in srgb, var(--danger) 10%, transparent); }
50% { box-shadow: 0 0 22px color-mix(in srgb, var(--danger) 50%, transparent), inset 0 0 12px color-mix(in srgb, var(--danger) 16%, transparent); }
}
.flag-led { width: 9px; height: 9px; border-radius: 50%; background: var(--border-strong); transition: background .3s, box-shadow .3s; flex-shrink: 0; }
.flag-led.on-ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.flag-led.on-err { background: var(--danger); box-shadow: 0 0 8px var(--danger); animation: ledPulse .8s infinite; }
.cpu-ring-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.cpu-ring { position: relative; width: 52px; height: 52px; flex-shrink: 0; }
.cpu-ring svg { transform: rotate(-90deg); }
.cpu-ring .ring-bg { fill: none; stroke: var(--border); stroke-width: 5; }
.cpu-ring .ring-fg { fill: none; stroke: var(--primary); stroke-width: 5; stroke-linecap: round; transition: stroke-dashoffset .6s ease; }
.cpu-pct {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
}
.cpu-info { display: flex; flex-direction: column; gap: 2px; }
.cpu-info .mi-lbl { font-size: 0.62rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); }
.cpu-info .mi-sub { font-size: 0.72rem; color: var(--fg-dim); font-family: var(--font-mono); }
.offline-overlay {
display: none; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-3); padding: var(--sp-6); color: var(--fg-dim); text-align: center;
}
.offline-overlay.show { display: flex; }
.offline-overlay .off-icon { opacity: .4; }
.status-live { display: flex; flex-direction: column; gap: var(--sp-4); }
.status-live.hide { display: none; }
/* ═══════════════════════ STATUS BAR ═══════════════════════ */
.statusbar {
grid-row: 3;
grid-column: 1;
position: relative; z-index: 10;
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-5);
background: color-mix(in srgb, var(--bg-panel) 92%, #000000 8%);
border-top: 1px solid var(--border);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
overflow: hidden;
}
.sb-item { display: flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
.sb-item .sb-val { color: var(--fg); }
.sb-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
.sb-last-send { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; color: var(--fg-dim); }
/* ═══════════════════════ TOAST & MODAL ═══════════════════════ */
.toast-container {
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: var(--sp-2); z-index: 1000;
pointer-events: none;
}
.toast {
padding: 10px 20px; border-radius: var(--radius);
background: var(--bg-raised); border: 1px solid var(--border-strong);
font-size: 0.82rem; box-shadow: 0 8px 32px rgba(0,0,0,.5);
animation: toastIn .25s ease;
display: flex; align-items: center; gap: var(--sp-2);
max-width: 80vw;
}
.toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.toast.err { border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.toast.warn { border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
.toast.out { animation: toastOut .2s ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }
.modal-mask {
position: fixed; inset: 0; z-index: 900;
background: rgba(4, 8, 14, .75);
display: none; align-items: center; justify-content: center;
}
.modal-mask.show { display: flex; }
.modal-box {
background: var(--bg-panel); border: 1px solid var(--border-strong);
border-radius: var(--radius-lg); padding: var(--sp-6);
min-width: 320px; max-width: 420px;
box-shadow: 0 24px 64px rgba(0,0,0,.6);
animation: toastIn .2s ease;
}
.modal-title { font-size: 1rem; font-weight: 600; margin-bottom: var(--sp-2); }
.modal-desc { font-size: 0.82rem; color: var(--fg-muted); margin-bottom: var(--sp-5); line-height: 1.6; }
.modal-actions { display: flex; gap: var(--sp-3); justify-content: flex-end; }
/* disconnected banner */
.net-banner {
display: none; grid-row: 1; grid-column: 1;
align-self: end; transform: translateY(100%); z-index: 20;
align-items: center; gap: var(--sp-2);
padding: 4px var(--sp-5); font-size: .72rem; font-family: var(--font-mono);
background: linear-gradient(90deg, color-mix(in srgb, var(--danger) 20%, var(--bg-panel)), var(--danger-dim)); color: var(--danger);
border-bottom: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .24);
}
.net-banner.show { display: flex; }
body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
/* ═══════════════════════ 命令序列 tab ═══════════════════════ */
.seq-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); }
.seq-status { font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-dim); margin-left: auto; white-space: nowrap; }
.seq-status.on { color: var(--primary); }
.seq-list { display: flex; flex-direction: column; gap: var(--sp-3); }
.seq-empty {
padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: 0.8rem;
border: 1px dashed var(--border-strong); border-radius: var(--radius); line-height: 1.7;
}
.seq-step {
padding: var(--sp-3); border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-root);
transition: border-color .2s, background .2s;
}
.seq-step:hover { border-color: var(--border-strong); }
.seq-step.current {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root));
}
.seq-locked .seq-step { opacity: .55; pointer-events: none; }
.seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; }
.seq-num {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
transition: border-color .2s;
}
.seq-num:focus { border-color: var(--primary); }
/* 隐藏原生上下箭头(样式丑且尺寸不可控):数值直接键入,聚焦后可用键盘 ↑↓ 微调 */
.seq-num { -moz-appearance: textfield; appearance: textfield; }
.seq-num::-webkit-inner-spin-button,
.seq-num::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.seq-step-head .seq-num { width: 6.8em; text-align: right; }
.seq-step-head select {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
}
.seq-step-head select:focus { border-color: var(--primary); }
.seq-hold-label, .seq-hold-unit { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); }
.seq-ops { margin-left: auto; display: flex; gap: 2px; }
.seq-ops button {
width: 26px; height: 26px; border-radius: var(--radius-sm);
display: inline-flex; align-items: center; justify-content: center;
color: var(--fg-dim); background: transparent; border: none;
transition: background .15s, color .15s;
}
.seq-ops button svg { flex-shrink: 0; }
.seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); }
.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); }
.seq-ops button:disabled { opacity: .25; cursor: default; }
.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); }
.seq-step-fields .field-label { font-size: 0.62rem; }
.seq-step-fields .seq-num { width: 100%; }
/* 动作组:一步内多个电机动作(近同时下发),动作行嵌套在步骤卡片内 */
.seq-count {
font-family: var(--font-mono); font-size: 0.68rem; color: var(--fg-dim);
padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap;
}
.seq-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.seq-action {
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg-panel);
}
.seq-action-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-action-tag { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; color: var(--fg-muted); min-width: 4.2em; }
.seq-action-head select {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
}
.seq-action-head select:focus { border-color: var(--primary); }
.seq-hint { margin-top: var(--sp-4); font-size: 0.72rem; color: var(--fg-dim); line-height: 1.6; }
/* ═══════════════════════ RESPONSIVE ═══════════════════════ */
@media (max-width: 1365px) {
.main-grid {
grid-template-columns: 1fr minmax(380px, 1.1fr);
grid-template-rows: auto 1fr;
}
.nodes-panel {
grid-column: 1 / -1; grid-row: 1;
}
.nodes-panel .panel-body { overflow-x: auto; overflow-y: hidden; }
.node-list { flex-direction: row; min-width: max-content; }
.node-card { min-width: 200px; }
.center-panel { grid-column: 1; grid-row: 2; }
.status-panel { grid-column: 2; grid-row: 2; }
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.center-panel { grid-column: 1; grid-row: 2; }
.status-panel { grid-column: 1; grid-row: 3; max-height: 380px; }
.toolbar { flex-wrap: wrap; height: auto; padding: var(--sp-2) var(--sp-4); }
body { grid-template-rows: auto 1fr var(--statusbar-h); overflow: auto; }
}
+477
View File
@@ -0,0 +1,477 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EtherCAT 控制台 — DCU LAN9252</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="ecat.css?v=20260831a">
<style>
/* ecat 页面专属补充(基于 motor.css 设计体系) */
.ecat-grid { grid-template-columns: minmax(240px, 290px) minmax(0, 1fr) minmax(0, 1.1fr); }
.center-col { display: flex; flex-direction: column; min-height: 0; }
.center-col .global-log { flex: 1; min-height: 90px; max-height: none; }
.mode-op { background: var(--ok-dim); color: var(--ok); }
.mode-safeop { background: var(--warn-dim); color: var(--warn); }
.mode-preop { background: color-mix(in srgb, var(--primary) 16%, transparent); color: var(--primary); }
.eep { margin-top: var(--sp-3); padding: var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.74rem; line-height: 1.7;
color: var(--fg-muted); white-space: pre; overflow: auto; max-height: 170px; }
.btn-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); align-items: center; }
.br-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2); margin-top: var(--sp-3); }
.br-row .big-reading { cursor: default; padding: var(--sp-2) var(--sp-2); }
.br-row .br-val { font-size: 0.95rem; }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2);
margin-top: var(--sp-3); }
.stat-cell { padding: var(--sp-2) var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--fg-dim); }
.stat-cell b { display: block; font-size: 1rem; color: var(--fg); font-weight: 600; }
.stat-cell b.err { color: var(--danger); }
.statusbar .btn { padding: 2px 12px; font-size: .68rem; }
</style>
</head>
<body>
<!-- ═══════════ TOOLBAR ═══════════ -->
<header class="toolbar">
<div class="toolbar-brand">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden="true">
<rect x="1" y="1" width="24" height="24" rx="6" stroke="var(--primary)" stroke-width="1.5" opacity=".6"/>
<path d="M8 7l-4.2 6L8 19M18 7l4.2 6L18 19" stroke="var(--primary)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="13" cy="13" r="1.6" fill="var(--primary)"/>
</svg>
<span>EtherCAT 控制台</span>
<span class="sub">DCU LAN9252</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<span class="led" id="connLed"></span>
<input type="text" id="substr" value="I226" style="width:96px" aria-label="适配器匹配子串">
<button class="btn btn-primary" id="btnConn">连接</button>
<button class="btn" id="btnDisc" disabled>断开</button>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<span class="refresh-hint">ESC EEPROM 直读 · 烧录后断电重启生效</span>
</div>
<div class="toolbar-right">
<span class="node-badge" id="alBadge">AL —</span>
</div>
</header>
<div class="net-banner" id="netBanner">
<span class="led led-err led-pulse" style="width:7px;height:7px"></span>
<span id="netBannerText">未连接 — 输入适配器匹配串(默认 I226)后点击连接</span>
</div>
<!-- ═══════════ MAIN GRID ═══════════ -->
<main class="main-grid ecat-grid">
<!-- LEFT: 从站身份 -->
<aside class="panel">
<div class="panel-head">
<span class="panel-title">从站身份</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="connDesc">—</span>
</div>
<div class="panel-body" style="padding:var(--sp-3)">
<div class="node-card" id="slaveCard">
<div class="nc-top">
<span class="nc-id">S0</span>
<span class="nc-mode mode-off" id="alMode">—</span>
</div>
<div class="nc-status-text" id="slaveName">未连接</div>
<div class="nc-readings">
<div class="nc-reading"><span class="lbl">VendorID</span><span class="val" id="rVid">—</span></div>
<div class="nc-reading"><span class="lbl">ProductCode</span><span class="val" id="rPid">—</span></div>
<div class="nc-reading"><span class="lbl">Revision</span><span class="val" id="rRev">—</span></div>
<div class="nc-reading"><span class="lbl">AL Status Code</span><span class="val" id="rCode">—</span></div>
</div>
</div>
<div class="mode-desc" style="margin-top:var(--sp-3)">
身份字来自 SII(ESC EEPROM 接口直读, 无需断电重载)。<br>
全 F = EEPROM 无效 → 先在 EEPROM 页烧录。<br>
AL 进 PREOP 后 SDO 可用; OP 需 SII 的 SM/FMMU 配置有效。
</div>
</div>
</aside>
<!-- CENTER: EEPROM + 全局日志 -->
<section class="panel center-col">
<div class="panel-head">
<span class="panel-title">EEPROM · SII / AT24C32</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)">4096 B · word 寻址</span>
</div>
<div class="panel-body" style="padding:var(--sp-4);display:flex;flex-direction:column;min-height:0">
<div class="cmd-grid">
<div class="field">
<span class="field-label">start <span class="field-unit">word 地址</span></span>
<input type="text" id="eeStart" value="0x0000">
</div>
<div class="field">
<span class="field-label">count <span class="field-unit">word 数</span></span>
<input type="number" id="eeCount" value="32" min="1" max="2048">
</div>
<div class="field">
<span class="field-label">&nbsp;</span>
<button class="btn" id="btnEeRead" disabled>读取</button>
</div>
</div>
<div class="eep" id="eeOut">—</div>
<div class="btn-row">
<button class="btn btn-danger" id="btnFlashGen" disabled>烧录 gen_eeprom.py 镜像</button>
<button class="btn btn-danger" id="btnFlashFile" disabled>烧录 bin 文件…</button>
<button class="btn" id="btnVerifyFile" disabled>校验 bin 文件…</button>
<input type="file" id="eeFile" style="display:none">
</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
烧录逐字校验约数秒; 完成后板子断电重启, ESC 才重载 PDI/SYNC 配置。
</div>
<div style="flex:1;min-height:0;display:flex;flex-direction:column;margin-top:var(--sp-4)">
<div class="panel-title" style="margin-bottom:var(--sp-2)">全局日志</div>
<div class="ota-log global-log" id="log"></div>
<div class="ota-log-toolbar">
<button id="btnClearLog">清空</button>
</div>
</div>
</div>
</section>
<!-- RIGHT: PDO / SDO -->
<section class="panel">
<div class="panel-head">
<span class="panel-title">过程数据 · 4×64bit</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="pdoSize">输出 — / 输入 —</span>
</div>
<div class="tabs-bar">
<button class="tab-btn active" data-tab="pdo">过程数据</button>
<button class="tab-btn" data-tab="sdo">SDO (CoE)</button>
</div>
<div class="tab-content">
<!-- PDO 页 -->
<div class="tab-page active" id="tabPdo">
<div class="cmd-grid">
<div class="field"><span class="field-label">输出 u64[0]</span><input type="text" id="o0" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[1]</span><input type="text" id="o1" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[2]</span><input type="text" id="o2" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[3]</span><input type="text" id="o3" value="0x0"></div>
</div>
<div class="btn-row">
<div class="field" style="min-width:110px">
<span class="field-label">周期 <span class="field-unit">ms</span></span>
<select id="rate"><option value="10">10</option><option value="50">50</option>
<option value="100" selected>100</option><option value="200">200</option></select>
</div>
<div class="field" style="min-width:150px">
<span class="field-label">DC SYNC0 <span class="field-unit">us</span></span>
<select id="dcCycle">
<option value="0" selected>关 (free-run)</option>
<option value="500">500</option><option value="1000">1000</option>
<option value="2000">2000</option><option value="4000">4000</option>
</select>
</div>
<div class="field" style="min-width:150px;justify-content:flex-end">
<span class="field-label">&nbsp;</span>
<button class="btn btn-primary" id="btnOp" disabled>进入 OP 并循环</button>
</div>
</div>
<div class="br-row">
<div class="big-reading"><div class="br-val" id="in0">—</div><div class="br-lbl">IN u64[0]</div></div>
<div class="big-reading"><div class="br-val" id="in1">—</div><div class="br-lbl">IN u64[1]</div></div>
<div class="big-reading"><div class="br-val" id="in2">—</div><div class="br-lbl">IN u64[2]</div></div>
<div class="big-reading"><div class="br-val" id="in3">—</div><div class="br-lbl">IN u64[3]</div></div>
</div>
<div class="stat-row">
<div class="stat-cell">周期<b id="kCyc">0</b></div>
<div class="stat-cell">WKC 错误<b id="kErr">0</b></div>
<div class="stat-cell">输出区<b id="kOut">—</b></div>
<div class="stat-cell">输入区<b id="kIn">—</b></div>
<div class="stat-cell">SYNC0<b id="kSync0">—</b></div>
<div class="stat-cell">SYNC0/s<b id="kSyncRate">—</b></div>
</div>
<div class="eep" id="inHex" style="margin-top:var(--sp-3)">—</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
循环期间每周期都把上方 4 个输出值写入输出 PDO, 修改即时生效。
</div>
</div>
<!-- SDO 页 -->
<div class="tab-page" id="tabSdo">
<div class="cmd-grid">
<div class="field"><span class="field-label">index</span><input type="text" id="sdoIdx" value="0x1000"></div>
<div class="field"><span class="field-label">sub</span><input type="text" id="sdoSub" value="0"></div>
<div class="field"><span class="field-label">size <span class="field-unit">读</span></span><input type="number" id="sdoSize" value="8" min="1"></div>
<div class="field"><span class="field-label">&nbsp;</span><button class="btn" id="btnSdoRead" disabled>SDO 读</button></div>
</div>
<div class="cmd-grid" style="margin-top:var(--sp-3)">
<div class="field"><span class="field-label">写 hex <span class="field-unit">字节流</span></span><input type="text" id="sdoHex" placeholder="AABBCC.."></div>
<div class="field"><span class="field-label">&nbsp;</span><button class="btn" id="btnSdoWrite" disabled>SDO 写</button></div>
</div>
<div class="eep" id="sdoOut">—</div>
</div>
</div>
</section>
</main>
<!-- ═══════════ STATUSBAR ═══════════ -->
<footer class="statusbar">
<span class="led" id="sbLed"></span>
<span id="sbState">未连接</span>
<span>·</span>
<span id="sbIdent">identity —</span>
<span style="margin-left:auto" id="sbLast"></span>
<button class="btn" id="btnQuit" title="结束后台 Python 服务进程(隐藏窗口模式下的退出入口)">退出服务</button>
</footer>
<script>
"use strict";
const $=id=>document.getElementById(id);
let connected=false, running=false, timer=null, cyc=0, wkcErr=0;
/* ---- 基础 ---- */
function log(msg, cls){
const d=new Date(), t=[d.getHours(),d.getMinutes(),d.getSeconds()]
.map(v=>String(v).padStart(2,"0")).join(":");
const el=document.createElement("div");
if(cls) el.className=cls;
el.innerHTML=`<span style="color:var(--fg-dim)">${t}</span> ${msg}`;
const box=$("log"); box.appendChild(el);
box.scrollTop=box.scrollHeight;
while(box.children.length>400) box.removeChild(box.firstChild);
$("sbLast").textContent=msg.replace(/<[^>]*>/g,"").slice(0,90);
}
function parseNum(v){v=String(v).trim();return /^0x/i.test(v)?parseInt(v,16):parseInt(v,10);}
async function api(path, body){
const r=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify(body||{})});
return await r.json();
}
function showLog(res){
(res.log||[]).forEach(l=>{
const err=/失败|FAIL|错误|不符|异常|无效|超/.test(l);
log(l, err?"log-err":(/OK|已进|已回|成功|全部一致/.test(l)?"log-ok":"log-info"));
});
}
function hex8(v){return "0x"+(v>>>0).toString(16).toUpperCase().padStart(8,"0");}
function setConnected(on, st){
connected=on;
["btnDisc","btnEeRead","btnFlashGen","btnFlashFile","btnVerifyFile",
"btnSdoRead","btnSdoWrite"].forEach(id=>$(id).disabled=!on);
$("btnOp").disabled=!on;
$("btnConn").disabled=on;
$("connLed").className="led "+(on?"led-on":"");
$("sbLed").className="led "+(on?"led-on":"led-err");
$("netBanner").classList.toggle("show",!on);
$("netBannerText").textContent=on?"":"未连接 — 输入适配器匹配串(默认 I226)后点击连接";
if(on&&st) updateSlave(st);
else if(!on){
$("slaveName").textContent="未连接"; $("alMode").textContent="—";
$("alMode").className="nc-mode mode-off"; $("alBadge").textContent="AL —";
["rVid","rPid","rRev","rCode"].forEach(id=>$(id).textContent="—");
$("connDesc").textContent="—"; $("sbState").textContent="未连接"; $("sbIdent").textContent="identity —";
}
}
function alInfo(al){
const name={0:"NONE",1:"INIT",2:"PREOP",3:"BOOT",4:"SAFEOP",8:"OP"}[al]||("0x"+al.toString(16));
const cls=al===8?"mode-op":(al===4?"mode-safeop":(al===2?"mode-preop":"mode-off"));
return {name, cls};
}
function updateSlave(st){
$("connDesc").textContent=st.desc||"—";
$("slaveName").textContent=st.name||"—";
const info=alInfo(st.state);
$("alMode").textContent=info.name; $("alMode").className="nc-mode "+info.cls;
$("alBadge").textContent="AL "+info.name;
$("rVid").textContent=hex8(st.man);
$("rPid").textContent=hex8(st.id);
$("rRev").textContent=(st.rev>>>0).toString(10);
$("rCode").textContent="0x"+(st.al_status>>>0).toString(16).toUpperCase().padStart(4,"0");
$("slaveCard").classList.toggle("fault", st.man===0xFFFFFFFF);
$("sbState").textContent="已连接";
$("sbIdent").textContent=`identity ${hex8(st.man)} / ${hex8(st.id)}`;
}
async function pollStatus(){
if(!connected||running) return;
try{const res=await api("/api/status"); if(res.ok) updateSlave(res.slave);}catch(e){}
}
setInterval(pollStatus,2000);
/* ---- 连接 ---- */
$("btnConn").onclick=async()=>{
const res=await api("/api/open",{substr:$("substr").value});
showLog(res);
if(res.ok){setConnected(true,res.slave); pollStatus();}
};
$("btnDisc").onclick=async()=>{
stopCycle(true);
const res=await api("/api/close");
showLog(res); setConnected(false);
};
let quitArmed=false, quitTimer=null;
$("btnQuit").onclick=async()=>{
if(!quitArmed){
quitArmed=true;
$("btnQuit").textContent="再点一次确认退出";
$("btnQuit").classList.add("btn-danger");
quitTimer=setTimeout(()=>{quitArmed=false;
$("btnQuit").textContent="退出服务";
$("btnQuit").classList.remove("btn-danger");},4000);
return;
}
clearTimeout(quitTimer); quitArmed=false;
$("btnQuit").textContent="退出服务";
$("btnQuit").classList.remove("btn-danger");
stopCycle(true);
const wv=(window.pywebview&&window.pywebview.api)?window.pywebview.api:null;
if(wv&&wv.quit_app){ log("正在退出…","log-info"); wv.quit_app(); return; }
try{await api("/api/shutdown");}catch(e){}
setConnected(false);
$("netBannerText").textContent="后台服务已退出 — 双击 ecat_web.vbs 重新启动";
log("后台服务已退出, 双击 ecat_web.vbs 重新启动","log-info");
};
/* ---- EEPROM ---- */
$("btnEeRead").onclick=async()=>{
const res=await api("/api/eeprom/read",
{start:parseNum($("eeStart").value),count:parseNum($("eeCount").value)});
showLog(res);
if(res.ok) $("eeOut").textContent=res.rows
.map(r=>`0x${r.addr.toString(16).padStart(4,"0")}: ${r.hex}`).join("\n");
};
function readBin(file,cb){
const r=new FileReader();
r.onload=()=>{const u=new Uint8Array(r.result);
cb(Array.from(u).map(b=>b.toString(16).padStart(2,"0")).join(""));};
r.readAsArrayBuffer(file);
}
$("eeFile").onchange=e=>{
const f=e.target.files[0]; if(!f) return;
readBin(f,async hex=>{
const isFlash=e.target.dataset.mode==="flash";
log(isFlash?"烧录上传的 bin…":"校验上传的 bin…");
const res=await api(isFlash?"/api/flash":"/api/eeprom/verify",{data:hex});
showLog(res);
if(res.ok&&isFlash) log(`烧录成功 ${res.written} 字, 记得板子断电重启`,"log-ok");
});
e.target.value="";
};
$("btnFlashGen").onclick=async()=>{
log("烧录 gen_eeprom.py 镜像…");
const res=await api("/api/flash",{});
showLog(res);
if(res.ok) log(`烧录成功 ${res.written} 字, 记得板子断电重启`,"log-ok");
};
$("btnFlashFile").onclick=()=>{$("eeFile").dataset.mode="flash";$("eeFile").click();};
$("btnVerifyFile").onclick=()=>{$("eeFile").dataset.mode="verify";$("eeFile").click();};
$("btnClearLog").onclick=()=>{$("log").innerHTML="";};
/* ---- SDO ---- */
$("btnSdoRead").onclick=async()=>{
const res=await api("/api/sdo",{op:"read",index:parseNum($("sdoIdx").value),
sub:parseNum($("sdoSub").value),size:parseNum($("sdoSize").value)});
showLog(res); $("sdoOut").textContent=res.data?res.data.toUpperCase():"—";
};
$("btnSdoWrite").onclick=async()=>{
const res=await api("/api/sdo",{op:"write",index:parseNum($("sdoIdx").value),
sub:parseNum($("sdoSub").value),hex:$("sdoHex").value.trim()});
showLog(res);
};
/* ---- tabs ---- */
document.querySelectorAll(".tab-btn").forEach(b=>{
b.onclick=()=>{
document.querySelectorAll(".tab-btn").forEach(x=>x.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
$(b.dataset.tab==="pdo"?"tabPdo":"tabSdo").classList.add("active");
};
});
/* ---- PDO ---- */
function outHex(){
const parts=[];
for(let i=0;i<4;i++){
let v; try{v=BigInt($("o"+i).value.trim()||"0");}catch(e){v=0n;}
if(v<0n) v&=0xFFFFFFFFFFFFFFFFn;
const h=v.toString(16).padStart(16,"0");
parts.push(h.match(/../g).reverse().join(""));
}
return parts.join("");
}
function renderInput(hex){
if(!hex||hex.length<64){["in0","in1","in2","in3"].forEach(id=>$(id).textContent="—");
$("inHex").textContent=hex||"—";return;}
for(let i=0;i<4;i++){
const bytes=hex.substr(i*16,16).match(/../g).reverse().join("");
$("in"+i).textContent="0x"+BigInt("0x"+bytes).toString(16).toUpperCase();
}
$("inHex").textContent="raw: "+hex.toUpperCase();
}
$("btnOp").onclick=async()=>{
if(!running){
const dc=parseNum($("dcCycle").value);
log(dc?`进入 OP(DC SYNC0 ${dc}us)…`:"进入 OP(free-run)…");
const res=await api("/api/op/start",{dc_cycle_us:dc});
showLog(res); if(!res.ok) return;
running=true; cyc=0; wkcErr=0;
$("btnOp").textContent="停止并回 INIT"; $("btnOp").className="btn btn-danger";
$("dcCycle").disabled=true;
$("kOut").textContent=$("kIn").textContent="…";
timer=setInterval(doCycle,parseNum($("rate").value));
doCycle();
syncTimer=setInterval(pollSync,2000); pollSync();
}else stopCycle();
};
function stopCycle(silent){
if(timer){clearInterval(timer);timer=null;}
if(syncTimer){clearInterval(syncTimer);syncTimer=null;}
if(running){
running=false;
api("/api/op/stop").then(res=>{if(!silent)showLog(res);});
$("btnOp").textContent="进入 OP 并循环"; $("btnOp").className="btn btn-primary";
$("dcCycle").disabled=false;
$("kSync0").textContent=$("kSyncRate").textContent="—";
}
}
/* DC 通路观测: 运行期每 2s 经 SDO 读从站 0x2000 SYNC Diag(固件≥1.5.31)。
DC 模式下 SYNC0/s 应≈1e6/周期us(1000us→1000/s); free-run 恒 0。 */
let syncTimer=null;
async function pollSync(){
if(!running) return;
try{
const res=await api("/api/sync_diag");
if(!res.ok){
$("kSync0").textContent="—"; $("kSyncRate").textContent="—";
if(!pollSync.warned){pollSync.warned=true;log(res.log.join(" | "),"log-err");}
return;
}
pollSync.warned=false;
$("kSync0").textContent=res.sync0.toLocaleString();
$("kSyncRate").textContent=res.sync0_rate==null?"…":res.sync0_rate.toLocaleString();
}catch(e){/* 网络瞬断, 下轮重试 */}
}
async function doCycle(){
if(!running) return;
try{
const res=await api("/api/op/cycle",{outputs:outHex()});
if(!res.ok){showLog(res);stopCycle(true);log("循环已停止","log-err");return;}
cyc++;
if(res.wkc<res.expected_wkc) wkcErr++;
$("kCyc").textContent=cyc;
$("kErr").textContent=wkcErr;
$("kErr").className=wkcErr>0?"err":"";
$("kOut").textContent=res.out_len+"B"; $("kIn").textContent=res.in_len+"B";
renderInput(res.input);
}catch(e){/* 网络瞬断, 下个周期重试 */}
}
window.addEventListener("beforeunload",()=>{
if(running) navigator.sendBeacon("/api/op/stop");
});
/* ---- 初始状态 ---- */
setConnected(false);
log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。");
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
' DCU EtherCAT desktop window (pywebview/WebView2). No console, no browser tab.
' Browser-tab launcher: ecat_web.vbs Console-mode debug launcher: ecat_web.bat
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
base = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = base
sh.Environment("PROCESS")("PYTHONUTF8") = "1"
' WebView2: keep GPU rasterization but disable GPU compositing.
' Full hardware acceleration white-screens this window on some GPU drivers;
' --disable-gpu-compositing fixes it while still using the GPU for raster.
sh.Environment("PROCESS")("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") = "--disable-gpu-compositing"
sh.Run """" & base & "\venv\Scripts\pythonw.exe"" """ & base & "\ecat_server.py"" --window", 0, False
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""ESC 网络侧寄存器诊断(绕过 GD32/HBI,直接从 EtherCAT 线上读 LAN9252)。
背景:GD32 侧 1.5.37 实测 EEPROM 300ms 不加载(DL_STATUS/EEP_CSR 经 PDI 读全 0),
但 PDI 侧读全 0 也可能是"PDI 未使能"的次生现象,不能区分:
A) EEPROM boot 加载真的失败(I2C 无应答 / CRC 错 / 寻址错)
B) 加载其实成功,PDI 通路另有问题
本工具从网络侧读真实寄存器一锤定音,并可用 --reload 命令 ESC 当场重新加载验证。
步骤:
1) pysoem 读 SII 前 16 字 + 重算 CRC -> EEPROM 内容是否完好(网络侧 I2C 是否可用)
2) Npcap 裸 EtherCAT 帧 APRD 读 0x0000/0x0010/0x0110/0x0130/0x0140/0x0500/0x0502
-> EEP Status(0x0502) 的 loaded/NACK/CRC 错误位
3) --reload:APWR 0x0502=0x0300 触发重新加载,再读一遍状态对比
用法:ecat_esc_regs.py [adapter-substr] [--reload] (默认 I226)
"""
import ctypes
import struct
import sys
import time
import pysoem
ECAT_ETHER_TYPE = 0x88A4
CMD_APRD = 0x07
CMD_APWR = 0x08
REGS = [
(0x0000, "Type/Revision"),
(0x0010, "Station Address"),
(0x0110, "DL Status"),
(0x0130, "AL Status"),
(0x0140, "ESC Config(PDI type)"),
(0x0500, "EEPROM Control"),
(0x0502, "EEPROM Status"),
]
EEP_FORCE_ECAT = 0x0001 # 0x0500 bit0: ECAT 侧强制取得 EEPROM 接口
EEP_CMD_RELOAD = 0x0300 # 0x0502: reload 命令(同 SOEM EC_ECMD_RELOAD)
def crc8_sii(data):
"""ETG.1000.6 word-7 checksum: CRC-8 poly 0x07, init 0xFF。"""
crc = 0xFF
for b in data:
crc ^= b
for _ in range(8):
crc = ((crc << 1) ^ 0x07) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
return crc
def find_adapter(substr):
for a in pysoem.find_adapters():
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
if substr.lower() in desc.lower():
return a
raise SystemExit(f"没有找到描述包含 {substr!r} 的适配器")
# ---------- 第 1 步:pysoem 读 SII 内容 + CRC 复算 ----------
def sii_check(adapter_name):
master = pysoem.Master()
master.open(adapter_name)
try:
if not master.config_init():
print("!! 总线上没有从站应答")
return False
slave = master.slaves[0]
# 与 ecat_flash_eeprom.py 相同的读回偏移探针(写完即恢复)
probe = b"\xC3\xA5"
slave.eeprom_write(0x0200, probe)
rb = bytes(slave.eeprom_read(0x0200))
off = rb.find(probe)
slave.eeprom_write(0x0200, b"\xff\xff")
if off < 0:
print(f"!! 探针字读写失败,读回 {rb.hex().upper()}")
print("!! 网络侧 EEPROM 访问已坏 -> I2C 物理层(R12 SDA 上拉/EEPROM 焊接)")
return False
words = []
for w in range(16):
rb = bytes(slave.eeprom_read(w))
words.append(struct.unpack_from("<H", rb, off)[0])
img14 = b"".join(struct.pack("<H", v) for v in words[:7])
crc_calc = crc8_sii(img14)
crc_stored = words[7] & 0xFF
print("SII word0-15: " + " ".join(f"{v:04X}" for v in words))
print(f" PDI Control = 0x{words[0]:04X}(低字节期望 0x8D = HBI 16-bit)")
print(f" Checksum = 0x{crc_stored:02X},重算 = 0x{crc_calc:02X} -> "
f"{'MATCH' if crc_calc == crc_stored else '*** MISMATCH ***'}")
vid = words[8] | (words[9] << 16)
pc = words[10] | (words[11] << 16)
print(f" VendorID=0x{vid:08X} ProductCode=0x{pc:08X}")
return crc_calc == crc_stored
finally:
master.close()
# ---------- 第 2 步:Npcap 裸 EtherCAT 帧读 ESC 寄存器 ----------
class PcapPktHdr(ctypes.Structure):
_fields_ = [("tv_sec", ctypes.c_long), ("tv_usec", ctypes.c_long),
("caplen", ctypes.c_uint32), ("len", ctypes.c_uint32)]
def load_wpcap():
for cand in ("wpcap.dll", r"C:\Windows\System32\Npcap\wpcap.dll"):
try:
return ctypes.WinDLL(cand)
except OSError:
continue
raise SystemExit("找不到 wpcap.dll(Npcap)")
def ecat_frame(cmd, idx, adp, ado, payload):
"""帧布局按 ecat_sniff.py 对 SOEM 流量的实测:
EtherCAT 头 len = datagram 区长度(不含自身 2 字节);
datagram 头 = 10 字节(cmd/idx/adp/ado/len/irq=0),后接 data + WKC(2)。"""
dst = b"\xff" * 6
src = bytes([0x02, 0x44, 0x43, 0x55, 0x00, 0x01]) # 本地管理 MAC,仅作源标识
eth = dst + src + struct.pack(">H", ECAT_ETHER_TYPE)
dlen = 10 + len(payload) + 2
ecat_hdr = struct.pack("<H", (dlen & 0x7FF) | (0x1 << 12))
dgram = (struct.pack("<BBHHHH", cmd, idx, adp, ado, len(payload) & 0x7FF, 0)
+ payload + b"\x00\x00")
return (eth + ecat_hdr + dgram).ljust(60, b"\x00")
class RawBus:
def __init__(self, devname):
wpcap = load_wpcap()
# 64-bit 下必须显式声明签名:默认 restype=c_int 会把 pcap_t* 截成 32 位
pkt_pp = ctypes.POINTER(ctypes.POINTER(PcapPktHdr))
data_pp = ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))
wpcap.pcap_open_live.restype = ctypes.c_void_p
wpcap.pcap_open_live.argtypes = [ctypes.c_char_p] + [ctypes.c_int] * 3 + [ctypes.c_char_p]
wpcap.pcap_sendpacket.restype = ctypes.c_int
wpcap.pcap_sendpacket.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
wpcap.pcap_next_ex.restype = ctypes.c_int
wpcap.pcap_next_ex.argtypes = [ctypes.c_void_p, pkt_pp, data_pp]
wpcap.pcap_close.restype = None
wpcap.pcap_close.argtypes = [ctypes.c_void_p]
wpcap.pcap_setdirection.restype = ctypes.c_int
wpcap.pcap_setdirection.argtypes = [ctypes.c_void_p, ctypes.c_int]
errbuf = ctypes.create_string_buffer(256)
name = devname.decode() if isinstance(devname, bytes) else devname
self.handle = wpcap.pcap_open_live(name.encode(), 65535, 1, 100, errbuf)
if not self.handle:
raise SystemExit(f"pcap_open_live 失败: {errbuf.value.decode(errors='replace')}")
self.wpcap = wpcap
self.idx = 0
def transact(self, cmd, ado, payload, timeout=1.5):
self.idx = (self.idx + 1) & 0xFF
idx = self.idx
frame = ecat_frame(cmd, idx, 0x0000, ado, payload)
if self.wpcap.pcap_sendpacket(self.handle, frame, len(frame)) != 0:
raise SystemExit("pcap_sendpacket 失败")
deadline = time.time() + timeout
ph = ctypes.POINTER(PcapPktHdr)()
pd = ctypes.POINTER(ctypes.c_ubyte)()
while time.time() < deadline:
r = self.wpcap.pcap_next_ex(self.handle, ctypes.byref(ph), ctypes.byref(pd))
if r != 1:
continue
data = bytes(bytearray(pd[:ph.contents.caplen]))
if len(data) < 28 or data[12:14] != b"\x88\xa4":
continue
d = data[16:]
if d[0] != cmd or d[1] != idx:
continue
if struct.unpack_from("<H", d, 4)[0] != ado:
continue # 并发主站流量的同 idx 帧
dlen = struct.unpack_from("<H", d, 6)[0] & 0x7FF
wkc = struct.unpack_from("<H", d, 10 + dlen)[0]
if wkc == 0:
continue # 自己发出去的帧(WKC=0),等回帧
return d[10:10 + dlen], wkc
return None, None
def read16(self, addr):
payload, wkc = self.transact(CMD_APRD, addr, b"\x00\x00")
if payload is None or wkc == 0:
return None
return struct.unpack_from("<H", payload)[0]
def close(self):
self.wpcap.pcap_close(self.handle)
def decode_eep_status(v):
bits = []
if v & 0x0800:
bits.append("LOADED")
if v & 0x1000:
bits.append("ERR:NACK(EEPROM 无应答 -> I2C 物理层: R12 SDA 上拉/焊接)")
if v & 0x2000:
bits.append("ERR:CRC(SII 校验失败 -> 内容或寻址)")
if v & 0x4000:
bits.append("ERR:CMD")
if v & 0x8000:
bits.append("BUSY")
return "|".join(bits) if bits else "(无错误位置位)"
def dump_regs(bus, tag):
vals = {}
print(f"--- ESC 寄存器({tag})---")
for addr, name in REGS:
v = bus.read16(addr)
vals[addr] = v
if v is None:
print(f" 0x{addr:04X} {name}: *** 无应答 ***")
continue
extra = ""
if addr == 0x0502:
extra = " | " + decode_eep_status(v)
elif addr == 0x0140:
extra = f" | pdi_type=0x{v & 0xFF:02X}(期望 0x8D)"
elif addr == 0x0110:
extra = f" | pdi_oper={v & 0x1}"
print(f" 0x{addr:04X} {name}: 0x{v:04X}{extra}")
return vals
def main():
substr = "I226"
do_reload = False
for arg in sys.argv[1:]:
if arg == "--reload":
do_reload = True
else:
substr = arg
adapter = find_adapter(substr)
print(f"适配器: {adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc}")
crc_ok = sii_check(adapter.name)
bus = RawBus(adapter.name)
try:
vals = dump_regs(bus, "当前")
if do_reload:
print("\n触发 EEPROM 重新加载(0x0500=0x0001, 0x0502=0x0300)…")
bus.transact(CMD_APWR, 0x0500, struct.pack("<H", EEP_FORCE_ECAT))
payload, wkc = bus.transact(CMD_APWR, 0x0502, struct.pack("<H", EEP_CMD_RELOAD))
if payload is None:
print(" reload 写无应答")
else:
for _ in range(20): # 最长等 ~1s
time.sleep(0.05)
st = bus.read16(0x0502)
if st is not None and not (st & 0x8000):
break
vals = dump_regs(bus, "reload 后")
finally:
bus.close()
print("\n===== 判读 =====")
st = vals.get(0x0502)
cfg = vals.get(0x0140)
if st is None:
print("ESC 连网络侧寄存器都读不到——超出本工具范围。")
elif st & 0x0800 and cfg is not None and (cfg & 0xFF) == 0x8D:
print("EEPROM 实际已加载、PDI 类型 0x8D 已生效!")
print("=> GD32 侧读全 0 不是加载失败,而是 HBI 访问核心寄存器的通路问题,转查 PDI。")
elif st & 0x1000:
print("EEPROM 加载失败:NACK —— LAN9252 在 I2C 上得不到 EEPROM 应答。")
print("=> 硬件:量 AT24C32 pin6(SCL)/pin5(SDA) 空闲电平应≈3.3V;")
print(" SDA 若≈0V 或悬空 -> R12(10k->D3V3) 没焊(原理图标 DNP 的那颗)。")
elif st & 0x2000:
print("EEPROM 加载失败:CRC 校验错。")
print("=> 内容/CRC/寻址问题:上方 SII CRC 重算" +
("也不符 -> 重跑 ecat_flash_eeprom.py 重刷" if not crc_ok
else "相符 -> 内容对但 loader 读错位置,查 E2PSIZE strap 寻址"))
elif not (st & 0x0800):
print("loaded=0 且无错误位——loader 未跑/未跑完,异常状态,把完整输出发回分析。")
else:
print("loaded=1 但 pdi_type != 0x8D——EEPROM word0 不对,重刷 SII。")
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Flash the SII EEPROM image (generated by gen_eeprom.py) into the LAN9252
over the EtherCAT cable — no external programmer, no HBI needed.
Usage:
ecat_flash_eeprom.py [adapter-substr] (default: I226)
Steps: load gen_eeprom.py in-process (its asserts validate the image),
probe the EEPROM byte order with a scratch word, write every non-0xFFFF
word with immediate read-back verify, then restore the scratch word.
Afterwards POWER CYCLE the board so the ESC reloads its config.
"""
import contextlib
import importlib.util
import io
import struct
import sys
import pysoem
GEN = r"G:\Robot_v2\DCU\DCU_APP\GD32E508_DCU_APP\comm\ssc\port\gen_eeprom.py"
SCRATCH_WORD = 0x0200 # inside the 0xFF filler, far beyond the categories
def load_image():
spec = importlib.util.spec_from_file_location("gen_eeprom", GEN)
mod = importlib.util.module_from_spec(spec)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
spec.loader.exec_module(mod) # runs its own self-check asserts
img = bytes(mod.eeprom)
print(f"镜像加载成功: {len(img)} 字节, checksum=0x{img[14]:02X}, "
f"VendorID=0x{struct.unpack_from('<I', img, 16)[0]:08X}, "
f"ProductCode=0x{struct.unpack_from('<I', img, 20)[0]:08X}")
return img
def find_adapter(substr):
for a in pysoem.find_adapters():
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
if substr.lower() in desc.lower():
return a
return None
def open_slave(substr):
adapter = find_adapter(substr)
if adapter is None:
raise SystemExit(f"没有找到描述包含 {substr!r} 的适配器")
master = pysoem.Master()
master.open(adapter.name)
try:
n = master.config_init()
except Exception as e:
raise SystemExit(f"总线上没有发现从站({type(e).__name__}: {e})")
if not n:
raise SystemExit("总线上没有发现从站:请检查板子供电和网线")
print(f"从站应答正常({n} 个)")
return master.slaves[0]
def probe_word_layout(slave):
"""Write 0x1234 at the scratch word and find where it lands in the
4-byte read-back, so verification slices the right two bytes."""
test = b"\x34\x12"
slave.eeprom_write(SCRATCH_WORD, test)
rb = bytes(slave.eeprom_read(SCRATCH_WORD))
off = rb.find(test)
if off < 0:
raise SystemExit(f"写入探针失败: 读回 {rb.hex().upper()}")
slave.eeprom_write(SCRATCH_WORD, b"\xff\xff") # restore erase state
rb2 = bytes(slave.eeprom_read(SCRATCH_WORD))
if rb2[off:off + 2] != b"\xff\xff":
raise SystemExit(f"探针字恢复失败: {rb2.hex().upper()}")
print(f"字节序/读回偏移探测: 写入 {test.hex()} 落在读回偏移 {off},恢复 OK")
return off
def main():
substr = sys.argv[1] if len(sys.argv) > 1 else "I226"
img = load_image()
slave = open_slave(substr)
verify_off = probe_word_layout(slave)
total = len(img) // 2
n_written = n_fail = 0
fails = []
for word in range(total):
val = struct.unpack_from("<H", img, word * 2)[0]
if val == 0xFFFF: # erase state, nothing to write
continue
data = struct.pack("<H", val)
try:
slave.eeprom_write(word, data)
except Exception as e:
fails.append((word, data.hex(), f"WRITE EXC {type(e).__name__}: {e}"))
n_fail += 1
continue
rb = bytes(slave.eeprom_read(word))[verify_off:verify_off + 2]
n_written += 1
if rb != data:
fails.append((word, data.hex(), rb.hex()))
n_fail += 1
if n_written % 32 == 0:
print(f" ... 已写 {n_written} 字")
print(f"\n刷写完成: 成功 {n_written} 字, 失败 {n_fail} 字 "
f"(跳过 0xFF 填充 {total - n_written - n_fail} 字)")
if fails:
print("失败明细 (word, 期望, 实际):")
for w, exp, act in fails[:20]:
print(f" word 0x{w:04X}: {exp.upper()} != {act.upper()}")
sys.exit(3)
# summary of key words
for name, w in (("PDI Control", 0), ("Checksum", 7), ("VendorID lo", 8),
("ProductCode lo", 0x0A), ("Size", 0x3E), ("Version", 0x3F)):
val = struct.unpack_from("<H", img, w * 2)[0]
print(f" word 0x{w:02X} {name} = 0x{val:04X}")
print("\n>>> 请给板子断电重新上电(ESC 重新加载 EEPROM 配置),")
print(">>> 然后运行 run_scan.bat I226 验证身份字。")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python
"""EtherCAT master bring-up test for the DCU LAN9252 slave (pysoem >= 1.1 API).
Usage:
ecat_scan.py list all Npcap adapters and exit
ecat_scan.py <name-substr> open the first adapter whose description matches
the substring (e.g. "I226"), scan the bus and
print slave identity + SII EEPROM dump
ecat_scan.py <substr> --op additionally request OP and run PDO cycles for 2s
"""
import sys
import time
try:
import pysoem
except ImportError:
print("pysoem 无法加载:请先安装 Npcap(本目录下的 npcap-1.88.exe,"
"安装时保持勾选 'Install WinPcap API-compatible Mode')")
sys.exit(1)
AL_STATE = {1: "INIT", 2: "PREOP", 4: "SAFEOP", 8: "OP"}
def al_state_name(code):
return AL_STATE.get(code, f"0x{code:02x}")
def list_adapters():
for i, a in enumerate(pysoem.find_adapters()):
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
print(f"[{i}] {desc}\n {a.name}")
def find_adapter(substr):
for a in pysoem.find_adapters():
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
if substr.lower() in desc.lower():
return a
return None
def dump_eeprom(slave, count=0x10):
print(" SII EEPROM dump (每行 8 个 word 地址,原始 32bit 返回):")
for base in range(0, count, 8):
words = []
for w in range(base, base + 8):
try:
v = bytes(slave.eeprom_read(w))
words.append(v.hex().upper())
except Exception:
words.append("ERRRRRRRR")
print(f" 0x{base:02X}: {' '.join(words)}")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("-l", "--list"):
list_adapters()
return
substr = sys.argv[1]
want_op = "--op" in sys.argv[2:]
adapter = find_adapter(substr)
if adapter is None:
print(f"没有找到描述包含 {substr!r} 的适配器,当前列表:")
list_adapters()
sys.exit(1)
desc = adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc
master = pysoem.Master()
master.open(adapter.name)
try:
n = master.config_init()
except Exception as e:
print(f"总线上没有发现从站({type(e).__name__}: {e}):"
"请检查 DCU 板子是否上电、网线是否接到 I226-V 网口")
sys.exit(2)
if not n:
print("总线上没有发现从站:请检查 DCU 板子是否上电、网线是否接到 I226-V 网口")
sys.exit(2)
print(f"\n适配器: {desc}")
print(f"发现 {n} 个从站:")
for i, s in enumerate(master.slaves):
print(f" [{i}] name={s.name!r}")
print(f" VendorID=0x{s.man:08X} ProductCode=0x{s.id:08X} Revision={s.rev}")
print(f" AL state={al_state_name(s.state)} AL status code=0x{s.al_status:04X}"
f" DC={s.dc_sync}")
dump_eeprom(master.slaves[0])
if not want_op:
return
master.config_map()
if master.state_check(pysoem.SAFEOP_STATE, 2000) != pysoem.SAFEOP_STATE:
print(f"进入 SAFEOP 失败, 当前 AL 状态: {al_state_name(master.state)}")
sys.exit(3)
t_end = time.time() + 2.0
frames = 0
while time.time() < t_end:
master.send_processdata()
master.receive_processdata(2000)
frames += 1
time.sleep(0.001)
master.state = pysoem.OP_STATE
master.write_state()
if master.state_check(pysoem.OP_STATE, 2000) == pysoem.OP_STATE:
print(f"已进入 OP, PDO 循环 {frames} 帧正常")
else:
print(f"进入 OP 失败, 当前 AL 状态: {al_state_name(master.state)}")
if __name__ == "__main__":
main()
+1196
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""抓 SOEM 自己发的 EtherCAT 帧,对照裸帧格式。
另开 pcap 句柄抓包,同时在同进程里跑 pysoem eeprom_read,hexdump 收发帧。
用法:ecat_sniff.py [adapter-substr]"""
import ctypes
import struct
import sys
import threading
import time
import pysoem
ECAT = b"\x88\xa4"
class PcapPktHdr(ctypes.Structure):
_fields_ = [("tv_sec", ctypes.c_long), ("tv_usec", ctypes.c_long),
("caplen", ctypes.c_uint32), ("len", ctypes.c_uint32)]
def main():
substr = sys.argv[1] if len(sys.argv) > 1 else "I226"
adapter = None
for a in pysoem.find_adapters():
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
if substr.lower() in desc.lower():
adapter = a
break
if adapter is None:
raise SystemExit("适配器未找到")
name = adapter.name.decode() if isinstance(adapter.name, bytes) else adapter.name
wpcap = ctypes.WinDLL("wpcap.dll")
wpcap.pcap_open_live.restype = ctypes.c_void_p
wpcap.pcap_open_live.argtypes = [ctypes.c_char_p] + [ctypes.c_int] * 3 + [ctypes.c_char_p]
wpcap.pcap_next_ex.restype = ctypes.c_int
wpcap.pcap_next_ex.argtypes = [ctypes.c_void_p,
ctypes.POINTER(ctypes.POINTER(PcapPktHdr)),
ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))]
wpcap.pcap_close.restype = None
wpcap.pcap_close.argtypes = [ctypes.c_void_p]
errbuf = ctypes.create_string_buffer(256)
handle = wpcap.pcap_open_live(name.encode(), 65535, 1, 50, errbuf)
if not handle:
raise SystemExit(f"pcap_open_live 失败: {errbuf.value.decode(errors='replace')}")
def soem_work():
try:
master = pysoem.Master()
master.open(adapter.name)
master.config_init()
slave = master.slaves[0]
slave.eeprom_read(0)
master.close()
except Exception as e:
print(f"soem_work: {type(e).__name__}: {e}")
t = threading.Thread(target=soem_work, daemon=True)
t.start()
frames = []
deadline = time.time() + 4.0
ph = ctypes.POINTER(PcapPktHdr)()
pd = ctypes.POINTER(ctypes.c_ubyte)()
while time.time() < deadline and len(frames) < 8:
r = wpcap.pcap_next_ex(handle, ctypes.byref(ph), ctypes.byref(pd))
if r != 1:
continue
data = bytes(bytearray(pd[:ph.contents.caplen]))
if len(data) >= 14 and data[12:14] == ECAT:
frames.append(data)
wpcap.pcap_close(handle)
print(f"抓到 {len(frames)} 个 EtherCAT 帧")
for i, f in enumerate(frames):
src = ":".join(f"{b:02X}" for b in f[6:12])
print(f"\n[{i}] len={len(f)} src={src}")
for off in range(14, min(len(f), 14 + 48), 16):
chunk = f[off:off + 16]
print(f" +{off - 14:02d}: " + " ".join(f"{b:02X}" for b in chunk))
# 解析前两个 datagram 头字段,确定头部布局
elen = struct.unpack_from("<H", f, 14)[0] & 0x7FF
print(f" ecat_hdr.len={elen} type={struct.unpack_from('<H', f, 14)[0] >> 12}")
d = f[16:]
if len(d) >= 12:
cmd, idx, adp, ado, ln = struct.unpack_from("<BBHHH", d, 0)
print(f" dgram: cmd=0x{cmd:02X} idx={idx} adp=0x{adp:04X} "
f"ado=0x{ado:04X} len_field={ln} (len={ln & 0x7FF})")
if __name__ == "__main__":
main()
+562
View File
@@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""ecat_tool.py — DCU LAN9252 EtherCAT 一体化主站工具(连接/收发/烧录)。
子命令:
adapters 列出 Npcap 适配器
status [substr] 扫描从站: 身份字/AL 状态/邮箱/PDO 尺寸
flash [--bin FILE] [substr] 烧录 EEPROM(gen_eeprom.py 镜像或 --bin)+逐字校验
eeprom read START COUNT [--out FILE] [substr]
eeprom write FILE [substr] 写 bin(奇数尾补 0xFF)+回读校验
eeprom verify FILE [substr] 只读校验
sdo read IDX SUB [--size N] [substr] CoE SDO 读(需 PREOP+,邮箱来自 SII)
sdo write IDX SUB HEX [substr] CoE SDO 写(HEX=字节流)
send [--hex H | --u64 a,b,c,d] [--rate-ms N] [--duration S | --forever]
[--dc-cycle-us N] [--dc-shift-us N] [substr]
进 OP 循环发送输出 PDO 并回显输入 PDO
monitor [--rate-ms N] [--duration S] [--dc-cycle-us N] [substr]
进 OP 只读输入(输出保持 0)
substr 默认 "I226"(按适配器描述模糊匹配)。send/monitor 的输出默认全 0。
--dc-cycle-us 非 0 时启用分布时钟: 从站 SYNC0 引脚按该周期输出脉冲,
GD32 侧 Sync0_Isr 才会被触发(默认 free-run 模式下 SYNC0 完全不打)。
验证: 主站设 1000us, 从站 [ECAT][SYNC] 日志应显示 sync0 ≈ 1000/s。
"""
import argparse
import os
import struct
import sys
import time
try:
import pysoem
except ImportError:
print("pysoem 无法加载:请先安装 Npcap(本目录 npcap-1.88.exe,"
"保持勾选 'Install WinPcap API-compatible Mode')")
sys.exit(1)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
EEPROM_SIZE = 4096 # AT24C32
# state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us");
# 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。
# 此前各处传 2000 实际只等 2ms,能通过纯属从站 ack 快,属侥幸。
EC_TIMEOUT_STATE_US = 2_000_000
AL_STATE = {0x00: "NONE", 0x01: "INIT", 0x02: "PREOP",
0x03: "BOOT", 0x04: "SAFEOP", 0x08: "OP"}
def al_name(code):
return AL_STATE.get(code, f"0x{code:02X}")
def find_adapter(substr):
for a in pysoem.find_adapters():
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
if substr.lower() in desc.lower():
return a
return None
def open_master(substr):
adapter = find_adapter(substr)
if adapter is None:
print(f"没有找到描述包含 {substr!r} 的适配器,可用:")
list_adapters()
sys.exit(1)
desc = adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc
master = pysoem.Master()
master.open(adapter.name)
try:
n = master.config_init()
except Exception as e:
raise SystemExit(f"总线上没有发现从站({type(e).__name__}: {e})——"
"检查 DCU 板供电、网线是否接到该网口")
if not n:
raise SystemExit("总线上没有发现从站——检查 DCU 板供电、网线是否接到该网口")
return master, desc
def list_adapters():
for i, a in enumerate(pysoem.find_adapters()):
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
print(f"[{i}] {desc}\n {a.name}")
def print_slave_info(master):
print(f"从站: {len(master.slaves)} 个")
for i, s in enumerate(master.slaves):
print(f" [{i}] name={s.name!r}")
print(f" VendorID=0x{s.man:08X} ProductCode=0x{s.id:08X} Rev={s.rev}")
print(f" AL state={al_name(s.state & 0x0F)} AL code=0x{s.al_status:04X}")
try:
print(f" 输出 {len(bytes(s.output))}B / 输入 {len(bytes(s.input))}B "
f"(config_map 后有效)")
except Exception:
pass
# ---------------------------------------------------------------- status
def cmd_status(args):
if args.substr in ("-l", "--list"):
list_adapters()
return
master, desc = open_master(args.substr)
print(f"适配器: {desc}")
print_slave_info(master)
if master.slaves and master.slaves[0].man == 0xFFFFFFFF:
print("\n提示: 身份字全 F = EEPROM 无效。先执行: ecat_tool.py flash")
# ---------------------------------------------------------------- eeprom
def eeprom_read_word(slave, word):
return bytes(slave.eeprom_read(word))[:2]
def flash_image(slave, img, label):
if len(img) % 2 != 0:
img += b"\xFF"
if len(img) > EEPROM_SIZE:
raise SystemExit(f"镜像 {len(img)}B 超过 EEPROM 容量 {EEPROM_SIZE}B")
# 字节序探针(写入 0x1234 找读回偏移,随后恢复擦除态)
test = b"\x34\x12"
slave.eeprom_write(0x0200, test)
rb = bytes(slave.eeprom_read(0x0200))
off = rb.find(test)
if off < 0:
raise SystemExit(f"EEPROM 写探针失败: 读回 {rb.hex().upper()}")
slave.eeprom_write(0x0200, b"\xFF\xFF")
print(f"字节序探针 OK (读回偏移 {off})")
n_written = n_fail = 0
fails = []
for word in range(len(img) // 2):
val = struct.unpack_from("<H", img, word * 2)[0]
if val == 0xFFFF:
continue
data = struct.pack("<H", val)
try:
slave.eeprom_write(word, data)
except Exception as e:
fails.append((word, data.hex(), f"WRITE EXC {type(e).__name__}: {e}"))
n_fail += 1
continue
if eeprom_read_word(slave, word) != data:
fails.append((word, data.hex(), eeprom_read_word(slave, word).hex()))
n_fail += 1
n_written += 1
if n_written % 64 == 0:
print(f" ... 已写 {n_written} 字")
print(f"[{label}] 刷写完成: 成功 {n_written} 字, 失败 {n_fail} 字")
if fails:
for w, exp, act in fails[:20]:
print(f" word 0x{w:04X}: 期望 {exp.upper()} 实际 {act.upper()}")
raise SystemExit(3)
print(">>> 板子断电重新上电后 ESC 才会重载配置(身份字/PDI/SYNC)")
def cmd_flash(args):
if args.bin:
with open(args.bin, "rb") as f:
img = f.read()
label = os.path.basename(args.bin)
if len(img) % 2:
img += b"\xFF"
else:
from ecat_flash_eeprom import load_image # 自带 gen_eeprom.py 自校验
img = load_image()
label = "gen_eeprom.py"
print(f"镜像: {label}, {len(img)} 字节")
master, desc = open_master(args.substr)
print(f"适配器: {desc}, 从站应答正常")
flash_image(master.slaves[0], img, label)
def cmd_eeprom(args):
master, _ = open_master(args.substr)
slave = master.slaves[0]
if args.op == "read":
start, count = args.start, args.count
rows = []
for w in range(start, start + count):
rows.append(struct.unpack("<H", eeprom_read_word(slave, w))[0])
for base in range(0, count, 8):
chunk = rows[base:base + 8]
print(f" 0x{start + base:04X}: " +
" ".join(f"{v:04X}" for v in chunk))
if args.out:
with open(args.out, "wb") as f:
f.write(struct.pack(f"<{count}H", *rows))
print(f"已保存 {args.out} ({count} word)")
elif args.op == "write":
with open(args.file, "rb") as f:
data = f.read()
if len(data) % 2:
data += b"\xFF"
if len(data) > EEPROM_SIZE:
raise SystemExit(f"{len(data)}B 超过 EEPROM 容量 {EEPROM_SIZE}B")
n = n_fail = 0
for word in range(len(data) // 2):
val = struct.unpack_from("<H", data, word * 2)[0]
if val == 0xFFFF:
continue
raw = struct.pack("<H", val)
slave.eeprom_write(word, raw)
if eeprom_read_word(slave, word) != raw:
print(f" word 0x{word:04X} 校验失败")
n_fail += 1
n += 1
print(f"写 {n} 字, 失败 {n_fail} 字" + ("" if not n_fail else " -> FAIL"))
sys.exit(0 if n_fail == 0 else 3)
elif args.op == "verify":
with open(args.file, "rb") as f:
data = f.read()
if len(data) % 2:
data += b"\xFF"
diffs = 0
for word in range(min(len(data), EEPROM_SIZE) // 2):
expect = struct.unpack_from("<H", data, word * 2)[0]
actual = struct.unpack("<H", eeprom_read_word(slave, word))[0]
if expect != actual:
print(f" word 0x{word:04X}: 文件 0x{expect:04X} != 芯片 0x{actual:04X}")
diffs += 1
print("verify: " + ("OK, 全部一致" if diffs == 0 else f"{diffs} 字不符 -> FAIL"))
sys.exit(0 if diffs == 0 else 3)
# ---------------------------------------------------------------- SDO
def cmd_sdo(args):
master, desc = open_master(args.substr)
s = master.slaves[0]
if args.op == "read":
try:
data = bytes(s.sdo_read(args.index, args.sub, args.size))
except Exception as e:
raise SystemExit(f"SDO 读失败: {type(e).__name__}: {e}")
print(f"0x{args.index:04X}:{args.sub} = {data.hex().upper()} ({len(data)}B)")
else:
try:
payload = bytes.fromhex(args.hex.replace(" ", ""))
except ValueError:
raise SystemExit("HEX 非法: 只能是十六进制字节流")
try:
s.sdo_write(args.index, args.sub, payload)
except Exception as e:
raise SystemExit(f"SDO 写失败: {type(e).__name__}: {e}")
print(f"0x{args.index:04X}:{args.sub} <- {payload.hex().upper()} ({len(payload)}B) OK")
# ---------------------------------------------------------------- PDO 收发
def enter_op(master, dc_cycle_us=0, dc_shift_us=0):
"""config_map → (可选 DC) → SAFEOP → OP。
dc_cycle_us=0 时保持 free-run(SYNC0 引脚无脉冲,从站 Sync0_Isr 不会触发)。
非 0 时先 config_dc() 测量传播延迟,再对从站下发 SYNC0 周期,
必须在 config_map() 之后、进 SAFEOP 之前完成(ETG.1020 DC 配置顺序)。
"""
master.state = pysoem.PREOP_STATE | pysoem.STATE_ACK
master.write_state()
if master.state_check(pysoem.PREOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.PREOP_STATE:
# read_state 拿真实 AL Status Code(广播 state_check 不刷新 al_status,
# 陈旧缓存恒显示 0x0000)。首败常见 0x0016/0x001D 类:停止回 INIT 时 SSC
# 禁用了邮箱 SM0/SM1,CheckSmSettings 拒绝 INIT→PREOP,重新 config_init 即恢复。
try:
master.read_state()
except Exception:
pass
codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves]
raise SystemExit(f"进 PREOP 失败: AL={codes}(真实值) —— 从站未 ack AL Control=PREOP。"
"0x0016/0x001D 类=邮箱 SM0/SM1 被停止时禁用(重跑即恢复);"
"抓 GD32 串口看 [ECAT][AL] evt/req")
# free-run 时清残留 DC 激活(2026-09-03 00:27 卡死根因之一):之前跑过 DC 的
# 0x0981 激活位跨重连不清,从站会按 DC 同步模式等 PLL,free-run 下永不进 OP。
# 必须在 config_map(其内部请求 SAFEOP)之前清除。
if not dc_cycle_us:
try:
master.slaves[0].dc_sync(False, 0, 0)
print("已清除残留 DC 激活(free-run)")
except Exception as e:
print(f"WARN: 清 DC 激活失败({type(e).__name__}: {e})")
master.config_map()
# 不要写 0x1C32:01 / 0x1C33:01 去改 SyncType:该子索引只允许在 PREOP 写
# (ACCESS_WRITE_PREOP),而 config_map() 已把从站推到 SAFEOP,实测返回
# SdoError 0x08000022(当前状态不可写)。而且没必要——SSC 在 PREOP→SAFEOP 的
# StartInputHandler() 会自动按 DC 寄存器/PDO 长度推导出 SM_SYNCHRON
# (ecatslv.c:849-880),coeappl.c 里的 FREERUN 只是上电初值。
if dc_cycle_us:
has_dc = master.config_dc()
if not has_dc:
raise SystemExit("config_dc(): 总线上没有支持 DC 的从站 —— "
"检查 SII word1 高字节(0x0151 Sync/Latch 配置)是否为 0xCC")
master.slaves[0].dc_sync(True, dc_cycle_us * 1000, dc_shift_us * 1000)
print(f"DC 已启用: SYNC0 周期 {dc_cycle_us}us"
+ (f", shift {dc_shift_us}us" if dc_shift_us else "")
+ f", 主站 DC 时间={master.dc_time}ns")
# 显式带 ACK 写 SAFEOP(config_map 内部已推过一次,这里再立一次并顺带清掉
# 上一轮残留的错误位——SSC 只有带 ACK 位的写才能清错+迁移,普通写会被忽略)
master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK
master.write_state()
if master.state_check(pysoem.SAFEOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.SAFEOP_STATE:
try:
master.read_state() # 真实 AL Status Code(广播 state_check 不刷新)
except Exception:
pass
codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves]
raise SystemExit(f"进 SAFEOP 失败: AL={codes}(真实值) —— 检查 SII 的 SM/FMMU 类别"
"(EEPROM 是否已刷)与 GD32 SSC 是否运行")
# 主站侧直写过程数据看门狗(双保险,SOEM set_watchdog → FPWR 0x0420 = 1000ms)。
# 从站固件 ≥1.5.45 也会写 1s,但板上固件未更新时 100ms 复位默认值会在 OP 等待期
# 触发 0x001B 拖回 SAFEOP;主站每次连接直接写,不依赖固件版本。
try:
master.slaves[0].set_watchdog('processdata', 1000.0)
except Exception as e:
print(f"WARN: 主站侧写看门狗失败({type(e).__name__}: {e})")
# SAFEOP -> OP:从站有输出 PDO 时,SSC 要求先收到至少一帧过程数据才 ack OP
# (ecatslv.c:1993 的 `nPdOutputSize == 0 || bEcatFirstOutputsReceived`),
# 否则迁移被挂起、随后回落,表现为 "进 OP 失败 ... code=0x0000"。
# 所以必须边发 processdata 边等,不能只 write_state 后干等。
# AL Control 一律带 ACK 位(0x10):SSC 对"错误位已置+无 ACK+请求≠INIT"的请求
# 直接忽略(ecatslv.c:1376-1384),残留的 0x001B 错误位不清掉,写 OP 无人应答。
master.state = pysoem.OP_STATE | pysoem.STATE_ACK
master.write_state()
# 状态查询用 read_state() 而非 state_check():后者 slave=0 广播模式不更新
# slavelist[0].ALstatuscode(报错里的 code 是陈旧缓存,恒 0x0000),且内部
# 1ms 轮询发帧风暴。read_state 无错误时 1 帧 BRD,有错误位时逐从站 FPRD
# 拿**真实 AL Status Code**。失败时输出真实错误码+状态轨迹。
deadline = time.time() + 12.0 # 12s:覆盖从站 ESM 超时(9s,固件≥1.5.47)+恢复重试
trail = []
last_key = None
state = None
code = 0
wkc_samples = []
next_rewrite = 0.0 # OP|ACK 重写节流:每 200ms 最多一次(避免 AL Control 写风暴)
while time.time() < deadline:
master.send_processdata()
wkc = master.receive_processdata(2000)
if not wkc_samples or wkc_samples[-1] != (wkc, master.expected_wkc):
wkc_samples.append((wkc, master.expected_wkc))
try:
master.read_state()
except Exception:
pass
state = master.slaves[0].state & 0x0F
code = master.slaves[0].al_status
key = (state, code)
if key != last_key:
trail.append((al_name(state), code))
last_key = key
if state == pysoem.OP_STATE and code == 0:
break
# 跌回 PREOP/INIT 时:带 ACK 重写 SAFEOP 立起状态,再带 ACK 重请 OP
if state < pysoem.SAFEOP_STATE:
master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK
master.write_state()
master.state_check(pysoem.SAFEOP_STATE, 500_000)
next_rewrite = 0.0
if time.time() >= next_rewrite:
master.state = pysoem.OP_STATE | pysoem.STATE_ACK
master.write_state()
next_rewrite = time.time() + 0.2
time.sleep(0.005) # 循环节奏 ~7ms:LRW 间隔稳低于任何看门狗配置
if state != pysoem.OP_STATE or code != 0:
try:
master.read_state()
except Exception:
pass
st = master.slaves[0]
trail.append((al_name(st.state & 0x0F), st.al_status))
try:
wd_ms = st.get_watchdog('processdata')
wd_info = f", 从站 PD 看门狗={wd_ms:.0f}ms"
except Exception:
wd_info = ""
wkc_info = " / ".join(f"wkc={w}/{e}" for w, e in wkc_samples[:6])
raise SystemExit(
f"进 OP 失败: AL={al_name(st.state & 0x0F)} code=0x{st.al_status:04X}(真实值{wd_info}) —— "
f"等待期轨迹: {' -> '.join(f'{n}+0x{c:04X}' for n, c in trail)}; LRW WKC: {wkc_info}。"
"0x001B=SM watchdog;0x001A=DC 残留激活;0x001D/0x001E=SM 配置不匹配;"
"0x0000=从站在等首帧输出。请抓 GD32 串口 [ECAT][AL] evt/req 日志对照")
def leave_init(master):
try:
master.state = pysoem.INIT_STATE
master.write_state()
except Exception:
pass
def run_pdo(master, out_data, rate_ms, duration, label, dc_cycle_us=0, dc_shift_us=0):
out_len = len(bytes(master.slaves[0].output))
if out_data is None:
out_data = bytes(out_len)
if len(out_data) < out_len:
out_data += bytes(out_len - len(out_data))
try:
master.slaves[0].output = out_data
except Exception as e:
raise SystemExit(f"写输出缓冲失败({type(e).__name__}: {e}) —— output 长度={out_len}")
enter_op(master, dc_cycle_us, dc_shift_us)
mode = f"DC {dc_cycle_us}us" if dc_cycle_us else "free-run"
print(f"[{label}] 已进 OP({mode}): 输出 {out_len}B / 输入 "
f"{len(bytes(master.slaves[0].input))}B, expected_wkc={master.expected_wkc}"
f"{', Ctrl+C 退出' if duration is None else f', 运行 {duration}s'}")
t_end = None if duration is None else time.time() + duration
cycles = wkc_errs = 0
last = 0.0
try:
while True:
master.send_processdata()
wkc = master.receive_processdata(2000)
cycles += 1
if wkc < master.expected_wkc:
wkc_errs += 1
now = time.time()
if now - last >= 0.5:
last = now
inp = bytes(master.slaves[0].input)
extra = ""
if len(inp) >= 32:
u64 = struct.unpack("<4Q", inp[:32])
extra = " u64=" + ",".join(f"0x{x:X}" for x in u64)
print(f"cycles={cycles} wkc_err={wkc_errs} in={inp.hex().upper()}{extra}")
if t_end is not None and now >= t_end:
break
time.sleep(rate_ms / 1000.0)
except KeyboardInterrupt:
print("\n用户中断")
finally:
leave_init(master)
print(f"[{label}] 结束: {cycles} 周期, wkc_err={wkc_errs}, 已回 INIT")
def parse_output_data(args):
if args.hex:
try:
return bytes.fromhex(args.hex.replace(" ", ""))
except ValueError:
raise SystemExit("--hex 非法: 只能是十六进制字节流")
if args.u64:
try:
vals = [int(v, 0) for v in args.u64.split(",")]
except ValueError:
raise SystemExit("--u64 非法: 逗号分隔的整数(支持 0x 前缀)")
if len(vals) > 4:
raise SystemExit("--u64 最多 4 个(4×64bit=32B)")
return struct.pack(f"<{len(vals)}Q", *vals)
return None
def cmd_send(args):
master, desc = open_master(args.substr)
print(f"适配器: {desc}")
run_pdo(master, parse_output_data(args), args.rate_ms,
None if args.forever else args.duration, "send",
args.dc_cycle_us, args.dc_shift_us)
def cmd_monitor(args):
master, desc = open_master(args.substr)
print(f"适配器: {desc}")
run_pdo(master, None, args.rate_ms,
None if args.forever else args.duration, "monitor",
args.dc_cycle_us, args.dc_shift_us)
# ---------------------------------------------------------------- main
def main():
p = argparse.ArgumentParser(description="DCU LAN9252 EtherCAT 主站工具")
sub = p.add_subparsers(dest="cmd", required=False)
sub.add_parser("adapters", help="列出 Npcap 适配器")
sp = sub.add_parser("status", help="扫描从站身份/AL 状态")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("flash", help="烧录 EEPROM + 逐字校验")
sp.add_argument("--bin", help="指定 bin(默认用 gen_eeprom.py 镜像)")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("eeprom", help="EEPROM read/write/verify")
sp.add_argument("op", choices=["read", "write", "verify"])
sp.add_argument("file_or_start", nargs="?", default=None)
sp.add_argument("count_or_file", nargs="?", default=None)
sp.add_argument("--out")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("sdo", help="CoE SDO read/write")
sp.add_argument("op", choices=["read", "write"])
sp.add_argument("index", type=lambda v: int(v, 0))
sp.add_argument("sub", type=lambda v: int(v, 0))
sp.add_argument("hex", nargs="?", default=None)
sp.add_argument("--size", type=lambda v: int(v, 0), default=8)
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("send", help="进 OP 循环发送输出 PDO")
sp.add_argument("--hex", help="输出字节流(≤输出区长度)")
sp.add_argument("--u64", help="4 个 64bit, 逗号分隔, 如 0x1,0x2,0x3,0x4")
sp.add_argument("--rate-ms", type=int, default=100)
sp.add_argument("--duration", type=float, default=5.0)
sp.add_argument("--forever", action="store_true")
sp.add_argument("--dc-cycle-us", type=int, default=0,
help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)")
sp.add_argument("--dc-shift-us", type=int, default=0,
help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("monitor", help="进 OP 只读输入 PDO")
sp.add_argument("--rate-ms", type=int, default=200)
sp.add_argument("--duration", type=float, default=10.0)
sp.add_argument("--forever", action="store_true")
sp.add_argument("--dc-cycle-us", type=int, default=0,
help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)")
sp.add_argument("--dc-shift-us", type=int, default=0,
help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效")
sp.add_argument("substr", nargs="?", default="I226")
args = p.parse_args()
if args.cmd is None:
# 双击/无参数时默认执行 status
print("(未指定子命令, 默认执行 status; 完整用法: ecat_tool.py -h)\n")
args.cmd = "status"
args.substr = "I226"
if args.cmd == "adapters":
list_adapters()
elif args.cmd == "status":
cmd_status(args)
elif args.cmd == "flash":
cmd_flash(args)
elif args.cmd == "eeprom":
if args.op == "read":
if args.file_or_start is None or args.count_or_file is None:
raise SystemExit("用法: eeprom read START COUNT [--out FILE]")
args.start = int(args.file_or_start, 0)
args.count = int(args.count_or_file, 0)
else:
if args.file_or_start is None:
raise SystemExit(f"用法: eeprom {args.op} FILE")
args.file = args.file_or_start
cmd_eeprom(args)
elif args.cmd == "sdo":
if args.op == "write" and args.hex is None:
raise SystemExit("用法: sdo write IDX SUB HEX")
cmd_sdo(args)
elif args.cmd == "send":
cmd_send(args)
elif args.cmd == "monitor":
cmd_monitor(args)
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
@echo off
rem DCU EtherCAT master web console (serves ecat.html + pysoem API)
chcp 65001 >nul
set PYTHONUTF8=1
cd /d "%~dp0"
"%~dp0venv\Scripts\python.exe" ecat_server.py
pause
+8
View File
@@ -0,0 +1,8 @@
' DCU EtherCAT web console - hidden launcher (no console window).
' Double-click me. Console-mode debug launcher (visible logs): ecat_web.bat
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
base = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = base
sh.Environment("PROCESS")("PYTHONUTF8") = "1"
sh.Run """" & base & "\venv\Scripts\python.exe"" """ & base & "\ecat_server.py""", 0, False
+14
View File
@@ -0,0 +1,14 @@
# venv 不入库,用以下命令重建:
# py -3.12 -m venv venv
# venv\Scripts\python.exe -m pip install -r requirements.txt
# 另需系统安装 Npcap(pysoem 抓包/发包依赖)。
pysoem==1.1.13
pywebview==6.2.1
bottle==0.13.4
pythonnet==3.1.0
clr-loader==0.3.1
cffi==2.1.1
pycparser==3.0
proxy-tools==0.1.0
typing_extensions==4.16.0
py-spy==0.4.2
+9
View File
@@ -0,0 +1,9 @@
@echo off
rem EtherCAT master bring-up scan (LAN9252)
rem Usage: run_scan.bat -> list Npcap adapters
rem run_scan.bat I226 -> scan EtherCAT slaves (add --op for PDO test)
chcp 65001 >nul
set PYTHONUTF8=1
cd /d "%~dp0"
"%~dp0venv\Scripts\python.exe" ecat_scan.py %*
pause