feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应

反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式,
选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms):
- 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz
  (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期,
  取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准
- WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries):
  GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新
  一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写)
- ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK
- GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答
  (SET DLC2 旧格式行为不变)
- transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照)

EtherCAT/基础设施(密排槽固件 1.6.21 配套):
- 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔
  自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线;
  STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉)
- esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例,
  双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘
- flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零),
  防"写入≠在跑"假台账
- lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板
  并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时
  优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
This commit is contained in:
cbb
2026-09-12 20:10:47 +08:00
parent 81096d0425
commit 209a869f96
21 changed files with 2550 additions and 981 deletions
+1 -1
View File
@@ -1 +1 @@
4e84a35431a6435613f13d1374f4df8e60082257186562500a0bcf0662fd8b9e
dda92d11876f059d27390232e88b5c9fa7c0eb57e442365f5247d4acdefb9b20
-754
View File
@@ -1,754 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LAN9252 通断测试</title>
<style>
/* === WebGL 液态玻璃 (参考 yzrt/html-liquid-glass-effect-webgl) ===
参数:IOR=1.5 / Thickness=50 / Normal=10 / Blur=0 / Highlight=4 / Radius=26
实现:SVG feDisplacementMap 做真实背景像素折射位移 + saturate/contrast 色散
+ 边缘 4px 白色高光带 (Highlight Width=4)
+ 边缘 1px 暗描边 (Normal Strength=10)
所有组件统一用此风格,旧 yzrt .box 多层 inset 阴影已舍弃
*/
:root{
--grid:#E1E1E1;
--text:#f0f0f5;
--text-2:#c8c8d0;
--text-3:#8a8a95;
--separator:rgba(255,255,255,.08);
--blue:#5b9eff;
--green:#30d158;
--red:#ff453a;
--orange:#ff9f0a;
--purple:#bf5af2;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transition:.25s;}
html,body{height:100%;}
body{
font-family:"Microsoft YaHei","SF Pro Display","Segoe UI",sans-serif;
color:var(--text);font-size:15px;line-height:1.5;
min-height:100vh;padding:32px 20px 80px;
position:relative;overflow-x:hidden;
/* 彩色 blur 球 + 细点阵 — 让液态玻璃折射位移清晰可见
色球提供颜色参照(折射色散),点阵提供位置参照(位移扭曲) */
background:
/* 细点阵:让折射位移有参照物 */
radial-gradient(circle at 50% 50%, rgba(255,255,255,.12) 1px, transparent 1.5px) 0 0/24px 24px,
/* 彩色 blur 球:中饱和度,不要太鲜艳以免干扰文字 */
radial-gradient(circle at 15% 20%, #4a90e2 0%, transparent 35%),
radial-gradient(circle at 85% 15%, #b06ab3 0%, transparent 38%),
radial-gradient(circle at 75% 80%, #e27d60 0%, transparent 40%),
radial-gradient(circle at 20% 75%, #41d3a3 0%, transparent 38%),
radial-gradient(circle at 50% 50%, #f0a500 0%, transparent 45%),
/* 底色渐变 */
linear-gradient(135deg, #2d3561 0%, #2d4a6b 50%, #1a2a45 100%);
background-attachment:fixed;
}
/* 大色球:增强折射参照(放固定层,不会被玻璃遮挡) */
body::before{
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
background:
radial-gradient(circle at 30% 40%, rgba(255,200,100,.18) 0%, transparent 20%),
radial-gradient(circle at 70% 60%, rgba(100,200,255,.15) 0%, transparent 25%);
}
.wrap{max-width:1100px;margin:0 auto;position:relative;z-index:1;}
h1{
font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:6px;color:#fff;
text-shadow:0 2px 8px rgba(0,0,0,.4);
}
.sub{color:rgba(255,255,255,.85);font-size:15px;margin-bottom:24px;font-weight:500;text-shadow:0 1px 4px rgba(0,0,0,.3);}
/* === 玻璃基础类 — 纯 WebGL 液态玻璃(无辅助效果) ===
IOR1.5/Thick50 → SVG feDisplacementMap 背景折射位移 + saturate/contrast 色散
Blur0 → 不模糊
Highlight4 → 边缘 8px 白色高光带(略扩散)
Normal10 → 边缘 2px 暗影
Radius26 → 圆角 */
.glass,.glass-strong{
position:relative;z-index:0;isolation:isolate;
border-radius:26px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
box-shadow:
inset 0 0 8px rgba(255,255,255,.6), /* Highlight Width=4 → 略向中间扩散 */
inset 0 0 2px rgba(0,0,0,.6), /* Normal Strength=10 */
0 4px 12px rgba(0,0,0,.18);
}
.glass-strong{
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 6px 16px rgba(0,0,0,.2);
}
.glass-subtle{
position:relative;z-index:0;isolation:isolate;
border-radius:18px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.card{padding:24px;margin-bottom:16px;}
.card > *{position:relative;z-index:1;}
.card h2{
font-size:22px;font-weight:700;letter-spacing:-.02em;margin-bottom:6px;color:#fff;
text-shadow:0 1px 6px rgba(0,0,0,.3);
}
.card-desc{font-size:13px;color:var(--text-2);margin-bottom:16px;}
/* 警告条 — 纯 WebGL 玻璃 + 橙红染色 */
.warn{
color:#fff;font-size:13px;margin-bottom:24px;padding:14px 18px;
position:relative;z-index:0;isolation:isolate;
background:linear-gradient(135deg,rgba(255,159,10,.35),rgba(255,69,58,.3));
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border:none;
border-radius:26px;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 4px 12px rgba(0,0,0,.16);
display:flex;align-items:flex-start;gap:10px;font-weight:500;
text-shadow:0 1px 3px rgba(0,0,0,.4);
}
.warn > *{position:relative;z-index:1;}
.warn-icon{font-size:16px;flex-shrink:0;}
/* === 按钮 — 纯 WebGL 液态玻璃(无辅助效果) === */
button{
--btn-glow:rgba(0,0,0,.3);
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
color:var(--text);
border:none;
border-radius:26px;
padding:11px 22px;
font-size:14px;font-weight:600;font-family:inherit;
cursor:pointer;letter-spacing:-.01em;
box-shadow:
inset 0 0 8px rgba(255,255,255,.6),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(0,0,0,.16);
}
button:hover{
transform:translateY(-1px);
backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 4px 10px rgba(0,0,0,.2);
}
button:active{transform:translateY(0);}
/* 液体弹动:点击时像液态玻璃被按压后弹性回弹
注:用 transform-origin:center 保证对称变形;
background 用 background-size:200% 避免渐变被拉伸变形;
box-shadow 跟随收缩,保持形状一致 */
button.pressed{
animation:liquidPress .6s cubic-bezier(.25,1.5,.5,1) 1;
background-size:200% 200% !important;
background-position:center !important;
}
@keyframes liquidPress{
0%{transform:scale(1,1);box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
25%{transform:scale(.90,.86);border-radius:32px;box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.6),0 1px 4px var(--btn-glow,rgba(0,0,0,.3));}
45%{transform:scale(1.06,.94);border-radius:22px;box-shadow:inset 0 0 10px rgba(255,255,255,.6),inset 0 0 2px rgba(0,0,0,.6),0 4px 12px var(--btn-glow,rgba(0,0,0,.3));}
65%{transform:scale(.97,1.03);border-radius:28px;box-shadow:inset 0 0 9px rgba(255,255,255,.55),inset 0 0 2px rgba(0,0,0,.6),0 3px 9px var(--btn-glow,rgba(0,0,0,.3));}
82%{transform:scale(1.01,.99);border-radius:25px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
100%{transform:scale(1,1);border-radius:26px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
}
button.selected{
--btn-glow:rgba(37,99,235,.45);
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(37,99,235,.45);
}
button.selected:hover{background:linear-gradient(135deg,rgba(29,78,216,.88),rgba(37,99,235,.88));}
button.confirm-btn{
--btn-glow:rgba(22,163,74,.4);
background:linear-gradient(135deg,rgba(22,163,74,.8),rgba(21,128,61,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(22,163,74,.4);
}
button.confirm-btn:hover{background:linear-gradient(135deg,rgba(21,128,61,.88),rgba(22,163,74,.88));}
button.confirm-btn:disabled{
--btn-glow:rgba(0,0,0,.2);
background:rgba(140,140,145,.3);color:rgba(255,255,255,.8);
cursor:not-allowed;filter:brightness(.85);
box-shadow:inset 0 0 2px rgba(0,0,0,.4);transform:none;
}
button.loop-btn{
--btn-glow:rgba(124,58,237,.4);
background:linear-gradient(135deg,rgba(124,58,237,.8),rgba(109,40,217,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(124,58,237,.4);
}
button.loop-btn:hover{background:linear-gradient(135deg,rgba(109,40,217,.92),rgba(124,58,237,.92));}
button.loop-btn.active{
--btn-glow:rgba(234,88,12,.5);
background:linear-gradient(135deg,rgba(234,88,12,.92),rgba(194,65,12,.92));
background-size:200% 200%;
animation:pulse 2s infinite;
}
button.stop-btn{
--btn-glow:rgba(220,38,38,.4);
background:linear-gradient(135deg,rgba(220,38,38,.8),rgba(185,28,28,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(220,38,38,.4);
}
button.stop-btn:hover{background:linear-gradient(135deg,rgba(185,28,28,.88),rgba(220,38,38,.88));}
@keyframes pulse{
0%,100%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 0 rgba(234,88,12,.5);}
50%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 12px rgba(234,88,12,0);}
}
.btn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;}
.section-label{
font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.08em;
margin:18px 0 10px;font-weight:700;
}
.section-label:first-child{margin-top:0;}
/* SVG 引脚图容器 — 纯 WebGL 玻璃 */
.pin-svg-wrap{
display:flex;justify-content:center;margin:12px 0;padding:20px;
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border-radius:18px;
border:none;
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.pin-svg-wrap svg{max-width:100%;height:auto;}
.pin-rect{stroke:rgba(255,255,255,.3);stroke-width:.5;transition:fill .3s ease;}
.pin-num{fill:rgba(255,255,255,.7);font-size:12px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name{fill:rgba(255,255,255,.9);font-size:11px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name-data{fill:#7fd3ff;font-weight:600;}
.pin-name-ctrl{fill:#ffb74d;font-weight:600;}
.chip-body{fill:url(#chipGrad);stroke:rgba(255,255,255,.25);stroke-width:1;}
.chip-label{fill:#fff;font-size:22px;font-weight:700;text-anchor:middle;}
.chip-sub{fill:rgba(255,255,255,.7);font-size:10px;text-anchor:middle;}
.pin1-dot{fill:var(--blue);}
.pin-legend{
display:flex;gap:18px;justify-content:center;flex-wrap:wrap;margin-top:16px;
font-size:12px;color:var(--text-2);padding-top:16px;
border-top:1px solid var(--separator);font-weight:500;
position:relative;z-index:1;
}
.pin-legend span{display:flex;align-items:center;gap:6px;}
.pin-legend .swatch{
width:14px;height:14px;border-radius:4px;display:inline-block;
box-shadow:0 1px 2px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.5);
}
/* 连接 / 操作 */
.connect-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:14px;position:relative;z-index:1;}
.connect-label{color:var(--text-2);font-weight:500;}
.connect-ip{
color:var(--blue);font-weight:700;
font-family:ui-monospace,"SF Mono",monospace;font-size:15px;
background:rgba(37,99,235,.15);padding:5px 14px;border-radius:26px;
border:none;
box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.3);
}
.connect-hint{color:var(--text-3);font-size:12px;}
.action-row{display:flex;gap:10px;flex-wrap:wrap;position:relative;z-index:1;}
.action-desc{
font-size:13px;color:var(--text-2);margin-top:16px;padding-top:14px;
border-top:1px solid var(--separator);line-height:1.7;position:relative;z-index:1;
}
.action-desc strong{color:var(--text);font-weight:600;}
/* 日志面板 — 纯 WebGL 玻璃 */
.log-panel{padding:0;overflow:hidden;}
.log-panel-header{
padding:18px 24px 14px;border-bottom:1px solid var(--separator);
display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;
background:transparent;position:relative;z-index:1;
}
.log-panel-title{font-size:17px;font-weight:700;letter-spacing:-.01em;}
.log-filters{display:flex;gap:6px;}
.log-filter{
position:relative;z-index:0;isolation:isolate;
padding:5px 14px;border-radius:26px;font-size:12px;font-weight:600;
border:none;background:transparent;color:var(--text-2);
cursor:pointer;font-family:inherit;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 5px rgba(255,255,255,.4),
inset 0 0 2px rgba(0,0,0,.4),
0 1px 3px rgba(0,0,0,.1);
}
.log-filter.active{
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(37,99,235,.35);
}
.log-filter:hover:not(.active){color:var(--text);}
.log-scroll{
padding:14px 24px;font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
font-size:12px;line-height:1.8;max-height:380px;overflow-y:auto;
background:transparent;position:relative;z-index:1;
}
.log-scroll::-webkit-scrollbar{width:6px;}
.log-scroll::-webkit-scrollbar-track{background:transparent;}
.log-scroll::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:3px;}
.log-scroll::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.35);}
.log-line{display:flex;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,.05);}
.log-line .lt{color:var(--text-3);flex-shrink:0;}
.log-line .lv{font-weight:700;min-width:18px;flex-shrink:0;}
.log-line .lv.E{color:var(--red);}
.log-line .lv.I{color:var(--blue);}
.log-line .lg{color:var(--purple);flex-shrink:0;}
.log-line .lm{color:#fff;word-break:break-all;}
.log-foot{
display:flex;justify-content:space-between;align-items:center;
padding:12px 24px;border-top:1px solid var(--separator);
font-size:12px;color:var(--text-3);font-weight:500;
background:transparent;position:relative;z-index:1;
}
</style>
</head>
<body>
<!-- SVG 折射滤镜:模拟 WebGL IOR=1.5 Thickness=50 的背景位移折射 -->
<svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<defs>
<filter id="glassRefract" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.012" numOctaves="2" seed="7" result="noise"/>
<feGaussianBlur in="noise" stdDeviation="1.5" result="softNoise"/>
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="14" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="wrap">
<h1>LAN9252 通断测试</h1>
<div class="sub">通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压</div>
<div class="warn">
<span class="warn-icon">⚠️</span>
<span>不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。</span>
</div>
<!-- 连接 -->
<div class="glass card">
<div class="connect-row">
<span class="connect-label">设备 IP</span>
<span id="ipDisplay" class="connect-ip">读取中...</span>
<span class="connect-hint">共用主页"设备连接"配置的 IP</span>
</div>
</div>
<!-- 引脚图 -->
<div class="glass-strong card">
<h2>LAN9252 64-QFN 引脚图</h2>
<p class="card-desc">俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。</p>
<div class="pin-svg-wrap">
<svg id="pinSvg" width="720" height="720" viewBox="0 0 720 720">
<defs>
<linearGradient id="chipGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(60,60,80,.6)"/>
<stop offset="100%" stop-color="rgba(40,40,60,.5)"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
</svg>
</div>
<div class="pin-legend">
<span><span class="swatch" style="background:#16a34a;"></span> HIGH 3.3V</span>
<span><span class="swatch" style="background:#dc2626;"></span> LOW 0V</span>
<span><span class="swatch" style="background:#ea580c;"></span> 控制线测试</span>
<span><span class="swatch" style="background:rgba(232,232,237,.8);"></span> 数据线空闲</span>
<span><span class="swatch" style="background:rgba(255,245,230,.8);"></span> 控制线空闲</span>
<span><span class="swatch" style="background:rgba(240,240,243,.8);"></span> 电源/其他</span>
<span><span class="swatch" style="background:#2563eb;border-radius:50%;"></span> Pin1</span>
</div>
</div>
<!-- 模式选择 -->
<div class="glass card">
<h2>模式选择</h2>
<p class="card-desc">点击预览引脚图,再点"确认应用"发送命令</p>
<div class="section-label">数据线 pattern · D0-D15</div>
<div class="btn-grid">
<button class="mode-btn" data-action="01">0xFFFF 全高</button>
<button class="mode-btn" data-action="02">0x0000 全低</button>
<button class="mode-btn" data-action="03">0xAAAA 奇数高</button>
<button class="mode-btn" data-action="04">0x5555 偶数高</button>
</div>
<div class="section-label">控制线测试</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="05">CS 拉低 · PD7→Pin28</button>
<button class="mode-btn" data-action="06">RD 拉低 · PD4→Pin31</button>
<button class="mode-btn" data-action="07">WR 拉低 · PD5→Pin30</button>
<button class="mode-btn" data-action="08">A1 拉高 · PD11→Pin25</button>
<button class="mode-btn" data-action="09">A2 拉高 · PD12→Pin29</button>
</div>
<div class="section-label">读周期保持 · 判定芯片是否驱动总线</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="10">读保持 · CS#+RD# 持续低</button>
</div>
<p class="card-desc" style="margin-top:10px;">
数据线切为高阻输入、CS#+RD# 持续拉低(读 IDX_0)。用万用表在 <strong>LAN9252 芯片引脚</strong> 上量数据线:
芯片活着 → D2(Pin12)、D4(Pin49)、D5(Pin50)、D9(Pin19)、D12(Pin21) ≈ 3.3V,其余数据脚 ≈ 0V(全部确定电平,即 0x1234);
全部中电平/漂移 → 芯片未驱动总线。图上预览色 = 芯片应答的期望值。
</p>
</div>
<!-- 操作 -->
<div class="glass card">
<h2>操作</h2>
<div class="action-row">
<button class="confirm-btn" id="confirmBtn" disabled>✓ 确认应用</button>
<button class="loop-btn" id="loopBtn">🔄 循环切换</button>
<button class="stop-btn" id="stopBtn">⏹ 停止 / 恢复</button>
</div>
<p class="action-desc">
<strong>确认应用</strong> · 发送当前选中的模式到 GD32<br>
<strong>循环切换</strong> · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)<br>
<strong>停止 / 恢复</strong> · 恢复 EXMC 正常模式
</p>
</div>
<!-- 日志 -->
<div class="glass log-panel">
<div class="log-panel-header">
<span class="log-panel-title">操作日志</span>
<div class="log-filters">
<button class="log-filter active" onclick="setLogFilter('all',this)">全部</button>
<button class="log-filter" onclick="setLogFilter('E',this)">错误</button>
<button class="log-filter" onclick="setLogFilter('I',this)">信息</button>
<button class="log-filter" onclick="clearLogs()">清除</button>
</div>
</div>
<div class="log-scroll" id="logScroll"></div>
<div class="log-foot">
<span>共 <span id="logCount">0</span> 条</span>
<span style="font-family:ui-monospace,monospace;">SPI 0xA2 · UDP 6666</span>
</div>
</div>
</div><!-- /.wrap -->
<script>
/* ===== LAN9252 64-QFN 引脚表 ===== */
var pinData=[
{n:1,name:"OSCI",type:"other"},{n:2,name:"OSCO",type:"other"},
{n:3,name:"VDD12",type:"power"},{n:4,name:"VSS",type:"gnd"},
{n:5,name:"VDD33",type:"power"},{n:6,name:"VDDCR",type:"power"},
{n:7,name:"REG_EN",type:"other"},{n:8,name:"FXLOSEN",type:"other"},
{n:9,name:"FXSDA",type:"other"},{n:10,name:"FXSDB",type:"other"},
{n:11,name:"RST#",type:"other"},{n:12,name:"D2",type:"data",d:2},
{n:13,name:"D1",type:"data",d:1},{n:14,name:"VDDIO",type:"power"},
{n:15,name:"D14",type:"data",d:14},{n:16,name:"D13",type:"data",d:13},
{n:17,name:"D0",type:"data",d:0},{n:18,name:"SYNC1",type:"other"},
{n:19,name:"D9",type:"data",d:9},{n:20,name:"VDDIO",type:"power"},
{n:21,name:"D12",type:"data",d:12},{n:22,name:"D11",type:"data",d:11},
{n:23,name:"D10",type:"data",d:10},{n:24,name:"VDDCR",type:"power"},
{n:25,name:"A1",type:"ctrl",ctrl:"A1"},{n:26,name:"A3",type:"other"},
{n:27,name:"A4",type:"other"},{n:28,name:"CS",type:"ctrl",ctrl:"CS"},
{n:29,name:"A2",type:"ctrl",ctrl:"A2"},{n:30,name:"WR",type:"ctrl",ctrl:"WR"},
{n:31,name:"RD",type:"ctrl",ctrl:"RD"},{n:32,name:"VDDIO",type:"power"},
{n:33,name:"D15",type:"data",d:15},{n:34,name:"SYNC0",type:"other"},
{n:35,name:"D3",type:"data",d:3},{n:36,name:"D6",type:"data",d:6},
{n:37,name:"VDDIO",type:"power"},{n:38,name:"VDDCR",type:"power"},
{n:39,name:"D7",type:"data",d:7},{n:40,name:"D8",type:"data",d:8},
{n:41,name:"TESTMODE",type:"other"},{n:42,name:"EESDA",type:"other"},
{n:43,name:"EESCL",type:"other"},{n:44,name:"IRQ",type:"other"},
{n:45,name:"RUNLED",type:"other"},{n:46,name:"MODE1",type:"other"},
{n:47,name:"VDDIO",type:"power"},{n:48,name:"MODE0",type:"other"},
{n:49,name:"D4",type:"data",d:4},{n:50,name:"D5",type:"data",d:5},
{n:51,name:"TXRX1",type:"power"},{n:52,name:"TXNA",type:"other"},
{n:53,name:"TXPA",type:"other"},{n:54,name:"RXNA",type:"other"},
{n:55,name:"RXPA",type:"other"},{n:56,name:"VDD12",type:"power"},
{n:57,name:"RBIAS",type:"other"},{n:58,name:"VDD33B",type:"power"},
{n:59,name:"VDD12",type:"power"},{n:60,name:"RXPB",type:"other"},
{n:61,name:"RXNB",type:"other"},{n:62,name:"TXPB",type:"other"},
{n:63,name:"TXNB",type:"other"},{n:64,name:"TXRX2",type:"power"}
];
var actionMap={
"01":{t:"data",val:0xFFFF,label:"0xFFFF 全高"},"02":{t:"data",val:0x0000,label:"0x0000 全低"},
"03":{t:"data",val:0xAAAA,label:"0xAAAA 奇数高"},"04":{t:"data",val:0x5555,label:"0x5555 偶数高"},
"05":{t:"ctrl",val:"CS",label:"CS 拉低"},"06":{t:"ctrl",val:"RD",label:"RD 拉低"},
"07":{t:"ctrl",val:"WR",label:"WR 拉低"},"08":{t:"ctrl",val:"A1",label:"A1 拉高"},
"09":{t:"ctrl",val:"A2",label:"A2 拉高"},
"10":{t:"readhold",val:0x1234,label:"读周期保持(CS#+RD# 持续低)"},
"00":{t:"stop",label:"停止/恢复"}
};
/* ===== SVG ===== */
var SVG_NS="http://www.w3.org/2000/svg";
var CHIP_X1=120,CHIP_Y1=120,CHIP_X2=600,CHIP_Y2=600;
var CHIP_W=CHIP_X2-CHIP_X1,PIN_GAP=CHIP_W/16,PIN_OFF=PIN_GAP/2;
var DEFAULT_COLORS={data:"rgba(200,200,210,.5)",ctrl:"rgba(255,200,150,.5)",power:"rgba(220,220,230,.5)",gnd:"rgba(180,180,190,.5)",other:"rgba(210,210,220,.5)"};
function el(tag,attrs,text){
var e=document.createElementNS(SVG_NS,tag);
for(var k in attrs)e.setAttribute(k,attrs[k]);
if(text!==undefined)e.textContent=text;
return e;
}
function getPinPos(n){
if(n>=1&&n<=16){
/* 顶部:引脚上方,名称在上、编号在下 */
var x=CHIP_X2-PIN_OFF-(n-1)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y1-14,rw:14,rh:14,tx:x,ty:CHIP_Y1-16,anchor:"middle",ny:CHIP_Y1-32};
}else if(n>=17&&n<=32){
/* 左侧:引脚左方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y1+PIN_OFF+(n-17)*PIN_GAP;
return{rx:CHIP_X1-14,ry:y-7,rw:14,rh:14,tx:CHIP_X1-18,ty:y-1,anchor:"end",ny:y+13};
}else if(n>=33&&n<=48){
/* 底部:引脚下方,编号在上、名称在下 */
var x=CHIP_X1+PIN_OFF+(n-33)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y2,rw:14,rh:14,tx:x,ty:CHIP_Y2+22,anchor:"middle",ny:CHIP_Y2+38};
}else{
/* 右侧:引脚右方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y2-PIN_OFF-(n-49)*PIN_GAP;
return{rx:CHIP_X2,ry:y-7,rw:14,rh:14,tx:CHIP_X2+18,ty:y-1,anchor:"start",ny:y+13};
}
}
var pinRects={};
function buildSvg(){
var svg=document.getElementById("pinSvg");
svg.appendChild(el("rect",{class:"chip-body",x:CHIP_X1,y:CHIP_Y1,width:CHIP_W,height:CHIP_W,rx:14}));
svg.appendChild(el("text",{class:"chip-label",x:360,y:345},"LAN9252"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:368},"64-QFN"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:386},"Pin1 → 右上角"));
svg.appendChild(el("circle",{class:"pin1-dot",cx:CHIP_X2-14,cy:CHIP_Y1+14,r:5}));
for(var i=0;i<pinData.length;i++){
var p=pinData[i],pos=getPinPos(p.n);
var rect=el("rect",{class:"pin-rect",x:pos.rx,y:pos.ry,width:pos.rw,height:pos.rh,rx:3,id:"pin-"+p.n});
rect.setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
svg.appendChild(rect);
pinRects[p.n]=rect;
svg.appendChild(el("text",{class:"pin-num",x:pos.tx,y:pos.ty,"text-anchor":pos.anchor},String(p.n)));
var nc="pin-name";
if(p.type==="data")nc="pin-name pin-name-data";
else if(p.type==="ctrl")nc="pin-name pin-name-ctrl";
svg.appendChild(el("text",{class:nc,x:pos.tx,y:pos.ny||pos.ty,"text-anchor":pos.anchor},p.name));
}
}
function updatePinColors(actionHex){
var act=actionMap[actionHex];if(!act)return;
for(var i=0;i<pinData.length;i++){
var p=pinData[i],color=DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other;
var glow=false;
if(act.t==="data"&&p.type==="data"){
color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;
}else if(act.t==="ctrl"&&p.type==="ctrl"&&p.ctrl===act.val){
color="#ff9f0a";glow=true;
}else if(act.t==="readhold"){
/* CS/RD 由 GD32 拉低(橙), 数据线 = 芯片应答期望值 0x1234 */
if(p.type==="ctrl"&&(p.ctrl==="CS"||p.ctrl==="RD")){color="#ff9f0a";glow=true;}
else if(p.type==="data"){color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;}
}
pinRects[p.n].setAttribute("fill",color);
if(glow)pinRects[p.n].setAttribute("filter","url(#glow)");
else pinRects[p.n].removeAttribute("filter");
}
}
function resetPinColors(){
for(var i=0;i<pinData.length;i++){
var p=pinData[i];
pinRects[p.n].setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
pinRects[p.n].removeAttribute("filter");
}
}
/* ===== IP ===== */
var LS_ESP_IP="dcu_esp_ip",DEFAULT_IP="192.168.31.127";
function getIp(){try{var s=localStorage.getItem(LS_ESP_IP);if(s&&s.trim())return s.trim();}catch(e){}return DEFAULT_IP;}
document.getElementById("ipDisplay").textContent=getIp();
window.addEventListener("storage",function(e){if(e.key===LS_ESP_IP)document.getElementById("ipDisplay").textContent=getIp();});
/* ===== 日志 ===== */
var logScroll=document.getElementById("logScroll"),logCountEl=document.getElementById("logCount"),logCount=0,logFilter="all";
function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");}
function addLog(level,tag,msg){
var line=document.createElement("div");
line.className="log-line";
if(logFilter!=="all"&&level!==logFilter){line.style.display="none";}
/* msg/tag 含设备响应内容(如 /spi/send JSON),拼 innerHTML 前必须转义 */
line.innerHTML='<span class="lt">['+new Date().toLocaleTimeString()+']</span>'+
'<span class="lv '+esc(level)+'">'+esc(level)+'</span>'+
'<span class="lg">['+esc(tag)+']</span>'+
'<span class="lm">'+esc(msg)+'</span>';
logScroll.insertBefore(line,logScroll.firstChild);
logCount++;logCountEl.textContent=logCount;
while(logScroll.children.length>200)logScroll.removeChild(logScroll.lastChild);
}
function setLogFilter(f,btn){
logFilter=f;
document.querySelectorAll(".log-filter").forEach(function(b){b.classList.remove("active");});
btn.classList.add("active");
logScroll.querySelectorAll(".log-line").forEach(function(l){
var lv=l.querySelector(".lv");
if(f==="all"||(lv&&lv.classList.contains(f)))l.style.display="";
else l.style.display="none";
});
}
function clearLogs(){logScroll.innerHTML="";logCount=0;logCountEl.textContent=0;}
/* ===== 按钮:液体弹动效果 ===== */
document.addEventListener("click",function(e){
var btn=e.target.closest("button");
if(!btn)return;
btn.classList.remove("pressed");
void btn.offsetWidth; /* 强制 reflow,允许重复触发 */
btn.classList.add("pressed");
var done=function(){
btn.classList.remove("pressed");
btn.removeEventListener("animationend",done);
};
btn.addEventListener("animationend",done);
},true);
/* ===== 模式选择 ===== */
var selectedAction=null,lastModeBtn=null;
document.querySelectorAll(".mode-btn").forEach(function(btn){
btn.addEventListener("click",function(){
var action=btn.getAttribute("data-action");
if(lastModeBtn)lastModeBtn.classList.remove("selected");
btn.classList.add("selected");
lastModeBtn=btn;
selectedAction=action;
updatePinColors(action);
document.getElementById("confirmBtn").disabled=false;
addLog("I","预览","选中: "+actionMap[action].label);
});
});
/* ===== 发送 ===== */
var busy=false;
function sendAction(actionHex){
if(busy)return;
var ip=getIp();if(!ip){addLog("E","错误","请先在主页配置 ESP32 IP");return;}
busy=true;
var label=actionMap[actionHex]?actionMap[actionHex].label:actionHex;
addLog("I","发送","action=0x"+actionHex+" ("+label+")");
fetch("http://"+ip+"/spi/send",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({type:"A2",data:actionHex,timeout_ms:2000}),
signal:AbortSignal.timeout(3000)
})
.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})
.then(function(j){
if(j.status==="ok"&&j.resp_type==="80")
addLog("I","成功",label+" → ACK"+(j.recv?" 0x"+j.recv:""));
else if(j.status==="ok"&&j.resp_type==="82")
addLog("E","失败",label+" → GD32 ERROR");
else
addLog("E","异常",label+" → "+JSON.stringify(j));
})
.catch(function(err){addLog("E","网络",label+" → "+err.message);})
.then(function(){busy=false;});
}
document.getElementById("confirmBtn").addEventListener("click",function(){
if(selectedAction)sendAction(selectedAction);
});
document.getElementById("stopBtn").addEventListener("click",function(){
stopLoop();
sendAction("00");
resetPinColors();
if(lastModeBtn)lastModeBtn.classList.remove("selected");
lastModeBtn=null;selectedAction=null;
document.getElementById("confirmBtn").disabled=true;
addLog("I","恢复","EXMC 恢复正常模式");
});
/* ===== 循环: 0x0000 ↔ 当前模式 ===== */
var loopTimer=null,loopToggle=false;
var loopBtn=document.getElementById("loopBtn");
loopBtn.addEventListener("click",function(){
if(loopTimer)stopLoop();else startLoop();
});
function startLoop(){
if(!selectedAction||actionMap[selectedAction].t!=="data"){
addLog("E","循环","请先选择一个数据线 pattern 模式");
return;
}
stopLoop();
var baseAction=selectedAction;
loopBtn.textContent="⏸ 停止循环";
loopBtn.classList.add("active");
addLog("I","循环","开始:每5s在 0x0000 ↔ "+actionMap[baseAction].label+" 切换");
loopToggle=false;
loopBtn.setAttribute("data-base",baseAction);
doLoopStep();
loopTimer=setInterval(doLoopStep,5000);
}
function stopLoop(){
if(loopTimer){clearInterval(loopTimer);loopTimer=null;}
loopBtn.textContent="🔄 循环切换";
loopBtn.classList.remove("active");
loopBtn.removeAttribute("data-base");
}
function doLoopStep(){
var baseAction=loopBtn.getAttribute("data-base");if(!baseAction)return;
loopToggle=!loopToggle;
var action=loopToggle?baseAction:"02";
if(lastModeBtn)lastModeBtn.classList.remove("selected");
var btn=document.querySelector('[data-action="'+action+'"]');
if(btn){btn.classList.add("selected");lastModeBtn=btn;}
selectedAction=action;
updatePinColors(action);
sendAction(action);
}
buildSvg();
addLog("I","系统","通断测试工具就绪");
</script>
</body>
</html>
+579
View File
@@ -0,0 +1,579 @@
<!DOCTYPE html>
<!--
lan9252_pm.html - LAN9252 芯片级电源管理诊断台
─────────────────────────────────────────────
风格复用全站设计体系(css/base.css + layout.css + components.css,与 OTA 中继/电机页同源),
布局沿用 .main-layout 双列栅格:左 400px 操作列(休眠/恢复),右侧观测列(状态仪表+日志)。
设备连接不设独立区块:直接读全局 localStorage['dcu_esp_ip'](js/common/base-url.js 同 key
同默认值 192.168.31.127,与 OTA/CAN/电机页共享,改 IP 请到 OTA 页)。
设备侧(DCU_APP 1.6.23):SPI 0xA2 action 30~34 → EscLan9252::pmDrive()
30(0x1E)=读状态 31(0x1F)=进 D1 32(0x20)=进 D2 33(0x21)=进 D3 34(0x22)=手动唤醒
NRST 兜底复用 action 20/21 → EscLan9252::nrstDrive()(拉低/推挽高 = 复位脉冲)
HTTP 链路:POST {ip}/spi/send body={type:"A2", data:"<action hex>", timeout_ms}
响应 recv hex 首字节 = status:bit0=READY bit1=PM_SLEEP_EN bit3:2=PM_MODE;
0xEE=无效 action 0xE1=HBI 未就绪;唤醒另有 1=成功 0=超时。
恢复可靠性(为什么睡不死):ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),
芯片睡得再深 0xA2 命令照样送达 GD32;NRST 硬复位覆盖 LAN9252 的一切软件可达状态
(D3 睡死/HBI 假死都在内);GD32 自身挂了还有断电重启兜底(固件 hardwareReset 自动重走
受控复位序列)。页面常驻 2s 自动状态轮询,睡没睡一目了然。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LAN9252 电源管理台</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<style>
/* 本页专属少量补充(令牌/组件全走 base/layout/components) */
.pm-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; }
.pm-cell { background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); padding:10px 12px; }
.pm-cell .k { color:var(--text-label); font-size:.7rem; letter-spacing:.08em; display:block; margin-bottom:2px; }
.pm-cell .v { font-family:var(--font-display); font-size:1.5rem; font-weight:700; }
.v-ok { color:var(--green); } .v-warn { color:var(--gd32-hi); } .v-bad { color:var(--red); } .v-dim { color:var(--text-label); }
.pm-sleep-banner { display:none; margin-bottom:14px; padding:11px 15px; border-radius:var(--radius-sm);
background:var(--gd32-dim); border:1px solid rgba(252,191,63,.4); color:var(--gd32-hi);
font-weight:600; font-size:.88rem; align-items:center; gap:10px; }
.pm-sleep-banner.show { display:flex; }
.pm-vbtn { display:flex; flex-direction:column; gap:9px; }
.pm-vbtn .btn { width:100%; }
.pm-table { width:100%; border-collapse:collapse; font-size:.78rem; }
.pm-table th,.pm-table td { border:1px solid var(--border); padding:5px 10px; text-align:left; }
.pm-table th { color:var(--text-label); font-weight:600; background:var(--bg-card); }
.pm-table code { font-family:var(--mono); color:var(--mem-hi); }
.pm-log { background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm);
font-family:var(--mono); font-size:.73rem; padding:10px 12px; height:300px; overflow-y:auto; }
.pm-log div { padding:1px 0; color:var(--text-dim); word-break:break-all; }
.pm-log .t { color:var(--text-label); }
.pm-log .ok { color:var(--green); } .pm-log .warn { color:var(--gd32-hi); } .pm-log .err { color:var(--red); }
.btn-armed { background:var(--red) !important; color:#fff !important; animation:blink 1s infinite; }
.pm-note { color:var(--text-dim); font-size:.78rem; margin-top:10px; line-height:1.7; }
.pm-note b { color:var(--gd32-hi); font-weight:600; }
.pm-note code { font-family:var(--mono); color:var(--mem-hi); background:var(--bg-card); border-radius:4px; padding:0 5px; }
.pm-chain { list-style:none; margin:0 0 4px; padding:0; }
.pm-chain li { position:relative; padding:6px 0 6px 34px; font-size:.82rem; color:var(--text-dim); }
.pm-chain li b { color:var(--text); }
.pm-chain li::before { content:attr(data-n); position:absolute; left:0; top:5px; width:22px; height:22px;
border-radius:50%; background:var(--gd32-dim); color:var(--gd32-hi); font-size:.72rem; font-weight:700;
display:flex; align-items:center; justify-content:center; border:1px solid rgba(252,191,63,.4); }
.pm-chain li + li::after { content:""; position:absolute; left:10px; top:-14px; height:14px;
border-left:1px dashed var(--border-strong); }
</style>
</head>
<body>
<div class="container">
<!-- ====== HEADER ====== -->
<header class="header-bar">
<div class="header-brand">
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/><path d="M8 2h8" opacity=".5"/>
</svg>
</span>
<div class="brand-text">
<strong>LAN9252 电源管理台</strong>
<span class="brand-sub">EtherCAT ESC · D1/D2/D3 休眠 · DCU_APP ≥1.6.23</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">连接中…</span></span>
<span class="pill" id="pillIp">IP --</span>
</div>
</header>
<!-- ====== MAIN ====== -->
<div class="main-layout">
<!-- 左列:操作 -->
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🌙</span> 进入低功耗档</div>
<div class="card-body">
<div class="pm-vbtn">
<button class="btn btn-primary" id="btnD1">进 D1 (31)</button>
<button class="btn btn-primary" id="btnD2">进 D2 (32)</button>
<button class="btn btn-danger" id="btnD3">进 D3 (33)</button>
</div>
<div class="pm-note">
<b>D1</b> 系统时钟关、晶振+PLL 保持(有自动唤醒路径)。<br>
<b>D2</b> PLL 请求关(两 PHY 已掉电才真关)。<br>
<b>D3</b> 晶振/PLL 全关,最省电、<b>只能手动唤醒</b>(二次确认)。<br>
<b>休眠期间 EtherCAT 中断、主站 wkc 报错属预期</b>;ESC 寄存器配置全保留,唤醒后自清睡眠位。
D3 进入前固件自动掉电两 PHY,唤醒后自动恢复(自协商需数秒)。
</div>
</div>
</div>
<div class="card card--gd32">
<div class="card-head"><span class="ico">🛟</span> 恢复链 —— 为什么睡不死</div>
<div class="card-body">
<ul class="pm-chain">
<li data-n="1"><b>action 34 软唤醒</b>(正常路径,≤2ms)</li>
<li data-n="2"><b>NRST 硬复位</b>:PB10 拉低 60ms 释放,等效上电复位,覆盖 D3 睡死/HBI 假死等一切软件状态</li>
<li data-n="3"><b>GD32 断电重启</b>:复位期间 PB10 浮空由 R44 上拉维持,重启后固件 hardwareReset() 自动重走受控复位</li>
</ul>
<div class="btn-row" style="margin-top:8px">
<button class="btn btn-danger" id="btnNrst" style="width:100%">🛟 ECAT_NRST 硬复位 LAN9252</button>
</div>
<div class="pm-note">
<b>可靠性</b>:ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),芯片睡得再深
<code>0xA2</code> 照样送达 GD32——恢复失败的唯一前提是 ESP32 和 GD32 同时失能,那已不是休眠问题。<br>
NRST 复位会清掉 SSC 已配置的芯片状态,EtherCAT 需主站重新走状态机。
</div>
</div>
</div>
<div class="card card--status">
<div class="card-head"><span class="ico">📋</span> status 字节判读(recv 首字节)</div>
<div class="card-body">
<table class="pm-table">
<tr><th>位/值</th><th>含义</th></tr>
<tr><td><code>bit0</code></td><td>READY:1=D0 且 PLL 稳定</td></tr>
<tr><td><code>bit1</code></td><td>PM_SLEEP_EN:1=低功耗档</td></tr>
<tr><td><code>bit3:2</code></td><td>PM_MODE 寄存器位(仅 SLEEP_EN=1 时有效;未睡眠可能残留旧档)</td></tr>
<tr><td><code>0xEE</code></td><td>无效 action</td></tr>
<tr><td><code>0xE1</code></td><td>HBI 未就绪(勿操作电源)</td></tr>
<tr><td><code>唤醒 1/0</code></td><td>成功 / 10ms 超时</td></tr>
</table>
</div>
</div>
</div>
<!-- 右列:观测 -->
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🔋</span> 芯片电源状态 <span style="margin-left:auto;font-size:.68rem;color:var(--text-label);font-weight:400">2s 自动刷新</span></div>
<div class="card-body">
<div class="pm-sleep-banner" id="sleepBanner">🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复</div>
<div class="pm-grid">
<div class="pm-cell"><span class="k">READY</span><span class="v v-dim" id="stReady">--</span></div>
<div class="pm-cell"><span class="k">SLEEP_EN</span><span class="v v-dim" id="stSleep">--</span></div>
<div class="pm-cell"><span class="k">电源档</span><span class="v v-dim" id="stMode">--</span></div>
<div class="pm-cell"><span class="k">原始字节</span><span class="v v-dim" id="stRaw">--</span></div>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-outline" id="btnStatus">读状态 (30)</button>
<button class="btn btn-success" id="btnWake">⏻ 手动唤醒 (34)</button>
</div>
<div class="pm-note">唤醒轮询上限 10ms(手册标称 ≤2ms)。超时多半是芯片不在睡眠或 HBI 异常——以「读状态」为准。</div>
</div>
</div>
<div class="card card--mem">
<div class="card-head"><span class="ico">📜</span> 操作日志</div>
<div class="card-body" style="padding-top:10px">
<div class="pm-log" id="log"></div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var logBox = $("log");
/* 与 js/common/base-url.js 同 key 同默认值:全局唯一设备 IP,不设独立连接区 */
var LS_IP = "dcu_esp_ip", DEFAULT_IP = "192.168.31.127";
function deviceIp() {
try { var s = localStorage.getItem(LS_IP); if (s && s.trim()) return s.trim(); } catch (e) {}
return DEFAULT_IP;
}
$("pillIp").textContent = "IP " + deviceIp();
function log(msg, cls) {
var d = document.createElement("div");
if (cls) d.className = cls;
var t = document.createElement("span");
t.className = "t";
t.textContent = "[" + new Date().toLocaleTimeString("zh-CN", { hour12: false }) + "] ";
d.appendChild(t);
d.appendChild(document.createTextNode(msg));
logBox.appendChild(d);
while (logBox.childNodes.length > 300) logBox.removeChild(logBox.firstChild);
logBox.scrollTop = logBox.scrollHeight;
}
/* 通用 0xA2 透传:actionHex 单字节 hex。recv hex 首字节 = status */
function sendA2(actionHex, timeoutMs) {
return fetch("http://" + deviceIp() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "A2", data: actionHex, timeout_ms: timeoutMs || 1500 }),
signal: AbortSignal.timeout((timeoutMs || 1500) + 2000),
}).then(function (r) {
return r.json().then(function (j) {
if (!r.ok || j.status !== "ok") throw new Error(j.message || ("HTTP " + r.status));
var hex = String(j.recv || "");
if (hex.length < 2) throw new Error("响应 payload 异常: " + JSON.stringify(hex));
return parseInt(hex.slice(0, 2), 16);
});
});
}
var MODES = ["D0", "D1", "D2", "D3"];
/* 有效电源档:SLEEP_EN=0 时芯片在 D0 工作(EtherCAT/READY 正常),
* PM_MODE 位可能残留上次进档写入的值(板级实测常停在 D2=0x09)。
* 只展示寄存器 MODE 会误导;有效档以 SLEEP_EN 为准。 */
function effectiveMode(b) {
if (b === 0xEE || b === 0xE1) return null;
if ((b & 2) === 0) return "D0";
return MODES[(b >> 2) & 3];
}
function rawMode(b) {
return MODES[(b >> 2) & 3];
}
function describe(b) {
if (b === 0xEE) return "无效 action (0xEE)";
if (b === 0xE1) return "HBI 未就绪 (0xE1),请确认 GD32 已启动且 ECAT init 通过";
var eff = effectiveMode(b), reg = rawMode(b);
var extra = ((b & 2) === 0 && reg !== "D0")
? "(寄存器 PM_MODE 残留 " + reg + ",未睡眠 → 有效 D0)" : "";
return "READY=" + (b & 1) + " SLEEP_EN=" + ((b >> 1) & 1) +
" 有效档=" + eff + " 寄存器MODE=" + reg + extra +
" (0x" + b.toString(16).toUpperCase().padStart(2, "0") + ")";
}
function setCell(id, text, cls) {
var e = $(id); e.textContent = text; e.className = "v " + cls;
}
function renderStatus(b) {
var ready = (b & 1) !== 0, sleep = (b & 2) !== 0;
var mode = effectiveMode(b) || MODES[(b >> 2) & 3];
setCell("stReady", ready ? "1" : "0", ready ? "v-ok" : "v-warn");
setCell("stSleep", sleep ? "1" : "0", sleep ? "v-warn" : "v-ok");
setCell("stMode", mode, mode === "D0" ? "v-ok" : "v-warn");
setCell("stRaw", "0x" + b.toString(16).toUpperCase().padStart(2, "0"), "v-dim");
$("sleepBanner").className = "pm-sleep-banner" + (sleep ? " show" : "");
}
/* 跨标签/重载保持睡眠:HBI 读会唤醒芯片,页面销毁后现场丢失会导致
* 切回时自动 poll(1E) 把睡眠叫醒。用 sessionStorage 记档位,重载时跳过自动读。 */
var SS_SLEEP = "lan9252_pm_asleep";
function saveSleepFlag(n) {
try { sessionStorage.setItem(SS_SLEEP, String(n)); } catch (e) {}
}
function clearSleepFlag() {
try { sessionStorage.removeItem(SS_SLEEP); } catch (e) {}
}
function loadSleepFlag() {
try {
var v = sessionStorage.getItem(SS_SLEEP);
if (v === "1" || v === "2" || v === "3") return parseInt(v, 10);
} catch (e) {}
return 0;
}
function renderAssumedSleep(n) {
/* 不读设备,按上次会话档位恢复 UI(芯片可能仍睡着) */
setCell("stReady", "--", "v-dim");
setCell("stSleep", "1", "v-warn");
setCell("stMode", "D" + n, "v-warn");
setCell("stRaw", "未读", "v-dim");
$("sleepBanner").textContent =
"🌙 上次会话处于 D" + n + " —— 已跳过自动状态读(避免 HBI 读唤醒)。点「手动唤醒」或「读状态」继续(读会唤醒)";
$("sleepBanner").className = "pm-sleep-banner show";
}
/* ---- 自动状态轮询:连接后 2s 一次;读到睡眠态即暂停(1.6.24 实测 HBI 读
* 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。
* pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */
var pollFail = 0, pollTimer = null, pollPaused = false, pollGen = 0;
function setConn(ok, text) {
var p = $("pillConn");
p.className = "pill " + (ok ? "online" : "offline");
$("pillConnTxt").textContent = text;
}
function bumpPollGen() { return ++pollGen; }
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
function markAwake() {
clearSleepFlag();
$("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复";
}
function pollOnce() {
if (pollPaused) return;
var gen = bumpPollGen();
sendA2("1E").then(function (b) {
if (gen !== pollGen || pollPaused) return; /* 过期响应 */
pollFail = 0;
setConn(true, "已连接");
renderStatus(b);
if ((b & 2) !== 0 && b !== 0xEE && b !== 0xE1) {
/* 睡眠态:本读已把它唤醒;暂停轮询等用户主动操作 */
pollPaused = true;
setConn(true, "睡眠中 · 自动刷新已暂停");
log("检测到睡眠态(本次读取已将其唤醒)。轮询暂停;点「读状态」或「唤醒」后恢复。", "warn");
} else {
if (b !== 0xEE && b !== 0xE1) markAwake();
pollTimer = setTimeout(pollOnce, 2000);
}
}).catch(function () {
if (gen !== pollGen || pollPaused) return;
pollFail++;
if (pollFail >= 3) {
setConn(false, "连接失败 · 5s 后重试");
pollTimer = setTimeout(probe, 5000);
} else {
pollTimer = setTimeout(pollOnce, 2000);
}
});
}
function resumePolling() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
pollPaused = false;
bumpPollGen();
pollTimer = setTimeout(pollOnce, 2000);
}
/* ESP32 可达性探测:只打 /system/status,不碰 LAN9252 HBI(睡眠恢复路径用) */
function probeEspOnly() {
setConn(false, "连接中…");
fetch("http://" + deviceIp() + "/system/status", { signal: AbortSignal.timeout(2500) })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "上次睡眠中 · 轮询已暂停");
log("ESP32 可达: " + deviceIp() + "(未读 LAN9252,保持睡眠)", "ok");
})
.catch(function (e) {
setConn(false, "连接失败 · 5s 后重试");
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
pollTimer = setTimeout(probeEspOnly, 5000);
});
}
function probe() {
setConn(false, "连接中…");
fetch("http://" + deviceIp() + "/system/status", { signal: AbortSignal.timeout(2500) })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "已连接");
log("设备可达: " + deviceIp(), "ok");
pollOnce();
})
.catch(function (e) {
setConn(false, "连接失败 · 5s 后重试");
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
pollTimer = setTimeout(probe, 5000);
});
}
function busy(btn, fn) {
btn.addEventListener("click", function () {
btn.disabled = true;
Promise.resolve().then(fn).catch(function (e) { log("失败: " + e.message, "err"); })
.then(function () { btn.disabled = false; });
});
}
/* 唤醒/NRST 后重拉 EtherCAT 主站 OP。
* 实测(2026-09-12):软唤醒后芯片 READY≠主站可进 OP——ESC SM/FMMU 在睡眠后失效,
* motor/start 卡在 SAFEOP AL=0x001E Invalid input configuration(偶发能过、多数失败),
* 电机页还会每 2s 探测重试把同一错误刷屏。
* 可靠路径只有:NRST 清 SSC → 等 EEPROM/ESC 启动 → motor/start 走 INIT→PREOP→SAFEOP→OP。
* 故唤醒后一律先 NRST,不再「先试 start 失败再 NRST」。 */
function ecatPost(path, body, timeoutMs) {
return fetch("/ecat/api/" + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 30000),
}).then(function (r) {
return r.json().then(function (j) {
return { ok: !!(j && j.ok), log: (j && j.log) || [], json: j };
});
});
}
function ecatMotorStart(timeoutMs) {
return ecatPost("motor/start", {}, timeoutMs || 30000);
}
function ecatEnsureOpen() {
/* bus_open=0 时 motor/start 直接回「未连接」。与电机页 ensureOpen 同源。 */
return ecatPost("native/health", {}, 6000).then(function (h) {
var open = h.json && h.json.bus_open;
if (open) return { ok: true, log: ["总线已 open"], json: h.json };
var adapter = "I210";
try { adapter = localStorage.getItem("dcu_ecat_adapter") || "I210"; } catch (e) {}
log("总线未 open,调用 /api/open (" + adapter + ") …");
return ecatPost("open", { substr: adapter }, 20000);
});
}
function nrstPulse() {
log("NRST 拉低 100ms …", "warn");
return sendA2("15").then(function () {
return new Promise(function (r) { setTimeout(r, 100); });
}).then(function () {
return sendA2("14");
}).then(function () {
/* EEPROM 加载 + HBI 探针:2s 不够时 start 会「重枚举没找到从站」 */
log("NRST 已释放,等待 ESC/EEPROM 启动 4s …");
return new Promise(function (r) { setTimeout(r, 4000); });
});
}
function recoverEcatMaster() {
log("重连 EtherCAT 主站:stop → open → NRST → motor/start …", "warn");
/* 先停在跑的 PDO 循环,避免与 NRST/init 抢总线 */
return ecatPost("motor/stop", {}, 8000).catch(function () { return { ok: true, log: ["stop 跳过"] }; })
.then(function (s) {
if (s && s.log && s.log.length) log("stop: " + s.log.join(";"));
return ecatEnsureOpen();
})
.then(function (op) {
if (!op.ok) log("open 失败: " + op.log.join(";"), "warn");
else log("open: " + op.log.join(";"), "ok");
return nrstPulse();
})
.then(function () {
return sendA2("1E");
})
.then(function (b) {
log("复位后状态: " + describe(b));
renderStatus(b);
markAwake();
return ecatMotorStart(30000);
})
.then(function (r) {
if (r.ok) {
log("主站已进 OP:" + r.log.join(";"), "ok");
setConn(true, "已连接");
resumePolling();
return r;
}
log("motor/start 失败:" + r.log.join(";") + " → 再 stop+open+NRST 重试", "err");
return ecatPost("motor/stop", {}, 8000)
.then(function () { return ecatEnsureOpen(); })
.then(function () { return nrstPulse(); })
.then(function () { return ecatMotorStart(30000); })
.then(function (r2) {
if (r2.ok) {
log("第二次重试主站已进 OP:" + r2.log.join(";"), "ok");
setConn(true, "已连接");
} else {
log("两次后仍失败:" + r2.log.join(";") + "。请查网线/供电,或到电机页手动连接。", "err");
}
resumePolling();
return r2;
});
})
.catch(function (e) {
log("重连主站异常: " + e.message + "(ecat_native 未起?)", "err");
resumePolling();
return { ok: false, log: [e.message] };
});
}
busy($("btnStatus"), function () {
return sendA2("1E").then(function (b) {
renderStatus(b);
log("状态: " + describe(b));
if ((b & 2) === 0 && b !== 0xEE && b !== 0xE1) markAwake();
resumePolling();
});
});
busy($("btnWake"), function () {
log("手动唤醒(HBI 写 BYTE_TEST)…");
bumpPollGen();
clearSleepFlag();
return sendA2("22").then(function (b) {
if (b === 1) {
log("唤醒成功: " + describe(b), "ok");
renderStatus(b);
markAwake();
setConn(true, "已连接");
/* 芯片 READY ≠ 主站 OP;必须 NRST 再 start,否则 SAFEOP AL=0x001E */
return recoverEcatMaster();
} else if (b === 0xEE || b === 0xE1) {
log("设备拒绝: " + describe(b), "err");
renderStatus(b);
resumePolling();
} else {
log("唤醒超时(status=0x" + b.toString(16) + "),改走 NRST 硬复位恢复", "err");
renderStatus(b);
return recoverEcatMaster();
}
});
});
function enterMode(n, hex) {
log("进入 D" + n + " …" + (n === 3 ? "(先掉电两 PHY)" : ""));
/* 作废在途 1E,防止旧 poll 响应把睡眠 UI 打回 D0 */
bumpPollGen();
return sendA2(hex).then(function (b) {
if (b === 0xEE || b === 0xE1) {
log("设备拒绝: " + describe(b), "err");
renderStatus(b);
return;
}
log("已进入 D" + n + "(固件置信返回,未读回)。", "warn");
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
pollPaused = true;
setConn(true, "睡眠中 · 轮询已暂停");
saveSleepFlag(n);
renderStatus(b);
/* 固件返回可能是新 status 或旧字节;强制按 D{n} 展示并记住 */
setCell("stSleep", "1", "v-warn");
setCell("stMode", "D" + n, "v-warn");
$("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复(切页回来会保持此睡眠态)";
$("sleepBanner").className = "pm-sleep-banner show";
});
}
busy($("btnD1"), function () { return enterMode(1, "1F"); });
busy($("btnD2"), function () { return enterMode(2, "20"); });
/* D3 二次确认:3 秒内再点一次才执行 */
(function () {
var armed = false, timer = null;
$("btnD3").addEventListener("click", function () {
if (!armed) {
armed = true;
$("btnD3").classList.add("btn-armed");
$("btnD3").textContent = "⚠ 再点一次确认进 D3";
timer = setTimeout(function () { disarm(); }, 3000);
return;
}
clearTimeout(timer); disarm();
$("btnD3").disabled = true;
enterMode(3, "21").catch(function (e) { log("失败: " + e.message, "err"); })
.then(function () { $("btnD3").disabled = false; });
});
function disarm() {
armed = false;
$("btnD3").classList.remove("btn-armed");
$("btnD3").textContent = "进 D3 (33)";
}
})();
/* NRST 硬复位:0x15 拉低 → 100ms → 0x14 释放 → 等 2s → 重拉主站 */
busy($("btnNrst"), function () {
bumpPollGen();
clearSleepFlag();
return recoverEcatMaster();
});
/* 启动:若上次会话记着睡眠档,跳过自动 1E(会唤醒),只探 ESP32 可达性 */
var restoredSleep = loadSleepFlag();
if (restoredSleep) {
pollPaused = true;
renderAssumedSleep(restoredSleep);
setConn(true, "上次睡眠中 · 轮询已暂停");
log("恢复上次会话:D" + restoredSleep + " 睡眠中。为避免 HBI 读唤醒芯片,已跳过自动状态刷新;点「读状态」或「手动唤醒」继续。", "warn");
probeEspOnly();
} else {
log("就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。", "warn");
probe();
}
})();
</script>
</body>
</html>
+902
View File
@@ -0,0 +1,902 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DCU EtherCAT PDO · 字节布局 1.6.21 · 固件 1.6.24</title>
<style>
:root{
--bg:#070b10; --panel:#101820; --panel2:#141e28;
--line:#1c2834; --line2:#2a3a4a;
--ink:#d8e4f0; --dim:#6d8299; --dim2:#4a5d72;
--cyan:#00d4a0; --blue:#4c9aff; --amber:#f0b429; --violet:#a78bfa;
--rose:#ff5c7a; --slate:#8b9cb0;
--mono:"Cascadia Code","Cascadia Mono","Consolas",monospace;
--sans:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
}
*{box-sizing:border-box}
body{
margin:0;padding:0 0 72px;
background:
radial-gradient(1100px 500px at 8% -8%, rgba(0,212,160,.07), transparent 50%),
radial-gradient(800px 420px at 95% 0%, rgba(76,154,255,.05), transparent 45%),
var(--bg);
color:var(--ink);
font:14px/1.7 var(--sans);
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1120px;margin:0 auto;padding:0 28px}
/* header */
header{
border-bottom:1px solid var(--line);
background:rgba(7,11,16,.92);
backdrop-filter:blur(10px);
position:sticky;top:0;z-index:40;
}
.hd-in{
max-width:1120px;margin:0 auto;padding:14px 28px;
display:flex;align-items:center;gap:20px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:12px}
.brand h1{margin:0;font-size:18px;font-weight:650;letter-spacing:.2px}
.ver{
font:650 11px/1 var(--mono);color:#07120f;background:var(--cyan);
padding:5px 8px;border-radius:5px;
}
.sub{color:var(--dim);font-size:12.5px}
.stats{display:flex;gap:8px;margin-left:auto;flex-wrap:wrap}
.stat{
padding:6px 12px;border:1px solid var(--line);border-radius:7px;
background:var(--panel);min-width:86px;
}
.stat b{display:block;font:650 14px/1.3 var(--mono)}
.stat span{display:block;font-size:10.5px;color:var(--dim);letter-spacing:.3px}
/* sections */
section{padding:40px 0 4px}
.sec-head{
display:flex;align-items:center;gap:12px;
margin:0 0 18px;padding-bottom:10px;border-bottom:1px solid var(--line);
}
.sec-no{
font:700 11px/1 var(--mono);color:#07120f;background:var(--cyan);
width:24px;height:24px;border-radius:50%;display:grid;place-items:center;
}
.sec-head h2{margin:0;font-size:17px;font-weight:650}
.sec-head .tagline{margin-left:auto;color:var(--dim);font:12px var(--mono)}
/* cards */
.card{
background:var(--panel);border:1px solid var(--line);border-radius:12px;
overflow:hidden;margin-bottom:16px;
}
.card-hd{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
padding:12px 16px;border-bottom:1px solid var(--line);background:var(--panel2);
}
.card-hd h3{margin:0;font-size:13.5px;font-weight:650}
.card-hd .meta{margin-left:auto;color:var(--dim);font:11.5px var(--mono)}
.card-bd{padding:16px}
/* callout */
.callout{
border:1px solid var(--line);border-left:3px solid var(--cyan);
border-radius:10px;background:var(--panel);
padding:14px 16px;margin:0 0 16px;font-size:13.5px;
}
.callout.warn{border-left-color:var(--amber)}
.callout.danger{border-left-color:var(--rose)}
.callout h4{margin:0 0 8px;font-size:13px;font-weight:650;color:var(--cyan)}
.callout.warn h4{color:var(--amber)}
.callout.danger h4{color:var(--rose)}
.callout ul{margin:8px 0 0;padding-left:20px}
.callout li{margin:5px 0}
.callout p{margin:0 0 8px}
.callout p:last-child{margin:0}
/* pills / legend */
.pills{display:flex;gap:8px;flex-wrap:wrap;margin:22px 0 18px}
.pill{
font:11.5px var(--mono);color:var(--dim);
border:1px solid var(--line);border-radius:99px;
padding:5px 11px;background:var(--panel);
}
.pill b{color:var(--ink);font-weight:650}
.legend{
display:flex;flex-wrap:wrap;gap:10px 18px;
margin:0 0 18px;font-size:12px;color:var(--dim);
}
.legend i{
display:inline-block;width:11px;height:11px;border-radius:3px;
margin-right:6px;vertical-align:-1px;
}
/* svg maps */
.map-wrap{margin:0 0 8px}
.map-wrap + .map-wrap{margin-top:14px}
.map-wrap svg{display:block;width:100%;height:auto}
.seg-label{
font:600 11px var(--mono);color:var(--dim);
margin:18px 0 8px;letter-spacing:.2px;
}
.seg-label:first-child{margin-top:0}
/* field bar (HTML, 防 SVG 叠压) */
.fbar{
display:flex;gap:2px;height:52px;
border:1px solid var(--line);border-radius:6px;overflow:hidden;
background:#0a1218;
}
.fcell{
position:relative;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2px;
min-width:0;padding:4px 2px;overflow:hidden;
border-right:1px solid rgba(0,0,0,.45);
}
.fcell:last-child{border-right:0}
.fcell .fn{
font:650 11px/1.2 var(--mono);color:var(--ink);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;
}
.fcell .fo{
font:10px/1.2 var(--mono);color:var(--dim);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;
}
.fcell.narrow .fn{font-size:10px}
.fcell.pad .fn,.fcell.pad .fo{color:var(--dim2)}
.ruler{
display:flex;gap:2px;margin-top:4px;
font:10px var(--mono);color:var(--dim2);
}
.ruler span{min-width:0;overflow:hidden;white-space:nowrap}
/* tables in cards */
.tbl-card{margin-top:0}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{
border-bottom:1px solid var(--line);
padding:9px 12px;text-align:left;vertical-align:top;
}
th{
color:var(--dim);font-weight:600;font-size:11px;
letter-spacing:.4px;text-transform:uppercase;
background:var(--panel2);
}
td.n,th.n{text-align:right;font-family:var(--mono);white-space:nowrap}
td.mono{font-family:var(--mono);white-space:nowrap;color:var(--dim)}
tbody tr:hover td{background:rgba(0,212,160,.035)}
tbody tr:last-child td{border-bottom:0}
tfoot td{font-weight:650;background:var(--panel2);border-bottom:0}
.dot{
display:inline-block;width:9px;height:9px;border-radius:2px;
margin-right:8px;vertical-align:0;
}
.chip{
display:inline-block;padding:2px 8px;border-radius:99px;
font:600 11px/1.5 var(--mono);
}
.chip.fix{background:rgba(255,92,122,.12);color:var(--rose)}
.chip.adp{background:rgba(0,212,160,.12);color:var(--cyan)}
.chip.hdr{background:rgba(240,180,41,.12);color:var(--amber)}
.chip.gone{background:rgba(167,139,250,.12);color:var(--violet)}
.chip.note{background:rgba(139,156,176,.12);color:var(--slate)}
code{
font-family:var(--mono);font-size:12px;
background:#0a1218;padding:1px 6px;border-radius:3px;border:1px solid var(--line);
}
pre{
margin:10px 0 0;padding:12px 14px;
background:#0a1218;border:1px solid var(--line);border-radius:8px;
overflow-x:auto;font:12px/1.6 var(--mono);color:var(--ink);
}
pre code{background:none;border:0;padding:0}
/* cost */
.cost-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:0 0 16px;
}
@media(max-width:800px){.cost-grid{grid-template-columns:1fr}}
.cost-card{
background:var(--panel);border:1px solid var(--line);border-radius:10px;
padding:14px 16px;
}
.cost-card .lbl{font-size:11px;color:var(--dim);letter-spacing:.4px;text-transform:uppercase}
.cost-card .val{font:650 32px/1.2 var(--mono);margin:6px 0 2px}
.cost-card .unit{font-size:12.5px;color:var(--dim)}
.cost-card .bar{height:5px;border-radius:3px;background:var(--line);margin-top:12px;overflow:hidden}
.cost-card .bar i{display:block;height:100%;border-radius:3px}
.cost-card.c-cyan .val{color:var(--cyan)}
.cost-card.c-cyan .bar i{background:var(--cyan)}
.cost-card.c-blue .val{color:var(--blue)}
.cost-card.c-blue .bar i{background:var(--blue)}
.cost-card.c-amber .val{color:var(--amber)}
.cost-card.c-amber .bar i{background:var(--amber)}
footer{
margin-top:48px;padding-top:18px;border-top:1px solid var(--line);
color:var(--dim);font-size:12px;
}
footer code{font-size:11px}
@media(max-width:720px){
.wrap,.hd-in{padding-left:16px;padding-right:16px}
.stats{width:100%;margin-left:0}
.fbar{height:46px}
.fcell .fn{font-size:10px}
}
</style>
</head>
<body>
<header>
<div class="hd-in">
<div class="brand">
<h1>DCU EtherCAT PDO 布局</h1>
<span class="ver">布局 1.6.21</span>
<span class="sub">固件 1.6.24 · 字段级 · 稀疏写</span>
</div>
<div class="stats">
<div class="stat"><b>384 B</b><span>输出 SM2</span></div>
<div class="stat"><b>600 B</b><span>输入 SM3</span></div>
<div class="stat"><b>96–600 B</b><span>稀疏写实占</span></div>
<div class="stat"><b>40 B</b><span>DPRAM 余量</span></div>
</div>
</div>
</header>
<div class="wrap">
<div class="pills">
<span class="pill">真源 <b>ethercat_can_bridge.h</b></span>
<span class="pill">尺寸 <b>ethercat_pdo_size.h</b></span>
<span class="pill">PRAM <b>0.441 µs/B</b></span>
<span class="pill">魔数 <b>@35=0xA5</b></span>
<span class="pill">电机槽 <b>≤9 × 56 B</b></span>
<span class="pill">稀疏写 <b>CONFIG=1</b></span>
</div>
<div class="callout">
<h4>布局 1.6.21 · 固件 1.6.24 · 写侧 1.6.22 起稀疏</h4>
<ul>
<li><b>字节布局仍是 1.6.21</b>:段 2 密排、删段 4、魔数 <code>@35=0xA5</code>;out 384 + in 600 不变,SII/EEPROM 无需重刷。</li>
<li><b>1.6.22 稀疏写 ESC</b>(<code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>):按 <code>count</code> 只写「段1+段2有效区」与「段3+段5」,跳过空槽区。主站解码不变,仍只按 count 迭代。</li>
<li><b>1.6.23/1.6.24</b> 为 LAN9252 电源管理诊断(pmDrive D1/D2/D3),不影响 PDO 布局。</li>
<li>回退整包 600B 的条件:开关置 0 / magic≠0xA5 / count 越界 / count 撑满(9 电机两段相接)。</li>
</ul>
</div>
<div class="legend">
<span><i style="background:var(--blue)"></i>桥状态 / 快照</span>
<span><i style="background:var(--amber)"></i>段头 / 元数据</span>
<span><i style="background:var(--cyan)"></i>电机密排槽</span>
<span><i style="background:var(--violet)"></i>批量发送区</span>
<span><i style="background:var(--rose)"></i>诊断</span>
<span><i style="background:var(--slate)"></i>保留 / 填充</span>
</div>
<!-- ═══ 输入 PDO ═══ -->
<section id="in">
<div class="sec-head">
<span class="sec-no">1</span>
<h2>输入 PDO · 600 B · 从站 → 主站</h2>
<span class="tagline">稀疏写 96–600 B · 整包 ≈265 µs · 3 电机 ≈116 µs</span>
</div>
<div class="card">
<div class="card-hd">
<h3>段总览</h3>
<span class="meta">SM3 · 0x1880–0x1F67 · 3-buffer · 写侧稀疏</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="in-overview"></div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h3>段内字段</h3>
<span class="meta">每段独占全宽 · 宽度 ∝ 字节数</span>
</div>
<div class="card-bd" id="in-fields"></div>
</div>
<div class="card tbl-card">
<div class="card-hd"><h3>字段明细</h3></div>
<table>
<thead>
<tr>
<th>段 / 字段</th><th class="n">偏移</th><th class="n">宽</th>
<th>类型</th><th>内容</th><th>属性</th>
</tr>
</thead>
<tbody id="in-tbody"></tbody>
<tfoot>
<tr>
<td colspan="2">合计</td>
<td class="n">600</td>
<td></td>
<td>逻辑总长;写侧按 count 稀疏(见下)</td>
<td class="n">96–600 B</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- ═══ 输出 PDO ═══ -->
<section id="out">
<div class="sec-head">
<span class="sec-no">2</span>
<h2>输出 PDO · 384 B · 主站 → 从站</h2>
<span class="tagline">pramRead(384) · 96 DWORD · ≈169 µs 折算</span>
</div>
<div class="card">
<div class="card-hd">
<h3>段总览</h3>
<span class="meta">SM2 · 0x1400–0x187F · WIN@8 忽略</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="out-overview"></div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h3>段内字段</h3>
<span class="meta">每段独占全宽 · 宽度 ∝ 字节数</span>
</div>
<div class="card-bd" id="out-fields"></div>
</div>
<div class="card tbl-card">
<div class="card-hd"><h3>字段明细</h3></div>
<table>
<thead>
<tr>
<th>段 / 字段</th><th class="n">偏移</th><th class="n">宽</th>
<th>类型</th><th>内容</th><th>属性</th>
</tr>
</thead>
<tbody id="out-tbody"></tbody>
<tfoot>
<tr>
<td colspan="2">合计</td>
<td class="n">384</td>
<td></td>
<td>每拍一次 <code>pramRead(384)</code>(未实测)</td>
<td class="n">≈169 µs</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- ═══ 电机槽 ═══ -->
<section id="slot">
<div class="sec-head">
<span class="sec-no">3</span>
<h2>电机槽内部 · 56 B × ≤9</h2>
<span class="tagline">PDO 地址 = 40 + i×56 · node_id 升序</span>
</div>
<div class="card">
<div class="card-hd">
<h3>密排槽区 @40..543</h3>
<span class="meta">空节点不占位 · count 决定有效槽数</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="slot-strip"></div>
<div class="seg-label">单槽 56 B 字段</div>
<div id="slot-fields"></div>
</div>
</div>
<div class="card">
<div class="card-hd"><h3>主站解码</h3></div>
<div class="card-bd">
<pre><code>count = in[32]; // 0..9
magic = in[35]; // 必须 0xA5
for (i = 0; i &lt; count; i++) {
p = in + 40 + i*56;
node = p[0]; ch = p[1]; online = p[2];
seq = le32(p+4);
status = p+8; // 48 B
}</code></pre>
<p style="margin:14px 0 0;color:var(--dim);font-size:13px">
同号多通道取 <b style="color:var(--ink)">seq 最新</b>的一条;写侧 seqlock 奇数(写入中)的槽本拍省略、下拍自愈。
节点集合与各 seq/online 未变时整段复用影子区(CPU 差分),<b style="color:var(--ink)">PRAM 写侧仍按 count 决定字节数</b>。
<b style="color:var(--amber)">1.6.22+ 稀疏写</b>:ESC 中 <code>40+count×56 .. 543</code> 为上次残留,
主站<b style="color:var(--ink)">只许按 count 迭代</b>,不得读空洞区。
</p>
</div>
</div>
<div class="card tbl-card">
<div class="card-hd"><h3>单槽字段明细</h3></div>
<table>
<thead>
<tr><th>字段</th><th class="n">槽内偏移</th><th class="n">宽</th><th>类型</th><th>内容</th></tr>
</thead>
<tbody id="slot-tbody"></tbody>
<tfoot>
<tr>
<td colspan="2">单槽合计</td><td class="n">56</td>
<td colspan="2">8 B 头 + 48 B STATUS</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- ═══ 成本 ═══ -->
<section id="cost">
<div class="sec-head">
<span class="sec-no">4</span>
<h2>每拍成本与 SM 预算</h2>
<span class="tagline">PRAM ≈0.441 µs/B · 稀疏写 1.6.22+</span>
</div>
<div class="cost-grid">
<div class="cost-card c-cyan">
<div class="lbl">输入写侧 · 3 电机稀疏</div>
<div class="val">116</div>
<div class="unit">µs/拍 · 264 B×0.441 · 11.6% @1ms</div>
<div class="bar"><i style="width:11.6%"></i></div>
</div>
<div class="cost-card c-blue">
<div class="lbl">输入写侧 · 整包基线</div>
<div class="val">265</div>
<div class="unit">µs/拍 · 600×0.441 · 26.5% @1ms</div>
<div class="bar"><i style="width:26.5%"></i></div>
</div>
<div class="cost-card c-amber">
<div class="lbl">输出读侧 · 探针命中</div>
<div class="val">12</div>
<div class="unit">B 探针 · 无沿时跳过 384B 全量读</div>
<div class="bar"><i style="width:5%"></i></div>
</div>
</div>
<div class="callout">
<h4>1.6.22 稀疏写 ESC · 写侧字节随 count 缩</h4>
<p><code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>(默认开)。<code>HW_EscWriteIsr</code> 只写两段:</p>
<pre><code>段A = [0 .. 40+count×56) // 段1 桥状态 + 段2 头 + 有效密排槽
段B = [544 .. 600) // 段3 长帧 + 段5 诊断 + 保留
跳过 = [40+count×56 .. 544) // 空槽区(ESC 中为 SM 缓冲上次残留)
count=0 → 96 B ≈ 42 µs
count=3 → 264 B ≈ 116 µs
count=8 → 544 B ≈ 240 µs
count=9 → 整包 600 B(两段相接,回退)</code></pre>
<p style="margin-top:10px;color:var(--dim);font-size:12.5px">
APPL 仍整填 <code>aPdInputData</code>(对齐 SDO 0x6000);省的是 PRAM 搬运时间,不是 RAM 拷贝。
主站必须继续只按 <code>count</code> 迭代——空洞区内容是上次残留,不可读。
</p>
</div>
<div class="callout warn">
<h4>实测锚点:从站自报负载</h4>
<p><b>1 ms 档 49.2% → 0.5 ms 档 81.4% → 0.25 ms 档 98.7%</b>,同时 λ 从 6000 帧/s 塌到 317 帧/s —— 饱和发生在从站,不在主站的 Npcap 或钉核上。</p>
<p style="color:var(--dim);font-size:12.5px">
上表为 1.6.21 整包基线。1.6.22 稀疏写开启后写侧字节随 count 缩,同工况 load_perm 应显著下降;
验收看 0x2000 sub10(EscWrite 周期)与 load_perm,同工况开关各测一轮。
</p>
</div>
<div class="card">
<div class="card-hd">
<h3>DPRAM 预算 · out + in ≤ 1024 B</h3>
<span class="meta">LAN9252 · 过程区 0x1400–0x1FFF · 3-buffer</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="dpram-map"></div>
<p style="margin:14px 0 0;color:var(--dim);font-size:12.5px">
邮箱 SM0/SM1 各 256 B 固定占 0x1000–0x13FF;SM2/SM3 均为 3-buffer(control 0x64/0x20,bit1=0),实占 (out+in)×3。
要再大只有两条路:邮箱压小(上限约 1280 B,代价 SDO/FoE 变慢)或改 1-buffer(上限 3072 B,代价失去三缓冲并发保护)。
</p>
</div>
</div>
</section>
<!-- ═══ 段4 删除 ═══ -->
<section id="seg4">
<div class="sec-head">
<span class="sec-no">5</span>
<h2>段 4 历史覆盖窗口 · 已删除</h2>
<span class="tagline">1.6.15 引入 → 1.6.21 删除</span>
</div>
<div class="callout danger">
<h4>为什么删</h4>
<p>1.6.15 曾把批量接收区做成「定长覆盖窗口」:5 个固定格子(每格 44 B = 12 B 头 + 32 B 数据)+ 写游标绕圈。它是「最近 5 帧」而不是队列 —— 第 6 帧盖掉第 1 帧,未取走的计入 <code>OVF</code>。</p>
<ul>
<li>覆盖窗口反复重发最近几条非 STATUS 帧,属于「历史帧 / 重复帧」,对电机状态视图是噪声;</li>
<li>用户实测病理:接 3 个电机,网页却看到 5 个状态帧 —— 旧段 2 固定 5 槽里空槽 <code>node_id=0</code> 也占位,主站不滤空槽就多报。</li>
</ul>
<p style="margin-top:10px">本版语义:<b>每一拍只携带「总线上已识别电机的完整一轮状态」</b>。版本/CONFIG 仍走段 1/段 3「最近一帧」快照。对象字典 sub21 恒 0;<code>596..599</code> 保留空洞。</p>
</div>
</section>
<footer>
布局真源 <code>comm/ethercat_can_bridge.h</code>(1.6.21)· 尺寸 <code>comm/ethercat_pdo_size.h</code> ·
固件版本 <code>config.h APP_FW_VERSION = 1.6.24</code> ·
稀疏写 <code>ethercat_pdo.cpp sparseEscWritePlan</code> + <code>ecatappl.c PDO_InputMapping</code> ·
成本系数 <code>0.441 µs/B</code> · 布局魔数 <code>kEcatMotLayoutMagic = 0xA5</code>
</footer>
</div>
<script>
const C = {
blue:'#4c9aff', amber:'#f0b429', cyan:'#00d4a0',
violet:'#a78bfa', rose:'#ff5c7a', slate:'#8b9cb0',
dim:'#6d8299', line:'#1c2834', ink:'#d8e4f0',
};
/* ── 段定义(总览用)── */
const IN_SEGS = [
{name:'段1 桥状态', off:0, len:32, color:C.blue},
{name:'段2 头', off:32, len:8, color:C.amber},
{name:'段2 密排槽', off:40, len:504, color:C.cyan},
{name:'段3 长帧', off:544, len:40, color:C.blue},
{name:'段5 诊断', off:584, len:12, color:C.rose},
{name:'保留', off:596, len:4, color:C.slate},
];
const OUT_SEGS = [
{name:'段1 单帧发送', off:0, len:48, color:C.blue},
{name:'段2 批量发送', off:48, len:336, color:C.violet},
];
/* ── 字段(按段分组)── */
const IN_GROUPS = [
{title:'段 1 · 桥状态 0..31 · 32 B', total:32, fields:[
{off:0,len:1,name:'ack',color:C.blue,type:'u8',txt:'回显已执行的发送序号',attr:'固定'},
{off:1,len:1,name:'ch',color:C.blue,type:'u8',txt:'该帧来自哪个通道 0/1/2',attr:'固定'},
{off:2,len:1,name:'dlc',color:C.blue,type:'u8',txt:'原始 DLC(截断前)',attr:'固定'},
{off:3,len:1,name:'flags',color:C.blue,type:'u8',txt:'bit0=有过接收;bit1=截断到 8B',attr:'固定'},
{off:4,len:4,name:'id',color:C.blue,type:'u32le',txt:'最近一帧 CAN ID',attr:'固定'},
{off:8,len:8,name:'data',color:C.blue,type:'u8[8]',txt:'短快照(长帧见段 3)',attr:'固定'},
{off:16,len:4,name:'rx_count',color:C.blue,type:'u32le',txt:'累计接收帧数',attr:'固定'},
{off:20,len:1,name:'txres',color:C.blue,type:'u8',txt:'最近发送结果(0=OK)',attr:'固定'},
{off:21,len:1,name:'backlog',color:C.blue,type:'u8',txt:'该通道软件 TX 队列积压',attr:'固定'},
{off:22,len:2,name:'txcnt',color:C.blue,type:'u16le',txt:'累计发送受理帧数',attr:'固定'},
{off:24,len:2,name:'drop',color:C.blue,type:'u16le',txt:'快照「未取先覆」次数',attr:'固定'},
{off:26,len:6,name:'pad',color:C.slate,type:'pad',txt:'段 1 尾部填充',attr:'保留'},
]},
{title:'段 2 · 头 32..39 · 8 B', total:8, fields:[
{off:32,len:1,name:'count',color:C.amber,type:'u8',txt:'本拍密排电机数 0..9',attr:'头部'},
{off:33,len:1,name:'active',color:C.amber,type:'u8',txt:'在线电机数(可与 count 不同)',attr:'头部'},
{off:34,len:1,name:'boot',color:C.amber,type:'u8',txt:'1=近 3s 收到过 0x103 BOOT',attr:'头部'},
{off:35,len:1,name:'magic',color:C.amber,type:'u8',txt:'恒 0xA5:密排标识',attr:'头部'},
{off:36,len:4,name:'gseq',color:C.amber,type:'u32le',txt:'状态表全局刷新序号',attr:'头部'},
]},
{title:'段 2 · 密排槽 40..543 · 最多 9×56 B', total:504, fields:[
{off:40,len:504,name:'slots[9]',color:C.cyan,type:'9×56B',txt:'密排:每槽 node/ch/online/dlc/seq + 48B STATUS;空节点不占位',attr:'密排'},
]},
{title:'段 3 · 长帧快照 544..583 · 40 B', total:40, fields:[
{off:544,len:1,name:'ch',color:C.blue,type:'u8',txt:'通道',attr:'固定'},
{off:545,len:1,name:'dlc',color:C.blue,type:'u8',txt:'原始 DLC(可达 32)',attr:'固定'},
{off:546,len:1,name:'flags',color:C.blue,type:'u8',txt:'bit0=有效',attr:'固定'},
{off:547,len:1,name:'rsvd',color:C.slate,type:'u8',txt:'保留',attr:'保留'},
{off:548,len:4,name:'id',color:C.blue,type:'u32le',txt:'CAN ID',attr:'固定'},
{off:552,len:32,name:'data',color:C.blue,type:'u8[32]',txt:'完整 payload(CONFIG / 版本帧)',attr:'固定'},
]},
{title:'段 5 · 诊断 584..595 · 12 B', total:12, fields:[
{off:584,len:2,name:'load',color:C.rose,type:'u16le',txt:'负载率 0.1%(1000=100%)',attr:'固定'},
{off:586,len:2,name:'torn',color:C.rose,type:'u16le',txt:'CAN 取帧撕裂丢帧数',attr:'固定'},
{off:588,len:2,name:'seq',color:C.rose,type:'u16le',txt:'诊断区序号,1s 窗口 +1',attr:'固定'},
{off:590,len:2,name:'RFO0',color:C.rose,type:'u16le',txt:'CAN RX FIFO 满溢计数',attr:'固定'},
{off:592,len:2,name:'relto',color:C.rose,type:'u16le',txt:'RFD0 释放握手超时计数',attr:'固定'},
{off:594,len:2,name:'reject',color:C.rose,type:'u16le',txt:'内容级拼接帧命中计数',attr:'固定'},
]},
{title:'保留 596..599 · 4 B', total:4, fields:[
{off:596,len:4,name:'—',color:C.slate,type:'pad',txt:'原段 4 空洞',attr:'保留'},
]},
];
const OUT_GROUPS = [
{title:'段 1 · 单帧发送 0..47 · 48 B', total:48, fields:[
{off:0,len:1,name:'seq',color:C.blue,type:'u8',txt:'变化沿触发一次发送',attr:'固定'},
{off:1,len:1,name:'ch',color:C.blue,type:'u8',txt:'目标通道 0/1/2',attr:'固定'},
{off:2,len:1,name:'dlc',color:C.blue,type:'u8',txt:'0..32;0=只发批量',attr:'固定'},
{off:3,len:1,name:'flags',color:C.blue,type:'u8',txt:'bit0=1 请求清零统计',attr:'固定'},
{off:4,len:4,name:'id',color:C.blue,type:'u32le',txt:'CAN ID(29 位扩展帧)',attr:'固定'},
{off:8,len:1,name:'win',color:C.slate,type:'u8',txt:'1.6.21 起忽略',attr:'忽略'},
{off:9,len:7,name:'pad',color:C.slate,type:'pad',txt:'对齐到 16B 边界',attr:'保留'},
{off:16,len:32,name:'data',color:C.blue,type:'u8[32]',txt:'一帧电机 CMD 数据区',attr:'固定'},
]},
{title:'段 2 · 批量发送 48..383 · 336 B', total:336, fields:[
{off:48,len:1,name:'count',color:C.violet,type:'u8',txt:'本次要发的帧数',attr:'头部'},
{off:49,len:1,name:'flags',color:C.violet,type:'u8',txt:'bit0=1 遇错即停',attr:'头部'},
{off:50,len:2,name:'rsvd',color:C.slate,type:'pad',txt:'帧数组 8B 对齐',attr:'保留'},
{off:52,len:332,name:'items[]',color:C.violet,type:'变长',txt:'每项 8B 头 + dlc 数据(CANFD ≤64B/帧)',attr:'批量'},
]},
];
const SLOT_FIELDS = [
{off:0,len:1,name:'node',color:C.cyan,type:'u8',txt:'节点号 1..9(密排下不会出现 0)',attr:'头部'},
{off:1,len:1,name:'ch',color:C.cyan,type:'u8',txt:'CAN 通道 0/1/2',attr:'头部'},
{off:2,len:1,name:'online',color:C.cyan,type:'u8',txt:'1=在线',attr:'头部'},
{off:3,len:1,name:'dlc',color:C.cyan,type:'u8',txt:'最后一帧 STATUS 的 DLC(正常 48)',attr:'头部'},
{off:4,len:4,name:'seq',color:C.amber,type:'u32le',txt:'STATUS 刷新序号',attr:'头部'},
{off:8,len:48,name:'status',color:C.blue,type:'u8[48]',txt:'原始 STATUS payload',attr:'固定'},
];
/* ── SVG helpers ── */
function svgEl(tag, attrs, text) {
const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
if (text != null) e.textContent = text;
return e;
}
/* 段总览:只画大块,标签宽敞 */
function drawOverview(container, segs, total) {
const W = 1000, pad = 6, barY = 28, barH = 56, H = barY + barH + 28;
const inner = W - pad * 2;
const svg = svgEl('svg', { viewBox:`0 0 ${W} ${H}`, role:'img' });
const x0 = b => pad + (b / total) * inner;
const xw = n => (n / total) * inner;
// ruler
const tick = total >= 400 ? 100 : 50;
for (let t = 0; t <= total; t += tick) {
const x = x0(t);
svg.appendChild(svgEl('line', { x1:x,y1:16,x2:x,y2:24, stroke:C.line,'stroke-width':1 }));
svg.appendChild(svgEl('text', {
x, y:12, 'text-anchor': t===0 ? 'start' : t===total ? 'end' : 'middle',
fill:C.dim, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, String(t)));
}
svg.appendChild(svgEl('line', { x1:pad,y1:24,x2:W-pad,y2:24, stroke:C.line,'stroke-width':1 }));
segs.forEach(s => {
const x = x0(s.off), w = xw(s.len);
const g = svgEl('g');
g.appendChild(svgEl('title', {}, `${s.name} @${s.off} · ${s.len} B`));
g.appendChild(svgEl('rect', {
x, y:barY, width:w, height:barH,
fill:s.color, 'fill-opacity':0.22,
stroke:s.color, 'stroke-width':1.2, rx:4
}));
g.appendChild(svgEl('rect', {
x, y:barY, width:w, height:5, fill:s.color, rx:2
}));
if (w >= 56) {
g.appendChild(svgEl('text', {
x:x+w/2, y:barY+barH/2-4, 'text-anchor':'middle', 'dominant-baseline':'central',
fill:C.ink, 'font-size': w>=120 ? 13 : 11,
'font-family':'Cascadia Code,Consolas,monospace', 'font-weight':650
}, s.name));
g.appendChild(svgEl('text', {
x:x+w/2, y:barY+barH/2+14, 'text-anchor':'middle',
fill:C.dim, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, `${s.off}..${s.off+s.len-1} · ${s.len}B`));
} else {
// 窄段:标签放在下方
g.appendChild(svgEl('line', {
x1:x+w/2, y1:barY+barH, x2:x+w/2, y2:barY+barH+8,
stroke:s.color, 'stroke-width':1
}));
g.appendChild(svgEl('text', {
x:x+w/2, y:barY+barH+20, 'text-anchor':'middle',
fill:s.color, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, `${s.name.replace('段','')}`));
}
svg.appendChild(g);
});
container.innerHTML = '';
container.appendChild(svg);
}
/* 段内字段条:HTML flex,宽度 ∝ 字节,不叠压 */
function drawFieldBar(host, group) {
const wrap = document.createElement('div');
wrap.style.marginBottom = '22px';
const label = document.createElement('div');
label.className = 'seg-label';
label.textContent = group.title;
wrap.appendChild(label);
const bar = document.createElement('div');
bar.className = 'fbar';
group.fields.forEach(f => {
const cell = document.createElement('div');
cell.className = 'fcell' + (f.len <= 2 ? ' narrow' : '') + (f.attr === '保留' || f.name === 'pad' || f.name === '—' || f.name === 'rsvd' ? ' pad' : '');
cell.style.flex = `${f.len} 1 0`;
cell.style.background = f.color;
cell.style.opacity = f.attr === '保留' || f.name === 'pad' || f.name === '—' || f.name === 'rsvd' ? '0.35' : '0.85';
cell.title = `${f.name} @${f.off} · ${f.len} B · ${f.type}\n${f.txt}`;
const fn = document.createElement('div');
fn.className = 'fn';
fn.textContent = f.name;
cell.appendChild(fn);
if (f.len >= 2 || group.total <= 16) {
const fo = document.createElement('div');
fo.className = 'fo';
fo.textContent = `@${f.off}`;
cell.appendChild(fo);
}
bar.appendChild(cell);
});
wrap.appendChild(bar);
// 底部偏移尺
const ruler = document.createElement('div');
ruler.className = 'ruler';
group.fields.forEach(f => {
const sp = document.createElement('span');
sp.style.flex = `${f.len} 1 0`;
sp.textContent = f.len >= 4 ? String(f.off) : '';
ruler.appendChild(sp);
});
wrap.appendChild(ruler);
host.appendChild(wrap);
}
function drawFieldGroups(hostId, groups) {
const host = document.getElementById(hostId);
host.innerHTML = '';
groups.forEach(g => drawFieldBar(host, g));
}
/* 槽区示意 */
function drawSlotStrip(container) {
const W = 1000, H = 70;
const svg = svgEl('svg', { viewBox:`0 0 ${W} ${H}`, role:'img' });
const pad = 4, gap = 6;
const n = 9;
const totalGaps = gap * (n - 1);
const slotW = (W - pad * 2 - totalGaps) / n;
svg.appendChild(svgEl('text', {
x:pad, y:14, fill:C.dim, 'font-size':11,
'font-family':'Cascadia Code,Consolas,monospace'
}, '9 个等宽槽 · 每槽 56 B · 密排后有效槽 = count · 空节点不占位'));
for (let i = 0; i < n; i++) {
const x = pad + i * (slotW + gap);
const g = svgEl('g');
g.appendChild(svgEl('title', {}, `slot[${i}] · 56 B`));
g.appendChild(svgEl('rect', {
x, y:26, width:slotW, height:34,
fill:C.cyan, 'fill-opacity': i===0 ? 0.4 : 0.14,
stroke:C.cyan, 'stroke-width': i===0 ? 1.6 : 0.8,
rx:4
}));
g.appendChild(svgEl('text', {
x:x+slotW/2, y:43, 'text-anchor':'middle', 'dominant-baseline':'central',
fill: i===0 ? C.ink : C.dim, 'font-size':12,
'font-family':'Cascadia Code,Consolas,monospace', 'font-weight': i===0 ? 650 : 400
}, i===0 ? 'slot 0' : `slot ${i}`));
svg.appendChild(g);
}
container.innerHTML = '';
container.appendChild(svg);
}
/* DPRAM 预算 */
function drawDpram(container) {
const W = 1000, H = 100;
const svg = svgEl('svg', { viewBox:`0 0 ${W} ${H}`, role:'img' });
const pad = 10, inner = W - pad * 2, total = 1024;
const y = 30, h = 36;
const x0 = b => pad + (b / total) * inner;
const xw = n => (n / total) * inner;
svg.appendChild(svgEl('text', {
x:pad, y:16, fill:C.dim, 'font-size':11,
'font-family':'Cascadia Code,Consolas,monospace'
}, '逻辑预算 (out+in) ≤ 1024 B · 3-buffer 等效 · 1.6.22+ 写侧按 count 稀疏'));
function block(from, len, color, label, opacity) {
const g = svgEl('g');
g.appendChild(svgEl('title', {}, `${label} · ${len} B`));
g.appendChild(svgEl('rect', {
x:x0(from), y, width:xw(len), height:h,
fill:color, 'fill-opacity':opacity, stroke:color, 'stroke-width':1.2, rx:4
}));
if (xw(len) > 48) {
g.appendChild(svgEl('text', {
x:x0(from)+xw(len)/2, y:y+h/2,
'text-anchor':'middle', 'dominant-baseline':'central',
fill:C.ink, 'font-size':12, 'font-family':'Cascadia Code,Consolas,monospace', 'font-weight':650
}, label));
}
svg.appendChild(g);
}
block(0, 384, C.blue, 'OUT 384', 0.28);
block(384, 600, C.cyan, 'IN 600', 0.28);
block(984, 40, C.slate, '', 0.22);
// 余量标注放在下方,避免与 984/1024 刻度挤在一起
svg.appendChild(svgEl('text', {
x:x0(1004), y:y+h+18, 'text-anchor':'middle',
fill:C.slate, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, '余 40'));
[0, 384, 984, 1024].forEach((t, i) => {
const x = x0(t);
svg.appendChild(svgEl('line', { x1:x,y1:y,x2:x,y2:y-6, stroke:C.line,'stroke-width':1 }));
svg.appendChild(svgEl('text', {
x, y:y-10,
'text-anchor': i===0 ? 'start' : i===3 ? 'end' : 'middle',
fill:C.dim, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, String(t)));
});
svg.appendChild(svgEl('text', {
x:pad, y:H-8, fill:C.dim, 'font-size':11,
'font-family':'Cascadia Code,Consolas,monospace'
}, 'SM2 0x1400–0x187F (3×384) · SM3 0x1880–0x1F67 (3×600) · DPRAM 侧余 120 B'));
container.innerHTML = '';
container.appendChild(svg);
}
/* 表格 */
function attrChip(a) {
const map = {固定:'fix',密排:'adp',头部:'hdr',保留:'note',忽略:'gone',批量:'adp'};
return `<span class="chip ${map[a]||'note'}">${a}</span>`;
}
function fillTable(tbody, groups, segOf) {
const rows = [];
groups.forEach(g => {
g.fields.forEach(f => {
rows.push(`
<tr>
<td>
<span class="dot" style="background:${f.color}"></span>
<code>${f.name}</code>
<span style="color:var(--dim);font-size:12px;margin-left:6px">${segOf(g)}</span>
</td>
<td class="n">${f.off}</td>
<td class="n">${f.len}</td>
<td class="mono">${f.type}</td>
<td>${f.txt}</td>
<td>${attrChip(f.attr)}</td>
</tr>`);
});
});
tbody.innerHTML = rows.join('');
}
function fillFlatTable(tbody, fields) {
tbody.innerHTML = fields.map(f => `
<tr>
<td><span class="dot" style="background:${f.color}"></span><code>${f.name}</code></td>
<td class="n">+${f.off}</td>
<td class="n">${f.len}</td>
<td class="mono">${f.type}</td>
<td>${f.txt}</td>
</tr>`).join('');
}
/* boot */
drawOverview(document.getElementById('in-overview'), IN_SEGS, 600);
drawOverview(document.getElementById('out-overview'), OUT_SEGS, 384);
drawFieldGroups('in-fields', IN_GROUPS);
drawFieldGroups('out-fields', OUT_GROUPS);
drawSlotStrip(document.getElementById('slot-strip'));
const slotHost = document.getElementById('slot-fields');
[{title:'单槽 56 B', total:56, fields:SLOT_FIELDS}].forEach(g => drawFieldBar(slotHost, g));
drawDpram(document.getElementById('dpram-map'));
fillTable(document.getElementById('in-tbody'), IN_GROUPS, g => g.title.split('·')[0].trim());
fillTable(document.getElementById('out-tbody'), OUT_GROUPS, g => g.title.split('·')[0].trim());
fillFlatTable(document.getElementById('slot-tbody'), SLOT_FIELDS);
</script>
</body>
</html>
+177 -87
View File
@@ -43,9 +43,8 @@
--border:#21304a; --border-strong:#2e4262; --border-subtle:#1a2740;
--text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99;
--esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14);
--gd32:#fcbf3f; --gd32-hi:#ffd97a; --gd32-dim:rgba(252,191,63,.14);
--can:#43d97b; --can-dim:rgba(67,217,123,.14);
--motor:#22d3ee; --motor-hi:#67e8f9; --motor-dim:rgba(34,211,238,.14);
--gd32:#fcbf3f; --gd32-dim:rgba(252,191,63,.14);
--can:#43d97b;
--mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14);
--green:#3ddc8e; --amber:#fcbf3f; --red:#ff6b6b; --red-dim:rgba(255,107,107,.14);
--spi:var(--esp32); --ecat:var(--mem); --ecat-hi:var(--mem-hi); --ecat-dim:var(--mem-dim);
@@ -266,7 +265,7 @@
<div class="metric"><div class="k">等效吞吐</div><div class="v" id="mTput">—</div><div class="f">KB/s</div></div>
<div class="metric"><div class="k">失败帧</div><div class="v" id="mFail">—</div><div class="f">必须为 0</div></div>
</div>
<div id="segWrap" style="margin-top:12px">
<div style="margin-top:12px">
<div class="k" style="font-size:11px; color:var(--text-label); margin-bottom:5px">单帧分段平均(固件五段拆解,占总往返比例)</div>
<div id="segBar" style="display:flex; height:20px; border-radius:6px; overflow:hidden; background:var(--bg-input)"></div>
<div id="segLegend" class="hint" style="margin-top:6px">跑一次测速后出现</div>
@@ -333,18 +332,18 @@
<div class="card">
<h2>刷新率 <span class="hint2">实测 = Δcycles/Δt;名义 = 1000/period_ms 仅作对照,两者差值就是软件调度开销</span></h2>
<div class="metrics">
<div class="metric hero" id="cHz"><div class="k">实测 PDO 刷新率</div><div class="v" id="eHz">—<small>Hz</small></div><div class="f" id="eHzF">cycles 差分</div></div>
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v" id="eAchieve">—</div><div class="f" id="eAchieveF">名义 — Hz</div></div>
<div class="metric" id="cBus"><div class="k">单拍耗时</div><div class="v" id="eBus">—<small>µs</small></div><div class="f" id="eBusF">p50 / p90 / max</div></div>
<div class="metric" id="cDuty"><div class="k">主站占空比</div><div class="v" id="eDuty">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
<div class="metric" id="cSlot"><div class="k">单电机状态刷新</div><div class="v" id="eSlot">—<small>Hz</small></div><div class="f" id="eSlotF">slot_period_ms —</div></div>
<div class="metric" id="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v" id="eLambda">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div>
<div class="metric" id="cDrop"><div class="k">真丢帧</div><div class="v" id="eDrop">—<small>帧/拍</small></div><div class="f" id="eDropF">主站 seq 空洞 · 从站 OVF(抽样单拍值)</div></div>
<div class="metric" id="cSkip"><div class="k">跳拍</div><div class="v" id="eSkip">—<small>/s</small></div><div class="f" id="eSkipF">累计 —</div></div>
<div class="metric" id="cLoad"><div class="k">从站 CPU 负载</div><div class="v" id="eLoad">—</div><div class="f" id="eLoadF">load_perm · age —</div></div>
<div class="metric" id="cWkc"><div class="k">WKC</div><div class="v" id="eWkc">—</div><div class="f" id="eWkcF">期望 —</div></div>
<div class="metric" id="cTorn"><div class="k">撕裂 / 重入</div><div class="v" id="eTorn">—</div><div class="f" id="eTornF">rx_torn / pdo_reentry</div></div>
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v" id="eRtt">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div>
<div class="metric hero" id="cHz"><div class="k">实测 PDO 刷新率</div><div class="v">—<small>Hz</small></div><div class="f">cycles 差分</div></div>
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v">—</div><div class="f">名义 — Hz</div></div>
<div class="metric" id="cBus"><div class="k">单拍耗时</div><div class="v">—<small>µs</small></div><div class="f">p50 / p90 / max</div></div>
<div class="metric" id="cDuty"><div class="k">主站占空比</div><div class="v">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
<div class="metric" id="cSlot"><div class="k">单电机状态刷新</div><div class="v">—<small>Hz</small></div><div class="f">slot_period_ms —</div></div>
<div class="metric" id="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div>
<div class="metric" id="cDrop"><div class="k">真丢帧</div><div class="v">—<small>帧/拍</small></div><div class="f">主站 seq 空洞 · GD32 OVF(抽样单拍值)</div></div>
<div class="metric" id="cSkip"><div class="k">跳拍</div><div class="v">—<small>/s</small></div><div class="f">累计 —</div></div>
<div class="metric" id="cLoad"><div class="k">从站 CPU 负载</div><div class="v">—</div><div class="f">load_perm · age —</div></div>
<div class="metric" id="cWkc"><div class="k">WKC</div><div class="v">—</div><div class="f">期望 —</div></div>
<div class="metric" id="cTorn"><div class="k">撕裂 / 重入</div><div class="v">—</div><div class="f">rx_torn / pdo_reentry</div></div>
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div>
</div>
<div class="hint">
判读:<b>达成率 &lt; 100%</b> = 节拍被软件拖住;<b>λ ÷ 实测Hz</b> = 每拍到达几帧 CAN(当前 1ms 档实测 6 帧/拍)。<br>
@@ -356,7 +355,7 @@
<b>真丢帧逐层看</b>:① CAN 控制器 err/TEC/REC;② CAN 软件 RX 队列满(深度 16)的
<code>rx_drop_count</code> —— 只在 ESP32 <code>/can/feedback</code> 有,过程数据里没有;
③ 段 4 覆盖窗口 <u>主站侧</u>按 seq 空洞算的 <code>rx_batch.lost</code>(<code>step/2−1</code>,
发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>从站侧</u>未取先覆
发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>GD32 侧</u>未取先覆
<code>rx_batch.pending</code>(= OVF,与 ③ 是两个独立仪表,正常应当同步增长);
⑤ CAN 取帧撕裂 <code>rx_torn</code>。③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。
撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。
@@ -405,9 +404,9 @@
B32-35 —— 补 0 会把"无数据"显示成"初始化正常"。</div>
</div>
<div class="card">
<h2>收到的东西 ② 批量接收区 <span class="hint2">rxraw.parsed(与下面槽表同一拍快照)· 段 4 覆盖窗口去重后的事件流</span></h2>
<h2>收到的东西 ② 密排整轮 + 长帧事件 <span class="hint2">rxraw.parsed · 1.6.21 密排:段2整轮槽 + 段3长帧变更</span></h2>
<div class="row" style="margin-bottom:8px">
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / 从站 OVF —</span>
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / GD32 OVF —</span>
<span id="rxbSeen" class="pill">本页帧样本 —</span>
<button id="btnRxbClear" class="sm ghost">清零计数</button>
</div>
@@ -417,13 +416,12 @@
<tbody></tbody>
</table>
</div>
<div style="font-size:11px; color:var(--text-label); margin:11px 0 5px">
覆盖窗口 5 槽原样(从站每拍整块拷出,写游标绕圈覆盖 —— <b>槽下标 ≠ 到达顺序</b>,seq 才是顺序)。
末列「本拍新上报」:<b>是</b> = 这一拍第一次见到这帧,主站把它交给上层;<b>否</b> = 它是上一拍就交过的旧帧
(窗口只留"最近 5 帧",一帧会连续几拍都躺在里面,但不会被重复上报)。</div>
<div style="font-size:11px; color:var(--text-label); margin:11px 0 5px" id="winSlotCap">
密排整轮槽(GD32 每拍原样拷出 count×56B:node ch online dlc seq + 48B STATUS)。
末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);<b>否</b> = seq 未变,固件本拍跳过填充。</div>
<div class="scroll" style="height:170px">
<table id="tblWinSlots">
<thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新上报</th></tr></thead>
<thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新数据</th></tr></thead>
<tbody></tbody>
</table>
</div>
@@ -433,18 +431,14 @@
</div>
</details>
<div class="hint">
<b>同一 ID + 同一载荷出现两次不是重复显示</b>:主站已按每槽 seq 去重,同一帧不会报第二次。
实测 120 个窗口快照里「同 ID 且 seq 相同」0 次、「同 ID 但 seq 不同」240 次 —— 那是<b>两帧真的各到达一次</b>,
只是电机空闲时 CONFIG 回报的 24B 逐字节相同,肉眼看一模一样。看下面槽表的 seq 列即可分辨。
<br><b>"前面像少了一帧"也不是丢帧</b>:① 主站每拍只把 seq 比上次大的槽交给上层,上一拍已经交过的帧
仍躺在窗口里(就是末列的"否"),但不会再进上面的表;② 页面 10Hz 采样、PDO 1kHz,两次采样之间约
100 拍的窗口内容已被覆盖,看不见(所以"样本数"只占全量约 0.8%,全量看 λ 那张卡)。
本表能看到的丢帧判据只有两个:<code>seq 空洞</code>(主站侧)与 <code>OVF</code>(从站侧),
上面 pill 里实时显示,实测同为 0;<u>本表看不到的还有三层</u> —— CAN 控制器 err/TEC/REC、
GD32 软件 RX 队列满丢(深度 16,只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂
<code>rx_torn</code>。逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。
<br>长帧(CONFIG 24B / 版本应答)在 8B 短快照里会被截断,所以后端另开 <code>state.xrx</code> 长帧槽,
逐字节的真凭实据在下面第 ③ 区的原始窗口里。</div>
<b>密排整轮(1.6.21)</b>:接 N 个电机本拍就发 N 个状态,空槽不发送。
STATUS 全在上方状态槽解码;本表是同一拍的<b>线级原样</b>(seq / online / 前 8B 载荷),
用来核对"差分是否真的跳过了未变电机"。<br>
<b>长帧(CONFIG 24B / 版本应答)</b>:段 3「最近一帧」,内容变了才进上面的事件表
(主站按 ch/dlc/id/data 逐字节比对,相同不重复上报)。逐字节的真凭实据在下面第 ③ 区。<br>
本表看不到的还有三层 —— CAN 控制器 err/TEC/REC、GD32 软件 RX 队列满丢(深度 16,
只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂 <code>rx_torn</code>。
逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。</div>
</div>
<div class="card">
@@ -453,8 +447,8 @@
<div class="paneFlex">
<div class="kv" id="rawKv"></div>
<div class="hint cap" style="height:2.6em">
<code>layout</code>:win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,解析器必须按 layout 选,
否则拿变长游标解定长窗口会整片错位。</div>
<code>layout</code>:dense=1.6.21 密排整轮 / win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,
解析器必须按 layout 选,否则拿变长游标解定长窗口会整片错位。</div>
<div class="grow" style="margin-top:6px; min-height:0; display:flex; flex-direction:column">
<div class="lbl">xrx 长帧完整载荷(kv 里那行是截断的,这里不截)</div>
<pre id="xrxFull" class="grow" style="margin-top:4px">—</pre>
@@ -469,7 +463,7 @@
</div>
</div>
<div style="margin-top:10px">
<div class="lbl">覆盖窗口原始字节 —— 按 44B/槽分组:头 12B(ch dlc flags pad ‖ id LE ‖ seq LE)+ 数据 32B(两行)</div>
<div class="lbl">接收区原始字节 —— dense:8B 整轮头 + count×56B 槽;win:44B/槽 覆盖窗口</div>
<div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div>
</div>
</div>
@@ -545,6 +539,11 @@ function kv(boxId, rows) {
box.appendChild(d);
}
}
/** 状态胶囊:三处(SPI / ECAT / 测速跑批)共用同一套 led + 文案结构 */
function setPill(sel, cls, text) {
const n = $(sel); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
}
const isNum = (v) => typeof v === "number" && isFinite(v);
const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—");
const f2 = (v) => (isNum(v) ? v.toFixed(2) : "—");
@@ -603,7 +602,7 @@ function showPane(name) {
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was;
ecatPill("#ecatState", "", "已暂停(在 SPI 页)");
setPill("#ecatState", "", "已暂停(在 SPI 页)");
}
}
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
@@ -611,7 +610,7 @@ for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
/* ═══════════════════ SPI 测速面板 ═══════════════════
* 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。
* 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */
const spiOut = $("#out"), spiHist = $("#hist"), spiPill = $("#spiState"), btnAbort = $("#btnAbort");
const spiOut = $("#out"), spiHist = $("#hist"), btnAbort = $("#btnAbort");
const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")];
let spiCtl = null;
try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {}
@@ -621,9 +620,7 @@ if (!ls("spi_test_ip")) { const shared = ls("dcu_esp_ip"); if (shared) $("#ip").
function spiBusy(busy, cls, text) {
for (const b of spiBtns) b.disabled = busy;
btnAbort.hidden = !busy;
spiPill.className = "pill" + (cls ? " " + cls : "");
spiPill.innerHTML = '<span class="led"></span>';
spiPill.appendChild(el("span", null, text));
setPill("#spiState", cls, text);
}
/** 固件返回是分行的中文纯文本;逐行取数,缺行就留 null(不能补 0 —— 会把"没这项数据"显示成"0µs") */
function spiParse(text) {
@@ -804,21 +801,20 @@ class RateWindow {
/** 返回窗口内的各类速率;无法判定的一律给 null,不猜 */
calc() {
const s = this.s, out = {
span: 0, pairs: 0, hz: null, lambda: null, drop: null, rx: null, skip: null,
span: 0, hz: null, lambda: null, drop: null, skip: null,
untrusted: 0, motorStep: {}, motorChanges: {},
rxbLostMax: 0, rxbPendingMax: 0, samples: s.length,
rxbLostMax: 0, rxbPendingMax: 0,
};
if (s.length < 2) return out;
let dCyc = 0, dLam = 0, dDrop = 0, dRx = 0, dSkip = null, dt = 0;
let dCyc = 0, dLam = 0, dDrop = 0, dSkip = null, dt = 0;
const steps = {}, changes = {};
for (let i = 1; i < s.length; i++) {
const a = s[i - 1], b = s[i];
const pair = (b.t - a.t) / 1000;
if (!(pair > 0)) continue;
dt += pair; out.pairs++;
dt += pair;
if (isNum(a.cycles) && isNum(b.cycles)) dCyc += (b.cycles - a.cycles);
if (isNum(a.gseq) && isNum(b.gseq)) dLam += wrapDelta(a.gseq, b.gseq, 32);
if (isNum(a.rx) && isNum(b.rx)) dRx += wrapDelta(a.rx, b.rx, 32);
if (isNum(a.drop) && isNum(b.drop)) {
const d = wrapDelta(a.drop, b.drop, 16);
/* 单拍增量超过 u16 半程 = 采样率不足以分辨回绕,该点对 drop 作废并计数 */
@@ -840,7 +836,7 @@ class RateWindow {
}
out.span = dt;
if (dt > 0) {
out.hz = dCyc / dt; out.lambda = dLam / dt; out.rx = dRx / dt; out.drop = dDrop / dt;
out.hz = dCyc / dt; out.lambda = dLam / dt; out.drop = dDrop / dt;
if (dSkip !== null) out.skip = dSkip / dt;
for (const k in steps) {
out.motorStep[k] = quantile(steps[k], 0.5);
@@ -882,7 +878,7 @@ const pcores = (mask) => {
const out = []; let m = mask >>> 0;
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
let hi = Math.floor(mask / 4294967296);
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (m & 1) out.push(b + 32);
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
return out;
};
const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs" : ms + "ms"));
@@ -895,7 +891,7 @@ const E = {
lastState: null, status: null, health: null, load: null, raw: null, dbg: null,
tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false,
lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
lastSeq: {}, seqAt: {},
lastSeq: {}, seqAt: {}, densLast: null,
chart: { t: [], hz: [], us: [] }, errors: 0, errText: "",
};
/** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */
@@ -905,11 +901,6 @@ const tblMotorsBody = $("#tblMotors tbody"), tblFramesBody = $("#tblFrames tbody
const tblSubsBody = $("#tblSubs tbody"), tblBeatsBody = $("#tblBeats tbody");
const tblWinBody = $("#tblWinSlots tbody");
function ecatPill(id, cls, text) {
const n = $(id); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
}
/** 节拍源:隐藏标签页会把主线程 setTimeout 节流到 ≥1s(实测被压到 1Hz,
* 采样密度一掉,所有"按相邻两拍差分"的判据就跟着失真)。电机页同款问题
* 用 js/motor/tick.worker.js 解决,Worker 的定时器不受此限;本页要保住
@@ -997,7 +988,7 @@ async function handleTick(st) {
for (const k in E.seqAt) if (!(k in seq)) delete E.seqAt[k];
E.win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null,
drop: b.valid && isNum(b.drop) ? b.drop : null,
skip: dbg && isNum(dbg.skip_total) ? dbg.skip_total : null, seq,
rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
});
@@ -1020,7 +1011,7 @@ function startEcatIfIdle() {
E.live = true; E.everStarted = true; E.wantLive = true; tickPrev = null; E.pollDts = [];
E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null;
$("#btnLive").textContent = "暂停观测";
ecatPill("#ecatState", "warn", "观测中");
setPill("#ecatState", "warn", "观测中");
eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); })
.catch((e) => { E.status = null; E.errText = e.message; });
eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; });
@@ -1031,7 +1022,7 @@ function stopEcat() {
E.live = false; E.wantLive = false;
ticker.stop();
$("#btnLive").textContent = "开始实时观测";
ecatPill("#ecatState", "", "已暂停");
setPill("#ecatState", "", "已暂停");
}
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
$("#btnRing").onclick = () => {
@@ -1084,18 +1075,28 @@ function renderMetrics(st, load, dbg, r, rtt) {
isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : "");
/* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧,
* 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做,
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与从站
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与 GD32
* OVF(rxbPendingMax) 取抽样单拍最大值(两者独立、正常应同步增长),颜色叠上撕裂累计。 */
const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0);
const tornN = E.tornAccum || 0;
metric("cDrop", holes + "<small>帧/拍</small>",
"主站空洞 " + (r.rxbLostMax || 0) + " · 从站OVF " + (r.rxbPendingMax || 0) +
"主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) +
" · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" +
(r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")",
(r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good"));
metric("cSkip", f1(r.skip) + "<small>/s</small>",
isNum(r.skip) ? "累计 " + ((dbg && dbg.skip_total) ?? "—") : "需开「逐拍环」才有 skip_total",
r.skip === null ? "" : (r.skip < 0.5 ? "good" : "bad"));
/* 超期/跳拍:cyc_us.late/lost 随 state 每拍就有(最近 n 拍窗口),不必开逐拍环。
* 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值,
* 旧构建恒报 0 —— 看到 0 先确认后端版本,别当"没有超期"。 */
const cu = (st && st.cyc_us) || {};
const lateN = isNum(cu.late) ? cu.late : null, lostN = isNum(cu.lost) ? cu.lost : null;
const winN = isNum(cu.n) ? cu.n : 0;
const lateHz = (lateN !== null && winN > 1 && isNum(period)) ? lateN / (winN * period / 1000) : null;
metric("cSkip", (lateHz === null ? "—" : f1(lateHz)) + "<small>/s</small>",
lateN === null ? "cyc_us.late 未上报(旧后端恒 0 占位,不可当无超期)"
: "窗口 " + winN + " 拍:超期 " + lateN + " · 跳过 " + (lostN ?? "—") +
(isNum(r.skip) ? " · skip_total 累计 " + ((dbg && dbg.skip_total) ?? "—")
: " · 开逐拍环可看自启动累计"),
lateN === null ? "" : (lateHz < 0.5 ? "good" : "bad"));
const loadFresh = load && load.valid && isNum(load.age_ms) && load.age_ms < 1000;
const loadPct = loadFresh ? load.load_perm / 10 : null;
@@ -1127,19 +1128,19 @@ function renderMetrics(st, load, dbg, r, rtt) {
rtt === null ? "bad" : rtt < 30 ? "good" : "warn");
if (st && st.ok === false) {
ecatPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
setPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
} else if (!exchanging) {
ecatPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
setPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
"," + (((st && st.motors) || []).length - mv.length) + " 槽 seq 停更)→ 去电机页/EtherCAT 页连一次");
} else {
ecatPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新");
setPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新");
}
const s = E.status && E.status.slave;
if (s) {
const al = AL_NAME[s.state] || ("0x" + (s.state || 0).toString(16));
ecatPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
setPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
} else {
ecatPill("#ecatAl", "", "AL —");
setPill("#ecatAl", "", "AL —");
}
}
@@ -1182,10 +1183,9 @@ function renderBridge(st) {
["rx_valid / truncated", b.valid ? b.rx_valid + " / " + b.truncated : null, (b.valid && b.truncated) ? "warn" : null],
["backlog", isNum(b.backlog) ? b.backlog : null, b.backlog ? "warn" : "good"],
["tx_result", b.valid ? (b.tx_result + " " + (b.tx_result_text || "")) : null, (b.valid && b.tx_result === 0) ? "good" : "warn"],
["rx_batch count / lost(主站空洞) / pending(从站OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending,
["rx_batch count / lost(主站空洞) / pending(GD32 OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending,
(rb.lost || rb.pending) ? "warn" : "good"],
["xrx 长帧", (st && st.xrx && st.xrx.data) ? "0x" + st.xrx.id.toString(16).toUpperCase() + " · " + st.xrx.dlc + "B" : null],
["TEC / REC", null, "dim"],
]);
}
@@ -1240,7 +1240,6 @@ function renderMotors(st, r) {
const hx = m.data || "";
for (let i = 0; i < 96; i += 32) mcell(" " + (hx.substr(i, 32) ? hexGrp(hx.substr(i, 32)) : "—"));
}
if (!motors.length) mcell("无槽", "hdr");
}
/* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */
@@ -1251,7 +1250,7 @@ function renderFrames(st, raw) {
const frames = (raw && raw.parsed) || [];
const rb = (st && st.rx_batch) || {};
$("#rxbPill").textContent = "rxraw 本拍新帧 " + frames.length + " · state 侧计数 新帧 " + rb.count +
" / seq 空洞 " + rb.lost + " / 从站 OVF " + rb.pending +
" / seq 空洞 " + rb.lost + " / GD32 OVF " + rb.pending +
" · layout " + (raw && raw.layout) + " · cycle " + (raw && raw.cycle);
/* 轮询只看得到"这一拍的覆盖窗口",全量帧永远采不完 —— 必须把采样占空比摊开说,
* 否则展示时这串数字会被当成总帧数。 */
@@ -1282,7 +1281,10 @@ function renderFrames(st, raw) {
const k = (s.id >>> 0) + ":" + s.data;
if (!best.has(k) || s.seq > best.get(k).seq) best.set(k, s);
}
if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim", "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)"));
if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim",
raw && raw.layout === "dense"
? "本拍无长帧变更(STATUS 在上方状态槽 + 下方整轮槽表;CONFIG/版本变了才会进这里)"
: "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)"));
for (const f of frames) {
const node = (f.id >> 16) & 0xF, sub = f.id & 0xFFFF;
const src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase());
@@ -1317,6 +1319,32 @@ function renderFrames(st, raw) {
/* 覆盖窗口单槽布局:44B = ch@0 dlc@1 flags@2 (pad@3) id@4..7 seq@8..11 data@12..43 */
const WIN_SLOT_SIZE = 44, WIN_SLOT_COUNT = 5;
/* 1.6.21 密排整轮 raw:8B 头(count active boot magic gseqLE) + count×56B 槽
* 槽 = node@0 ch@1 online@2 dlc@3 seq@4..7 + 48B STATUS@8..55 */
const DENSE_HDR = 8, DENSE_SLOT = 56, DENSE_STATUS = 48;
function decodeDenseRound(hex) {
if (!hex || hex.length < DENSE_HDR * 2) return null;
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16);
const dv = new DataView(b.buffer);
const head = { count: b[0], active: b[1], boot: b[2], magic: b[3], gseq: dv.getUint32(4, true) };
const slots = [];
for (let s = 0; s < head.count; s++) {
const o = DENSE_HDR + s * DENSE_SLOT;
if (o + DENSE_SLOT > b.length) break;
const dlc = b[o + 3];
let data = "";
const nd = Math.min(dlc || 0, 8);
for (let i = 0; i < nd; i++) data += b[o + 8 + i].toString(16).padStart(2, "0");
/* 48B 全长 hex(详情用) */
let full = "";
const nf = Math.min(dlc || 0, DENSE_STATUS);
for (let i = 0; i < nf; i++) full += b[o + 8 + i].toString(16).padStart(2, "0");
slots.push({ slot: s, node: b[o], ch: b[o + 1], online: b[o + 2] !== 0, dlc,
seq: dv.getUint32(o + 4, true), data: data.toUpperCase(), full: full.toUpperCase() });
}
return { head, slots };
}
function decodeWinSlots(hex) {
if (!hex) return [];
const b = new Uint8Array(hex.length / 2);
@@ -1335,8 +1363,11 @@ function decodeWinSlots(hex) {
}
function renderWinSlots(raw, slots, best) {
const body = tblWinBody;
slots = slots || ((raw && raw.layout === "win") ? decodeWinSlots(raw.raw) : []);
body.textContent = "";
if (raw && raw.layout === "dense") {
renderDenseRound(raw, body);
return;
}
if (!slots.length) {
const tr = el("tr");
tr.appendChild(el("td", "dim", raw && raw.layout !== "win"
@@ -1384,20 +1415,67 @@ function renderWinSlots(raw, slots, best) {
}
}
/* 1.6.21 密排整轮槽表:同一拍的线级原样,与上方状态槽解码结果对账用 */
function renderDenseRound(raw, body) {
const round = decodeDenseRound(raw.raw);
if (!round || !round.slots.length) {
const tr = el("tr");
tr.appendChild(el("td", "dim", "密排整轮 raw 为空或 count=0(这一拍没有电机槽)"));
tr.children[0].colSpan = 10;
body.appendChild(tr);
return;
}
if (!E.densLast) E.densLast = new Map();
const cap = document.getElementById("winSlotCap");
if (cap) cap.innerHTML =
"密排整轮槽 · count=" + round.head.count + " active=" + round.head.active +
" boot=" + round.head.boot + " magic=0x" + round.head.magic.toString(16).toUpperCase() +
" gseq=" + round.head.gseq.toLocaleString("en-US") +
"(node ch online dlc seq + 48B STATUS,空槽不发送)。" +
" 末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);" +
"<b>否</b> = seq 未变,固件本拍跳过填充。";
for (const s of round.slots) {
const prev = E.densLast.get(s.node + ":" + s.ch);
const dseq = (prev === undefined) ? null : (s.seq - prev);
const isNew = dseq !== null && dseq !== 0;
E.densLast.set(s.node + ":" + s.ch, s.seq);
/* STATUS CAN ID:0x180+node(DBC v0.74,默认扩展帧在 MotorState 侧,
* 这里只标语义,真 ID 以状态槽/DBC 为准) */
const tr = el("tr");
const td = (v, cls) => tr.appendChild(el("td", cls, v));
td(s.slot, "num");
td(s.online ? "在线" : "离线", s.online ? "good" : "warn");
td(s.seq.toLocaleString("en-US"), "num");
td(dseq === null ? "—" : (dseq === 0 ? "0(未变)" : "+" + dseq), "num " + (dseq ? "good" : "dim"));
td(s.ch, "num");
td(s.dlc, "num");
td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num");
td("N" + s.node + " · STATUS");
td(hexShort(s.data, 16), "hex");
td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"),
isNew || prev === undefined ? "good" : "dim");
tr.title = "48B STATUS: " + (s.full || s.data);
body.appendChild(tr);
}
}
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
function renderRaw(st, raw) {
const x = (st && st.xrx) || null;
/* rxraw 的三个头字节是"原样 dump 不翻译":win 布局下它们是
* slot_count / wr_idx(写游标) / ovf(从站侧未取先覆计数),沿用旧名会把写游标读成丢帧数 */
/* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf;
* dense=count/active/boot(lost/pending 对 STATUS 不适用) */
const isWin = raw && raw.layout === "win";
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "从站 OVF 未取先覆"]
: ["count", "lost", "pending"];
const isDense = raw && raw.layout === "dense";
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "GD32 OVF 未取先覆"]
: isDense ? ["count 密排电机数", "active", "boot"]
: ["count", "lost", "pending"];
kv("rawKv", [
["rxraw cycle", isNum(raw && raw.cycle) ? raw.cycle.toLocaleString("en-US") : null],
["layout", raw ? raw.layout : null],
[hLbl[0], raw ? raw.count : null],
[hLbl[1], raw ? raw.lost : null],
[hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null],
["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : null],
["len (字节)", raw ? raw.len : null],
["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null],
["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null],
@@ -1416,6 +1494,20 @@ function renderRaw(st, raw) {
grid.textContent = "";
const cell = (t, cls) => grid.appendChild(el("i", cls, t));
if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; }
if (raw.layout === "dense") {
/* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */
const hex = raw.raw;
if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr");
for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) {
const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2);
if (!slotHex) break;
cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot");
cell(" " + hexGrp(slotHex.substr(16, 32)));
if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32)));
if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80)));
}
return;
}
if (raw.layout !== "win") {
for (let i = 0; i < raw.raw.length; i += 32)
cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32)));
@@ -1524,11 +1616,8 @@ function drawChart() {
window.addEventListener("resize", layoutChart);
/* ═══════════════════ 热改 PDO 周期 ═══════════════════ */
const runPill = $("#runPill"), runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
function runBusy(cls, text) {
runPill.className = "pill" + (cls ? " " + cls : "");
runPill.innerHTML = '<span class="led"></span>'; runPill.appendChild(el("span", null, text));
}
const runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
function runBusy(cls, text) { setPill("#runPill", cls, text); }
$("#btnPeriod").onclick = async () => {
const v = parseFloat($("#periodPick").value);
if (!(v > 0)) { runBusy("warn", "先在「PDO 周期档位」里选一档"); return; }
@@ -1607,7 +1696,7 @@ async function measureOnce(kind, seconds, hz, extraNote) {
}
win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null,
drop: b.valid && isNum(b.drop) ? b.drop : null,
seq, rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
});
if (cyc.total) { p50s.push(cyc.total.p50); p90s.push(cyc.total.p90); pmaxs.push(cyc.total.max); }
@@ -1689,7 +1778,7 @@ async function withLivePaused(fn) {
finally {
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false;
btnEcatAbort.hidden = true;
if (wasLive) { E.live = true; tickPrev = null; ecatPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
if (wasLive) { E.live = true; tickPrev = null; setPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
else runBusy("", "空闲");
}
}
@@ -1761,6 +1850,7 @@ $("#btnSweep").onclick = () => withLivePaused(async () => {
$("#btnRxbClear").onclick = () => {
E.seenFrames = 0; E.subs.clear(); E.lastRawCycle = null; E.tornPrev = null; E.reentryPrev = null;
E.tornAccum = 0; E.reentryAccum = 0; E.tornCap = false; E.reentryCap = false;
E.densLast = null;
E.seenT0 = performance.now();
/* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */
$("#rxbSeen").textContent = "本页轮询采到的帧样本 0 条";