变更点: 1. esp32_server.py - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在) - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP 2. js/common/desktop-bridge.js(新) - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥 3. connection.js + responsive.css + components.css - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画 - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘 4. device-link.js - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时 - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期 5. motor/main.js + transport-ecat.js - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出 - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp 6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态 7. .gitignore - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
652 lines
30 KiB
HTML
652 lines
30 KiB
HTML
<!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 自动重走
|
||
受控复位序列)。自动状态轮询默认已关(AUTO_POLL=false),仅手动「读状态/唤醒/休眠」,
|
||
避免持续 /spi/send 0xA2 在 ESP32 串口刷「WEB SPI」日志;需恢复自动刷新改 AUTO_POLL。
|
||
-->
|
||
<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=quiet20260919a">
|
||
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
|
||
<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">仅手动读状态</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");
|
||
|
||
/* DeviceLink 统一健康态(外壳 Owner 经 BroadcastChannel 广播)。
|
||
SPI 0xA2 失败 ≠ 设备离线:灯只认共享层;命令错误单独文案。 */
|
||
window.__pmSetConn = null;
|
||
|
||
function setConn(ok, text) {
|
||
var p = $("pillConn");
|
||
p.className = "pill " + (ok ? "online" : "offline");
|
||
$("pillConnTxt").textContent = text;
|
||
}
|
||
window.__pmSetConn = setConn;
|
||
|
||
/** SPI/业务失败:不得把「设备离线」打红(除非共享层已判离线)。 */
|
||
function setSpiFail(msg) {
|
||
log(msg, "err");
|
||
var dl = window.__pmDeviceLink;
|
||
var s = dl && dl.getSnapshot ? dl.getSnapshot() : null;
|
||
if (s && s.links && s.links.esp32 && s.links.esp32.online) {
|
||
setConn(true, "设备在线 · SPI 失败");
|
||
} else if (!s) {
|
||
/* 无外壳/尚未收到快照:退回旧语义 */
|
||
setConn(false, "连接失败 · 5s 后重试");
|
||
}
|
||
/* 有快照且 offline:留给 DeviceLink subscribe 刷新灯 */
|
||
}
|
||
|
||
/* 与 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 记本标签档位;另存一份
|
||
* localStorage 供换标签页/重开浏览器时兜底(宁可按睡眠处理少一次自动读,
|
||
* 也不能自动 1E 把芯片叫醒)。 */
|
||
var SS_SLEEP = "lan9252_pm_asleep";
|
||
var LS_SLEEP = "lan9252_pm_asleep_any";
|
||
function saveSleepFlag(n) {
|
||
try { sessionStorage.setItem(SS_SLEEP, String(n)); } catch (e) {}
|
||
/* localStorage: "n@ms" —— 带时间戳,换标签/重开浏览器可追溯入睡时刻 */
|
||
try { localStorage.setItem(LS_SLEEP, n + "@" + Date.now()); } catch (e) {}
|
||
}
|
||
function clearSleepFlag() {
|
||
try { sessionStorage.removeItem(SS_SLEEP); } catch (e) {}
|
||
try { localStorage.removeItem(LS_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) {}
|
||
try {
|
||
var lv = localStorage.getItem(LS_SLEEP) || "";
|
||
/* 兼容旧值 "1"/"2"/"3" 与新值 "1@1760029000000" */
|
||
var m = String(lv).match(/^([123])(?:@(\d+))?$/);
|
||
if (m) return parseInt(m[1], 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 轮询会持续打 /spi/send 0xA2,
|
||
* 在 ESP32 串口刷「WEB SPI」日志。手动「读状态 / 唤醒 / 休眠」仍可用。
|
||
* 若需恢复自动刷新:把 AUTO_POLL 改 true。 ---- */
|
||
var AUTO_POLL = false;
|
||
var pollFail = 0, pollTimer = null, pollPaused = !AUTO_POLL, pollGen = 0;
|
||
/** 主站看门狗豁免是否已置位(pmHold 幂等,防 2s 轮询刷屏) */
|
||
var pmHoldOn = false;
|
||
function bumpPollGen() { return ++pollGen; }
|
||
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
|
||
function markAwake() {
|
||
clearSleepFlag();
|
||
/* 只在「之前置过豁免」时才解除:pollOnce 每 2s 调本函数,
|
||
* 无脑 pmHold(0) 会把「主站看门狗豁免解除」刷屏。 */
|
||
if (pmHoldOn) pmHold(0);
|
||
$("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) {
|
||
setSpiFail("SPI 轮询失败 ×3(IP " + deviceIp() + ")· 5s 后重试");
|
||
pollTimer = setTimeout(probe, 5000);
|
||
} else {
|
||
pollTimer = setTimeout(pollOnce, 2000);
|
||
}
|
||
});
|
||
}
|
||
function resumePolling() {
|
||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||
if (!AUTO_POLL) {
|
||
pollPaused = true;
|
||
return;
|
||
}
|
||
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) {
|
||
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
|
||
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() +
|
||
(AUTO_POLL ? "。" : "。自动状态轮询已关闭,请点「读状态」手动查询。"), "ok");
|
||
if (AUTO_POLL) pollOnce();
|
||
})
|
||
.catch(function (e) {
|
||
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
|
||
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);
|
||
}
|
||
/* 主站楔死看门狗豁免:休眠期间 ecat_native 不判死不重拉(否则芯片醒来
|
||
* 即被偷偷拉回 OP——2026-09-12 实测休眠 ~20s 自动"复活")。解除时主站
|
||
* 自带 60s 冷却,让本页唤醒流程先走完。best-effort:native 未起只记日志。 */
|
||
function pmHold(on) {
|
||
var want = !!on;
|
||
if (pmHoldOn === want) return Promise.resolve();
|
||
return ecatPost("pm/hold", { on: want }, 5000).then(function (r) {
|
||
if (r.ok) pmHoldOn = want;
|
||
log("主站看门狗豁免" + (want ? "置位" : "解除") +
|
||
(r.ok ? "" : "失败: " + r.log.join(";")), r.ok ? "ok" : "warn");
|
||
}).catch(function (e) {
|
||
log("主站看门狗豁免" + (want ? "置位" : "解除") + "失败: " + e.message, "warn");
|
||
});
|
||
}
|
||
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 = (window.__pmDeviceLink && window.__pmDeviceLink.getEcatAdapter)
|
||
? window.__pmDeviceLink.getEcatAdapter() : "I210";
|
||
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() {
|
||
pmHold(0); /* 唤醒流程开始即解除豁免(主站给 60s 冷却,不干扰本流程) */
|
||
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);
|
||
pmHold(1); /* 请主站看门狗整段静默,别把睡着/刚醒的芯片偷偷拉回 OP */
|
||
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");
|
||
/* 新标签/重开浏览器:native 可能刚重启导致 g_pm_hold=0,楔死线程会趁
|
||
* 芯片睡着时重拉(D1/D2 报文即唤醒)。必须重新置位豁免。 */
|
||
pmHold(1);
|
||
probeEspOnly();
|
||
} else {
|
||
log(AUTO_POLL
|
||
? "就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。"
|
||
: "就绪。自动状态轮询已关闭(避免持续 SPI 诊断流量);需要时点「读状态」。改设备 IP 请到 OTA 中继页。", "warn");
|
||
probe();
|
||
}
|
||
})();
|
||
</script>
|
||
<script type="module">
|
||
import { deviceLink } from '../js/common/device-link.js?v=20260916a';
|
||
window.__pmDeviceLink = deviceLink;
|
||
deviceLink.subscribe(function (s) {
|
||
if (!s || !s.links || !window.__pmSetConn) return;
|
||
var e = s.links.esp32;
|
||
window.__pmSetConn(!!e.online, e.online ? '已连接' : '离线');
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|