feat(motor): 电机控制台前端 + 后端自测脚本
motor.html/motor.js: N1-N9 九节点控制台, 走 EtherCAT 过程数据(不经
WiFi/HTTP)。节点总览、CMD 控制(8 种 Control_Mode)、STATUS 实时解码、
示波器曲线、故障标签。样式复用 ecat.css(它与 ESP32 motor.css md5 一致),
不再拷第三份避免三处漂移; 本页专有的链路状态条/槽扫描指示走内联 style。
test_motor_backend.py: 不接硬件的后端自测, 假从站模拟 GD32
buildInput/processOutput, 覆盖 8 项(见 f09decc)。跑法:
venv\Scripts\python.exe test_motor_backend.py
This commit is contained in:
+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