Compare commits
5
Commits
8d414ae3fb
...
47ea85fb23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ea85fb23 | ||
|
|
3bfa240dfa | ||
|
|
85d326d445 | ||
|
|
f09decc127 | ||
|
|
71c4ee6a14 |
@@ -27,10 +27,8 @@ venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||
| `ecat_esc_regs.py` | 从**网络侧**直读 LAN9252 ESC 寄存器,绕开 GD32/PDI,用于判断 EEPROM 加载是否真的失败;支持 `--reload` |
|
||||
| `ecat_flash_eeprom.py` | 通过网线烧写 `gen_eeprom.py` 生成的 SII 镜像,逐字回读校验(烧完需断电重启让 ESC 重新加载) |
|
||||
| `ecat_sniff.py` | 旁路抓包,hexdump SOEM 自己发出的帧,用于对照裸帧格式 |
|
||||
| `_probe_wedge.py` | 邮箱楔死现场逐环节定位:A 写 SM0 的 WKC → B `0x220` bit0 是否置位 → C SSC 是否清事件并置 SM1 就绪 → D SM1 是否滞留旧响应,用于区分 ESC 硬件楔死与 SSC 软件死循环 |
|
||||
| `_probe_wedge2.py` | 按真实邮箱协议探测:查写邮箱空 → 写满 256B → 验事件/响应 |
|
||||
| `_probe_stuck.py` | 验证「SM1 响应滞留 → 邮箱死锁」假说:复现失败 → 手动读走 SM1 清 bit3 → 再读是否恢复 |
|
||||
| `_probe_scan.py` | 扫描定位响应帧头在 SM1 RAM 中的确切偏移,同步 dump SM0 请求侧 |
|
||||
| `motor.html` / `motor.js` | 电机控制台:N1–N9 九节点,走 EtherCAT 过程数据(不经 WiFi/HTTP)。节点总览、8 种 Control_Mode、STATUS 实时解码、示波器曲线 |
|
||||
| `test_motor_backend.py` | 电机后端自测,**不接硬件**(假从站模拟 GD32 buildInput/processOutput)。跑法:`venv\Scripts\python.exe test_motor_backend.py` |
|
||||
|
||||
## 启动方式
|
||||
|
||||
@@ -42,9 +40,17 @@ venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||
| `ecat.bat` | 命令行工具入口,如 `ecat.bat status`、`ecat.bat flash` |
|
||||
| `run_scan.bat` | 直接跑 bring-up 扫描,无参数则列出 Npcap 适配器 |
|
||||
|
||||
电机控制台在服务起来后从主页右上「电机控制台」进入,或直接访问 `/motor.html`。
|
||||
它只读写 EtherCAT 过程数据,需先在主页点「连接」并让从站进入 OP。
|
||||
|
||||
## 注意
|
||||
|
||||
- ESC 的 SyncManager 寄存器是 `0x0800 + 8n`:邮箱是 SM0=`0x0800`、SM1=`0x0808`,
|
||||
`0x0810`/`0x0818` 是 SM2/SM3(过程数据)。写诊断代码时别混用,否则抓到的现场会误导判断。
|
||||
- **邮箱楔死判据**:SM0/SM1 寄存器正常(start=0x1000/0x1200、act=01)但 SDO 持续失败,
|
||||
就是 SSC 的 `u16EscAddrSendMbx` 错位——响应帧写到 SM1 start+4/+8 且逐会话累积,
|
||||
断电才清零。固件 <1.5.52 无自愈需断电重启,1.5.52+ 有守护自动写回。
|
||||
`config_map` 失败时后端会自动转储 4 个 SM 现场,可直接对照。
|
||||
- 前端 CSS/JS 改动后记得 bump `?v=` 缓存版本,否则浏览器会加载旧代码。
|
||||
`ecat.css` 被 `ecat.html` 与 `motor.html` 共用,两处版本号要一起改。
|
||||
- EEPROM 烧写后必须断电冷启动,ESC 才会重新加载配置。
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""_probe_scan.py — 精确定位响应帧头在 SM1 RAM 的偏移 + SM0 请求侧同步 dump。"""
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import ecat_server as es
|
||||
import pysoem
|
||||
|
||||
r = es.api_open({"substr": "I226"})
|
||||
if not r.get("ok"):
|
||||
sys.exit(1)
|
||||
m = es.g_master
|
||||
s = m.slaves[0]
|
||||
|
||||
|
||||
def fprd(addr, size):
|
||||
try:
|
||||
return bytes(s._fprd(addr, size, 20000))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def sm_reg(addr):
|
||||
d = fprd(addr, 8)
|
||||
if d is None:
|
||||
return None
|
||||
start, length = struct.unpack("<HH", d[:4])
|
||||
return {"start": start, "len": length, "ctl": d[4], "st": d[5], "act": d[6]}
|
||||
|
||||
|
||||
m.state = pysoem.INIT_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
m.state_check(pysoem.INIT_STATE, 1_000_000)
|
||||
m.config_init()
|
||||
m.state = pysoem.PREOP_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
print("PREOP:", "OK" if m.state_check(pysoem.PREOP_STATE, 1_000_000) == pysoem.PREOP_STATE else "FAIL", flush=True)
|
||||
|
||||
sm0, sm1 = sm_reg(0x0800), sm_reg(0x0808)
|
||||
print(f"SM0: start=0x{sm0['start']:04X} len={sm0['len']} | SM1: start=0x{sm1['start']:04X} len={sm1['len']}", flush=True)
|
||||
|
||||
# 注入请求(SOEM 标准格式 SDO 读 0x1018:01)
|
||||
req = bytes([0x0A, 0x00, 0x00, 0x00, 0x00, 0x03,
|
||||
0x00, 0x10, 0x40, 0x01, 0x18, 0x10, 0x01, 0x00, 0x00, 0x00])
|
||||
mbx = (req + bytes(sm0["len"]))[:sm0["len"]]
|
||||
s._fpwr(sm0["start"], mbx)
|
||||
print("请求已写入 SM0", flush=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
# 扫描 SM1 start-8 .. start+72
|
||||
base = sm1["start"]
|
||||
d = fprd(base - 8, 80)
|
||||
if d:
|
||||
print(f"\nSM1 RAM 扫描 [0x{base - 8:04X} .. 0x{base + 72:04X}]:", flush=True)
|
||||
for i in range(0, 80, 8):
|
||||
chunk = d[i:i + 8]
|
||||
off = i - 8
|
||||
mark = " <-- SM1.start" if off == 0 else ""
|
||||
print(f" {off:+4d} (0x{base + off:04X}): {chunk.hex().upper()}{mark}", flush=True)
|
||||
|
||||
st1 = sm_reg(0x0808)
|
||||
print(f"\nSM1 st=0x{st1['st']:02X}(bit3={st1['st'] >> 3 & 1})", flush=True)
|
||||
|
||||
# 同步看 SM0 侧(请求是否被读走)
|
||||
st0 = sm_reg(0x0800)
|
||||
print(f"SM0 st=0x{st0['st']:02X}(bit3={st0['st'] >> 3 & 1})", flush=True)
|
||||
|
||||
es.api_close({})
|
||||
print("DONE", flush=True)
|
||||
@@ -1,77 +0,0 @@
|
||||
"""_probe_stuck.py — 验证"SM1 响应滞留 → 邮箱死锁"假说。
|
||||
板上现状: SM1 挂着一个未读走的响应(上一探针注入)。实验:
|
||||
1. SOEM SDO 读 → 预期失败(复现楔死现象)
|
||||
2. 手动读走 SM1 全长(清 bit3) → 模拟"主站把滞留响应读掉"
|
||||
3. SOEM SDO 再读 → 是否恢复?
|
||||
若恢复 = 根治方向明确:邮箱死锁的物理本质是响应滞留,主站侧可自愈,从站侧可加超时复位。
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import ecat_server as es
|
||||
import pysoem
|
||||
|
||||
r = es.api_open({"substr": "I226"})
|
||||
if not r.get("ok"):
|
||||
sys.exit(1)
|
||||
m = es.g_master
|
||||
s = m.slaves[0]
|
||||
|
||||
|
||||
def fprd(addr, size):
|
||||
try:
|
||||
return bytes(s._fprd(addr, size, 20000))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def sm1_state(tag):
|
||||
d = fprd(0x0808, 8)
|
||||
if d is None:
|
||||
print(f"[{tag}] SM1 读失败", flush=True)
|
||||
return None
|
||||
start, length = struct.unpack("<HH", d[:4])
|
||||
st = d[5]
|
||||
print(f"[{tag}] SM1 st=0x{st:02X}(bit3={st >> 3 & 1} 读邮箱满)", flush=True)
|
||||
return {"start": start, "len": length, "st": st}
|
||||
|
||||
|
||||
m.state = pysoem.INIT_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
m.state_check(pysoem.INIT_STATE, 1_000_000)
|
||||
m.config_init()
|
||||
m.state = pysoem.PREOP_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
print("PREOP:", "OK" if m.state_check(pysoem.PREOP_STATE, 1_000_000) == pysoem.PREOP_STATE else "FAIL", flush=True)
|
||||
|
||||
# --- 1. 现状: SM1 挂着滞留响应 ---
|
||||
sm1_state("实验前")
|
||||
|
||||
print("\n--- SOEM SDO #1(预期失败) ---", flush=True)
|
||||
try:
|
||||
d = bytes(s.sdo_read(0x1000, 0, 8))
|
||||
print(f" 成功?! = {d.hex().upper()}", flush=True)
|
||||
except Exception as e:
|
||||
print(f" 失败: {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
sm1_state("SDO#1 后")
|
||||
|
||||
# --- 2. 读走 SM1 滞留响应(全长度读取,读到末字节才清 bit3) ---
|
||||
sm1 = sm1_state("读滞留前")
|
||||
if sm1 and (sm1["st"] & 0x08):
|
||||
d = fprd(sm1["start"], sm1["len"])
|
||||
print(f" 读走 SM1 {sm1['len']}B: {d[:16].hex().upper()}...", flush=True)
|
||||
sm1_state("读走后")
|
||||
|
||||
# --- 3. SOEM SDO 再试 ---
|
||||
print("\n--- SOEM SDO #2(读走滞留后) ---", flush=True)
|
||||
try:
|
||||
d = bytes(s.sdo_read(0x1000, 0, 8))
|
||||
print(f" 成功! = {d.hex().upper()}", flush=True)
|
||||
print(">>> 假说成立:响应滞留是死锁根源,读走即恢复!", flush=True)
|
||||
except Exception as e:
|
||||
print(f" 仍失败: {type(e).__name__}: {e}", flush=True)
|
||||
sm1_state("SDO#2 后")
|
||||
|
||||
es.api_close({})
|
||||
print("DONE", flush=True)
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
"""_probe_wedge.py — 楔死现场逐环节定位(纠正之前 0x0810/0x0818=SM2/SM3 的标号错误,真邮箱 SM 是 0x0800/0x0808)。
|
||||
|
||||
环节定位:
|
||||
A. FPWR 写 SM0 RAM 的 WKC —— 写都进不去 = SM0 硬件层被拒(disabled/错址)
|
||||
B. 写入后 0x220 bit0(SM0 写事件)是否置位 —— ESC 没置事件 = ESC 硬件楔死
|
||||
C. 200ms 后 bit0 是否被 SSC 清掉 + SM1 status bit3(读邮箱满)是否置位 —— 没处理 = SSC 软件死了
|
||||
D. SM1 RAM 里有没有滞留的旧响应(bSendMbxIsFull 死锁证据)
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import ecat_server as es
|
||||
import pysoem
|
||||
|
||||
r = es.api_open({"substr": "I226"})
|
||||
if not r.get("ok"):
|
||||
print("open 失败,板子可能已断电", flush=True)
|
||||
sys.exit(1)
|
||||
m = es.g_master
|
||||
s = m.slaves[0]
|
||||
|
||||
|
||||
def fprd(addr, size):
|
||||
try:
|
||||
return bytes(s._fprd(addr, size, 20000))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def dump(tag):
|
||||
al = fprd(0x0130, 8)
|
||||
ev = fprd(0x0220, 2)
|
||||
am = fprd(0x0204, 2)
|
||||
sm0 = fprd(0x0800, 8)
|
||||
sm1 = fprd(0x0808, 8)
|
||||
def fmt(d):
|
||||
if d is None:
|
||||
return "ERR"
|
||||
start, length = struct.unpack("<HH", d[:4])
|
||||
return f"start=0x{start:04X} len={length} ctl=0x{d[4]:02X} st=0x{d[5]:02X} act=0x{d[6]:02X} pdi=0x{d[7]:02X}"
|
||||
al_s = f"AL=0x{struct.unpack('<H', al[:2])[0]:04X} code=0x{struct.unpack('<H', al[4:6])[0]:04X}" if al else "AL=ERR"
|
||||
ev_v = struct.unpack("<H", ev)[0] if ev else -1
|
||||
am_v = struct.unpack("<H", am)[0] if am else -1
|
||||
print(f"[{tag}] {al_s} 0x220=0x{ev_v:04X}(bit0={ev_v & 1}bit1={(ev_v >> 1) & 1}) 0x204=0x{am_v:04X}", flush=True)
|
||||
print(f" SM0(0x0800): {fmt(sm0)}", flush=True)
|
||||
print(f" SM1(0x0808): {fmt(sm1)}", flush=True)
|
||||
return sm0, sm1, ev_v
|
||||
|
||||
|
||||
# --- 阶段 1: PREOP 下探(邮箱 SM 应处于使能态) ---
|
||||
m.state = pysoem.INIT_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
m.state_check(pysoem.INIT_STATE, 1_000_000)
|
||||
m.config_init()
|
||||
m.state = pysoem.PREOP_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
st = m.state_check(pysoem.PREOP_STATE, 1_000_000)
|
||||
print(f"PREOP state_check = 0x{st & 0x0F:02X}", flush=True)
|
||||
sm0, sm1, ev = dump("PREOP 稳态")
|
||||
|
||||
if sm0 is None or sm1 is None:
|
||||
print("SM0/SM1 读取失败", flush=True)
|
||||
es.api_close({})
|
||||
sys.exit(1)
|
||||
|
||||
# SM0/SM1 RAM 内容(滞留响应证据)
|
||||
mbx0_addr, mbx0_len = struct.unpack("<HH", sm0[:4])
|
||||
mbx1_addr, mbx1_len = struct.unpack("<HH", sm1[:4])
|
||||
print(f"邮箱 RAM: SM0@0x{mbx0_addr:04X}({mbx0_len}B) SM1@0x{mbx1_addr:04X}({mbx1_len}B)", flush=True)
|
||||
for name, addr, ln in (("SM0", mbx0_addr, mbx0_len), ("SM1", mbx1_addr, mbx1_len)):
|
||||
d = fprd(addr, 16)
|
||||
if d:
|
||||
print(f" {name} RAM[0:16] = {d.hex().upper()}", flush=True)
|
||||
|
||||
# --- 阶段 2: 手动注入一个合法 CoE SDO 读请求(0x1000:00 expedited upload) ---
|
||||
# 邮箱帧: len=0x000A addr=0x0000 pri=0 type=0x03(CoE, counter=0) | CANOpen=0x1000+8(UP_REQ) idx=0x1000 sub=0
|
||||
req = struct.pack("<HHBB", 10, 0, 0, 0x03) + struct.pack("<HBBB", 0x1000, 0x100 & 0xFF, 0x00, 0x00)[:5]
|
||||
req = bytes([0x0A, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
print(f"\n--- 阶段A: FPWR 写 SM0 RAM(0x{mbx0_addr:04X}) {req.hex().upper()} ---", flush=True)
|
||||
try:
|
||||
s._fpwr(mbx0_addr, req)
|
||||
print(" 写入 OK (WKC=1) —— SM0 硬件接受写入", flush=True)
|
||||
except Exception as e:
|
||||
print(f" 写入失败: {type(e).__name__}: {e} —— SM0 硬件拒绝(环节A死点)", flush=True)
|
||||
|
||||
ev2 = fprd(0x0220, 2)
|
||||
if ev2:
|
||||
v = struct.unpack("<H", ev2)[0]
|
||||
print(f" 阶段B: 0x220=0x{v:04X} bit0(SM0写事件)={v & 1} —— {'已置位' if v & 1 else '未置位!ESC 硬件楔死'}", flush=True)
|
||||
|
||||
# --- 阶段 3: 给 SSC 200ms 处理时间 ---
|
||||
time.sleep(0.2)
|
||||
sm0b, sm1b, ev3 = dump("注入请求 200ms 后")
|
||||
sm1_st = sm1b[5] if sm1b else 0
|
||||
print(f" 阶段C: 0x220 bit0={ev3 & 1}(应=0,SSC 已读走) SM1 st=0x{sm1_st:02X}(bit3={sm1_st >> 3 & 1},应=1,响应就绪)", flush=True)
|
||||
if ev3 is not None and (ev3 & 1):
|
||||
print(" >>> bit0 仍挂起:SSC 轮询没处理邮箱(环节C死点:MainLoop/ECAT_Main 邮箱分支死了)", flush=True)
|
||||
elif sm1_st & 0x08:
|
||||
d = fprd(mbx1_addr, 16)
|
||||
print(f" 阶段D: SM1 响应 RAM = {d.hex().upper() if d else 'ERR'}", flush=True)
|
||||
print(" >>> SSC 活着!邮箱处理正常 —— 楔死只在特定条件下", flush=True)
|
||||
else:
|
||||
print(" >>> bit0 被清了但 SM1 无响应:SSC 收到了但没回(处理链中途死)", flush=True)
|
||||
|
||||
es.api_close({})
|
||||
print("DONE", flush=True)
|
||||
@@ -1,93 +0,0 @@
|
||||
"""_probe_wedge2.py — 按真实邮箱协议探针: 检查写邮箱空 → 写满 256B → 查事件/响应。"""
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import ecat_server as es
|
||||
import pysoem
|
||||
|
||||
r = es.api_open({"substr": "I226"})
|
||||
if not r.get("ok"):
|
||||
sys.exit(1)
|
||||
m = es.g_master
|
||||
s = m.slaves[0]
|
||||
|
||||
|
||||
def fprd(addr, size):
|
||||
try:
|
||||
return bytes(s._fprd(addr, size, 20000))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def sm_reg(addr):
|
||||
d = fprd(addr, 8)
|
||||
if d is None:
|
||||
return None
|
||||
start, length = struct.unpack("<HH", d[:4])
|
||||
return {"start": start, "len": length, "ctl": d[4], "st": d[5], "act": d[6],
|
||||
"raw": d.hex().upper()}
|
||||
|
||||
|
||||
def show(tag):
|
||||
sm0, sm1 = sm_reg(0x0800), sm_reg(0x0808)
|
||||
ev = fprd(0x0220, 2)
|
||||
al = fprd(0x0130, 8)
|
||||
ev_v = struct.unpack("<H", ev)[0] if ev else -1
|
||||
al_v = struct.unpack("<H", al[:2])[0] if al else -1
|
||||
print(f"[{tag}] AL=0x{al_v:02X} 0x220=0x{ev_v:04X} SM0[st=0x{sm0['st']:02X} act=0x{sm0['act']:02X}] "
|
||||
f"SM1[st=0x{sm1['st']:02X} act=0x{sm1['act']:02X}] (SM0.bit3={sm0['st'] >> 3 & 1}写满 SM1.bit3={sm1['st'] >> 3 & 1}读满)",
|
||||
flush=True)
|
||||
return sm0, sm1, ev_v
|
||||
|
||||
|
||||
m.state = pysoem.INIT_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
m.state_check(pysoem.INIT_STATE, 1_000_000)
|
||||
m.config_init()
|
||||
m.state = pysoem.PREOP_STATE | pysoem.STATE_ACK
|
||||
m.write_state()
|
||||
print("PREOP:", "OK" if m.state_check(pysoem.PREOP_STATE, 1_000_000) == pysoem.PREOP_STATE else "FAIL", flush=True)
|
||||
|
||||
sm0, sm1, ev = show("稳态")
|
||||
|
||||
# 合法 CoE SDO 读 0x1000:00(expedited), padded 到整个邮箱长度 256B
|
||||
req_hdr = bytes([0x0A, 0x00, 0x00, 0x00, 0x00, 0x03, # len=10, addr=0, pri=0, type=CoE(cnt=0)
|
||||
0x10, 0x00, # CANOpen = 0x0010 (SDO req<<12 | 0x1000? 不对, 服务号 0x10?) —— 用标准 SOEM 格式
|
||||
0x00, 0x10, # Command = 0x40(up req, exp)
|
||||
0x00, 0x10, # Index = 0x1000
|
||||
0x00, 0x00]) # sub=0, +pad
|
||||
# SOEM ecx_SDOread 的帧: CANOpen = (ECT_COES_SDOREQ<<12)=0x1000 | number; Command=0x40; Index; SubIndex
|
||||
req = bytes([0x0A, 0x00, 0x00, 0x00, 0x00, 0x03,
|
||||
0x00, 0x10, 0x40, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
mbx = (req + bytes(sm0["len"]))[:sm0["len"]]
|
||||
print(f"\n写满 SM0 RAM {sm0['len']}B(合法 CoE 读 0x1000:00)...", flush=True)
|
||||
try:
|
||||
s._fpwr(sm0["start"], mbx)
|
||||
print(" 写入 OK (WKC=1)", flush=True)
|
||||
except Exception as e:
|
||||
print(f" 写入失败: {e}", flush=True)
|
||||
|
||||
sm0b, sm1b, ev2 = show("写入后立即")
|
||||
time.sleep(0.2)
|
||||
sm0c, sm1c, ev3 = show("写入后 200ms")
|
||||
|
||||
if sm1c and (sm1c["st"] & 0x08):
|
||||
d = fprd(sm1c["start"], 32)
|
||||
print(f"*** SSC 响应了! SM1 RAM = {d.hex().upper() if d else 'ERR'}", flush=True)
|
||||
print(">>> ESC+SSC 完全正常,楔死另有其因", flush=True)
|
||||
else:
|
||||
print("\nSSC 无响应。检查 0x220 bit0 事件是否置位过...", flush=True)
|
||||
if sm0c and (sm0c["st"] & 0x08):
|
||||
print(f"SM0.bit3(写满)={sm0c['st'] >> 3 & 1} —— 写满标志挂着(主站侧协议已触发)", flush=True)
|
||||
# 模拟 SSC 解锁: 读 SM0 首 word + 末 word
|
||||
fprd(sm0c["start"], 2)
|
||||
fprd(sm0c["start"] + sm0c["len"] - 2, 2)
|
||||
sm0d, sm1d, ev4 = show("模拟 SSC 解锁后")
|
||||
if not (sm0d["st"] & 0x08):
|
||||
print("解锁有效(SM0.bit3 清零) —— SM 状态机活着", flush=True)
|
||||
else:
|
||||
print(f"SM0.bit3(写满)={sm0c['st'] >> 3 & 1 if sm0c else '?'} —— 写满标志都没置!写到末字节也没触发 mailbox full", flush=True)
|
||||
|
||||
es.api_close({})
|
||||
print("DONE", flush=True)
|
||||
@@ -233,6 +233,52 @@ label { display: block; }
|
||||
.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; }
|
||||
|
||||
/* motor.html 的 N1–N9 紧凑列表项(.node-item/.ni-*)。
|
||||
* 与上面 .node-card 是两套东西: .node-card 供 ecat.html 的单从站卡片,
|
||||
* 这里是 motor.js renderNodeList() 渲染的九行节点。类名对不上时整列表会裸奔。
|
||||
* 单行布局: LED | N1 | 故障标签 | 右对齐读数 */
|
||||
.node-item {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-root);
|
||||
cursor: pointer;
|
||||
opacity: .5; /* 默认离线态; .online 时恢复 */
|
||||
transition: border-color .2s, background .2s, opacity .2s, transform .12s;
|
||||
}
|
||||
.node-item:hover { border-color: var(--border-strong); transform: translateX(2px); }
|
||||
.node-item:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
|
||||
.node-item.online { opacity: 1; }
|
||||
.node-item.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-item .ni-led {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--fg-dim); transition: background .3s, box-shadow .3s;
|
||||
}
|
||||
.node-item.online .ni-led { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
||||
.node-item .ni-name {
|
||||
font-family: var(--font-mono); font-weight: 700; font-size: .9rem;
|
||||
letter-spacing: -0.02em; min-width: 2.1em;
|
||||
}
|
||||
/* 故障标签默认不占位, 有内容(.has)时才显示 */
|
||||
.node-item .ni-fault { display: none; }
|
||||
.node-item .ni-fault.has {
|
||||
display: inline-block;
|
||||
font-size: .65rem; padding: 1px 6px; border-radius: 99px;
|
||||
background: var(--danger-dim); color: var(--danger);
|
||||
font-weight: 600; letter-spacing: .02em;
|
||||
max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.node-item.online:has(.ni-fault.has) { border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
|
||||
.node-item .ni-sub {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono); font-size: .68rem; color: var(--fg-muted);
|
||||
letter-spacing: .01em; white-space: nowrap;
|
||||
}
|
||||
/* 独立 BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳),不归属任何 Nx
|
||||
* 注意:不要用内联 style="display:none",否则 .show 的 display:flex 盖不过。 */
|
||||
.boot-badge {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="ecat.css?v=20260903b">
|
||||
<style>
|
||||
/* ecat 页面专属补充(基于 motor.css 设计体系) */
|
||||
.ecat-grid { grid-template-columns: minmax(240px, 290px) minmax(0, 1fr) minmax(0, 1.1fr); }
|
||||
@@ -57,6 +57,7 @@
|
||||
<span class="refresh-hint">ESC EEPROM 直读 · 烧录后断电重启生效</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a class="btn btn-sm" href="/motor.html" title="电机控制台(经 EtherCAT 过程数据,不走 WiFi/HTTP)">电机控制台</a>
|
||||
<span class="node-badge" id="alBadge">AL —</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -147,6 +148,7 @@
|
||||
<div class="tabs-bar">
|
||||
<button class="tab-btn active" data-tab="pdo">过程数据</button>
|
||||
<button class="tab-btn" data-tab="sdo">SDO (CoE)</button>
|
||||
<button class="tab-btn" data-tab="can">CAN 转发</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<!-- PDO 页 -->
|
||||
@@ -209,6 +211,52 @@
|
||||
</div>
|
||||
<div class="eep" id="sdoOut">—</div>
|
||||
</div>
|
||||
<!-- CAN 转发页 -->
|
||||
<div class="tab-page" id="tabCan">
|
||||
<div class="cmd-grid">
|
||||
<div class="field">
|
||||
<span class="field-label">通道</span>
|
||||
<select id="canCh">
|
||||
<option value="0">CH0</option><option value="1">CH1</option><option value="2">CH2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><span class="field-label">CAN ID <span class="field-unit">29bit</span></span><input type="text" id="canId" value="0x18DA00F1"></div>
|
||||
<div class="field"><span class="field-label">DLC <span class="field-unit">≤24</span></span><input type="number" id="canDlc" value="8" min="0" max="24"></div>
|
||||
</div>
|
||||
<div class="cmd-grid" style="margin-top:var(--sp-3)">
|
||||
<div class="field" style="grid-column:1/-1">
|
||||
<span class="field-label">数据 <span class="field-unit">hex</span></span>
|
||||
<input type="text" id="canData" value="0102030405060708" placeholder="0102030405060708">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--sp-3);align-items:flex-end;margin-top:var(--sp-3);flex-wrap:wrap">
|
||||
<button class="btn btn-primary" id="btnCanSend" disabled>发送一帧</button>
|
||||
<div class="field" style="width:110px">
|
||||
<span class="field-label">周期 <span class="field-unit">ms</span></span>
|
||||
<input type="number" id="canRepeatMs" value="200" min="50" max="10000" step="10">
|
||||
</div>
|
||||
<button class="btn" id="btnCanRepeat" disabled>开始周期</button>
|
||||
</div>
|
||||
<div class="br-row" style="margin-top:var(--sp-3)">
|
||||
<div class="big-reading"><div class="br-val" id="canRxId">—</div><div class="br-lbl">最近接收 ID</div></div>
|
||||
<div class="big-reading"><div class="br-val" id="canRxData">—</div><div class="br-lbl">接收数据</div></div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat-cell">接收<b id="canRxCnt">0</b></div>
|
||||
<div class="stat-cell">发送<b id="canTxCnt">0</b></div>
|
||||
<div class="stat-cell">来源<b id="canRxCh">—</b></div>
|
||||
<div class="stat-cell">发送结果<b id="canTxRes">—</b></div>
|
||||
<div class="stat-cell">队列积压<b id="canBacklog">—</b></div>
|
||||
<div class="stat-cell">丢帧<b id="canDrop">0</b></div>
|
||||
</div>
|
||||
<div class="mode-desc" style="margin-top:var(--sp-2)">
|
||||
CAN 帧承载在过程数据里(固件 ≥1.5.56 为 384B 出 / 640B 入):输出 PDO = 待发帧, 输入 PDO = 最近收到的帧 + 计数。
|
||||
发送用<b>序号沿触发</b>, 同一帧不会被每周期重发; 从站回显 ack 即证明整条 输出→应用→输入 链路通。
|
||||
本调试页只用兼容区(输出前 48B / 输入前 32B, 与 1.5.53 逐字节一致), 接收仍只显示前 8 字节, 超长帧标记截断。
|
||||
要一周期合并多条 CANFD 帧、或看完整 32B/64B payload, 用<a href="/motor.html">电机控制台</a>(走批量帧区)。
|
||||
未进 OP 时点发送会自动先进 OP。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -257,7 +305,7 @@ 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);
|
||||
"btnSdoRead","btnSdoWrite","btnCanSend","btnCanRepeat"].forEach(id=>$(id).disabled=!on);
|
||||
$("btnOp").disabled=!on;
|
||||
$("btnConn").disabled=on;
|
||||
$("connLed").className="led "+(on?"led-on":"");
|
||||
@@ -379,12 +427,13 @@ $("btnSdoWrite").onclick=async()=>{
|
||||
};
|
||||
|
||||
/* ---- tabs ---- */
|
||||
const TAB_PAGE={pdo:"tabPdo", sdo:"tabSdo", can:"tabCan"};
|
||||
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");
|
||||
$(TAB_PAGE[b.dataset.tab]).classList.add("active");
|
||||
};
|
||||
});
|
||||
|
||||
@@ -469,6 +518,78 @@ window.addEventListener("beforeunload",()=>{
|
||||
if(running) navigator.sendBeacon("/api/op/stop");
|
||||
});
|
||||
|
||||
/* ---- CAN 转发 ---- */
|
||||
let canRepeatTimer=null;
|
||||
function renderCanState(r){
|
||||
if(!r || r.valid===false) return;
|
||||
$("canRxCnt").textContent=r.rx_count;
|
||||
$("canTxCnt").textContent=r.tx_count;
|
||||
$("canDrop").textContent=r.drop;
|
||||
$("canDrop").className=r.drop>0?"err":"";
|
||||
$("canBacklog").textContent=r.backlog;
|
||||
$("canTxRes").textContent=r.tx_result_text||"—";
|
||||
$("canTxRes").className=(r.tx_result&&r.tx_result!==0)?"err":"";
|
||||
if(r.rx_valid){
|
||||
$("canRxId").textContent="0x"+r.id.toString(16).toUpperCase();
|
||||
$("canRxData").textContent=(r.data||"—")+(r.truncated?" ⋯":"");
|
||||
$("canRxCh").textContent="CH"+r.ch+(r.truncated?" (截断)":"");
|
||||
}else{
|
||||
$("canRxId").textContent="—"; $("canRxData").textContent="—";
|
||||
$("canRxCh").textContent="尚无接收";
|
||||
}
|
||||
}
|
||||
async function doCanSend(quiet){
|
||||
const body={ch:parseNum($("canCh").value), id:$("canId").value.trim(),
|
||||
dlc:parseNum($("canDlc").value), data:$("canData").value.replace(/\s+/g,""),
|
||||
dc_cycle_us:parseNum($("dcCycle").value)};
|
||||
const res=await api("/api/can/send",body);
|
||||
if(!quiet||!res.ok) showLog(res);
|
||||
if(res.ok) renderCanState(res);
|
||||
return res.ok;
|
||||
}
|
||||
$("btnCanSend").onclick=async()=>{
|
||||
$("btnCanSend").disabled=true;
|
||||
try{ await doCanSend(false); }
|
||||
catch(e){ log("发送异常: "+e,"log-err"); }
|
||||
finally{ $("btnCanSend").disabled=!connected; }
|
||||
};
|
||||
$("btnCanRepeat").onclick=()=>{
|
||||
if(canRepeatTimer){
|
||||
clearInterval(canRepeatTimer); canRepeatTimer=null;
|
||||
$("btnCanRepeat").textContent="开始周期";
|
||||
$("btnCanRepeat").classList.remove("btn-primary");
|
||||
log("周期发送已停止");
|
||||
return;
|
||||
}
|
||||
const period=Math.max(50,parseNum($("canRepeatMs").value)||200);
|
||||
canRepeatTimer=setInterval(async()=>{
|
||||
try{
|
||||
// 一帧没发成功就停下来,避免掉线后无声空转刷屏
|
||||
if(!await doCanSend(true)){
|
||||
clearInterval(canRepeatTimer); canRepeatTimer=null;
|
||||
$("btnCanRepeat").textContent="开始周期";
|
||||
$("btnCanRepeat").classList.remove("btn-primary");
|
||||
log("周期发送因错误停止","log-err");
|
||||
}
|
||||
}catch(e){/* 网络瞬断, 下个周期重试 */}
|
||||
},period);
|
||||
$("btnCanRepeat").textContent="停止周期";
|
||||
$("btnCanRepeat").classList.add("btn-primary");
|
||||
log(`周期发送已启动: 每 ${period}ms 一帧`);
|
||||
};
|
||||
// CAN 页可见且未在周期发送时, 1s 轮询一次桥状态(读接收帧/计数)
|
||||
setInterval(async()=>{
|
||||
if(!connected || canRepeatTimer) return;
|
||||
if(!$("tabCan").classList.contains("active")) return;
|
||||
try{
|
||||
const r=await api("/api/can/state",{});
|
||||
if(r.ok) renderCanState(r);
|
||||
}catch(e){/* 网络瞬断, 下轮重试 */}
|
||||
},1000);
|
||||
window.addEventListener("beforeunload",()=>{
|
||||
if(canRepeatTimer) clearInterval(canRepeatTimer);
|
||||
});
|
||||
|
||||
/* ---- 初始状态 ---- */
|
||||
setConnected(false);
|
||||
log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。");
|
||||
|
||||
+740
-20
@@ -90,6 +90,9 @@ _RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/")
|
||||
|
||||
g_lock = threading.Lock()
|
||||
|
||||
# 本进程启动时刻:用于 404 时判断"源码已改但服务没重启"(见 Handler._not_found)
|
||||
SERVER_START_TIME = time.time()
|
||||
|
||||
# state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us");
|
||||
# 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。
|
||||
EC_TIMEOUT_STATE_US = 2_000_000
|
||||
@@ -154,7 +157,10 @@ def api_open(req):
|
||||
return {"ok": False,
|
||||
"log": [f"总线上没有发现从站({type(e).__name__}: {e})",
|
||||
"检查 DCU 板供电、网线是否接到该网口"]}
|
||||
if not n:
|
||||
# config_init() 返回从站数;**失败时返回 -1 而不是 0**,而 -1 是真值,
|
||||
# 旧写法 `if not n` 放行了 -1,随后 g_master.slaves[0] 抛 IndexError
|
||||
# (板子断电/网线拔出时点"连接"就会崩)。这里按 n <= 0 统一判失败。
|
||||
if n <= 0:
|
||||
try:
|
||||
m.close()
|
||||
except Exception:
|
||||
@@ -410,16 +416,18 @@ def _require_op_ready(dc_cycle_us=0, dc_shift_us=0):
|
||||
try:
|
||||
g_master.config_map()
|
||||
except Exception as e:
|
||||
# 顺带抓邮箱 SM 寄存器现场(8B: start/len/ctl/status/activate/pdi)——
|
||||
# 2026-09-03 02:12 实测楔死时 activate 位/StartAddr 异常(INIT 态读回全零,
|
||||
# PREOP 态读回 SII 值),这是区分"邮箱 SM 被禁用/未配置"与"SSC 不处理邮箱"
|
||||
# 的关键证据;失败信息里直接带上,抓 GD32 串口时可对照。
|
||||
# SM 寄存器 = 0x0800 + 8n, 邮箱是 SM0=0x0800 / SM1=0x0808;
|
||||
# 0x0810/0x0818 是 SM2/SM3(过程数据), 勿混用。
|
||||
# 顺带抓全部 SM 寄存器现场(各 8B: start/len/ctl/status/activate/pdi)。
|
||||
# 2026-09-03 03:00 探针实锤:楔死本质是 SSC 的 u16EscAddrSendMbx 错位
|
||||
# (响应帧写到 SM1 start+4/+8 且逐会话累积,断电才清零),主站从 0x1200 读
|
||||
# 永远对不上帧头;SM 寄存器本身(start=0x1000/0x1200,act=01)恒正确。
|
||||
# 此转储用于区分"SM 配置坏"与"SSC 地址错位"(后者:SM0/SM1 正常但 SDO
|
||||
# 持续失败;固件<1.5.52 无自愈,需断电重启;1.5.52+ 守护自动写回恢复)。
|
||||
try:
|
||||
sm0 = bytes(s._fprd(0x0800, 8, 20000)).hex().upper()
|
||||
sm1 = bytes(s._fprd(0x0808, 8, 20000)).hex().upper()
|
||||
sm_info = f"; 邮箱SM现场 SM0={sm0} SM1={sm1}(act位=byte[6],01=使能)"
|
||||
sm_dump = " ".join(
|
||||
f"SM{i}={bytes(s._fprd(0x0800 + i * 8, 8, 20000)).hex().upper()}"
|
||||
for i in range(4))
|
||||
sm_info = (f"; SM现场[0x0800..0x081F,各8B: start,len,ctl,st,act,pdi]: {sm_dump}"
|
||||
" —— SM0/SM1 配置正常却持续失败=SSC 邮箱地址错位楔死")
|
||||
except Exception:
|
||||
sm_info = ""
|
||||
return log + [f"config_map 失败: {type(e).__name__}: {e}{sm_info}"], False
|
||||
@@ -668,6 +676,632 @@ def api_op_cycle(req):
|
||||
return {"ok": True, "log": [], **st}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- CAN over PDO
|
||||
# 从站固件 1.5.56 起过程数据扩到 384B/640B —— LAN9252 的硬上限
|
||||
# (布局定义见 DCU_APP/GD32E508_DCU_APP/comm/ethercat_can_bridge.h)。
|
||||
#
|
||||
# 为何是 384+640:邮箱占 0x1000-0x13FF,过程数据区只剩 0x1400-0x1FFF=3072B,
|
||||
# SM2/SM3 都是 3-buffer 实占 3×size ⇒ out+in ≤ 1024B。384+640 恰好用满。
|
||||
#
|
||||
# 输出 PDO(主站→从站, 384B):
|
||||
# 段1 [0..47] 单帧发送,与 1.5.55 逐字节兼容
|
||||
# [0]seq [1]ch [2]dlc [3]flags [4..7]id(LE) [8]win_base
|
||||
# [9..15]rsvd [16..47]data(32B)。dlc=0 表示本次只发批量区
|
||||
# 段2 [48..383] CANFD 批量发送区: [48]count [49]flags [50..51]rsvd
|
||||
# [52..] 帧数组,每项 (8B 头 + dlc 数据) 紧密排列
|
||||
# 帧头: [0]ch [1]dlc [2]flags [3]result [4..7]id(LE)
|
||||
# 输入 PDO(从站→主站, 640B):
|
||||
# 段1 [0..31] 桥状态 + 短快照,与 1.5.53 逐字节兼容(CAN 调试页照旧可用)
|
||||
# 段2 [32..319] 电机状态窗口: [32]active [33]boot [34]win_base [35]win_count
|
||||
# [36..39]global_seq [40..] 7×40B 槽
|
||||
# 槽: [0]node [1]ch [2]online [3]dlc [4..7]seq [8..39]STATUS(32B)
|
||||
# 段3 [320..359] 最近一帧完整快照(版本/CONFIG 帧 DLC24 装得下)
|
||||
# 段4 [360..639] CANFD 批量接收区: [360]count [361]lost [362]pending
|
||||
# [363]rsvd [364..] 帧数组,项布局同发送向
|
||||
PDO_OUT_LEN = 384
|
||||
PDO_IN_LEN = 640
|
||||
|
||||
CAN_TX_DATA_MAX = 32 # 段1 单帧数据区(一帧电机 CMD 是 DLC 32)
|
||||
CAN_RX_DATA_MAX = 8 # 段1 短快照,保持兼容
|
||||
CAN_XRX_DATA_MAX = 32 # 段3 长快照
|
||||
CAN_FD_DATA_MAX = 64 # 批量区单帧上限(CANFD 全量)
|
||||
|
||||
TX_OFF_WIN = 8
|
||||
TX_OFF_DATA = 16
|
||||
|
||||
# 批量帧项头布局(收发两向共用)
|
||||
FRM_OFF_CH = 0
|
||||
FRM_OFF_DLC = 1
|
||||
FRM_OFF_FLAGS = 2
|
||||
FRM_OFF_RES = 3
|
||||
FRM_OFF_ID = 4
|
||||
FRM_HDR_SIZE = 8
|
||||
|
||||
# 段2: 批量发送区
|
||||
TXB_OFF_COUNT = 48
|
||||
TXB_OFF_FLAGS = 49
|
||||
TXB_OFF_ITEMS = 52
|
||||
TXB_BYTES = PDO_OUT_LEN - TXB_OFF_ITEMS # 332B
|
||||
TXB_MAX_FRAMES = TXB_BYTES // (FRM_HDR_SIZE + CAN_FD_DATA_MAX) # 4 (全 DLC64)
|
||||
|
||||
MOTW_OFF_ACTIVE = 32
|
||||
MOTW_OFF_BOOT = 33
|
||||
MOTW_OFF_WIN_BASE = 34
|
||||
MOTW_OFF_WIN_COUNT = 35
|
||||
MOTW_OFF_GLOBAL_SEQ = 36
|
||||
MOTW_OFF_SLOTS = 40
|
||||
MOT_SLOT_SIZE = 40
|
||||
MOT_SLOTS_PER_PDO = 7
|
||||
MOT_STATUS_DLC = 32
|
||||
|
||||
XRX_OFF_CH = 320
|
||||
XRX_OFF_DLC = 321
|
||||
XRX_OFF_FLAGS = 322
|
||||
XRX_OFF_ID = 324
|
||||
XRX_OFF_DATA = 328
|
||||
|
||||
# 段4: 批量接收区
|
||||
RXB_OFF_COUNT = 360
|
||||
RXB_OFF_LOST = 361
|
||||
RXB_OFF_PENDING = 362
|
||||
RXB_OFF_ITEMS = 364
|
||||
RXB_BYTES = PDO_IN_LEN - RXB_OFF_ITEMS # 276B
|
||||
RXB_MAX_FRAMES = RXB_BYTES // (FRM_HDR_SIZE + CAN_FD_DATA_MAX) # 3 (全 DLC64)
|
||||
|
||||
# 电机状态表规格(须与 GD32 device/motor_state.h 一致)
|
||||
MOTOR_MAX_NODES = 9
|
||||
MOTOR_CHANNELS = 3
|
||||
MOTOR_SLOT_COUNT = MOTOR_MAX_NODES * MOTOR_CHANNELS # 27
|
||||
MOTOR_SCAN_CYCLES = -(-MOTOR_SLOT_COUNT // MOT_SLOTS_PER_PDO) # 4
|
||||
|
||||
# 发送序号:每调一次 /api/can/send 自增。固件按"变化沿"触发发送,所以
|
||||
# 必须由主站维护单调递增的序号,而不能每次都发同一个值。
|
||||
_can_seq = 0
|
||||
# 最近一次要写进输出 PDO 的字节(在后续周期里保持不变,固件靠 seq 去重,
|
||||
# 不会重复发送;保持不变是为了让 seq 字段稳定可回显)。
|
||||
_can_out = bytearray(PDO_OUT_LEN)
|
||||
|
||||
CAN_TX_RESULT_DESC = {
|
||||
0x00: "OK",
|
||||
0xE2: "BUS_OFF",
|
||||
0xE3: "TEC 过高",
|
||||
0xE4: "邮箱满",
|
||||
0xE5: "参数无效",
|
||||
}
|
||||
|
||||
|
||||
def _can_pump(cycles=3):
|
||||
"""跑若干个 PDO 周期,让输出帧下发、输入帧回读。
|
||||
|
||||
固件的发送在 APPL_Application 里执行、结果在同周期写进输入 PDO,
|
||||
但主站的一次 send/receive 只保证"这一帧过程数据被交换",
|
||||
要读到 ack 回显通常需要再跑 1~2 个周期,故默认 3 个。
|
||||
"""
|
||||
s = g_master.slaves[0]
|
||||
out_len = len(bytes(s.output))
|
||||
wkc = 0
|
||||
for _ in range(max(1, cycles)):
|
||||
if out_len > 0:
|
||||
s.output = bytes(_can_out[:out_len]).ljust(out_len, b"\x00")
|
||||
g_master.send_processdata()
|
||||
wkc = g_master.receive_processdata(2000)
|
||||
time.sleep(0.002)
|
||||
return wkc, bytes(s.input)
|
||||
|
||||
|
||||
def _can_decode_input(inp):
|
||||
"""把输入 PDO 的段 1(桥状态 + 短快照)解成结构化状态。
|
||||
|
||||
段 1 布局自 1.5.53 未变,故本函数不随扩容改动 —— CAN 调试页照旧可用。
|
||||
段 3 的完整 payload 由 _decode_xrx() 单独解。
|
||||
"""
|
||||
if len(inp) < 32:
|
||||
return {"valid": False}
|
||||
flags = inp[3]
|
||||
dlc = inp[2]
|
||||
st = {
|
||||
"valid": True,
|
||||
"ack": inp[0],
|
||||
"rx_valid": bool(flags & 0x01),
|
||||
"truncated": bool(flags & 0x02),
|
||||
"ch": inp[1],
|
||||
"dlc": dlc,
|
||||
"id": struct.unpack_from("<I", inp, 4)[0],
|
||||
"data": inp[8:8 + min(dlc, CAN_RX_DATA_MAX)].hex().upper(),
|
||||
"rx_count": struct.unpack_from("<I", inp, 16)[0],
|
||||
"tx_result": inp[20],
|
||||
"backlog": inp[21],
|
||||
"tx_count": struct.unpack_from("<H", inp, 22)[0],
|
||||
"drop": struct.unpack_from("<H", inp, 24)[0],
|
||||
}
|
||||
st["tx_result_text"] = CAN_TX_RESULT_DESC.get(st["tx_result"],
|
||||
f"0x{st['tx_result']:02X}")
|
||||
return st
|
||||
|
||||
|
||||
def _decode_xrx(inp):
|
||||
"""解段 3:最近一帧**完整** payload(版本帧/CONFIG 帧都是 DLC 24)。"""
|
||||
if len(inp) < XRX_OFF_DATA + CAN_XRX_DATA_MAX:
|
||||
return None
|
||||
if not (inp[XRX_OFF_FLAGS] & 0x01):
|
||||
return None
|
||||
dlc = min(inp[XRX_OFF_DLC], CAN_XRX_DATA_MAX)
|
||||
return {
|
||||
"ch": inp[XRX_OFF_CH],
|
||||
"dlc": inp[XRX_OFF_DLC],
|
||||
"id": struct.unpack_from("<I", inp, XRX_OFF_ID)[0],
|
||||
"data": inp[XRX_OFF_DATA:XRX_OFF_DATA + dlc].hex().upper(),
|
||||
}
|
||||
|
||||
|
||||
def _decode_motor_window(inp):
|
||||
"""解段 2:电机状态窗口 → {slot_index: slot_dict}。
|
||||
|
||||
只回传 node_id != 0 的槽;空槽没有意义,前端按 node/ch 索引即可。
|
||||
"""
|
||||
out = {}
|
||||
if len(inp) < MOTW_OFF_SLOTS + MOT_SLOTS_PER_PDO * MOT_SLOT_SIZE:
|
||||
return out, {}
|
||||
base = inp[MOTW_OFF_WIN_BASE]
|
||||
count = min(inp[MOTW_OFF_WIN_COUNT], MOT_SLOTS_PER_PDO)
|
||||
head = {
|
||||
"active": inp[MOTW_OFF_ACTIVE],
|
||||
"boot_active": bool(inp[MOTW_OFF_BOOT]),
|
||||
"win_base": base,
|
||||
"win_count": count,
|
||||
"global_seq": struct.unpack_from("<I", inp, MOTW_OFF_GLOBAL_SEQ)[0],
|
||||
}
|
||||
for i in range(count):
|
||||
off = MOTW_OFF_SLOTS + i * MOT_SLOT_SIZE
|
||||
node = inp[off]
|
||||
if node == 0:
|
||||
continue
|
||||
out[base + i] = {
|
||||
"slot": base + i,
|
||||
"node": node,
|
||||
"ch": inp[off + 1],
|
||||
"online": bool(inp[off + 2]),
|
||||
"dlc": inp[off + 3],
|
||||
"seq": struct.unpack_from("<I", inp, off + 4)[0],
|
||||
"data": inp[off + 8:off + 8 + MOT_STATUS_DLC].hex().upper(),
|
||||
}
|
||||
return out, head
|
||||
|
||||
|
||||
def _pack_tx_batch(frames, stop_on_error=False):
|
||||
"""把多帧 CAN 打包进输出 PDO 的批量发送区(段2)。
|
||||
|
||||
返回 (装入帧数, 用掉的字节数)。装不下的帧留给调用方下一批 ——
|
||||
这里按**字节**判边界而不是按帧数:全 DLC8 能装 20 帧,全 DLC64 只能装 4 帧,
|
||||
用固定帧数上限会白白浪费三分之二的空间。
|
||||
"""
|
||||
_can_out[TXB_OFF_COUNT] = 0
|
||||
_can_out[TXB_OFF_FLAGS] = 0x01 if stop_on_error else 0x00
|
||||
off = TXB_OFF_ITEMS
|
||||
n = 0
|
||||
for ch, can_id, dlc, data in frames:
|
||||
dlc = min(int(dlc), CAN_FD_DATA_MAX)
|
||||
if off + FRM_HDR_SIZE + dlc > PDO_OUT_LEN:
|
||||
break
|
||||
_can_out[off + FRM_OFF_CH] = ch & 0xFF
|
||||
_can_out[off + FRM_OFF_DLC] = dlc
|
||||
_can_out[off + FRM_OFF_FLAGS] = 0
|
||||
_can_out[off + FRM_OFF_RES] = 0
|
||||
struct.pack_into("<I", _can_out, off + FRM_OFF_ID, can_id & 0xFFFFFFFF)
|
||||
_can_out[off + FRM_HDR_SIZE:off + FRM_HDR_SIZE + dlc] = \
|
||||
bytes(data[:dlc]).ljust(dlc, b"\x00")
|
||||
off += FRM_HDR_SIZE + dlc
|
||||
n += 1
|
||||
_can_out[TXB_OFF_COUNT] = n
|
||||
return n, off - TXB_OFF_ITEMS
|
||||
|
||||
|
||||
def _batch_capacity(frames):
|
||||
"""算出这批帧里前多少帧能装进一个 PDO 周期(纯计算,不写缓冲)。"""
|
||||
off = TXB_OFF_ITEMS
|
||||
n = 0
|
||||
for _ch, _id, dlc, _data in frames:
|
||||
dlc = min(int(dlc), CAN_FD_DATA_MAX)
|
||||
if off + FRM_HDR_SIZE + dlc > PDO_OUT_LEN:
|
||||
break
|
||||
off += FRM_HDR_SIZE + dlc
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _decode_rx_batch(inp):
|
||||
"""解码输入 PDO 的批量接收区(段4)。
|
||||
|
||||
count 是从站声明的帧数,但边界判据始终是"头+数据是否完整落在 PDO 内" ——
|
||||
不信任对端给的计数,越界即停。
|
||||
"""
|
||||
if len(inp) <= RXB_OFF_ITEMS:
|
||||
return {"count": 0, "lost": 0, "pending": 0, "frames": []}
|
||||
count = inp[RXB_OFF_COUNT]
|
||||
frames = []
|
||||
off = RXB_OFF_ITEMS
|
||||
for _ in range(min(count, RXB_BYTES // FRM_HDR_SIZE)):
|
||||
if off + FRM_HDR_SIZE > len(inp):
|
||||
break
|
||||
dlc = inp[off + FRM_OFF_DLC]
|
||||
if dlc > CAN_FD_DATA_MAX or off + FRM_HDR_SIZE + dlc > len(inp):
|
||||
break
|
||||
frames.append({
|
||||
"ch": inp[off + FRM_OFF_CH],
|
||||
"dlc": dlc,
|
||||
"valid": bool(inp[off + FRM_OFF_FLAGS] & 0x01),
|
||||
"id": struct.unpack_from("<I", inp, off + FRM_OFF_ID)[0],
|
||||
"data": inp[off + FRM_HDR_SIZE:off + FRM_HDR_SIZE + dlc].hex().upper(),
|
||||
})
|
||||
off += FRM_HDR_SIZE + dlc
|
||||
return {
|
||||
"count": count,
|
||||
"lost": inp[RXB_OFF_LOST], # 从站环形队列满导致的丢帧(增量)
|
||||
"pending": inp[RXB_OFF_PENDING], # 仍待回收的帧数 >0 说明主站该提速
|
||||
"frames": frames,
|
||||
}
|
||||
|
||||
|
||||
def _fill_can_out(ch, can_id, dlc, data, clear_stats=False, win_base=None,
|
||||
batch=None, stop_on_error=False):
|
||||
"""把一帧待发 CAN 写进输出 PDO 暂存区,并推进 seq(变化沿触发发送)。
|
||||
|
||||
win_base=None 表示保持当前的电机状态窗口下标不动 —— 窗口滚动由
|
||||
_pdo_worker 独占推进,发送路径不该顺手把它改回去。
|
||||
|
||||
batch 给出额外的多帧列表 [(ch,id,dlc,data)],一并放进段2 批量区,
|
||||
与单帧共用同一个 seq 沿:从站在一次 processOutput 里先发单帧再发批量。
|
||||
dlc=0 表示只发批量、不发单帧。
|
||||
"""
|
||||
global _can_seq
|
||||
_can_seq = (_can_seq + 1) & 0xFF
|
||||
if _can_seq == 0: # 0 与固件的"未初始化"基准无法区分,跳过
|
||||
_can_seq = 1
|
||||
keep_win = _can_out[TX_OFF_WIN] if win_base is None else (win_base & 0xFF)
|
||||
|
||||
_can_out[:] = bytes(PDO_OUT_LEN)
|
||||
_can_out[0] = _can_seq
|
||||
_can_out[1] = ch
|
||||
_can_out[2] = dlc
|
||||
_can_out[3] = 0x01 if clear_stats else 0x00
|
||||
struct.pack_into("<I", _can_out, 4, can_id & 0xFFFFFFFF)
|
||||
_can_out[TX_OFF_WIN] = keep_win
|
||||
if dlc:
|
||||
_can_out[TX_OFF_DATA:TX_OFF_DATA + dlc] = data[:dlc]
|
||||
if batch:
|
||||
_pack_tx_batch(batch, stop_on_error)
|
||||
return _can_seq
|
||||
|
||||
|
||||
def api_can_send(req):
|
||||
"""经 EtherCAT 过程数据发一帧 CAN。
|
||||
|
||||
请求: {ch:0..2, id:int|"0x..", dlc:0..32, data:"HEXSTR", clear_stats:bool}
|
||||
"""
|
||||
if g_master is None:
|
||||
return {"ok": False, "log": ["未连接"]}
|
||||
if not g_op_active:
|
||||
log, ok = _require_op_ready(*_dc_args(req))
|
||||
if not ok:
|
||||
return {"ok": False, "log": log}
|
||||
|
||||
try:
|
||||
ch = int(req.get("ch") or 0)
|
||||
raw_id = req.get("id", 0)
|
||||
can_id = int(raw_id, 16) if isinstance(raw_id, str) and raw_id.lower().startswith("0x") \
|
||||
else int(raw_id or 0)
|
||||
data = bytes.fromhex((req.get("data") or "").replace(" ", ""))
|
||||
dlc = int(req.get("dlc") if req.get("dlc") not in (None, "") else len(data))
|
||||
except (TypeError, ValueError) as e:
|
||||
return {"ok": False, "log": [f"参数解析失败: {e}"]}
|
||||
|
||||
if ch not in (0, 1, 2):
|
||||
return {"ok": False, "log": [f"通道必须是 0/1/2,收到 {ch}"]}
|
||||
if dlc > CAN_TX_DATA_MAX:
|
||||
return {"ok": False, "log": [f"DLC {dlc} 超出 PDO 单帧上限 {CAN_TX_DATA_MAX}"]}
|
||||
if len(data) < dlc:
|
||||
data = data.ljust(dlc, b"\x00")
|
||||
|
||||
seq = _fill_can_out(ch, can_id, dlc, data, bool(req.get("clear_stats")))
|
||||
|
||||
wkc, inp = _can_pump()
|
||||
st = _can_decode_input(inp)
|
||||
acked = st.get("ack") == seq
|
||||
txres = st.get("tx_result", 0)
|
||||
|
||||
log = [f"已下发 CH{ch} 0x{can_id:X} DLC{dlc} "
|
||||
f"{data[:dlc].hex().upper() or '(空)'} seq={seq}"]
|
||||
if not acked:
|
||||
log.append(f"警告: 从站回显 ack={st.get('ack')} != seq={seq},"
|
||||
"过程数据可能未被应用层处理(确认已进 OP、固件 ≥1.5.55)")
|
||||
elif txres != 0:
|
||||
log.append(f"从站发送失败: {st.get('tx_result_text')} "
|
||||
"(0xE4 邮箱满=总线无应答/未接收器;0xE2 BUS_OFF=接线或终端电阻)")
|
||||
return {"ok": True, "log": log, "wkc": wkc,
|
||||
"expected_wkc": g_master.expected_wkc, "seq": seq,
|
||||
"acked": acked, **st}
|
||||
|
||||
|
||||
def api_can_state(req):
|
||||
"""跑一个 PDO 周期并回读 CAN 桥状态(接收帧 + 计数)。"""
|
||||
if g_master is None:
|
||||
return {"ok": False, "log": ["未连接"]}
|
||||
if not g_op_active:
|
||||
return {"ok": False, "log": ["未进 OP —— 先点『进入 OP』或发送一帧"]}
|
||||
# 电机后台循环在跑时不要再自己踩 PDO:直接给它的最新快照,
|
||||
# 否则两个写者抢 s.output,且额外周期会打乱窗口滚动节奏。
|
||||
snap = _motor_snapshot()
|
||||
if snap is not None and snap.get("bridge"):
|
||||
return {"ok": True, "log": [], "wkc": snap.get("wkc", 0),
|
||||
"expected_wkc": snap.get("expected_wkc", 0), **snap["bridge"]}
|
||||
wkc, inp = _can_pump(cycles=1)
|
||||
return {"ok": True, "log": [], "wkc": wkc,
|
||||
"expected_wkc": g_master.expected_wkc, **_can_decode_input(inp)}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 电机控制台后端
|
||||
#
|
||||
# 为什么必须有后台循环线程,而不是沿用「一次 HTTP 请求 = 一次 PDO 周期」:
|
||||
#
|
||||
# _require_op_ready 给 SM2 写了 1000ms 过程数据看门狗(set_watchdog)。请求驱动
|
||||
# 的周期一旦被任何慢调用挡住(EEPROM 烧写数十秒、_require_op_ready 最长 12s、
|
||||
# 甚至浏览器后台标签把 setInterval 节流到 ≥1s),从站就会 OP→SAFEOP 报
|
||||
# AL 0x001B,而 g_op_active 仍是 True —— 服务端继续"报告成功",故障静默。
|
||||
# 所以周期性喂数据是**正确性需求**,不只是性能优化。
|
||||
#
|
||||
# 线程纪律(与本文件既有 g_lock 约定共存):
|
||||
# - 只有本线程调 send_processdata/receive_processdata,HTTP 路由一律读快照;
|
||||
# - 每周期取一次 g_lock,睡眠**在锁外**,绝不持锁 sleep;
|
||||
# - CoE/EEPROM 类路由本身持 g_lock,与本线程天然互斥(邮箱楔死风险见上方注释);
|
||||
# - 快照用整体 dict 重绑定发布,读者无需加锁(CPython 引用赋值原子)。
|
||||
_motor_thread = None
|
||||
_motor_stop = threading.Event()
|
||||
_motor_snap = None # 最新快照(整体替换,不原地改)
|
||||
_motor_cmd_q = collections.deque() # 待下发的 CAN 帧队列(线程安全)
|
||||
# 批量接收区回收到的帧(有界)。1.5.56 起从站一周期能回多帧,前端按需一次取走;
|
||||
# maxlen 防前端长时间不取时内存无限增长 —— 满了丢最旧的,快照里 lost/pending
|
||||
# 已经能告诉前端"有没有漏"。
|
||||
_motor_rx_log = collections.deque(maxlen=2000)
|
||||
_motor_period_ms = 4 # 后台周期,默认 4ms(250Hz) —— 远快于 1s 看门狗
|
||||
_motor_win = 0 # 当前窗口起始槽号,由本线程独占推进
|
||||
|
||||
|
||||
def _motor_snapshot():
|
||||
return _motor_snap
|
||||
|
||||
|
||||
def _pdo_worker():
|
||||
"""后台 PDO 循环:持续喂看门狗、滚动扫描电机状态窗口、下发排队的 CAN 帧。"""
|
||||
global _motor_snap, _motor_win, g_op_active
|
||||
slots = {} # 槽号 -> 槽快照(跨周期累积,窗口只覆盖自己那部分)
|
||||
head = {}
|
||||
errors = 0
|
||||
while not _motor_stop.is_set():
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
with g_lock:
|
||||
if g_master is None or not g_op_active:
|
||||
raise RuntimeError("未连接或未进 OP")
|
||||
s = g_master.slaves[0]
|
||||
|
||||
# 有排队的命令帧就装进输出 PDO。
|
||||
#
|
||||
# 1.5.56:一周期能发多帧了。此前受单帧槽限制,每周期只能发一帧
|
||||
# (seq 沿语义要求每个 seq 至少被从站看到一个周期),4ms 周期即
|
||||
# 250 帧/s 上限,9 轴同步下发要 36ms 才发完、各轴不同拍。
|
||||
# 现在把第一帧放单帧区、其余放批量区,共用同一个 seq 沿,
|
||||
# 从站在一次 processOutput 里全部发出 —— 真正的同步下发。
|
||||
sent = None
|
||||
nsent = 0
|
||||
if _motor_cmd_q:
|
||||
ch, can_id, dlc, data = _motor_cmd_q.popleft()
|
||||
# 按字节容量决定这一批能带几帧(全 DLC8 可 20 帧,全 DLC64 仅 4 帧)
|
||||
pend = list(_motor_cmd_q)
|
||||
take = _batch_capacity(pend)
|
||||
batch = [_motor_cmd_q.popleft() for _ in range(take)]
|
||||
sent = _fill_can_out(ch, can_id, dlc, data, batch=batch or None)
|
||||
nsent = 1 + len(batch)
|
||||
|
||||
# 推进窗口:每周期换一段,MOTOR_SCAN_CYCLES 个周期扫完全表
|
||||
_can_out[TX_OFF_WIN] = _motor_win & 0xFF
|
||||
|
||||
out_len = len(bytes(s.output))
|
||||
if out_len > 0:
|
||||
s.output = bytes(_can_out[:out_len]).ljust(out_len, b"\x00")
|
||||
g_master.send_processdata()
|
||||
wkc = g_master.receive_processdata(2000)
|
||||
inp = bytes(s.input)
|
||||
expected = g_master.expected_wkc
|
||||
|
||||
win_slots, win_head = _decode_motor_window(inp)
|
||||
slots.update(win_slots)
|
||||
if win_head:
|
||||
head = win_head
|
||||
rxb = _decode_rx_batch(inp)
|
||||
# 批量接收区的帧按到达顺序累积到一个有界缓冲,供前端一次取走。
|
||||
# 用 deque(maxlen) 而不是无界 list:前端若长时间不取,内存不能无限涨。
|
||||
if rxb["frames"]:
|
||||
_motor_rx_log.extend(rxb["frames"])
|
||||
_motor_snap = {
|
||||
"ts": time.time(),
|
||||
"wkc": wkc,
|
||||
"expected_wkc": expected,
|
||||
"cycles": (_motor_snap or {}).get("cycles", 0) + 1,
|
||||
"period_ms": _motor_period_ms,
|
||||
"sent_seq": sent,
|
||||
"sent_frames": nsent,
|
||||
"queue_depth": len(_motor_cmd_q),
|
||||
"bridge": _can_decode_input(inp),
|
||||
"xrx": _decode_xrx(inp),
|
||||
"rx_batch": rxb,
|
||||
"head": head,
|
||||
"motors": sorted(slots.values(), key=lambda m: (m["ch"], m["node"])),
|
||||
}
|
||||
errors = 0
|
||||
_motor_win = (_motor_win + MOT_SLOTS_PER_PDO) % (
|
||||
MOTOR_SCAN_CYCLES * MOT_SLOTS_PER_PDO)
|
||||
except Exception as e: # noqa: BLE001 - 循环不能因单次异常退出
|
||||
errors += 1
|
||||
if errors in (1, 50) or errors % 500 == 0:
|
||||
print(f"[PDO] 周期异常({errors}): {type(e).__name__}: {e}", flush=True)
|
||||
if errors > 3:
|
||||
# 连续失败:很可能已掉出 OP。清标志让下次请求走完整的重进 OP,
|
||||
# 而不是继续假装在线(这正是旧实现 0x001B 静默失败的成因)。
|
||||
g_op_active = False
|
||||
_motor_snap = {"ts": time.time(), "error": f"{type(e).__name__}: {e}",
|
||||
"cycles": (_motor_snap or {}).get("cycles", 0)}
|
||||
_motor_stop.wait(0.2)
|
||||
continue
|
||||
# 睡眠必须在锁外:持锁 sleep 会把所有 HTTP 路由一起冻住
|
||||
dt = time.perf_counter() - t0
|
||||
_motor_stop.wait(max(0.0, _motor_period_ms / 1000.0 - dt))
|
||||
|
||||
|
||||
def _motor_thread_start(period_ms=None):
|
||||
"""启动后台 PDO 循环(幂等)。"""
|
||||
global _motor_thread, _motor_period_ms
|
||||
if period_ms:
|
||||
_motor_period_ms = max(1, min(1000, int(period_ms)))
|
||||
if _motor_thread is not None and _motor_thread.is_alive():
|
||||
return False
|
||||
_motor_stop.clear()
|
||||
_motor_thread = threading.Thread(target=_pdo_worker, name="pdo-cycle",
|
||||
daemon=True)
|
||||
_motor_thread.start()
|
||||
return True
|
||||
|
||||
|
||||
def _motor_thread_stop():
|
||||
"""停后台 PDO 循环并等它退出(必须在拿 g_lock 之前调,否则自锁)。"""
|
||||
global _motor_thread
|
||||
if _motor_thread is None:
|
||||
return False
|
||||
_motor_stop.set()
|
||||
_motor_thread.join(timeout=2.0)
|
||||
_motor_thread = None
|
||||
return True
|
||||
|
||||
|
||||
def api_motor_start(req):
|
||||
"""进 OP 并启动后台 PDO 循环。
|
||||
|
||||
@note dispatcher 已在取 g_lock 前停掉旧循环,本函数在持锁状态下启动新线程。
|
||||
新线程第一次取 g_lock 会等到本路由返回,属正常排队,不是死锁。
|
||||
"""
|
||||
if g_master is None:
|
||||
return {"ok": False, "log": ["未连接"]}
|
||||
if not g_op_active:
|
||||
log, ok = _require_op_ready(*_dc_args(req))
|
||||
if not ok:
|
||||
return {"ok": False, "log": log}
|
||||
else:
|
||||
log = []
|
||||
started = _motor_thread_start(req.get("period_ms"))
|
||||
log.append(f"后台 PDO 循环{'已启动' if started else '已在运行'}: "
|
||||
f"周期 {_motor_period_ms}ms,窗口 {MOT_SLOTS_PER_PDO} 槽/周期,"
|
||||
f"{MOTOR_SCAN_CYCLES} 周期扫完 {MOTOR_SLOT_COUNT} 槽 "
|
||||
f"(全表刷新 ≈{_motor_period_ms * MOTOR_SCAN_CYCLES}ms)")
|
||||
return {"ok": True, "log": log, "period_ms": _motor_period_ms}
|
||||
|
||||
|
||||
def api_motor_stop(req):
|
||||
"""停后台 PDO 循环(保持 OP,不动总线状态)。
|
||||
|
||||
@note 实际的 stop/join 由 dispatcher 在锁外完成(见 _BUS_EXCLUSIVE_PATHS),
|
||||
这里只报告结果 —— 持锁 join 一个要抢同一把锁的线程会自锁死。
|
||||
"""
|
||||
return {"ok": True, "log": [f"后台 PDO 循环已停止(周期曾为 {_motor_period_ms}ms)"]}
|
||||
|
||||
|
||||
def api_motor_state(req):
|
||||
"""读最新电机状态快照(不碰总线,纯读内存,可高频轮询)。"""
|
||||
snap = _motor_snapshot()
|
||||
if snap is None:
|
||||
return {"ok": False, "log": ["后台 PDO 循环未启动 —— 先调 /api/motor/start"]}
|
||||
if "error" in snap:
|
||||
return {"ok": False, "log": [f"PDO 周期异常: {snap['error']}"], **snap}
|
||||
return {"ok": True, "log": [], **snap}
|
||||
|
||||
|
||||
def api_motor_send(req):
|
||||
"""把一帧(或多帧)电机 CAN 排入后台循环下发队列。
|
||||
|
||||
请求: {frames:[{ch,id,dlc,data}]} 或单帧 {ch,id,dlc,data}
|
||||
1.5.56 起 _pdo_worker 每周期能取多帧(首帧走单帧区、其余走批量区,
|
||||
共用一个 seq 沿),所以一次提交 9 轴指令会在**同一个 PDO 周期**发出,
|
||||
而不是排队 9 个周期。DLC 支持到 64(CANFD 全量)。
|
||||
"""
|
||||
if g_master is None:
|
||||
return {"ok": False, "log": ["未连接"]}
|
||||
frames = req.get("frames")
|
||||
if not frames:
|
||||
frames = [req]
|
||||
queued = []
|
||||
for f in frames:
|
||||
try:
|
||||
ch = int(f.get("ch") or 0)
|
||||
raw_id = f.get("id", 0)
|
||||
can_id = int(raw_id, 16) if isinstance(raw_id, str) and \
|
||||
raw_id.lower().startswith("0x") else int(raw_id or 0)
|
||||
data = bytes.fromhex((f.get("data") or "").replace(" ", ""))
|
||||
dlc = int(f.get("dlc") if f.get("dlc") not in (None, "") else len(data))
|
||||
except (TypeError, ValueError) as e:
|
||||
return {"ok": False, "log": [f"参数解析失败: {e}"]}
|
||||
if ch not in (0, 1, 2):
|
||||
return {"ok": False, "log": [f"通道必须是 0/1/2,收到 {ch}"]}
|
||||
if dlc > CAN_FD_DATA_MAX:
|
||||
return {"ok": False,
|
||||
"log": [f"DLC {dlc} 超出 CANFD 上限 {CAN_FD_DATA_MAX}"]}
|
||||
if len(data) < dlc:
|
||||
data = data.ljust(dlc, b"\x00")
|
||||
_motor_cmd_q.append((ch, can_id, dlc, data))
|
||||
queued.append(f"CH{ch} 0x{can_id:X} DLC{dlc}")
|
||||
|
||||
if _motor_thread is None or not _motor_thread.is_alive():
|
||||
# 没有后台循环就没人取队列。同步跑一次,避免命令悄无声息地烂在队列里。
|
||||
# 本函数已由 dispatcher 持 g_lock 调用,此处不能再取锁(非可重入锁)。
|
||||
if not g_op_active:
|
||||
log, ok = _require_op_ready(*_dc_args(req))
|
||||
if not ok:
|
||||
return {"ok": False, "log": log}
|
||||
# 同步路径也要用批量区:否则"后台循环没开"时会退化成每周期一帧,
|
||||
# 多轴同步下发的时序保证在这条路上就悄悄丢了(自测第 6 项即抓到这点)。
|
||||
n = 0
|
||||
cycles = 0
|
||||
while _motor_cmd_q:
|
||||
ch, can_id, dlc, data = _motor_cmd_q.popleft()
|
||||
take = _batch_capacity(list(_motor_cmd_q))
|
||||
batch = [_motor_cmd_q.popleft() for _ in range(take)]
|
||||
_fill_can_out(ch, can_id, dlc, data, batch=batch or None)
|
||||
_can_pump(cycles=2)
|
||||
n += 1 + len(batch)
|
||||
cycles += 1
|
||||
return {"ok": True, "queued": 0, "sent_sync": n, "pdo_cycles": cycles,
|
||||
"log": [f"后台循环未运行,已同步下发 {n} 帧"
|
||||
f"({cycles} 个 PDO 周期): " + "; ".join(queued)]}
|
||||
|
||||
return {"ok": True, "queued": len(queued), "backlog": len(_motor_cmd_q),
|
||||
"batch_max_bytes": TXB_BYTES,
|
||||
"log": []}
|
||||
|
||||
|
||||
def api_motor_rx(req):
|
||||
"""取走批量接收区累积的 CAN 帧(不碰总线,纯读内存)。
|
||||
|
||||
请求: {limit:int} —— 一次最多取多少帧(默认全取)
|
||||
取走即从缓冲移除,语义是"消费"而非"查看",避免前端反复处理同一批帧。
|
||||
"""
|
||||
limit = req.get("limit")
|
||||
try:
|
||||
limit = int(limit) if limit not in (None, "") else len(_motor_rx_log)
|
||||
except (TypeError, ValueError):
|
||||
limit = len(_motor_rx_log)
|
||||
out = []
|
||||
for _ in range(max(0, min(limit, len(_motor_rx_log)))):
|
||||
out.append(_motor_rx_log.popleft())
|
||||
return {"ok": True, "log": [], "frames": out, "remain": len(_motor_rx_log)}
|
||||
|
||||
|
||||
def api_op_stop(req):
|
||||
global g_op_active, _sync_diag_prev
|
||||
g_op_active = False
|
||||
@@ -738,6 +1372,12 @@ def api_sync_diag(req):
|
||||
|
||||
def _do_shutdown():
|
||||
"""子进程(browser 模式)退出路径: 断开设备 → serve_forever 返回 → 进程退出。"""
|
||||
# 先停后台 PDO 循环再关设备:否则线程会继续对已 close 的 master 发过程数据。
|
||||
# 必须在取 g_lock 之前停(join 的目标线程自己也抢这把锁)。
|
||||
try:
|
||||
_motor_thread_stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with g_lock:
|
||||
api_close({})
|
||||
@@ -845,11 +1485,35 @@ ROUTES = {
|
||||
"/api/op/start": ("POST", api_op_start),
|
||||
"/api/op/cycle": ("POST", api_op_cycle),
|
||||
"/api/op/stop": ("POST", api_op_stop),
|
||||
"/api/can/send": ("POST", api_can_send),
|
||||
"/api/can/state": ("POST", api_can_state),
|
||||
# 电机控制台(motor.html):状态读快照、命令走后台循环队列
|
||||
"/api/motor/start": ("POST", api_motor_start),
|
||||
"/api/motor/stop": ("POST", api_motor_stop),
|
||||
"/api/motor/state": ("POST", api_motor_state),
|
||||
"/api/motor/send": ("POST", api_motor_send),
|
||||
"/api/motor/rx": ("POST", api_motor_rx),
|
||||
"/api/sdo": ("POST", api_sdo),
|
||||
"/api/sync_diag": ("POST", api_sync_diag),
|
||||
"/api/shutdown": ("POST", api_shutdown),
|
||||
}
|
||||
|
||||
# 需要独占总线的路由:dispatcher 会在取 g_lock 之前先停掉后台 PDO 循环。
|
||||
# 判据是"会自己驱动 PDO 周期、或走 CoE/EEPROM 邮箱"——两者都不能与后台循环并行:
|
||||
# - 抢 s.output / send_processdata,输出帧互相覆盖;
|
||||
# - 邮箱类操作与周期数据并发是历史上邮箱楔死 + SOEM 分段读崩溃的诱因。
|
||||
# /api/motor/start 与 /api/motor/stop 也在内:它们要 start/join 那个同样抢
|
||||
# g_lock 的线程,若在持锁状态下 join 会直接自锁死。
|
||||
_BUS_EXCLUSIVE_PATHS = frozenset({
|
||||
"/api/open", "/api/close",
|
||||
"/api/eeprom/read", "/api/eeprom/write", "/api/eeprom/verify", "/api/flash",
|
||||
"/api/op/start", "/api/op/cycle", "/api/op/stop",
|
||||
"/api/can/send",
|
||||
"/api/sdo", "/api/sync_diag",
|
||||
"/api/motor/start", "/api/motor/stop",
|
||||
"/api/shutdown",
|
||||
})
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "EcatTool/1.0"
|
||||
@@ -857,6 +1521,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass # 静默访问日志
|
||||
|
||||
def _not_found(self, path):
|
||||
"""404 响应。
|
||||
|
||||
网页(ecat.html)每次请求都从磁盘重读,而 API 路由表是进程启动时固化的:
|
||||
改完 ecat_server.py 不重启服务,就会出现"页面上有新按钮、点了报 not found"
|
||||
这种自相矛盾的现象(2026-09-03 实测踩到)。这里直接比对源码 mtime 与进程
|
||||
启动时刻,把这个错因写进响应,省得下次再从零排查。
|
||||
"""
|
||||
log = [f"接口不存在: {path}"]
|
||||
try:
|
||||
if os.path.getmtime(os.path.abspath(__file__)) > SERVER_START_TIME:
|
||||
log.append("原因: ecat_server.py 在本服务启动之后被修改过 —— "
|
||||
"当前进程仍是旧版本,没有这个接口。")
|
||||
log.append("解决: 点右上角「退出服务」(或结束 pythonw 进程)后重新"
|
||||
"双击 ecat_app.vbs / ecat_web.vbs 启动。")
|
||||
except OSError:
|
||||
pass
|
||||
self._send(404, json.dumps({"ok": False, "log": log}).encode("utf-8"))
|
||||
|
||||
def _send(self, code, body, ctype="application/json; charset=utf-8"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
@@ -865,19 +1548,37 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
# 静态文件白名单。注意 Content-Type 必须按后缀给对:
|
||||
# - .js 用 text/html 会被浏览器的严格 MIME 检查拒掉(module script 与 Worker
|
||||
# 都会直接报错不执行),这是加新页面最容易踩的坑;
|
||||
# - 只有 HTML 才注入 _RELOAD_SNIPPET(注入到 js/css 里会破坏文件)。
|
||||
_STATIC_MAP = {
|
||||
"/": "ecat.html",
|
||||
"/ecat.html": "ecat.html",
|
||||
"/index.html": "ecat.html",
|
||||
"/ecat.css": "ecat.css",
|
||||
# 电机控制台(EtherCAT 版)。样式直接复用 ecat.css —— 它本身就是 ESP32
|
||||
# motor.css 的副本(md5 一致),再拷一份 motor.css 只会多出一处会漂移的副本。
|
||||
"/motor.html": "motor.html",
|
||||
"/motor.js": "motor.js",
|
||||
}
|
||||
_CTYPE_BY_EXT = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
static_map = {"/": "ecat.html", "/ecat.html": "ecat.html",
|
||||
"/index.html": "ecat.html", "/ecat.css": "ecat.css"}
|
||||
if path in static_map:
|
||||
fp = os.path.join(BASE_DIR, static_map[path])
|
||||
name = self._STATIC_MAP.get(path)
|
||||
if name:
|
||||
fp = os.path.join(BASE_DIR, name)
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
ctype = self._CTYPE_BY_EXT.get(ext, "application/octet-stream")
|
||||
try:
|
||||
with open(fp, "rb") as f:
|
||||
body = f.read()
|
||||
if path.endswith(".css"):
|
||||
ctype = "text/css; charset=utf-8"
|
||||
else:
|
||||
ctype = "text/html; charset=utf-8"
|
||||
if ext == ".html":
|
||||
html = body.decode("utf-8", "replace")
|
||||
if "</body>" in html:
|
||||
html = html.replace(
|
||||
@@ -896,7 +1597,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]}
|
||||
self._send(200, json.dumps(result).encode("utf-8"))
|
||||
return
|
||||
self._send(404, b'{"ok":false,"log":["not found"]}')
|
||||
self._not_found(path)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
@@ -907,7 +1608,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
route = ROUTES.get(path)
|
||||
if not route or route[0] != "POST":
|
||||
self._send(404, b'{"ok":false,"log":["not found"]}')
|
||||
self._not_found(path)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
@@ -917,6 +1618,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send(200, json.dumps(
|
||||
{"ok": False, "log": [f"请求解析失败: {e}"]}).encode("utf-8"))
|
||||
return
|
||||
|
||||
# /api/motor/state 与 /api/motor/rx 只读内存(快照 / 接收缓冲),不碰总线
|
||||
# —— 放在 g_lock 外面。否则高频轮询会和后台 PDO 线程(每 4ms 取一次锁)
|
||||
# 排队互等,既拖慢响应又给循环加抖动。
|
||||
if path in ("/api/motor/state", "/api/motor/rx"):
|
||||
try:
|
||||
result = route[1](req)
|
||||
except Exception as e:
|
||||
result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]}
|
||||
self._send(200, json.dumps(result).encode("utf-8"))
|
||||
return
|
||||
|
||||
# 独占总线的操作必须先停后台 PDO 循环,且必须在取 g_lock **之前** 停:
|
||||
# _motor_thread_stop() 要 join 的那个线程自己也在抢 g_lock,
|
||||
# 持锁去 join 会直接自锁死。停完再进锁,语义等价于"独占期间不喂看门狗",
|
||||
# 从站可能掉出 OP —— 这些操作本来就要重进 OP,可接受。
|
||||
if path in _BUS_EXCLUSIVE_PATHS:
|
||||
_motor_thread_stop()
|
||||
|
||||
with g_lock:
|
||||
try:
|
||||
result = route[1](req)
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
<!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>
|
||||
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- 样式直接复用 ecat.css(它本身就是 ESP32 motor.css 的副本,md5 一致),
|
||||
不再拷第三份,避免以后三处漂移。改 CSS 后记得 bump ?v= -->
|
||||
<link rel="stylesheet" href="ecat.css?v=20260903b">
|
||||
<style>
|
||||
/* 本页专有:EtherCAT 链路状态条 + 槽扫描指示 */
|
||||
.link-chips{display:flex;gap:var(--sp-2);flex-wrap:wrap;align-items:center}
|
||||
.lchip{font-family:var(--font-mono);font-size:.68rem;padding:2px 7px;border-radius:var(--radius-sm);
|
||||
border:1px solid var(--border);color:var(--fg-muted);background:var(--bg-raised)}
|
||||
.lchip b{color:var(--fg);font-weight:600}
|
||||
.lchip.ok{border-color:var(--ok);color:var(--ok)}
|
||||
.lchip.err{border-color:var(--danger);color:var(--danger)}
|
||||
.scan-bar{display:flex;gap:3px;margin-top:var(--sp-2)}
|
||||
.scan-cell{flex:1;height:4px;border-radius:2px;background:var(--border);transition:background .12s}
|
||||
.scan-cell.hit{background:var(--primary)}
|
||||
</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"/>
|
||||
<circle cx="13" cy="13" r="5.5" stroke="var(--primary)" stroke-width="1.8"/>
|
||||
<path d="M13 4v3.5M13 18.5V22M4 13h3.5M18.5 13H22" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="13" cy="13" r="1.8" fill="var(--primary)"/>
|
||||
</svg>
|
||||
<span>电机控制台</span>
|
||||
<span class="sub">EtherCAT PDO</span>
|
||||
</div>
|
||||
<div class="toolbar-sep"></div>
|
||||
<div class="conn-group" title="EtherCAT 网卡与从站状态;连接配置在 EtherCAT 主页">
|
||||
<span class="led" id="connLed"></span>
|
||||
<span class="conn-ip-label" id="ipLabel">未连接</span>
|
||||
<span style="font-size:.72rem;color:var(--fg-dim);font-family:var(--font-mono)" id="devStatus">探测中…</span>
|
||||
</div>
|
||||
<div class="toolbar-sep"></div>
|
||||
<div class="conn-group">
|
||||
<label for="chSelect" style="font-size:.72rem;color:var(--fg-muted);font-family:var(--font-mono);letter-spacing:.06em">通道</label>
|
||||
<select class="ch-select" id="chSelect">
|
||||
<option value="0">CH0</option>
|
||||
<option value="1">CH1</option>
|
||||
<option value="2">CH2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar-sep"></div>
|
||||
<div class="conn-group" title="后台 PDO 循环周期(ms,1~100)。这是真实的总线节拍:状态窗口每周期滚动 7 槽,4 个周期扫完 27 槽">
|
||||
<label for="periodMs" class="refresh-hint">PDO 周期</label>
|
||||
<input type="number" id="periodMs" class="refresh-input" value="4" min="1" max="100" step="1" aria-label="PDO 循环周期(毫秒)" />
|
||||
<span class="refresh-hint">ms</span>
|
||||
</div>
|
||||
<div class="conn-group" title="页面读取快照的间隔(ms)。读快照不碰总线,可以比 PDO 周期慢">
|
||||
<label for="refreshRate" class="refresh-hint">界面刷新</label>
|
||||
<input type="number" id="refreshRate" class="refresh-input" value="100" min="30" max="2000" step="10" aria-label="界面刷新间隔(毫秒)" />
|
||||
<span class="refresh-hint">ms</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a class="btn btn-sm" href="/" title="返回 EtherCAT 主页配置连接">返回主页</a>
|
||||
<span class="node-badge" id="nodeBadge">NODE -</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">未连接 — 请先在 EtherCAT 主页点「连接」,再回到本页启动 PDO 循环</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ MAIN 3-ZONE GRID ═══════════ -->
|
||||
<main class="main-grid">
|
||||
|
||||
<!-- LEFT: NODE OVERVIEW -->
|
||||
<aside class="panel nodes-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="nodeSummary">0/9 在线 · PDO</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- 独立 BOOT 状态:总线检测到 0x103 心跳时显示,不归属任何 Nx -->
|
||||
<div id="bootBadge" class="boot-badge" aria-hidden="true">
|
||||
<span class="boot-dot" aria-hidden="true"></span>
|
||||
<span class="boot-txt">电机在 BOOT</span>
|
||||
<span class="boot-sub" id="bootBadgeSub">0x103</span>
|
||||
</div>
|
||||
<div class="node-list" id="nodeList" role="listbox" aria-label="电机节点列表 N1-N9(离线可选)">
|
||||
<!-- N1–N9 由 JS 固定渲染,离线也可选中 -->
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER: CONTROL -->
|
||||
<section class="panel center-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">CMD 控制</span>
|
||||
<span style="font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono);margin-left:auto" id="cmdIdLabel">ID 0x000-0200 · DLC 32</span>
|
||||
</div>
|
||||
<div class="cmd-section">
|
||||
<div class="cmd-grid">
|
||||
<div class="field">
|
||||
<span class="field-label">Control_Mode <span class="field-unit">控制模式</span></span>
|
||||
<select id="modeSelect" aria-label="控制模式">
|
||||
<option value="0">0 — Disable 关闭</option>
|
||||
<option value="1">1 — Angle 位置控制</option>
|
||||
<option value="2">2 — Velocity 速度控制</option>
|
||||
<option value="3" selected>3 — Velocity_Angle 速度+位置</option>
|
||||
<option value="4">4 — MIT Mode</option>
|
||||
<option value="5">5 — Current 电流/力矩</option>
|
||||
<option value="6">6 — 开环速度</option>
|
||||
<option value="7">7 — 开环速度 (2)</option>
|
||||
</select>
|
||||
<span class="mode-desc" id="modeDesc">速度+位置环联合控制,目标速度与目标角度同时生效</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Limit_Voltage <span class="field-unit">V · 0~65.535</span></span>
|
||||
<input type="number" id="inVoltage" value="24.000" step="0.5" min="0" max="65.535" aria-label="电压限制" />
|
||||
</div>
|
||||
<div class="field" id="fldCurrent">
|
||||
<span class="field-label">Target_Current <span class="field-unit">mA</span></span>
|
||||
<input type="number" id="inCurrent" value="0" step="100" aria-label="目标电流" />
|
||||
</div>
|
||||
<div class="field" id="fldVelocity">
|
||||
<span class="field-label">Target_Velocity <span class="field-unit">deg/s</span></span>
|
||||
<input type="number" id="inVelocity" value="360" step="10" aria-label="目标速度" />
|
||||
</div>
|
||||
<div class="field" id="fldAngle">
|
||||
<span class="field-label">Target_Angle <span class="field-unit">deg</span></span>
|
||||
<input type="number" id="inAngle" value="180.0" step="1" aria-label="目标角度" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top:var(--sp-4)">
|
||||
<span class="field-label">PID 增益 <span class="field-unit">P × 3 环</span></span>
|
||||
<div class="pid-group">
|
||||
<div class="field"><span class="field-label" style="font-size:.62rem">电流环 P</span><input type="number" id="pidCurP" value="100" step="1" /></div>
|
||||
<div class="field"><span class="field-label" style="font-size:.62rem">速度环 P</span><input type="number" id="pidVelP" value="100" step="1" /></div>
|
||||
<div class="field"><span class="field-label" style="font-size:.62rem">角度环 P</span><input type="number" id="pidAngP" value="100" step="1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-run" id="quickRun" title="把值写入当前模式的主目标字段并立即发送 CMD">
|
||||
<span class="qr-label">快捷运行 <span class="qr-target" id="qrTargetHint">→ 目标角度 deg</span></span>
|
||||
<div class="qr-btns">
|
||||
<button class="qr-btn" data-val="-9999">-9999</button>
|
||||
<button class="qr-btn" data-val="-200">-200</button>
|
||||
<button class="qr-btn" data-val="-100">-100</button>
|
||||
<button class="qr-btn" data-val="0">0</button>
|
||||
<button class="qr-btn" data-val="50">50</button>
|
||||
<button class="qr-btn" data-val="100">100</button>
|
||||
<button class="qr-btn" data-val="200">200</button>
|
||||
<button class="qr-btn" data-val="1000">1000</button>
|
||||
<button class="qr-btn" data-val="9999">9999</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cmd-actions">
|
||||
<button class="btn btn-primary" id="sendCmdBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M1.5 7h9M8 3.5L11.5 7 8 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
发送 CMD
|
||||
</button>
|
||||
<button class="btn" id="sendRepeatBtn">周期发送</button>
|
||||
<label class="repeat-period" title="周期发送间隔(最小 20ms,EtherCAT 下不受 HTTP socket 限制)">
|
||||
<input type="number" id="repeatPeriod" value="50" min="20" max="10000" step="10" />
|
||||
<span>ms</span>
|
||||
</label>
|
||||
<span class="send-hint" id="sendHint">POST /api/motor/send · CH0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABS -->
|
||||
<nav class="tabs-bar" aria-label="辅助功能">
|
||||
<button class="tab-btn active" data-tab="config">配置操作</button>
|
||||
<button class="tab-btn" data-tab="version">版本信息</button>
|
||||
<button class="tab-btn" data-tab="nodeid">节点 ID</button>
|
||||
<button class="tab-btn" data-tab="link">链路诊断</button>
|
||||
<button class="tab-btn" data-tab="log">日志</button>
|
||||
</nav>
|
||||
<div class="tab-content">
|
||||
<!-- CONFIG TAB -->
|
||||
<div class="tab-page active" id="tab-config">
|
||||
<div class="config-grid">
|
||||
<button class="config-btn danger-action" data-action="motReset">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M4 11a7 7 0 1 1 2 4.9M4 16v-5h5" stroke="var(--danger)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span class="cb-name">电机复位</span><span class="cb-sig">motReset · bit0</span>
|
||||
</button>
|
||||
<button class="config-btn" data-action="saveConfig">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M5 3h9l4 4v12H5V3z" stroke="var(--primary)" stroke-width="1.6" stroke-linejoin="round"/><path d="M8 3v5h6V3M8 18v-5h7v5" stroke="var(--primary)" stroke-width="1.4"/></svg>
|
||||
<span class="cb-name">保存配置</span><span class="cb-sig">saveConfig · bit2</span>
|
||||
</button>
|
||||
<button class="config-btn" data-action="angleReset">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7.5" stroke="var(--primary)" stroke-width="1.6"/><path d="M11 6v5l3.5 2" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<span class="cb-name">角度置零</span><span class="cb-sig">angle_reset · bit3</span>
|
||||
</button>
|
||||
<button class="config-btn" data-action="zeroCalib">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7" stroke="var(--primary)" stroke-width="1.6"/><path d="M11 2v4M11 16v4M2 11h4M16 11h4" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="1.5" fill="var(--primary)"/></svg>
|
||||
<span class="cb-name">零点校准</span><span class="cb-sig">zero_calibration · bit5</span>
|
||||
</button>
|
||||
<button class="config-btn" data-action="sleep">
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M17 13.5A7 7 0 0 1 8.5 5a7 7 0 1 0 8.5 8.5z" stroke="var(--warn)" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||
<span class="cb-name">休眠</span><span class="cb-sig">SLEEP · bit56</span>
|
||||
</button>
|
||||
</div>
|
||||
<p style="margin-top:var(--sp-4);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
|
||||
CMD_CONFIG (0x000<span class="cfgNodeNibble">-</span>0301) · DLC 24 · 每个操作发送单帧脉冲信号,操作前需确认
|
||||
</p>
|
||||
</div>
|
||||
<!-- VERSION TAB -->
|
||||
<div class="tab-page" id="tab-version">
|
||||
<button class="btn btn-sm" id="queryVerBtn">
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.3"/><path d="M6.5 4.2v.1M6.5 6v3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
查询版本 (GET_VERSION)
|
||||
</button>
|
||||
<div class="version-grid" style="margin-top:var(--sp-4)" id="versionGrid">
|
||||
<div class="ver-card"><div class="ver-type">软件版本</div><div class="ver-date ver-empty" id="verSoft">--</div></div>
|
||||
<div class="ver-card"><div class="ver-type">硬件版本</div><div class="ver-date ver-empty" id="verHard">--</div></div>
|
||||
<div class="ver-card"><div class="ver-type">Bootloader</div><div class="ver-date ver-empty" id="verBoot">--</div></div>
|
||||
</div>
|
||||
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim)">
|
||||
请求/回报共用 0x000<span class="cfgNodeNibble">-</span>00FF(同 ID)· DLC 24 · major.minor.patch.build ×3 (soft/hard/boot)。
|
||||
回报帧经输入 PDO 段 3「长帧快照」回传 —— 段 1 只有 8 字节,装不下 DLC 24。
|
||||
</p>
|
||||
</div>
|
||||
<!-- NODE ID TAB -->
|
||||
<div class="tab-page" id="tab-nodeid">
|
||||
<div class="nodeid-row">
|
||||
<div class="field" style="min-width:140px">
|
||||
<span class="field-label">目标 Node ID</span>
|
||||
<input type="number" id="nodeIdInput" min="1" max="9" value="3" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="setNodeIdBtn">设置 ID (0xN0400)</button>
|
||||
<button class="btn btn-sm" id="getNodeIdBtn">读取 ID (0xN0401)</button>
|
||||
</div>
|
||||
<div class="nodeid-result" id="nodeIdResult">连接设备后读取节点 ID</div>
|
||||
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
|
||||
SET_NODE_ID (0x000N0400 DLC2) / GET_NODE_ID (0x000N0401 DLC2)。写入后 CAN ID 前缀变为 0x000Nxxxx。
|
||||
</p>
|
||||
</div>
|
||||
<!-- LINK DIAG TAB -->
|
||||
<div class="tab-page" id="tab-link">
|
||||
<div class="link-chips">
|
||||
<span class="lchip">从站 <b id="dgSlave">—</b></span>
|
||||
<span class="lchip">AL <b id="dgAl">—</b></span>
|
||||
<span class="lchip">WKC <b id="dgWkc">—</b></span>
|
||||
<span class="lchip">PDO <b id="dgPdo">—</b></span>
|
||||
<span class="lchip">周期数 <b id="dgCycles">0</b></span>
|
||||
<span class="lchip">循环 <b id="dgLoop">停止</b></span>
|
||||
</div>
|
||||
<div class="link-chips" style="margin-top:var(--sp-3)">
|
||||
<span class="lchip">ack <b id="dgAck">—</b></span>
|
||||
<span class="lchip">TX <b id="dgTx">0</b></span>
|
||||
<span class="lchip">RX <b id="dgRx">0</b></span>
|
||||
<span class="lchip">丢帧 <b id="dgDrop">0</b></span>
|
||||
<span class="lchip">积压 <b id="dgBacklog">0</b></span>
|
||||
<span class="lchip">发送结果 <b id="dgTxRes">—</b></span>
|
||||
</div>
|
||||
<div class="link-chips" style="margin-top:var(--sp-3)">
|
||||
<span class="lchip" title="上一个 PDO 周期实际发出的帧数(批量下发时 >1)">本周期发 <b id="dgSentN">0</b></span>
|
||||
<span class="lchip" title="待下发队列深度">待发 <b id="dgQueue">0</b></span>
|
||||
<span class="lchip" title="上一周期从批量接收区回收的帧数">本周期收 <b id="dgRxN">0</b></span>
|
||||
<span class="lchip" title="从站环形队列仍待回收的帧数,持续 >0 说明该调小 PDO 周期">待收 <b id="dgRxPend">0</b></span>
|
||||
<span class="lchip" title="从站接收队列满导致的丢帧(增量)">收丢 <b id="dgRxLost">0</b></span>
|
||||
</div>
|
||||
<div class="scan-bar" id="scanBar" title="状态窗口滚动扫描进度:每格 = 一个 PDO 周期覆盖的 7 个槽"></div>
|
||||
<div style="display:flex;gap:var(--sp-3);margin-top:var(--sp-4);flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="loopStartBtn">启动 PDO 循环</button>
|
||||
<button class="btn btn-sm" id="loopStopBtn">停止循环</button>
|
||||
</div>
|
||||
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.7">
|
||||
过程数据 <b>384B 出 / 640B 入</b>(固件 ≥1.5.56,需重刷 EEPROM 并<b>冷启动</b>)。
|
||||
这是 LAN9252 的硬上限:邮箱占 0x1000-0x13FF,过程数据区只剩 3072B,
|
||||
而 SM2/SM3 都是 3-buffer 实占 3×长度 ⇒ 出+入 ≤ 1024B,384+640 恰好用满。
|
||||
输出 PDO 段 2 是 CANFD 批量发送区(<b id="dgTxCap">332</b>B,
|
||||
一周期可发 8 帧 DLC32 或 4 帧 DLC64),多轴指令因此能在<b>同一个 PDO 周期</b>下发,
|
||||
而不是排队一周期一帧;输入 PDO 段 2 每周期回传 7 个电机状态槽,
|
||||
<b id="dgScanInfo">4 周期</b> 扫完 27 槽(node×channel 双键),
|
||||
段 4 是批量接收区(<b id="dgRxCap">276</b>B),一周期可回收多帧而非只带最近一帧。
|
||||
后台循环持续喂 1000ms 过程数据看门狗 —— 停掉循环超过 1 秒,从站会掉出 OP 报 AL 0x001B。
|
||||
</p>
|
||||
</div>
|
||||
<!-- LOG TAB -->
|
||||
<div class="tab-page" id="tab-log">
|
||||
<div class="ota-log-toolbar">
|
||||
<button id="logCopyBtn" title="复制全部日志">复制</button>
|
||||
<button id="logClearBtn" title="清空日志">清空</button>
|
||||
</div>
|
||||
<div class="ota-log" id="opLog" aria-live="polite">
|
||||
<div class="log-info">[就绪] EtherCAT 电机控制台 · 状态经输入 PDO 段 2 回传,命令经输出 PDO 批量区同周期下发</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: STATUS FEEDBACK -->
|
||||
<section class="panel status-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">STATUS 实时反馈</span>
|
||||
<span style="font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono);margin-left:auto" id="statusIdLabel">0x000-0100 · PDO</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="offline-overlay show" id="offlineOverlay">
|
||||
<svg class="off-icon" width="40" height="40" viewBox="0 0 40 40" fill="none" aria-hidden="true"><circle cx="20" cy="20" r="16" stroke="var(--fg-dim)" stroke-width="1.5" stroke-dasharray="4 4"/><path d="M14 26l12-12M26 26L14 14" stroke="var(--fg-dim)" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<span style="font-size:.85rem" id="offlineTitle">节点离线 — 未收到 STATUS 帧</span>
|
||||
<span style="font-size:.72rem" id="offlineSub">检查电机供电与 CAN 总线连接</span>
|
||||
</div>
|
||||
<div class="status-live hide" id="statusLive">
|
||||
<div class="big-readings">
|
||||
<div class="big-reading" data-signal="angle" title="点击展开/收起角度波形">
|
||||
<div class="br-val" id="rtAngle">--</div>
|
||||
<div class="br-unit">deg</div>
|
||||
<div class="br-lbl">RT_Angle <span class="br-toggle">▾ 波形</span></div>
|
||||
<div class="br-target">目标 <span id="tgtAngle">--</span> deg</div>
|
||||
<div class="br-scope"><canvas id="miniAngle" aria-label="角度波形" role="img"></canvas></div>
|
||||
</div>
|
||||
<div class="big-reading" data-signal="velocity" title="点击展开/收起速度波形">
|
||||
<div class="br-val" id="rtVelocity">--</div>
|
||||
<div class="br-unit">deg/s</div>
|
||||
<div class="br-lbl">RT_Velocity <span class="br-toggle">▾ 波形</span></div>
|
||||
<div class="br-target">目标 <span id="tgtVelocity">--</span> deg/s</div>
|
||||
<div class="br-scope"><canvas id="miniVelocity" aria-label="速度波形" role="img"></canvas></div>
|
||||
</div>
|
||||
<div class="big-reading" data-signal="current" title="点击展开/收起电流波形">
|
||||
<div class="br-val" id="rtCurrent">--</div>
|
||||
<div class="br-unit">mA</div>
|
||||
<div class="br-lbl">RT_Current <span class="br-toggle">▾ 波形</span></div>
|
||||
<div class="br-target">目标 <span id="tgtCurrent">--</span> mA</div>
|
||||
<div class="br-scope"><canvas id="miniCurrent" aria-label="电流波形" role="img"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<div class="temp-block" data-signal="mcuTemp" title="点击展开/收起 MCU 温度曲线">
|
||||
<div class="temp-head"><span class="temp-name">MCU_Temp<span class="temp-toggle">▾ 曲线</span></span><span class="temp-val" id="mcuTemp">-- °C</span></div>
|
||||
<div class="temp-bar"><div class="temp-fill" id="mcuTempBar" style="width:0%"></div></div>
|
||||
<div class="br-scope"><canvas id="miniMcuTemp" aria-label="MCU温度曲线" role="img"></canvas></div>
|
||||
</div>
|
||||
<div class="temp-block" data-signal="motTemp" title="点击展开/收起电机温度曲线">
|
||||
<div class="temp-head"><span class="temp-name">Motor_Temp<span class="temp-toggle">▾ 曲线</span></span><span class="temp-val" id="motTemp">-- °C</span></div>
|
||||
<div class="temp-bar"><div class="temp-fill" id="motTempBar" style="width:0%"></div></div>
|
||||
<div class="br-scope"><canvas id="miniMotTemp" aria-label="电机温度曲线" role="img"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase-currents">
|
||||
<div class="pc-head">
|
||||
<span class="pc-title">三相电流 U / V / W</span>
|
||||
<div class="pc-live-vals">
|
||||
<span class="pc-chip" style="--ch-color:var(--primary)">U <b id="valU">+0</b> mA</span>
|
||||
<span class="pc-chip" style="--ch-color:var(--ok)">V <b id="valV">+0</b> mA</span>
|
||||
<span class="pc-chip" style="--ch-color:var(--warn)">W <b id="valW">+0</b> mA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scope-wrap">
|
||||
<canvas id="scopeCanvas" aria-label="三相电流实时波形" role="img"></canvas>
|
||||
<div class="scope-grid-labels"><span>+5A</span><span>0</span><span>-5A</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="misc-grid">
|
||||
<div class="misc-item"><span class="mi-lbl">Power_Voltage</span><span class="mi-val" id="pwrVolt">-- V</span></div>
|
||||
<div class="misc-item"><span class="mi-lbl">test_float</span><span class="mi-val" id="testFloat">--</span></div>
|
||||
<div class="misc-item"><span class="mi-lbl">test_U16</span><span class="mi-val" id="testU16">--</span></div>
|
||||
<div class="cpu-ring-wrap" style="grid-column:2;grid-row:2">
|
||||
<div class="cpu-ring">
|
||||
<svg width="52" height="52" viewBox="0 0 52 52" role="img" aria-label="CPU占用率">
|
||||
<circle class="ring-bg" cx="26" cy="26" r="21"/>
|
||||
<circle class="ring-fg" id="cpuRingFg" cx="26" cy="26" r="21" stroke-dasharray="131.9" stroke-dashoffset="131.9"/>
|
||||
</svg>
|
||||
<span class="cpu-pct" id="cpuPct">--%</span>
|
||||
</div>
|
||||
<div class="cpu-info"><span class="mi-lbl">CPU_Usage</span><span class="mi-sub" id="cpuSub">-- </span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flag-lights">
|
||||
<div class="flag-item" id="flagItemMot"><span class="flag-led" id="flagMot"></span>motStatus 使能<span class="flag-state" id="flagMotState">—</span></div>
|
||||
<div class="flag-item" id="flagItemMag"><span class="flag-led" id="flagMag"></span>magStatus 磁编码<span class="flag-state" id="flagMagState">—</span></div>
|
||||
<div class="flag-item" id="flagItemThermal"><span class="flag-led" id="flagThermal"></span>thermal 过温<span class="flag-state" id="flagThermalState">—</span></div>
|
||||
<div class="flag-item" id="flagItemEeprom"><span class="flag-led" id="flagEeprom"></span>EEPROM 存储<span class="flag-state" id="flagEepromState">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ═══════════ STATUS BAR ═══════════ -->
|
||||
<footer class="statusbar">
|
||||
<div class="sb-item"><span class="led" id="sbLed" style="width:7px;height:7px"></span> <span id="sbCh">CH0</span> <span class="sb-val" id="sbRx">RX --</span></div>
|
||||
<div class="sb-sep"></div>
|
||||
<div class="sb-item">TX <span class="sb-val" id="sbTx">--</span></div>
|
||||
<div class="sb-sep"></div>
|
||||
<div class="sb-item">WKC <span class="sb-val" id="sbWkc">--</span></div>
|
||||
<div class="sb-sep"></div>
|
||||
<div class="sb-item">状态刷新 <span class="sb-val" id="sbHz">--</span></div>
|
||||
<span class="sb-last-send" id="sbLastSend">尚未发送</span>
|
||||
</footer>
|
||||
|
||||
<!-- TOAST & MODAL -->
|
||||
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||||
<div class="modal-mask" id="modalMask" role="dialog" aria-modal="true">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title" id="modalTitle">确认操作</div>
|
||||
<div class="modal-desc" id="modalDesc"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" id="modalCancel">取消</button>
|
||||
<button class="btn btn-primary" id="modalConfirm">确认执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="motor.js?v=20260903a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,279 @@
|
||||
"""EtherCAT 电机控制台后端自测(不接硬件)。
|
||||
|
||||
用一个模拟 GD32 buildInput/processOutput 行为的假从站,验证:
|
||||
1. DLC 32 的电机 CMD 能完整下发(旧 24B 布局会静默截断后 8 字节)
|
||||
2. 后台 PDO 循环靠滚动窗口扫完 27 个槽
|
||||
3. STATUS 完整 32B 回传(旧 8B 短快照根本装不下)
|
||||
4. 命令队列按 seq 沿下发,不互相覆盖
|
||||
5. 循环可干净停止
|
||||
6. 【1.5.56】批量发送区:多帧在**同一个周期**发出(同步下发)
|
||||
7. 【1.5.56】批量接收区:一周期回收多帧,含 DLC64 CANFD
|
||||
8. 【1.5.56】兼容性:批量区为空时,字节布局与 1.5.55 完全一致
|
||||
|
||||
跑法: python test_motor_backend.py
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import os
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, BASE)
|
||||
|
||||
# pysoem 依赖 Npcap 与真网卡,自测里用桩替掉
|
||||
_fake = types.ModuleType("pysoem")
|
||||
_fake.find_adapters = lambda: []
|
||||
_fake.INIT_STATE, _fake.PREOP_STATE = 1, 2
|
||||
_fake.SAFEOP_STATE, _fake.OP_STATE = 4, 8
|
||||
_fake.STATE_ACK = 0x10
|
||||
_fake.Master = object
|
||||
sys.modules["pysoem"] = _fake
|
||||
|
||||
import ecat_server as S # noqa: E402
|
||||
|
||||
SLOT = S.MOT_SLOT_SIZE
|
||||
NSLOT = S.MOT_SLOTS_PER_PDO
|
||||
|
||||
|
||||
class FakeSlave:
|
||||
"""按 comm/ethercat_can_bridge.cpp 的语义模拟从站侧。"""
|
||||
|
||||
def __init__(self):
|
||||
self._out = bytes(S.PDO_OUT_LEN)
|
||||
self.rx_frames = [] # 从站"发出"的帧 (ch,id,dlc,hexdata)
|
||||
self.tx_cycles = [] # 每个 seq 沿发出的帧数,用来证明"同一周期多帧"
|
||||
self.last_seq = None # 对应固件 s_lastSeq / s_seqPrimed
|
||||
self.tx_count = 0
|
||||
self.pending_rx = [] # 待经批量接收区回给主站的帧
|
||||
self.rx_lost = 0
|
||||
|
||||
@property
|
||||
def output(self):
|
||||
return self._out
|
||||
|
||||
@output.setter
|
||||
def output(self, v):
|
||||
self._out = bytes(v)
|
||||
seq = self._out[0]
|
||||
# 固件语义:首次只记基准不发送;之后仅 seq 变化沿触发一次发送
|
||||
if self.last_seq is not None and seq != self.last_seq:
|
||||
n = 0
|
||||
# 段1 单帧区(dlc=0 表示本次只发批量)
|
||||
dlc = self._out[2]
|
||||
if dlc:
|
||||
self.rx_frames.append((
|
||||
self._out[1],
|
||||
struct.unpack_from("<I", self._out, 4)[0],
|
||||
dlc,
|
||||
self._out[S.TX_OFF_DATA:S.TX_OFF_DATA + dlc].hex().upper(),
|
||||
))
|
||||
n += 1
|
||||
# 段2 批量发送区:逐帧解析 (8B 头 + dlc),越界即停
|
||||
count = self._out[S.TXB_OFF_COUNT]
|
||||
off = S.TXB_OFF_ITEMS
|
||||
for _ in range(count):
|
||||
if off + S.FRM_HDR_SIZE > S.PDO_OUT_LEN:
|
||||
break
|
||||
d = self._out[off + S.FRM_OFF_DLC]
|
||||
if off + S.FRM_HDR_SIZE + d > S.PDO_OUT_LEN:
|
||||
break
|
||||
self.rx_frames.append((
|
||||
self._out[off + S.FRM_OFF_CH],
|
||||
struct.unpack_from("<I", self._out, off + S.FRM_OFF_ID)[0],
|
||||
d,
|
||||
self._out[off + S.FRM_HDR_SIZE:
|
||||
off + S.FRM_HDR_SIZE + d].hex().upper(),
|
||||
))
|
||||
off += S.FRM_HDR_SIZE + d
|
||||
n += 1
|
||||
if n:
|
||||
self.tx_cycles.append(n)
|
||||
self.tx_count += n
|
||||
self.last_seq = seq
|
||||
|
||||
@property
|
||||
def input(self):
|
||||
b = bytearray(S.PDO_IN_LEN)
|
||||
b[0] = self.last_seq or 0
|
||||
struct.pack_into("<H", b, 22, self.tx_count)
|
||||
win = self._out[S.TX_OFF_WIN]
|
||||
b[S.MOTW_OFF_ACTIVE] = S.MOTOR_SLOT_COUNT
|
||||
b[S.MOTW_OFF_WIN_BASE] = win
|
||||
cnt = 0
|
||||
for i in range(NSLOT):
|
||||
idx = win + i
|
||||
if idx >= S.MOTOR_SLOT_COUNT:
|
||||
break
|
||||
off = S.MOTW_OFF_SLOTS + i * SLOT
|
||||
b[off] = idx // S.MOTOR_CHANNELS + 1 # node 1..9
|
||||
b[off + 1] = idx % S.MOTOR_CHANNELS # ch 0..2
|
||||
b[off + 2] = 1 # online
|
||||
b[off + 3] = 32 # dlc
|
||||
struct.pack_into("<I", b, off + 4, 1000 + idx)
|
||||
struct.pack_into("<h", b, off + 8, idx * 100) # MCU_temp ×0.01
|
||||
cnt += 1
|
||||
b[S.MOTW_OFF_WIN_COUNT] = cnt
|
||||
|
||||
# 段4 批量接收区:按字节容量装,装不下的留到下周期(模拟 fillRxBatch)
|
||||
off = S.RXB_OFF_ITEMS
|
||||
n = 0
|
||||
while self.pending_rx:
|
||||
ch, cid, dlc, data = self.pending_rx[0]
|
||||
if off + S.FRM_HDR_SIZE + dlc > S.PDO_IN_LEN:
|
||||
break
|
||||
self.pending_rx.pop(0)
|
||||
b[off + S.FRM_OFF_CH] = ch
|
||||
b[off + S.FRM_OFF_DLC] = dlc
|
||||
b[off + S.FRM_OFF_FLAGS] = 0x01
|
||||
struct.pack_into("<I", b, off + S.FRM_OFF_ID, cid)
|
||||
b[off + S.FRM_HDR_SIZE:off + S.FRM_HDR_SIZE + dlc] = data[:dlc]
|
||||
off += S.FRM_HDR_SIZE + dlc
|
||||
n += 1
|
||||
b[S.RXB_OFF_COUNT] = n
|
||||
b[S.RXB_OFF_LOST] = self.rx_lost
|
||||
b[S.RXB_OFF_PENDING] = len(self.pending_rx)
|
||||
return bytes(b)
|
||||
|
||||
|
||||
class FakeMaster:
|
||||
expected_wkc = 3
|
||||
|
||||
def __init__(self):
|
||||
self.slaves = [FakeSlave()]
|
||||
|
||||
def send_processdata(self):
|
||||
pass
|
||||
|
||||
def receive_processdata(self, timeout):
|
||||
return 3
|
||||
|
||||
|
||||
def main():
|
||||
S.g_master = FakeMaster()
|
||||
S.g_op_active = True
|
||||
slave = S.g_master.slaves[0]
|
||||
|
||||
# 完成 seq priming(等价于固件上电后的第一个输出周期)
|
||||
slave.output = bytes(S.PDO_OUT_LEN)
|
||||
|
||||
print("=== 1. 单帧 CMD (DLC 32) 往返 ===")
|
||||
cmd = bytes(range(32))
|
||||
r = S.api_can_send({"ch": 1, "id": "0x30200", "dlc": 32, "data": cmd.hex()})
|
||||
assert r["ok"] and r["acked"], r
|
||||
ch, can_id, dlc, data = slave.rx_frames[-1]
|
||||
print(f" 从站收到 ch={ch} id=0x{can_id:X} dlc={dlc}")
|
||||
assert dlc == 32, f"DLC 被截断成 {dlc}"
|
||||
assert data == cmd.hex().upper(), "32B payload 不一致"
|
||||
print(" [OK] DLC32 完整送达,未被旧 24B 上限截断")
|
||||
|
||||
print("\n=== 2. 后台循环 + 窗口滚动扫全表 ===")
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.4)
|
||||
snap = S.api_motor_state({})
|
||||
assert snap["ok"], snap
|
||||
motors = snap["motors"]
|
||||
print(f" cycles={snap['cycles']} 聚合槽数={len(motors)}/{S.MOTOR_SLOT_COUNT}")
|
||||
assert len(motors) == S.MOTOR_SLOT_COUNT, f"只扫到 {len(motors)} 槽"
|
||||
nodes = sorted({m["node"] for m in motors})
|
||||
chans = sorted({m["ch"] for m in motors})
|
||||
print(f" 节点={nodes} 通道={chans}")
|
||||
assert nodes == list(range(1, 10)) and chans == [0, 1, 2]
|
||||
print(f" [OK] {S.MOTOR_SCAN_CYCLES} 周期滚动扫完,node×ch 双键齐全")
|
||||
|
||||
print("\n=== 3. STATUS payload 完整 32B ===")
|
||||
m5 = next(m for m in motors if m["slot"] == 5)
|
||||
raw = bytes.fromhex(m5["data"])
|
||||
temp = struct.unpack_from("<h", raw, 0)[0] * 0.01
|
||||
print(f" slot5 node={m5['node']} ch={m5['ch']} len={len(raw)}B mcuTemp={temp}°C")
|
||||
assert len(raw) == 32, f"STATUS 只有 {len(raw)}B"
|
||||
assert abs(temp - 5.0) < 1e-6
|
||||
print(" [OK] 32B STATUS 完整(旧 8B 短快照装不下,decodeStatus 会直接拒绝)")
|
||||
|
||||
print("\n=== 4. 命令队列经后台循环下发 ===")
|
||||
before = len(slave.rx_frames)
|
||||
S.api_motor_send({"frames": [
|
||||
{"ch": 0, "id": "0x10200", "dlc": 32, "data": "AA" * 32},
|
||||
{"ch": 1, "id": "0x20200", "dlc": 32, "data": "BB" * 32},
|
||||
{"ch": 2, "id": "0x30301", "dlc": 24, "data": "CC" * 24},
|
||||
]})
|
||||
time.sleep(0.3)
|
||||
sent = slave.rx_frames[before:]
|
||||
for f in sent:
|
||||
print(f" ch={f[0]} id=0x{f[1]:X} dlc={f[2]}")
|
||||
assert len(sent) == 3, f"队列丢帧: 只发出 {len(sent)}/3"
|
||||
assert [f[2] for f in sent] == [32, 32, 24]
|
||||
print(" [OK] 3 帧全部下发,未互相覆盖")
|
||||
|
||||
print("\n=== 5. 循环可干净停止 ===")
|
||||
S._motor_thread_stop()
|
||||
assert S._motor_thread is None
|
||||
print(" [OK] 线程已退出")
|
||||
|
||||
# ─────────── 1.5.56 批量收发 ───────────
|
||||
print(f"\n=== 6. 批量发送:多帧同一周期发出 (容量 {S.TXB_BYTES}B) ===")
|
||||
slave.tx_cycles.clear()
|
||||
before = len(slave.rx_frames)
|
||||
nine = [{"ch": i % 3, "id": f"0x{i+1:X}0200", "dlc": 32,
|
||||
"data": f"{i:02X}" * 32} for i in range(9)]
|
||||
S.api_motor_send({"frames": nine})
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.25)
|
||||
S._motor_thread_stop()
|
||||
sent = slave.rx_frames[before:]
|
||||
print(f" 下发 9 帧 DLC32 -> 从站收到 {len(sent)} 帧,"
|
||||
f"各 seq 沿帧数={slave.tx_cycles}")
|
||||
assert len(sent) == 9, f"批量下发丢帧: {len(sent)}/9"
|
||||
assert max(slave.tx_cycles) > 1, \
|
||||
f"仍是每周期一帧({slave.tx_cycles}),批量区没生效"
|
||||
# 关键:9 帧 DLC32 应在 1 个周期内发完(单帧区1 + 批量区8),而非排队 9 个周期
|
||||
assert len(slave.tx_cycles) <= 2, \
|
||||
f"9 帧用了 {len(slave.tx_cycles)} 个周期,未达到同步下发"
|
||||
ids = [f"0x{f[1]:X}" for f in sent]
|
||||
print(f" 帧序 ={ids}")
|
||||
assert [f[2] for f in sent] == [32] * 9, "批量帧 DLC 有截断"
|
||||
print(f" [OK] 9 帧在 {len(slave.tx_cycles)} 个周期内发完(旧实现需 9 个周期)")
|
||||
|
||||
print(f"\n=== 7. 批量接收:一周期回收多帧 (容量 {S.RXB_BYTES}B) ===")
|
||||
# 含一帧满长 DLC64,验证 CANFD 全量能过
|
||||
slave.pending_rx = [
|
||||
(0, 0x10100, 32, bytes(range(32))),
|
||||
(1, 0x20100, 32, bytes(range(32, 64))),
|
||||
(2, 0x30100, 64, bytes(range(64))), # CANFD 满长
|
||||
(0, 0x40100, 8, bytes(range(8))),
|
||||
]
|
||||
S._motor_rx_log.clear()
|
||||
S._motor_thread_start(period_ms=2)
|
||||
time.sleep(0.25)
|
||||
snap = S.api_motor_state({})
|
||||
S._motor_thread_stop()
|
||||
got = S.api_motor_rx({})
|
||||
frames = got["frames"]
|
||||
print(f" 回收 {len(frames)} 帧, rx_batch.count={snap['rx_batch']['count']}, "
|
||||
f"pending={snap['rx_batch']['pending']}, lost={snap['rx_batch']['lost']}")
|
||||
for f in frames:
|
||||
print(f" ch={f['ch']} id=0x{f['id']:X} dlc={f['dlc']}")
|
||||
assert len(frames) == 4, f"批量接收丢帧: {len(frames)}/4"
|
||||
assert [f["dlc"] for f in frames] == [32, 32, 64, 8], "接收 DLC 有错"
|
||||
fd = next(f for f in frames if f["dlc"] == 64)
|
||||
assert bytes.fromhex(fd["data"]) == bytes(range(64)), "DLC64 payload 不一致"
|
||||
assert got["remain"] == 0, "取走后缓冲应清空"
|
||||
print(" [OK] 4 帧含 DLC64 满长 CANFD 全部完整回收")
|
||||
|
||||
print("\n=== 8. 兼容性:批量区为空时布局与 1.5.55 一致 ===")
|
||||
S._fill_can_out(1, 0x30200, 32, bytes(range(32)))
|
||||
out = bytes(S._can_out)
|
||||
assert out[1] == 1 and out[2] == 32
|
||||
assert struct.unpack_from("<I", out, 4)[0] == 0x30200
|
||||
assert out[S.TX_OFF_DATA:S.TX_OFF_DATA + 32] == bytes(range(32))
|
||||
assert out[S.TXB_OFF_COUNT] == 0, "未请求批量时 count 必须为 0"
|
||||
assert all(v == 0 for v in out[S.TXB_OFF_ITEMS:]), "未请求批量时帧区必须全零"
|
||||
print(f" 单帧区 [0..47] 与 1.5.55 逐字节一致,批量区 count=0 全零")
|
||||
print(" [OK] 老主站/CAN 调试页行为不变")
|
||||
|
||||
print("\n全部通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user