feat(server): CANFD 批量收发区 + 电机控制台后端 API

PDO 布局扩展(与 GD32 1.5.56 对齐):
  段2 [48..383]  CANFD 批量发送区 —— 多帧共用一个 seq 沿, 从站在同一次
                 processOutput 里先发单帧再发批量, 9 帧 DLC32 由原来 9 个
                 周期压缩到 1 个周期。
  段4 [360..639] CANFD 批量接收区 —— 一周期回收多帧, 支持 DLC64 满长 CANFD,
                 带 lost/pending 计数。
  单帧区 [0..47] 布局不动, 批量区 count=0 时逐字节兼容 1.5.55, 老主站与
  CAN 调试页行为不变。

新增 API: /api/can/{send,state} 与 /api/motor/{start,stop,state,send,rx};
电机 PDO 后台循环靠滚动窗口扫 27 个槽, 命令走队列按 seq 沿下发。
ecat.html 加「电机控制台」入口与「CAN 转发」标签页。

test_motor_backend.py 8 项全通过(不接硬件, 假从站模拟 GD32 buildInput/
processOutput): DLC32 不截断、滚动窗口扫全槽、STATUS 32B 完整回传、
队列不互相覆盖、循环可干净停止、批量同步下发、DLC64 回收、1.5.55 兼容。
This commit is contained in:
cbb
2026-09-03 21:42:07 +08:00
parent 71c4ee6a14
commit f09decc127
2 changed files with 864 additions and 23 deletions
+124 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>EtherCAT 控制台 — DCU LAN9252</title> <title>EtherCAT 控制台 — DCU LAN9252</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="ecat.css?v=20260831a"> <link rel="stylesheet" href="ecat.css?v=20260903b">
<style> <style>
/* ecat 页面专属补充(基于 motor.css 设计体系) */ /* ecat 页面专属补充(基于 motor.css 设计体系) */
.ecat-grid { grid-template-columns: minmax(240px, 290px) minmax(0, 1fr) minmax(0, 1.1fr); } .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> <span class="refresh-hint">ESC EEPROM 直读 · 烧录后断电重启生效</span>
</div> </div>
<div class="toolbar-right"> <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> <span class="node-badge" id="alBadge">AL —</span>
</div> </div>
</header> </header>
@@ -147,6 +148,7 @@
<div class="tabs-bar"> <div class="tabs-bar">
<button class="tab-btn active" data-tab="pdo">过程数据</button> <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="sdo">SDO (CoE)</button>
<button class="tab-btn" data-tab="can">CAN 转发</button>
</div> </div>
<div class="tab-content"> <div class="tab-content">
<!-- PDO 页 --> <!-- PDO 页 -->
@@ -209,6 +211,52 @@
</div> </div>
<div class="eep" id="sdoOut">—</div> <div class="eep" id="sdoOut">—</div>
</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> </div>
</section> </section>
@@ -257,7 +305,7 @@ function hex8(v){return "0x"+(v>>>0).toString(16).toUpperCase().padStart(8,"0");
function setConnected(on, st){ function setConnected(on, st){
connected=on; connected=on;
["btnDisc","btnEeRead","btnFlashGen","btnFlashFile","btnVerifyFile", ["btnDisc","btnEeRead","btnFlashGen","btnFlashFile","btnVerifyFile",
"btnSdoRead","btnSdoWrite"].forEach(id=>$(id).disabled=!on); "btnSdoRead","btnSdoWrite","btnCanSend","btnCanRepeat"].forEach(id=>$(id).disabled=!on);
$("btnOp").disabled=!on; $("btnOp").disabled=!on;
$("btnConn").disabled=on; $("btnConn").disabled=on;
$("connLed").className="led "+(on?"led-on":""); $("connLed").className="led "+(on?"led-on":"");
@@ -379,12 +427,13 @@ $("btnSdoWrite").onclick=async()=>{
}; };
/* ---- tabs ---- */ /* ---- tabs ---- */
const TAB_PAGE={pdo:"tabPdo", sdo:"tabSdo", can:"tabCan"};
document.querySelectorAll(".tab-btn").forEach(b=>{ document.querySelectorAll(".tab-btn").forEach(b=>{
b.onclick=()=>{ b.onclick=()=>{
document.querySelectorAll(".tab-btn").forEach(x=>x.classList.remove("active")); document.querySelectorAll(".tab-btn").forEach(x=>x.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach(x=>x.classList.remove("active")); document.querySelectorAll(".tab-page").forEach(x=>x.classList.remove("active"));
b.classList.add("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"); 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); setConnected(false);
log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。"); log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。");
+740 -20
View File
@@ -90,6 +90,9 @@ _RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/")
g_lock = threading.Lock() g_lock = threading.Lock()
# 本进程启动时刻:用于 404 时判断"源码已改但服务没重启"(见 Handler._not_found)
SERVER_START_TIME = time.time()
# state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us"); # state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us");
# 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。 # 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。
EC_TIMEOUT_STATE_US = 2_000_000 EC_TIMEOUT_STATE_US = 2_000_000
@@ -154,7 +157,10 @@ def api_open(req):
return {"ok": False, return {"ok": False,
"log": [f"总线上没有发现从站({type(e).__name__}: {e})", "log": [f"总线上没有发现从站({type(e).__name__}: {e})",
"检查 DCU 板供电、网线是否接到该网口"]} "检查 DCU 板供电、网线是否接到该网口"]}
if not n: # config_init() 返回从站数;**失败时返回 -1 而不是 0**,而 -1 是真值,
# 旧写法 `if not n` 放行了 -1,随后 g_master.slaves[0] 抛 IndexError
# (板子断电/网线拔出时点"连接"就会崩)。这里按 n <= 0 统一判失败。
if n <= 0:
try: try:
m.close() m.close()
except Exception: except Exception:
@@ -410,16 +416,18 @@ def _require_op_ready(dc_cycle_us=0, dc_shift_us=0):
try: try:
g_master.config_map() g_master.config_map()
except Exception as e: except Exception as e:
# 顺带抓邮箱 SM 寄存器现场(8B: start/len/ctl/status/activate/pdi)—— # 顺带抓全部 SM 寄存器现场(各 8B: start/len/ctl/status/activate/pdi)。
# 2026-09-03 02:12 实测楔死时 activate 位/StartAddr 异常(INIT 态读回全零, # 2026-09-03 03:00 探针实锤:楔死本质是 SSC 的 u16EscAddrSendMbx 错位
# PREOP 态读回 SII 值),这是区分"邮箱 SM 被禁用/未配置"与"SSC 不处理邮箱" # (响应帧写到 SM1 start+4/+8 且逐会话累积,断电才清零),主站从 0x1200 读
# 的关键证据;失败信息里直接带上,抓 GD32 串口时可对照。 # 永远对不上帧头;SM 寄存器本身(start=0x1000/0x1200,act=01)恒正确。
# SM 寄存器 = 0x0800 + 8n, 邮箱是 SM0=0x0800 / SM1=0x0808; # 此转储用于区分"SM 配置坏"与"SSC 地址错位"(后者:SM0/SM1 正常但 SDO
# 0x0810/0x0818 是 SM2/SM3(过程数据), 勿混用。 # 持续失败;固件<1.5.52 无自愈,需断电重启;1.5.52+ 守护自动写回恢复)。
try: try:
sm0 = bytes(s._fprd(0x0800, 8, 20000)).hex().upper() sm_dump = " ".join(
sm1 = bytes(s._fprd(0x0808, 8, 20000)).hex().upper() f"SM{i}={bytes(s._fprd(0x0800 + i * 8, 8, 20000)).hex().upper()}"
sm_info = f"; 邮箱SM现场 SM0={sm0} SM1={sm1}(act位=byte[6],01=使能)" 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: except Exception:
sm_info = "" sm_info = ""
return log + [f"config_map 失败: {type(e).__name__}: {e}{sm_info}"], False 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} 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): def api_op_stop(req):
global g_op_active, _sync_diag_prev global g_op_active, _sync_diag_prev
g_op_active = False g_op_active = False
@@ -738,6 +1372,12 @@ def api_sync_diag(req):
def _do_shutdown(): def _do_shutdown():
"""子进程(browser 模式)退出路径: 断开设备 → serve_forever 返回 → 进程退出。""" """子进程(browser 模式)退出路径: 断开设备 → serve_forever 返回 → 进程退出。"""
# 先停后台 PDO 循环再关设备:否则线程会继续对已 close 的 master 发过程数据。
# 必须在取 g_lock 之前停(join 的目标线程自己也抢这把锁)。
try:
_motor_thread_stop()
except Exception:
pass
try: try:
with g_lock: with g_lock:
api_close({}) api_close({})
@@ -845,11 +1485,35 @@ ROUTES = {
"/api/op/start": ("POST", api_op_start), "/api/op/start": ("POST", api_op_start),
"/api/op/cycle": ("POST", api_op_cycle), "/api/op/cycle": ("POST", api_op_cycle),
"/api/op/stop": ("POST", api_op_stop), "/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/sdo": ("POST", api_sdo),
"/api/sync_diag": ("POST", api_sync_diag), "/api/sync_diag": ("POST", api_sync_diag),
"/api/shutdown": ("POST", api_shutdown), "/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): class Handler(BaseHTTPRequestHandler):
server_version = "EcatTool/1.0" server_version = "EcatTool/1.0"
@@ -857,6 +1521,25 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
pass # 静默访问日志 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"): def _send(self, code, body, ctype="application/json; charset=utf-8"):
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", ctype)
@@ -865,19 +1548,37 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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): def do_GET(self):
path = self.path.split("?")[0] path = self.path.split("?")[0]
static_map = {"/": "ecat.html", "/ecat.html": "ecat.html", name = self._STATIC_MAP.get(path)
"/index.html": "ecat.html", "/ecat.css": "ecat.css"} if name:
if path in static_map: fp = os.path.join(BASE_DIR, name)
fp = os.path.join(BASE_DIR, static_map[path]) ext = os.path.splitext(name)[1].lower()
ctype = self._CTYPE_BY_EXT.get(ext, "application/octet-stream")
try: try:
with open(fp, "rb") as f: with open(fp, "rb") as f:
body = f.read() body = f.read()
if path.endswith(".css"): if ext == ".html":
ctype = "text/css; charset=utf-8"
else:
ctype = "text/html; charset=utf-8"
html = body.decode("utf-8", "replace") html = body.decode("utf-8", "replace")
if "</body>" in html: if "</body>" in html:
html = html.replace( html = html.replace(
@@ -896,7 +1597,7 @@ class Handler(BaseHTTPRequestHandler):
result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]} result = {"ok": False, "log": [f"服务器异常: {type(e).__name__}: {e}"]}
self._send(200, json.dumps(result).encode("utf-8")) self._send(200, json.dumps(result).encode("utf-8"))
return return
self._send(404, b'{"ok":false,"log":["not found"]}') self._not_found(path)
def do_POST(self): def do_POST(self):
path = self.path.split("?")[0] path = self.path.split("?")[0]
@@ -907,7 +1608,7 @@ class Handler(BaseHTTPRequestHandler):
return return
route = ROUTES.get(path) route = ROUTES.get(path)
if not route or route[0] != "POST": if not route or route[0] != "POST":
self._send(404, b'{"ok":false,"log":["not found"]}') self._not_found(path)
return return
try: try:
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
@@ -917,6 +1618,25 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, json.dumps( self._send(200, json.dumps(
{"ok": False, "log": [f"请求解析失败: {e}"]}).encode("utf-8")) {"ok": False, "log": [f"请求解析失败: {e}"]}).encode("utf-8"))
return 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: with g_lock:
try: try:
result = route[1](req) result = route[1](req)