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:
@@ -0,0 +1,2 @@
|
||||
# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写
|
||||
* -text
|
||||
+23
@@ -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
|
||||
@@ -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 才会重新加载配置。
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"> </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"> </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"> </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"> </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>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user